mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2025-12-25 11:19:39 -05:00
Compare commits
122 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f08815482 | ||
|
|
6c0a81a4c5 | ||
|
|
9179bde8f9 | ||
|
|
0684c47e2a | ||
|
|
be52238413 | ||
|
|
a0332c432d | ||
|
|
35b038a33c | ||
|
|
9ac3d79b95 | ||
|
|
6f24b7d34e | ||
|
|
e84d15a7ed | ||
|
|
8f268b3b75 | ||
|
|
f9cb44c66b | ||
|
|
d09c6dbfed | ||
|
|
67d7cd1d23 | ||
|
|
bf3337b5e1 | ||
|
|
0b0d214085 | ||
|
|
60dc008b05 | ||
|
|
475b6e3728 | ||
|
|
0e70cd83e2 | ||
|
|
27297d170a | ||
|
|
bc8f8b8138 | ||
|
|
87ba53fde9 | ||
|
|
098dda28a4 | ||
|
|
8ffe6abb5c | ||
|
|
5706776002 | ||
|
|
fbe528e935 | ||
|
|
0df86b940f | ||
|
|
81c3707090 | ||
|
|
9fd691b9f0 | ||
|
|
821136787d | ||
|
|
6aedba09f3 | ||
|
|
7b17a1acfa | ||
|
|
9dd538519f | ||
|
|
b8821f1f72 | ||
|
|
3420dcd07d | ||
|
|
445c01bddc | ||
|
|
dd5996084d | ||
|
|
dfb1d80ca0 | ||
|
|
744fbc7a46 | ||
|
|
cd11cc58cf | ||
|
|
569e385915 | ||
|
|
abf552cd18 | ||
|
|
c6959488dc | ||
|
|
85e3155b50 | ||
|
|
f6aa50bbfc | ||
|
|
5ad27c015e | ||
|
|
4a68a99907 | ||
|
|
123dc1a74d | ||
|
|
2e23fcfd5d | ||
|
|
edbc21df19 | ||
|
|
f0e1c901c6 | ||
|
|
22e403e0ff | ||
|
|
6a7b02b700 | ||
|
|
4aa2983681 | ||
|
|
18888bc3ae | ||
|
|
07a0a3f598 | ||
|
|
76e1274ba5 | ||
|
|
598387efc8 | ||
|
|
f00ee7d9fa | ||
|
|
6abe6f2ee4 | ||
|
|
bd69f2d103 | ||
|
|
6a963c26b2 | ||
|
|
4c08ade3ee | ||
|
|
37f7326f4c | ||
|
|
c398fda15c | ||
|
|
e9da17151a | ||
|
|
fd4354f16d | ||
|
|
0d0c6c9066 | ||
|
|
4620c78f5a | ||
|
|
349b9629f8 | ||
|
|
64ee18c4d8 | ||
|
|
3a9e5a80ba | ||
|
|
de85a6b334 | ||
|
|
25318b691d | ||
|
|
77e778caac | ||
|
|
b53f83a76c | ||
|
|
2304c43a60 | ||
|
|
16963c17dc | ||
|
|
1d9dc0f952 | ||
|
|
a9fe821067 | ||
|
|
c7b1b08516 | ||
|
|
1617fa7a3f | ||
|
|
ad467fae28 | ||
|
|
c7046bc705 | ||
|
|
52946a8e4c | ||
|
|
dd6b77e029 | ||
|
|
396c1f3d5f | ||
|
|
379d5a5177 | ||
|
|
85a4d5d432 | ||
|
|
43eb10e488 | ||
|
|
d702c08a12 | ||
|
|
e78323d214 | ||
|
|
d2e866dd74 | ||
|
|
76687ad5df | ||
|
|
dab77e8e4f | ||
|
|
0b250c71aa | ||
|
|
571f670db0 | ||
|
|
4e9e628162 | ||
|
|
4f49b06704 | ||
|
|
8eb0c36665 | ||
|
|
6f69c09aca | ||
|
|
8e6f153882 | ||
|
|
07183fd40f | ||
|
|
08cccfa133 | ||
|
|
04b7f0a398 | ||
|
|
1735fda48f | ||
|
|
1c9ea0eda7 | ||
|
|
83b5b6695c | ||
|
|
342fb3c96d | ||
|
|
b7a18466b5 | ||
|
|
0cdc4d51df | ||
|
|
e177669514 | ||
|
|
fd294dfcdd | ||
|
|
bdd092e5d3 | ||
|
|
f1c5a0ef5f | ||
|
|
1e8ff763d5 | ||
|
|
4cf6a3b219 | ||
|
|
de145b6b18 | ||
|
|
84a8308bf3 | ||
|
|
8d191fa1a1 | ||
|
|
b47a0197e2 | ||
|
|
4e7c5f9495 |
@@ -1,10 +1,21 @@
|
||||
DEBUG=1
|
||||
# only set this to true when testing/debugging
|
||||
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=
|
||||
|
||||
# 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=db_recipes
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_USER=djangodb
|
||||
POSTGRES_PASSWORD=
|
||||
POSTGRES_DB=djangodb
|
||||
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.
|
||||
GUNICORN_MEDIA=0
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
name: Deploy Docker Image
|
||||
|
||||
name: publish dev image docker
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
branches:
|
||||
- '*'
|
||||
- '*/*'
|
||||
- '!master'
|
||||
jobs:
|
||||
build-push:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
- name: Publish to Registry
|
||||
uses: AhnSeongHyun/action-tag-docker-build-push@v1.0.0
|
||||
uses: elgohr/Publish-Docker-Github-Action@2.13
|
||||
with:
|
||||
repo_name: vabene1111/recipes
|
||||
name: vabene1111/recipes
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
19
.github/workflows/docker-publish-latest.yml
vendored
Normal file
19
.github/workflows/docker-publish-latest.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
name: publish latest image docker
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
- name: Build and publish image
|
||||
uses: ilteoood/docker_buildx@master
|
||||
with:
|
||||
publish: true
|
||||
imageName: vabene1111/recipes
|
||||
tag: latest
|
||||
dockerHubUser: ${{ secrets.DOCKER_USERNAME }}
|
||||
dockerHubPassword: ${{ secrets.DOCKER_PASSWORD }}
|
||||
25
.github/workflows/docker-publish-release.yml
vendored
Normal file
25
.github/workflows/docker-publish-release.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
name: publish tagged release docker
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build image job
|
||||
steps:
|
||||
- name: Checkout master
|
||||
uses: actions/checkout@master#
|
||||
- name: Get the version
|
||||
id: get_version
|
||||
run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//}
|
||||
- name: Build and publish image
|
||||
uses: ilteoood/docker_buildx@master
|
||||
with:
|
||||
publish: true
|
||||
imageName: vabene1111/recipes
|
||||
tag: ${{ steps.get_version.outputs.VERSION }}
|
||||
dockerHubUser: ${{ secrets.DOCKER_USERNAME }}
|
||||
dockerHubPassword: ${{ secrets.DOCKER_PASSWORD }}
|
||||
13
.github/workflows/docker-publish.yml
vendored
13
.github/workflows/docker-publish.yml
vendored
@@ -1,13 +0,0 @@
|
||||
name: Publish Docker
|
||||
on: [push]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
- name: Publish to Registry
|
||||
uses: elgohr/Publish-Docker-Github-Action@2.13
|
||||
with:
|
||||
name: vabene1111/recipes
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
5
.idea/codeStyles/codeStyleConfig.xml
generated
@@ -1,5 +0,0 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
||||
</state>
|
||||
</component>
|
||||
8
.idea/dictionaries/vabene1111_PC.xml
generated
Normal file
8
.idea/dictionaries/vabene1111_PC.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<component name="ProjectDictionaryState">
|
||||
<dictionary name="vabene1111-PC">
|
||||
<words>
|
||||
<w>gunicorn</w>
|
||||
<w>traefik</w>
|
||||
</words>
|
||||
</dictionary>
|
||||
</component>
|
||||
14
.idea/inspectionProfiles/Project_Default.xml
generated
14
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -1,14 +0,0 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="ignoredPackages">
|
||||
<value>
|
||||
<list size="1">
|
||||
<item index="0" class="java.lang.String" itemvalue="psycopg2" />
|
||||
</list>
|
||||
</value>
|
||||
</option>
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
1
.idea/inspectionProfiles/profiles_settings.xml
generated
1
.idea/inspectionProfiles/profiles_settings.xml
generated
@@ -1,6 +1,5 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<settings>
|
||||
<option name="PROJECT_PROFILE" value="Default" />
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
|
||||
6
.idea/jsLibraryMappings.xml
generated
6
.idea/jsLibraryMappings.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="JavaScriptLibraryMappings">
|
||||
<file url="file://$PROJECT_DIR$" libraries="{jquery-3.4.1, pdf, pdf_viewer, pretty-checkbox}" />
|
||||
</component>
|
||||
</project>
|
||||
47
README.md
47
README.md
@@ -3,6 +3,8 @@ Recipes is a Django application to manage, tag and search recipes using either b
|
||||
|
||||

|
||||
|
||||
[More Screenshots](https://imgur.com/a/V01151p)
|
||||
|
||||
### Features
|
||||
|
||||
- :package: **Sync** files with Dropbox and Nextcloud (more can easily be added)
|
||||
@@ -12,25 +14,29 @@ Recipes is a Django application to manage, tag and search recipes using either b
|
||||
- :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
|
||||
- :family: **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**
|
||||
- :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 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.
|
||||
|
||||
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
|
||||
|
||||
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](https://github.com/vabene1111/recipes/tree/develop/docs/docker).
|
||||
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 `
|
||||
3. Start the container `docker-compose up -d`
|
||||
4. Create a default user by running `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.
|
||||
|
||||
@@ -38,7 +44,6 @@ 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)).
|
||||
|
||||
## 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!**
|
||||
@@ -46,34 +51,18 @@ While intermediate updates can be skipped when updating please make sure to **re
|
||||
2. Pull the latest image using `docker-compose pull`
|
||||
3. Start the container again using `docker-compose up -d`
|
||||
|
||||
# Documentation
|
||||
## Kubernetes
|
||||
|
||||
Most things should be straight forward but there are some more complicated things.
|
||||
##### Storage Backends
|
||||
A `Storage Backend` is a remote storage location where PDF files are read from. 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 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.
|
||||
|
||||
##### Syncing Data
|
||||
To sync the recipes app with the storage backends press `Sync now` under `Storage Data >> Configure Sync`.
|
||||
##### 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. 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).
|
||||
|
||||
> Currently the only option is word contains, maybe some more SQL like operators will be added later.
|
||||
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
|
||||
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.
|
||||
|
||||
2
boot.sh
2
boot.sh
@@ -6,4 +6,6 @@ python manage.py migrate
|
||||
python manage.py collectstatic --noinput
|
||||
echo "Done"
|
||||
|
||||
chmod -R 755 /opt/recipes/mediafiles
|
||||
|
||||
exec gunicorn -b :8080 --access-logfile - --error-logfile - recipes.wsgi
|
||||
@@ -3,7 +3,7 @@ from .models import *
|
||||
|
||||
|
||||
class UserPreferenceAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'theme', 'nav_color')
|
||||
list_display = ('name', 'theme', 'nav_color', 'default_page', 'search_style')
|
||||
|
||||
@staticmethod
|
||||
def name(obj):
|
||||
@@ -80,7 +80,7 @@ class RecipeBookAdmin(admin.ModelAdmin):
|
||||
|
||||
@staticmethod
|
||||
def user_name(obj):
|
||||
return obj.user.get_user_name()
|
||||
return obj.created_by.get_user_name()
|
||||
|
||||
|
||||
admin.site.register(RecipeBook, RecipeBookAdmin)
|
||||
@@ -98,7 +98,7 @@ class MealPlanAdmin(admin.ModelAdmin):
|
||||
|
||||
@staticmethod
|
||||
def user(obj):
|
||||
return obj.user.get_user_name()
|
||||
return obj.created_by.get_user_name()
|
||||
|
||||
|
||||
admin.site.register(MealPlan, MealPlanAdmin)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from dal import autocomplete
|
||||
from django import forms
|
||||
from django.forms import widgets
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
from emoji_picker.widgets import EmojiPickerTextInput
|
||||
|
||||
@@ -31,10 +31,17 @@ class UserPreferenceForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = UserPreference
|
||||
fields = ('theme', 'nav_color')
|
||||
fields = ('default_unit', 'theme', 'nav_color', 'default_page', 'show_recent', 'search_style', 'plan_share')
|
||||
|
||||
help_texts = {
|
||||
'nav_color': _('Color of the top navigation bar. Not all colors work with all themes, just try them out!')
|
||||
'nav_color': _('Color of the top navigation bar. Not all colors work with all themes, just try them out!'),
|
||||
'default_unit': _('Default Unit to be used when inserting a new ingredient into a recipe.'),
|
||||
'plan_share': _('Default user to share newly created meal plan entries with.'),
|
||||
'show_recent': _('Show recently viewed recipes on search page.'),
|
||||
}
|
||||
|
||||
widgets = {
|
||||
'plan_share': MultiSelectWidget
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +92,9 @@ class InternalRecipeForm(forms.ModelForm):
|
||||
'waiting_time': _('Waiting time (cooking/baking) in minutes'),
|
||||
}
|
||||
widgets = {'keywords': MultiSelectWidget}
|
||||
help_texts = {
|
||||
'instructions': _('You can use markdown to format this field. See the <a href="/docs/markdown/">docs here</a>')
|
||||
}
|
||||
|
||||
|
||||
class ShoppingForm(forms.Form):
|
||||
@@ -99,6 +109,25 @@ class ShoppingForm(forms.Form):
|
||||
)
|
||||
|
||||
|
||||
class ExportForm(forms.Form):
|
||||
recipe = forms.ModelChoiceField(
|
||||
queryset=Recipe.objects.filter(internal=True).all(),
|
||||
widget=SelectWidget
|
||||
)
|
||||
image = forms.BooleanField(
|
||||
help_text=_('Export Base64 encoded image?'),
|
||||
required=False
|
||||
)
|
||||
download = forms.BooleanField(
|
||||
help_text=_('Download export directly or show on page?'),
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class ImportForm(forms.Form):
|
||||
recipe = forms.CharField(widget=forms.Textarea, help_text=_('Simply paste a JSON export into this textarea and click import.'))
|
||||
|
||||
|
||||
class UnitMergeForm(forms.Form):
|
||||
prefix = 'unit'
|
||||
|
||||
@@ -218,12 +247,33 @@ class ImportRecipeForm(forms.ModelForm):
|
||||
class RecipeBookForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = RecipeBook
|
||||
fields = ('name',)
|
||||
fields = ('name', 'icon', 'description', 'shared')
|
||||
widgets = {'icon': EmojiPickerTextInput, 'shared': MultiSelectWidget}
|
||||
|
||||
|
||||
class MealPlanForm(forms.ModelForm):
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super(MealPlanForm, self).clean()
|
||||
|
||||
if cleaned_data['title'] == '' and cleaned_data['recipe'] is None:
|
||||
raise forms.ValidationError(_('You must provide at least a recipe or a title.'))
|
||||
|
||||
return cleaned_data
|
||||
|
||||
class Meta:
|
||||
model = MealPlan
|
||||
fields = ('recipe', 'meal', 'note', 'date')
|
||||
fields = ('recipe', 'title', 'meal', 'note', 'date', 'shared')
|
||||
|
||||
widgets = {'recipe': SelectWidget, 'date': DateWidget}
|
||||
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>')
|
||||
}
|
||||
|
||||
widgets = {'recipe': SelectWidget, 'date': DateWidget, 'shared': MultiSelectWidget}
|
||||
|
||||
|
||||
class SuperUserForm(forms.Form):
|
||||
name = forms.CharField()
|
||||
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'}))
|
||||
|
||||
81
cookbook/helper/mdx_urlize.py
Normal file
81
cookbook/helper/mdx_urlize.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""A more liberal autolinker
|
||||
|
||||
Inspired by Django's urlize function.
|
||||
|
||||
Positive examples:
|
||||
|
||||
>>> import markdown
|
||||
>>> md = markdown.Markdown(extensions=['urlize'])
|
||||
|
||||
>>> md.convert('http://example.com/')
|
||||
u'<p><a href="http://example.com/">http://example.com/</a></p>'
|
||||
|
||||
>>> md.convert('go to http://example.com')
|
||||
u'<p>go to <a href="http://example.com">http://example.com</a></p>'
|
||||
|
||||
>>> md.convert('example.com')
|
||||
u'<p><a href="http://example.com">example.com</a></p>'
|
||||
|
||||
>>> md.convert('example.net')
|
||||
u'<p><a href="http://example.net">example.net</a></p>'
|
||||
|
||||
>>> md.convert('www.example.us')
|
||||
u'<p><a href="http://www.example.us">www.example.us</a></p>'
|
||||
|
||||
>>> md.convert('(www.example.us/path/?name=val)')
|
||||
u'<p>(<a href="http://www.example.us/path/?name=val">www.example.us/path/?name=val</a>)</p>'
|
||||
|
||||
>>> md.convert('go to <http://example.com> now!')
|
||||
u'<p>go to <a href="http://example.com">http://example.com</a> now!</p>'
|
||||
|
||||
Negative examples:
|
||||
|
||||
>>> md.convert('del.icio.us')
|
||||
u'<p>del.icio.us</p>'
|
||||
|
||||
"""
|
||||
|
||||
import markdown
|
||||
|
||||
# Global Vars
|
||||
URLIZE_RE = '(%s)' % '|'.join([
|
||||
r'<(?:f|ht)tps?://[^>]*>',
|
||||
r'\b(?:f|ht)tps?://[^)<>\s]+[^.,)<>\s]',
|
||||
r'\bwww\.[^)<>\s]+[^.,)<>\s]',
|
||||
r'[^(<\s]+\.(?:com|net|org)\b',
|
||||
])
|
||||
|
||||
class UrlizePattern(markdown.inlinepatterns.Pattern):
|
||||
""" Return a link Element given an autolink (`http://example/com`). """
|
||||
def handleMatch(self, m):
|
||||
url = m.group(2)
|
||||
|
||||
if url.startswith('<'):
|
||||
url = url[1:-1]
|
||||
|
||||
text = url
|
||||
|
||||
if not url.split('://')[0] in ('http','https','ftp'):
|
||||
if '@' in url and not '/' in url:
|
||||
url = 'mailto:' + url
|
||||
else:
|
||||
url = 'http://' + url
|
||||
|
||||
el = markdown.util.etree.Element("a")
|
||||
el.set('href', url)
|
||||
el.text = markdown.util.AtomicString(text)
|
||||
return el
|
||||
|
||||
class UrlizeExtension(markdown.Extension):
|
||||
""" Urlize Extension for Python-Markdown. """
|
||||
|
||||
def extendMarkdown(self, md, md_globals):
|
||||
""" Replace autolink with UrlizePattern """
|
||||
md.inlinePatterns['autolink'] = UrlizePattern(URLIZE_RE, md)
|
||||
|
||||
def makeExtension(*args, **kwargs):
|
||||
return UrlizeExtension(*args, **kwargs)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import doctest
|
||||
doctest.testmod()
|
||||
68
cookbook/helper/permission_helper.py
Normal file
68
cookbook/helper/permission_helper.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
Source: https://djangosnippets.org/snippets/1703/
|
||||
"""
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import user_passes_test
|
||||
from django.utils.translation import gettext as _
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse_lazy, reverse
|
||||
|
||||
|
||||
def get_allowed_groups(groups_required):
|
||||
groups_allowed = tuple(groups_required)
|
||||
if 'guest' in groups_required:
|
||||
groups_allowed = groups_allowed + ('user', 'admin')
|
||||
if 'user' in groups_required:
|
||||
groups_allowed = groups_allowed + ('admin',)
|
||||
return groups_allowed
|
||||
|
||||
|
||||
def group_required(*groups_required):
|
||||
"""Requires user membership in at least one of the groups passed in."""
|
||||
|
||||
def in_groups(u):
|
||||
groups_allowed = get_allowed_groups(groups_required)
|
||||
if u.is_authenticated:
|
||||
if u.is_superuser | bool(u.groups.filter(name__in=groups_allowed)):
|
||||
return True
|
||||
return False
|
||||
|
||||
return user_passes_test(in_groups, login_url='index')
|
||||
|
||||
|
||||
class GroupRequiredMixin(object):
|
||||
"""
|
||||
groups_required - list of strings, required param
|
||||
"""
|
||||
|
||||
groups_required = None
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not request.user.is_authenticated:
|
||||
messages.add_message(request, messages.ERROR, _('You are not logged in and therefore cannot view this page!'))
|
||||
return HttpResponseRedirect(reverse_lazy('login'))
|
||||
else:
|
||||
if not request.user.is_superuser:
|
||||
group_allowed = get_allowed_groups(self.groups_required)
|
||||
user_groups = []
|
||||
for group in request.user.groups.values_list('name', flat=True):
|
||||
user_groups.append(group)
|
||||
if len(set(user_groups).intersection(group_allowed)) <= 0:
|
||||
messages.add_message(request, messages.ERROR, _('You do not have the required permissions to view this page!'))
|
||||
return HttpResponseRedirect(reverse_lazy('index'))
|
||||
return super(GroupRequiredMixin, self).dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
class OwnerRequiredMixin(object):
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not request.user.is_authenticated:
|
||||
messages.add_message(request, messages.ERROR, _('You are not logged in and therefore cannot view this page!'))
|
||||
return HttpResponseRedirect(reverse_lazy('login'))
|
||||
else:
|
||||
obj = self.get_object()
|
||||
if not (obj.created_by == request.user or request.user.is_superuser):
|
||||
messages.add_message(request, messages.ERROR, _('You cannot interact with this object as its not owned by you!'))
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
|
||||
return super(OwnerRequiredMixin, self).dispatch(request, *args, **kwargs)
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
BIN
cookbook/locale/en/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/en/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
1105
cookbook/locale/en/LC_MESSAGES/django.po
Normal file
1105
cookbook/locale/en/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
18
cookbook/migrations/0031_auto_20200407_1841.py
Normal file
18
cookbook/migrations/0031_auto_20200407_1841.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.0.4 on 2020-04-07 16:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0030_recipeingredient_note'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='keyword',
|
||||
name='icon',
|
||||
field=models.CharField(blank=True, max_length=16, null=True),
|
||||
),
|
||||
]
|
||||
18
cookbook/migrations/0032_userpreference_default_unit.py
Normal file
18
cookbook/migrations/0032_userpreference_default_unit.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.0.4 on 2020-04-13 20:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0031_auto_20200407_1841'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='default_unit',
|
||||
field=models.CharField(default='g', max_length=32),
|
||||
),
|
||||
]
|
||||
18
cookbook/migrations/0033_userpreference_default_page.py
Normal file
18
cookbook/migrations/0033_userpreference_default_page.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.0.4 on 2020-04-13 20:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0032_userpreference_default_unit'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='default_page',
|
||||
field=models.CharField(choices=[('SEARCH', 'Search'), ('PLAN', 'Meal-Plan')], default='SEARCH', max_length=64),
|
||||
),
|
||||
]
|
||||
22
cookbook/migrations/0034_auto_20200426_1614.py
Normal file
22
cookbook/migrations/0034_auto_20200426_1614.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 3.0.5 on 2020-04-26 14:14
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def apply_migration(apps, schema_editor):
|
||||
Group = apps.get_model('auth', 'Group')
|
||||
Group.objects.bulk_create([
|
||||
Group(name=u'guest'),
|
||||
Group(name=u'user'),
|
||||
Group(name=u'admin'),
|
||||
])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('cookbook', '0033_userpreference_default_page'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(apply_migration)
|
||||
]
|
||||
28
cookbook/migrations/0035_auto_20200427_1637.py
Normal file
28
cookbook/migrations/0035_auto_20200427_1637.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 3.0.5 on 2020-04-27 14:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0034_auto_20200426_1614'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='mealplan',
|
||||
old_name='user',
|
||||
new_name='created_by',
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='recipebook',
|
||||
old_name='user',
|
||||
new_name='created_by',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='userpreference',
|
||||
name='default_page',
|
||||
field=models.CharField(choices=[('SEARCH', 'Search'), ('PLAN', 'Meal-Plan'), ('BOOKS', 'Books')], default='SEARCH', max_length=64),
|
||||
),
|
||||
]
|
||||
22
cookbook/migrations/0036_auto_20200427_1800.py
Normal file
22
cookbook/migrations/0036_auto_20200427_1800.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 3.0.5 on 2020-04-27 16:00
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def apply_migration(apps, schema_editor):
|
||||
Group = apps.get_model('auth', 'Group')
|
||||
User = apps.get_model('auth', 'User')
|
||||
for u in User.objects.all():
|
||||
if u.groups.count() < 1:
|
||||
u.groups.add(Group.objects.get(name='admin'))
|
||||
u.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('cookbook', '0035_auto_20200427_1637'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(apply_migration)
|
||||
]
|
||||
18
cookbook/migrations/0037_userpreference_search_style.py
Normal file
18
cookbook/migrations/0037_userpreference_search_style.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.0.5 on 2020-05-02 10:45
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0036_auto_20200427_1800'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='search_style',
|
||||
field=models.CharField(choices=[('SMALL', 'Small'), ('LARGE', 'Large')], default='LARGE', max_length=64),
|
||||
),
|
||||
]
|
||||
23
cookbook/migrations/0038_auto_20200502_1259.py
Normal file
23
cookbook/migrations/0038_auto_20200502_1259.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 3.0.5 on 2020-05-02 10:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0037_userpreference_search_style'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='recipebook',
|
||||
name='description',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='recipebook',
|
||||
name='icon',
|
||||
field=models.CharField(blank=True, max_length=16, null=True),
|
||||
),
|
||||
]
|
||||
20
cookbook/migrations/0039_recipebook_shared.py
Normal file
20
cookbook/migrations/0039_recipebook_shared.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 3.0.5 on 2020-05-02 12:04
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('cookbook', '0038_auto_20200502_1259'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='recipebook',
|
||||
name='shared',
|
||||
field=models.ManyToManyField(blank=True, related_name='shared_with', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
22
cookbook/migrations/0040_auto_20200502_1433.py
Normal file
22
cookbook/migrations/0040_auto_20200502_1433.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 3.0.5 on 2020-05-02 12:33
|
||||
|
||||
import annoying.fields
|
||||
from django.conf import settings
|
||||
from django.db import migrations
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('cookbook', '0039_recipebook_shared'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='userpreference',
|
||||
name='user',
|
||||
field=annoying.fields.AutoOneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
24
cookbook/migrations/0041_auto_20200502_1446.py
Normal file
24
cookbook/migrations/0041_auto_20200502_1446.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 3.0.5 on 2020-05-02 12:46
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0040_auto_20200502_1433'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='mealplan',
|
||||
name='title',
|
||||
field=models.CharField(blank=True, default='', max_length=64),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='mealplan',
|
||||
name='recipe',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.Recipe'),
|
||||
),
|
||||
]
|
||||
27
cookbook/migrations/0042_cooklog.py
Normal file
27
cookbook/migrations/0042_cooklog.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 3.0.5 on 2020-05-02 14:47
|
||||
|
||||
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', '0041_auto_20200502_1446'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CookLog',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('rating', models.IntegerField(null=True)),
|
||||
('servings', models.IntegerField(default=0)),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
('recipe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.Recipe')),
|
||||
],
|
||||
),
|
||||
]
|
||||
25
cookbook/migrations/0043_auto_20200507_2302.py
Normal file
25
cookbook/migrations/0043_auto_20200507_2302.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 3.0.5 on 2020-05-07 21:02
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('cookbook', '0042_cooklog'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='mealplan',
|
||||
name='shared',
|
||||
field=models.ManyToManyField(blank=True, related_name='plan_share', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='plan_share',
|
||||
field=models.ManyToManyField(blank=True, related_name='plan_share_default', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
25
cookbook/migrations/0044_viewlog.py
Normal file
25
cookbook/migrations/0044_viewlog.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 3.0.5 on 2020-05-11 10:21
|
||||
|
||||
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', '0043_auto_20200507_2302'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ViewLog',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
('recipe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.Recipe')),
|
||||
],
|
||||
),
|
||||
]
|
||||
18
cookbook/migrations/0045_userpreference_show_recent.py
Normal file
18
cookbook/migrations/0045_userpreference_show_recent.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.0.5 on 2020-06-02 08:51
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0044_viewlog'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='show_recent',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
||||
@@ -1,5 +1,6 @@
|
||||
import re
|
||||
|
||||
from annoying.fields import AutoOneToOneField
|
||||
from django.contrib import auth
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils.translation import gettext as _
|
||||
@@ -41,12 +42,30 @@ class UserPreference(models.Model):
|
||||
|
||||
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)
|
||||
# Default Page
|
||||
SEARCH = 'SEARCH'
|
||||
PLAN = 'PLAN'
|
||||
BOOKS = 'BOOKS'
|
||||
|
||||
PAGES = ((SEARCH, _('Search')), (PLAN, _('Meal-Plan')), (BOOKS, _('Books')),)
|
||||
|
||||
# Search Style
|
||||
SMALL = 'SMALL'
|
||||
LARGE = 'LARGE'
|
||||
|
||||
SEARCH_STYLE = ((SMALL, _('Small')), (LARGE, _('Large')),)
|
||||
|
||||
user = AutoOneToOneField(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)
|
||||
default_unit = models.CharField(max_length=32, default='g')
|
||||
default_page = models.CharField(choices=PAGES, max_length=64, default=SEARCH)
|
||||
search_style = models.CharField(choices=SEARCH_STYLE, max_length=64, default=LARGE)
|
||||
show_recent = models.BooleanField(default=True)
|
||||
plan_share = models.ManyToManyField(User, blank=True, related_name='plan_share_default')
|
||||
|
||||
def __str__(self):
|
||||
return self.user
|
||||
return str(self.user)
|
||||
|
||||
|
||||
class Storage(models.Model):
|
||||
@@ -90,7 +109,7 @@ class SyncLog(models.Model):
|
||||
|
||||
class Keyword(models.Model):
|
||||
name = models.CharField(max_length=64, unique=True)
|
||||
icon = models.CharField(max_length=1, blank=True, null=True)
|
||||
icon = models.CharField(max_length=16, blank=True, null=True)
|
||||
description = models.TextField(default="", blank=True)
|
||||
created_by = models.IntegerField(default=0)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
@@ -123,10 +142,6 @@ class Recipe(models.Model):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def all_tags(self):
|
||||
return ' '.join([(str(x)) for x in self.keywords.all()])
|
||||
|
||||
|
||||
class Unit(models.Model):
|
||||
name = models.CharField(unique=True, max_length=128)
|
||||
@@ -145,8 +160,8 @@ class Ingredient(models.Model):
|
||||
|
||||
|
||||
class RecipeIngredient(models.Model):
|
||||
ingredient = models.ForeignKey(Ingredient, on_delete=models.PROTECT)
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
|
||||
ingredient = models.ForeignKey(Ingredient, on_delete=models.PROTECT)
|
||||
unit = models.ForeignKey(Unit, on_delete=models.PROTECT)
|
||||
amount = models.DecimalField(default=0, decimal_places=2, max_digits=16)
|
||||
note = models.CharField(max_length=64, null=True, blank=True)
|
||||
@@ -179,7 +194,10 @@ class RecipeImport(models.Model):
|
||||
|
||||
class RecipeBook(models.Model):
|
||||
name = models.CharField(max_length=128)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
description = models.TextField(blank=True)
|
||||
icon = models.CharField(max_length=16, blank=True, null=True)
|
||||
shared = models.ManyToManyField(User, blank=True, related_name='shared_with')
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -200,11 +218,39 @@ class MealPlan(models.Model):
|
||||
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)
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, blank=True, null=True)
|
||||
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')
|
||||
meal = models.CharField(choices=MEAL_TYPES, max_length=128, default=BREAKFAST)
|
||||
note = models.TextField(blank=True)
|
||||
date = models.DateField()
|
||||
|
||||
def get_label(self):
|
||||
if self.title:
|
||||
return self.title
|
||||
return str(self.recipe)
|
||||
|
||||
def get_meal_name(self):
|
||||
meals = dict(self.MEAL_TYPES)
|
||||
return meals.get(self.meal)
|
||||
|
||||
|
||||
class CookLog(models.Model):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
rating = models.IntegerField(null=True)
|
||||
servings = models.IntegerField(default=0)
|
||||
|
||||
def __str__(self):
|
||||
return self.meal + ' (' + str(self.date) + ') ' + str(self.recipe)
|
||||
return self.recipe.name
|
||||
|
||||
|
||||
class ViewLog(models.Model):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.recipe.name
|
||||
|
||||
@@ -16,9 +16,10 @@ class Nextcloud(Provider):
|
||||
@staticmethod
|
||||
def get_client(storage):
|
||||
options = {
|
||||
'webdav_hostname': storage.url + '/remote.php/dav/files/' + storage.username,
|
||||
'webdav_hostname': storage.url,
|
||||
'webdav_login': storage.username,
|
||||
'webdav_password': storage.password
|
||||
'webdav_password': storage.password,
|
||||
'webdav_root': '/remote.php/dav/files/' + storage.username
|
||||
}
|
||||
return wc.Client(options)
|
||||
|
||||
|
||||
12
cookbook/static/css/pretty-checkbox.min.css
vendored
Normal file
12
cookbook/static/css/pretty-checkbox.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
721
cookbook/static/css/select2-bootstrap.css
Normal file
721
cookbook/static/css/select2-bootstrap.css
Normal file
@@ -0,0 +1,721 @@
|
||||
/*!
|
||||
* 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;
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
1
cookbook/static/css/select2.min.css
vendored
Normal file
1
cookbook/static/css/select2.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
21
cookbook/static/custom/css/markdown_blockquote.css
Normal file
21
cookbook/static/custom/css/markdown_blockquote.css
Normal file
@@ -0,0 +1,21 @@
|
||||
/* css classes needed to render markdown blockquotes */
|
||||
blockquote {
|
||||
background: #f9f9f9;
|
||||
border-left: 4px solid #ccc;
|
||||
margin: 1.5em 10px;
|
||||
padding: .5em 10px;
|
||||
quotes: none;
|
||||
}
|
||||
|
||||
blockquote:before {
|
||||
color: #ccc;
|
||||
content: open-quote;
|
||||
font-size: 4em;
|
||||
line-height: .1em;
|
||||
margin-right: .25em;
|
||||
vertical-align: -.4em;
|
||||
}
|
||||
|
||||
blockquote p {
|
||||
display: inline;
|
||||
}
|
||||
7
cookbook/static/js/bootstrap.min.js
vendored
Normal file
7
cookbook/static/js/bootstrap.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
cookbook/static/js/bootstrap.min.js.map
Normal file
1
cookbook/static/js/bootstrap.min.js.map
Normal file
File diff suppressed because one or more lines are too long
2
cookbook/static/js/jquery-3.5.1.min.js
vendored
Normal file
2
cookbook/static/js/jquery-3.5.1.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
cookbook/static/js/pdf.min.js
vendored
Normal file
1
cookbook/static/js/pdf.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
7677
cookbook/static/js/pdf_viewer.js
Normal file
7677
cookbook/static/js/pdf_viewer.js
Normal file
File diff suppressed because it is too large
Load Diff
5
cookbook/static/js/popper.min.js
vendored
Normal file
5
cookbook/static/js/popper.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
cookbook/static/js/popper.min.js.map
Normal file
1
cookbook/static/js/popper.min.js.map
Normal file
File diff suppressed because one or more lines are too long
2
cookbook/static/js/select2.min.js
vendored
Normal file
2
cookbook/static/js/select2.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
66
cookbook/static/recipe_no_image.svg
Normal file
66
cookbook/static/recipe_no_image.svg
Normal file
@@ -0,0 +1,66 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
data-prefix="fas"
|
||||
data-icon="pizza-slice"
|
||||
class="svg-inline--fa fa-pizza-slice fa-w-16"
|
||||
role="img"
|
||||
viewBox="0 0 512 512"
|
||||
version="1.1"
|
||||
id="svg4"
|
||||
sodipodi:docname="recipe_no_image.svg"
|
||||
inkscape:version="0.92.4 (5da689c313, 2019-01-14)">
|
||||
<metadata
|
||||
id="metadata10">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<defs
|
||||
id="defs8" />
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="3840"
|
||||
inkscape:window-height="2066"
|
||||
id="namedview6"
|
||||
showgrid="false"
|
||||
inkscape:zoom="0.921875"
|
||||
inkscape:cx="309.52383"
|
||||
inkscape:cy="214.71807"
|
||||
inkscape:window-x="2869"
|
||||
inkscape:window-y="54"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg4" />
|
||||
<rect
|
||||
style="fill:#f5f5f6;fill-opacity:1;stroke:#d8dde0;stroke-width:3.77952766;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="rect817"
|
||||
width="1717.1526"
|
||||
height="1092.339"
|
||||
x="-602.57629"
|
||||
y="-290.16949" />
|
||||
<path
|
||||
d="m 198.99508,105.32039 c -9.4835,-0.89523 -18.30973,4.9591 -20.73342,14.20588 l -8.69126,33.14115 c 110.10488,3.23343 184.58794,76.92493 189.24752,186.70242 l 33.41527,-9.29389 c 9.22528,-2.56789 14.95881,-11.59086 13.8614,-21.1439 C 393.84116,202.45866 305.74904,115.43295 198.99508,105.32039 Z m -34.31314,65.96427 -58.59701,223.50695 a 9.5128449,9.5471493 0 0 0 11.73701,11.63209 l 222.4163,-61.9004 C 337.73239,241.51893 268.0087,172.46259 164.68194,171.30821 Z m 16.19707,178.95751 a 18.779213,18.846933 0 1 1 18.77921,-18.84693 18.779213,18.846933 0 0 1 -18.77921,18.84693 z m 28.16882,-89.52293 a 18.779213,18.846933 0 1 1 18.77921,-18.84693 18.779213,18.846933 0 0 1 -18.77921,18.84693 z m 61.03245,61.25253 a 18.779213,18.846933 0 1 1 18.77921,-18.84693 18.779213,18.846933 0 0 1 -18.77921,18.84693 z"
|
||||
id="path2"
|
||||
style="fill:#d9cfbe;fill-opacity:1;stroke-width:0.58790755"
|
||||
inkscape:connector-curvature="0" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
23
cookbook/static/tabulator/tabulator.min.js
vendored
23
cookbook/static/tabulator/tabulator.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -6,7 +6,14 @@ from django_tables2.utils import A # alias for Accessor
|
||||
from .models import *
|
||||
|
||||
|
||||
class RecipeTable(tables.Table):
|
||||
class ImageUrlColumn(tables.Column):
|
||||
def render(self, value):
|
||||
if value.url:
|
||||
return value.url
|
||||
return None
|
||||
|
||||
|
||||
class RecipeTableSmall(tables.Table):
|
||||
id = tables.LinkColumn('edit_recipe', args=[A('id')])
|
||||
name = tables.LinkColumn('view_recipe', args=[A('id')])
|
||||
all_tags = tables.Column(
|
||||
@@ -18,6 +25,19 @@ class RecipeTable(tables.Table):
|
||||
fields = ('id', 'name', 'all_tags')
|
||||
|
||||
|
||||
class RecipeTable(tables.Table):
|
||||
edit = tables.TemplateColumn("<a style='color: inherit' href='{% url 'edit_recipe' record.id %}' >" + _('Edit') + "</a>")
|
||||
name = tables.LinkColumn('view_recipe', args=[A('id')])
|
||||
all_tags = tables.Column(
|
||||
attrs={'td': {'class': 'd-none d-lg-table-cell'}, 'th': {'class': 'd-none d-lg-table-cell'}})
|
||||
image = ImageUrlColumn()
|
||||
|
||||
class Meta:
|
||||
model = Recipe
|
||||
template_name = 'recipes_table.html'
|
||||
fields = ('id', 'name', 'all_tags', 'image', 'instructions', 'working_time', 'waiting_time', 'internal')
|
||||
|
||||
|
||||
class KeywordTable(tables.Table):
|
||||
id = tables.LinkColumn('edit_keyword', args=[A('id')])
|
||||
|
||||
@@ -86,3 +106,21 @@ class RecipeImportTable(tables.Table):
|
||||
model = RecipeImport
|
||||
template_name = 'generic/table_template.html'
|
||||
fields = ('id', 'name', 'file_path')
|
||||
|
||||
|
||||
class ViewLogTable(tables.Table):
|
||||
recipe = tables.LinkColumn('view_recipe', args=[A('recipe_id')])
|
||||
|
||||
class Meta:
|
||||
model = ViewLog
|
||||
template_name = 'generic/table_template.html'
|
||||
fields = ('recipe', 'created_at')
|
||||
|
||||
|
||||
class CookLogTable(tables.Table):
|
||||
recipe = tables.LinkColumn('view_recipe', args=[A('recipe_id')])
|
||||
|
||||
class Meta:
|
||||
model = CookLog
|
||||
template_name = 'generic/table_template.html'
|
||||
fields = ('recipe', 'rating', 'serving', 'created_at')
|
||||
|
||||
@@ -20,29 +20,19 @@
|
||||
|
||||
<!-- Bootstrap 4 -->
|
||||
<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="{% static 'js/jquery-3.5.1.min.js' %}"></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>
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js"
|
||||
integrity="sha384-wfSDF2E50Y2D1uUdj0O3uMBJnjuUD4Ih7YwaYd1iqfktj0Uod8GCExl3Og8ifwB6"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="{% static 'js/popper.min.js' %}"></script>
|
||||
<script src="{% static 'js/bootstrap.min.js' %}"></script>
|
||||
|
||||
<!-- Select2 for use with django autocomplete light -->
|
||||
<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>
|
||||
<link href="{% static 'css/select2.min.css' %}" rel="stylesheet"/>
|
||||
<script src="{% static '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 'css/select2-bootstrap.css' %}"/>
|
||||
|
||||
<link rel="stylesheet"
|
||||
href="{% static 'themes/select2-bootstrap-theme.css' %}"
|
||||
crossorigin="anonymous"/>
|
||||
<link rel="stylesheet" href="{% static 'themes/select2-bootstrap-theme.css' %}"/>
|
||||
|
||||
<script type="text/javascript">
|
||||
$.fn.select2.defaults.set("theme", "bootstrap");
|
||||
@@ -54,32 +44,23 @@
|
||||
{% block extra_head %} <!-- block for templates to put stuff into header -->
|
||||
{% endblock %}
|
||||
|
||||
<style>
|
||||
@media (max-width: 1025px) {
|
||||
.container {
|
||||
width: 95% !important;
|
||||
margin-left: 20px !important;
|
||||
margin-right: 20px !important;
|
||||
max-width: 1200px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<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">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarText">
|
||||
<ul class="navbar-nav mr-auto">
|
||||
<li class="nav-item {% if request.resolver_match.url_name in 'index,edit_recipe,edit_internal_recipe,edit_external_recipe,view_recipe' %}active{% endif %}">
|
||||
<a class="nav-link" href="{% url 'index' %}"><i class="fas fa-book"></i> {% trans 'Cookbook' %}<span
|
||||
<li class="nav-item {% if request.resolver_match.url_name in 'view_search,edit_recipe,edit_internal_recipe,edit_external_recipe,view_recipe' %}active{% endif %}">
|
||||
<a class="nav-link" href="{% url 'view_search' %}"><i
|
||||
class="fas fa-book"></i> {% trans 'Cookbook' %}<span
|
||||
class="sr-only">(current)</span></a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item dropdown {% if request.resolver_match.url_name in 'view_books,view_plan,view_shopping,list_ingredient' %}active{% endif %}">
|
||||
<li class="nav-item dropdown {% if request.resolver_match.url_name in 'view_books,view_plan,view_shopping,list_ingredient,view_plan_entry' %}active{% endif %}">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdownMenuLink" data-toggle="dropdown"
|
||||
aria-haspopup="true" aria-expanded="false">
|
||||
<i class="fas fa-mortar-pestle"></i> {% trans 'Utensils' %}
|
||||
@@ -121,13 +102,15 @@
|
||||
<a class="dropdown-item" href="{% url 'data_sync' %}"><i
|
||||
class="fas fa-sync-alt fa-fw"></i> {% trans 'Configure Sync' %}</a>
|
||||
<a class="dropdown-item" href="{% url 'list_recipe_import' %}"><i
|
||||
class="far fa-file-alt fa-fw"></i> {% trans 'Import Recipes' %}</a>
|
||||
class="far fa-file-alt fa-fw"></i> {% trans 'Discovered Recipes' %}</a>
|
||||
<a class="dropdown-item" href="{% url 'list_sync_log' %}"><i
|
||||
class="fas fa-history fa-fw"></i> {% trans 'Import Log' %}</a>
|
||||
class="fas fa-history fa-fw"></i> {% trans 'Discovery Log' %}</a>
|
||||
<a class="dropdown-item" href="{% url 'data_stats' %}"><i
|
||||
class="fas fa-chart-line fa-fw"></i> {% trans 'Statistics' %}</a>
|
||||
<a class="dropdown-item" href="{% url 'edit_ingredient' %}"><i
|
||||
class="fas fa-balance-scale fa-fw"></i> {% trans 'Units & Ingredients' %}</a>
|
||||
<a class="dropdown-item" href="{% url 'view_import' %}"><i
|
||||
class="fas fa-file-import"></i> {% trans 'Import Recipe' %}</a>
|
||||
|
||||
</div>
|
||||
</li>
|
||||
@@ -135,19 +118,27 @@
|
||||
|
||||
<ul class="navbar-nav ml-auto">
|
||||
{% if user.is_authenticated %}
|
||||
<li class="nav-item dropdown {% if request.resolver_match.url_name in 'view_settings' %}active{% endif %}">
|
||||
<li class="nav-item dropdown {% if request.resolver_match.url_name in 'view_settings,view_history' %}active{% endif %}">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdownMenuLink" data-toggle="dropdown"
|
||||
aria-haspopup="true" aria-expanded="false"><i class="fas fa-user-alt"></i> {{ user.get_user_name }}
|
||||
aria-haspopup="true" aria-expanded="false"><i
|
||||
class="fas fa-user-alt"></i> {{ user.get_user_name }}
|
||||
</a>
|
||||
|
||||
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="navbarDropdownMenuLink">
|
||||
<a class="dropdown-item" href="{% url 'view_settings' %}"><i
|
||||
class="fas fa-user-cog fa-fw"></i> {% trans 'Settings' %}</a>
|
||||
<a class="dropdown-item" href="{% url 'view_history' %}"><i
|
||||
class="fas fa-history"></i> {% trans 'History' %}</a>
|
||||
{% if user.is_superuser %}
|
||||
<a class="dropdown-item" href="{% url 'admin:index' %}"><i
|
||||
class="fas fa-user-shield fa-fw"></i> {% trans 'Admin' %}</a>
|
||||
{% endif %}
|
||||
<div class="dropdown-divider"></div>
|
||||
<a class="dropdown-item" href="{% url 'docs_markdown' %}"><i
|
||||
class="fab fa-markdown fa-fw"></i> {% trans 'Markdown Help' %}</a>
|
||||
<a class="dropdown-item" href="https://github.com/vabene1111/recipes"><i
|
||||
class="fab fa-github fa-fw"></i> {% trans 'GitHub' %}</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<a class="dropdown-item" href="{% url 'logout' %}"><i
|
||||
class="fas fa-sign-out-alt fa-fw"></i> {% trans 'Logout' %}</a>
|
||||
</div>
|
||||
@@ -164,13 +155,16 @@
|
||||
<br/>
|
||||
|
||||
<div class="container">
|
||||
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% load custom_tags %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans 'Recipe Books' %}{% endblock %}
|
||||
@@ -16,46 +17,65 @@
|
||||
</div>
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
{% for b in book_list %}
|
||||
<div class="row">
|
||||
<div class="col col-md-10">
|
||||
<a data-toggle="collapse" href="#collapse_{{ b.book.pk }}" role="button" aria-expanded="false"
|
||||
aria-controls="collapse_{{ b.book.pk }}"><h4>{{ b.book.name }}</h4></a>
|
||||
</div>
|
||||
<div class="col col-md-2" style="text-align: right">
|
||||
<h4>
|
||||
<div class="col-12">
|
||||
<div class="card" style="margin-top: 2px">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{% if b.book.icon %}{{ b.book.icon }} {% endif %}{{ b.book.name }}</h5>
|
||||
<h6 class="card-subtitle mb-2 text-muted">{% if b.book.created_by != request.user %}
|
||||
{% trans 'by' %} {{ b.book.created_by.get_user_name }}
|
||||
{% endif %}</h6>
|
||||
|
||||
<a href="{% url 'edit_recipe_book' b.book.pk %}"> <i class="fas fa-pencil-alt"></i></a>
|
||||
<a href="{% url 'delete_recipe_book' b.book.pk %}"><i class="fas fa-trash-alt"></i></a>
|
||||
</h4>
|
||||
</div>
|
||||
<hr/>
|
||||
</div>
|
||||
{% if b.book.description %}
|
||||
<p class="card-text">{{ b.book.description }}</p>
|
||||
{% endif %}
|
||||
<a data-toggle="collapse" href="#collapse_{{ b.book.pk }}" role="button" aria-expanded="false"
|
||||
aria-controls="collapse_{{ b.book.pk }}" class="card-link">{% trans 'Toggle Recipes' %}</a>
|
||||
{% if b.book.created_by == request.user or request.user.is_superuser %}
|
||||
<a href="{% url 'edit_recipe_book' b.book.pk %}" class="card-link">{% trans 'Edit' %}</a>
|
||||
<a href="{% url 'delete_recipe_book' b.book.pk %}"
|
||||
class="card-link">{% trans 'Delete' %}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="collapse" id="collapse_{{ b.book.pk }}">
|
||||
{% if b.recipes %}
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for r in b.recipes %}
|
||||
<li class="list-group-item">
|
||||
<div class="row">
|
||||
<div class="col-10">
|
||||
{% recipe_last r.recipe request.user as last_cooked %}
|
||||
<a href="{% url 'view_recipe' r.recipe.pk %}">{{ r.recipe.name }}</a>
|
||||
{% recipe_rating r.recipe request.user as rating %}
|
||||
{{ rating|safe }}
|
||||
{% if last_cooked %}
|
||||
|
||||
<span class="badge badge-primary">{% trans 'Last cooked' %} {{ last_cooked|date }}</span>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<div class="collapse" id="collapse_{{ b.book.pk }}">
|
||||
{% if b.recipes %}
|
||||
<ul>
|
||||
{% for r in b.recipes %}
|
||||
<div class="row">
|
||||
<div class="col col-md-10">
|
||||
<li><a href="{% url 'view_recipe' r.recipe.pk %}">{{ r.recipe.name }}</a></li>
|
||||
</div>
|
||||
<div class="col col-md-2" style="text-align: right">
|
||||
<a href="{% url 'delete_recipe_book_entry' r.pk %}"><i class="fas fa-trash-alt"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
{% trans 'There are no recipes in this book yet.' %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if b.book.created_by == request.user or request.user.is_superuser %}
|
||||
<div class="col-2" style="text-align: right">
|
||||
<a href="{% url 'delete_recipe_book_entry' r.pk %}"
|
||||
class="pull-right"><i class="fas fa-trash-alt"></i></a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div class="card-body">
|
||||
<p>
|
||||
{% trans 'There are no recipes in this book yet.' %}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br/>
|
||||
{% endfor %}
|
||||
|
||||
{% endblock %}
|
||||
70
cookbook/templates/export.html
Normal file
70
cookbook/templates/export.html
Normal file
@@ -0,0 +1,70 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
{% load crispy_forms_filters %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{% trans 'Export Recipes' %}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{ form.media }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<form action="." method="post">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<button class="btn btn-success" type="submit"><i class="fas fa-file-export"></i> {% trans 'Export' %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if export %}
|
||||
|
||||
<br/>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<label for="id_export">
|
||||
{% trans 'Exported Recipe' %}</label>
|
||||
<textarea id="id_export" class="form-control" rows="12">
|
||||
{{ export }}
|
||||
</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
<div class="row">
|
||||
<div class="col col-md-12 text-center">
|
||||
<button class="btn btn-success" onclick="copy()" style="width: 15vw" data-toggle="tooltip"
|
||||
data-placement="right" title="{% trans 'Copy to clipboard' %}" id="id_btn_copy"
|
||||
onmouseout="resetTooltip()"><i
|
||||
class="far fa-copy"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
function copy() {
|
||||
let json = $('#id_export');
|
||||
|
||||
json.select();
|
||||
|
||||
$('#id_btn_copy').attr('data-original-title', '{% trans 'Copied!' %}').tooltip('show');
|
||||
|
||||
document.execCommand("copy");
|
||||
}
|
||||
|
||||
function resetTooltip() {
|
||||
setTimeout(function () {
|
||||
$('#id_btn_copy').attr('data-original-title', '{% trans 'Copy list to clipboard' %}');
|
||||
}, 300);
|
||||
}
|
||||
|
||||
$(function () {
|
||||
$('[data-toggle="tooltip"]').tooltip()
|
||||
})
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -26,11 +26,14 @@
|
||||
</div>
|
||||
{% if field.name == 'name' %}
|
||||
<label>{% trans 'Ingredients' %}</label>
|
||||
{{ form.ingredients.errors }}
|
||||
<div id="ingredients-table"></div>
|
||||
<br>
|
||||
<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 class="btn btn-warning" id="new_header" type="button" data-toggle="tooltip"
|
||||
data-placement="top" title="{% trans 'Insert a header between the ingredients.' %}"><i class="fas fa-heading"></i></button>
|
||||
|
||||
<button type="button" class="btn btn-secondary" data-container="body" data-toggle="popover"
|
||||
data-placement="right" data-html="true" data-trigger="focus"
|
||||
@@ -56,7 +59,7 @@
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
<script>
|
||||
<script type="application/javascript">
|
||||
|
||||
$(function () {
|
||||
$('[data-toggle="popover"]').popover()
|
||||
@@ -144,7 +147,7 @@
|
||||
validator: "required",
|
||||
editor: select2IngredientEditor
|
||||
},
|
||||
{title: "{% trans 'Amount' %}", field: "amount", validator: "required", editor: "input"},
|
||||
{title: "{% trans 'Amount' %}", field: "amount", validator: "required", editor: "number"},
|
||||
{
|
||||
title: "{% trans 'Unit' %}",
|
||||
field: "unit__name",
|
||||
@@ -188,7 +191,7 @@
|
||||
data.push({
|
||||
ingredient__name: "{% trans 'Ingredient' %}",
|
||||
amount: "100",
|
||||
unit__name: "g",
|
||||
unit__name: "{{ request.user.userpreference.default_unit }}",
|
||||
note: "",
|
||||
id: Math.floor(Math.random() * 10000000),
|
||||
delete: false,
|
||||
@@ -198,6 +201,20 @@
|
||||
input.select();
|
||||
}
|
||||
|
||||
function addHeaderRow(type) {
|
||||
data.push({
|
||||
ingredient__name: '{% trans 'Header' %}',
|
||||
amount: "0",
|
||||
unit__name: "Special:Header",
|
||||
note: "{% trans 'write header here' %}",
|
||||
id: Math.floor(Math.random() * 10000000),
|
||||
delete: false,
|
||||
});
|
||||
input = table.rowManager.rows[((table.rowManager.rows).length) - 1].cells[4].getElement()
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
|
||||
document.onkeyup = function (e) {
|
||||
if (e.shiftKey && e.ctrlKey && (e.which === 83 || e.keyCode === 83)) {
|
||||
$('#id_form').submit()
|
||||
@@ -207,7 +224,12 @@
|
||||
};
|
||||
|
||||
document.getElementById("new_empty").addEventListener("click", addIngredientRow);
|
||||
document.getElementById("new_header").addEventListener("click", addHeaderRow);
|
||||
});
|
||||
|
||||
$(function () {
|
||||
$('[data-toggle="tooltip"]').tooltip()
|
||||
})
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -66,7 +66,7 @@
|
||||
{% block pagination %}
|
||||
{% if table.page and table.paginator.num_pages > 1 %}
|
||||
<nav aria-label="Table navigation">
|
||||
<ul class="pagination justify-content-center">
|
||||
<ul class="pagination justify-content-center flex-wrap">
|
||||
{% if table.page.has_previous %}
|
||||
{% block pagination.previous %}
|
||||
<li class="previous page-item">
|
||||
|
||||
37
cookbook/templates/history.html
Normal file
37
cookbook/templates/history.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{% extends "base.html" %}
|
||||
{% load django_tables2 %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "History" %}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h3>{% trans 'History' %}</h3>
|
||||
<br/>
|
||||
|
||||
<ul class="nav nav-tabs" id="id_tab_nav" role="tablist">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" id="home-tab" data-toggle="tab" href="#view_log" role="tab" aria-controls="view_log"
|
||||
aria-selected="true">{% trans 'View Log' %}</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" id="profile-tab" data-toggle="tab" href="#cook_log" role="tab" aria-controls="cook_log"
|
||||
aria-selected="false">{% trans 'Cook Log' %}</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" id="id_tab_content">
|
||||
<div class="tab-pane fade show active" id="view_log" role="tabpanel" aria-labelledby="view-log">
|
||||
{% render_table view_log %}
|
||||
</div>
|
||||
<div class="tab-pane fade" id="cook_log" role="tabpanel" aria-labelledby="profile-tab">
|
||||
{% render_table cook_log %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
19
cookbook/templates/import.html
Normal file
19
cookbook/templates/import.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
{% load crispy_forms_tags %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{% trans 'Import Recipes' %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<form action="." method="post">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<button class="btn btn-success" type="submit"><i class="fas fa-file-import"></i> {% trans 'Import' %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
78
cookbook/templates/include/log_cooking.html
Normal file
78
cookbook/templates/include/log_cooking.html
Normal file
@@ -0,0 +1,78 @@
|
||||
{% load i18n %}
|
||||
|
||||
<div class="modal" tabindex="-1" role="dialog" id="id_modal_cook_log">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{% trans 'Log Recipe Cooking' %}</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>{% trans 'All fields are optional and can be left empty.' %}</p>
|
||||
<form>
|
||||
|
||||
<label for="id_log_servings">{% trans 'Servings' %} </label>
|
||||
<input class="form-control" type="number" id="id_log_servings">
|
||||
<br/>
|
||||
<label for="id_log_rating">{% trans 'Rating' %} - <span id="id_rating_show">0/5</span></label>
|
||||
<input type="range" class="custom-range" min="0" max="5" id="id_log_rating" name="log_rating"
|
||||
value="0">
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">{% trans 'Close' %}</button>
|
||||
<button type="button" class="btn btn-primary" onclick="logCook()">{% trans 'Save' %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="application/javascript">
|
||||
let modal = $('#id_modal_cook_log')
|
||||
let rating = $('#id_log_rating')
|
||||
|
||||
function openCookLogModal(id) {
|
||||
modal.data('recipe_id', id)
|
||||
modal.modal('show')
|
||||
}
|
||||
|
||||
//TODO there is definitely a nicer way to do this than this ugly shit
|
||||
function logCook() {
|
||||
let id = modal.data('recipe_id');
|
||||
|
||||
let url = "{% url 'api_log_cooking' recipe_id=12345 %}".replace(/12345/, id);
|
||||
|
||||
let val_servings = $('#id_log_servings').val()
|
||||
if (val_servings !== '' && val_servings !== 0) {
|
||||
url += '?s=' + val_servings
|
||||
}
|
||||
|
||||
let val_rating = rating.val()
|
||||
if (val_rating !== '' && val_rating !== 0) {
|
||||
if (val_servings !== '' && val_servings !== 0) {
|
||||
url += '&'
|
||||
}else {
|
||||
url += '?'
|
||||
}
|
||||
url += 'r=' + val_rating
|
||||
}
|
||||
|
||||
let request = new XMLHttpRequest();
|
||||
request.onreadystatechange = function () {
|
||||
|
||||
};
|
||||
request.open("GET", url, true);
|
||||
request.send();
|
||||
|
||||
modal.modal('hide')
|
||||
}
|
||||
|
||||
rating.on("input", () => {
|
||||
$('#id_rating_show').html(rating.val() + '/5')
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -18,59 +18,71 @@
|
||||
|
||||
{% block content %}
|
||||
{% if filter %}
|
||||
<form action="" method="get" id="search_form">
|
||||
{% csrf_token %}
|
||||
{{ form.non_field_errors }}
|
||||
<div class="row">
|
||||
<div class="col md-12">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" placeholder="{% trans 'Search recipe ...' %}"
|
||||
id="{{ filter.form.name.id_for_label }}" name="{{ filter.form.name.name }}"
|
||||
aria-describedby="button-addon4">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<form action="" method="get" id="search_form">
|
||||
{% csrf_token %}
|
||||
{{ form.non_field_errors }}
|
||||
<div class="row">
|
||||
<div class="col md-12">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" placeholder="{% trans 'Search recipe ...' %}"
|
||||
id="{{ filter.form.name.id_for_label }}" name="{{ filter.form.name.name }}"
|
||||
aria-describedby="button-addon4">
|
||||
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-primary" type="submit"><i class="fas fa-search"></i></button>
|
||||
<button type="button" class="btn btn-light dropdown-toggle dropdown-toggle-split dropdown-toggle-no-arrow"
|
||||
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<i class="fas fa-ellipsis-v"></i>
|
||||
<span class="sr-only">Toggle Dropdown</span>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<button class="dropdown-item" type="button"
|
||||
onclick="location.href='{% url 'new_recipe' %}'"><i
|
||||
class="fas fa-plus-circle fa-fw"></i> {% trans 'New Recipe' %}</button>
|
||||
<button data-toggle="collapse" href="#collapse_adv_search"
|
||||
role="button" class="dropdown-item"
|
||||
aria-expanded="false" type="button"
|
||||
aria-controls="collapse_adv_search"><i
|
||||
class="fas fa-search-plus fa-fw"></i> {% trans 'Advanced Search' %}
|
||||
</button>
|
||||
<button class="dropdown-item" type="button"
|
||||
onclick="window.location = window.location.pathname;"><i
|
||||
class="fas fa-sync fa-fw"></i> {% trans 'Reset Search' %}</button>
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-primary" type="submit"><i class="fas fa-search"></i></button>
|
||||
<button type="button"
|
||||
class="btn btn-light dropdown-toggle dropdown-toggle-split dropdown-toggle-no-arrow"
|
||||
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<i class="fas fa-ellipsis-v"></i>
|
||||
<span class="sr-only">Toggle Dropdown</span>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<button class="dropdown-item" type="button"
|
||||
onclick="location.href='{% url 'new_recipe' %}'"><i
|
||||
class="fas fa-plus-circle fa-fw"></i> {% trans 'New Recipe' %}</button>
|
||||
<button data-toggle="collapse" href="#collapse_adv_search"
|
||||
role="button" class="dropdown-item"
|
||||
aria-expanded="false" type="button"
|
||||
aria-controls="collapse_adv_search"><i
|
||||
class="fas fa-search-plus fa-fw"></i> {% trans 'Advanced Search' %}
|
||||
</button>
|
||||
<button class="dropdown-item" type="button"
|
||||
onclick="window.location = window.location.pathname;"><i
|
||||
class="fas fa-sync fa-fw"></i> {% trans 'Reset Search' %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="collapse col-md-12" id="collapse_adv_search">
|
||||
<div style="margin-top: 1vh">
|
||||
{{ filter.form.keywords | as_crispy_field }}
|
||||
<div class="row">
|
||||
<div class="collapse col-md-12" id="collapse_adv_search">
|
||||
<div style="margin-top: 1vh">
|
||||
{{ filter.form.keywords | as_crispy_field }}
|
||||
</div>
|
||||
<div>
|
||||
{{ filter.form.ingredients | as_crispy_field }}
|
||||
</div>
|
||||
<div>
|
||||
{{ filter.form.internal | as_crispy_field }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{{ filter.form.ingredients | as_crispy_field }}
|
||||
</div>
|
||||
<div>
|
||||
{{ filter.form.internal | as_crispy_field }}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
<br/>
|
||||
|
||||
{% if last_viewed %}
|
||||
<h4>{% trans 'Last viewed' %}</h4>
|
||||
{% render_table last_viewed %}
|
||||
<h4>{% trans 'Recipes' %}</h4>
|
||||
{% endif %}
|
||||
|
||||
{% if user.is_authenticated and recipes %}
|
||||
{% render_table recipes %}
|
||||
{% else %}
|
||||
@@ -79,4 +91,5 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% include 'include/log_cooking.html' %}
|
||||
{% endblock %}
|
||||
189
cookbook/templates/markdown_info.html
Normal file
189
cookbook/templates/markdown_info.html
Normal file
@@ -0,0 +1,189 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Markdown Info" %}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="{% static 'custom/css/markdown_blockquote.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1>{% trans 'Markdown Info' %}</h1>
|
||||
{% blocktrans %}
|
||||
Markdown is lightweight markup language that can be used to format plain text easily.
|
||||
This site uses the <a href="https://python-markdown.github.io/" target="_blank">Python Markdown</a> library to
|
||||
convert your text into nice looking html. Its full markdown documentation can be found
|
||||
<a href="https://daringfireball.net/projects/markdown/syntax" target="_blank">here</a>.
|
||||
An incomplete but most likely sufficient documentation can be found below.
|
||||
{% endblocktrans %}
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
<h2>{% trans 'Headers' %}</h2>
|
||||
<pre class="intro-code code-block"><code>
|
||||
# Header 1
|
||||
## Header 2
|
||||
### Header 3
|
||||
#### Header 4
|
||||
##### Header 5
|
||||
###### Header 6
|
||||
</code></pre>
|
||||
|
||||
<div style="text-align: center">
|
||||
<i class="fas fa-arrow-down fa-2x"></i>
|
||||
<br/>
|
||||
<br/>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h1>Header 1</h1>
|
||||
<h2>Header 2</h2>
|
||||
<h3>Header 3</h3>
|
||||
<h4>Header 4</h4>
|
||||
<h5>Header 5</h5>
|
||||
<h6>Header 6</h6>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
<h2>{% trans 'Formatting' %}</h2>
|
||||
<pre class="intro-code code-block"><code>
|
||||
{% trans 'Line breaks are inserted by adding two spaces after the end of a line' %}
|
||||
{% trans 'or by leaving a blank line inbetween.' %}
|
||||
|
||||
**{% trans 'This text is bold' %}**
|
||||
*{% trans 'This text is in italics' %}*
|
||||
> {% trans 'Blockquotes are also possible' %}
|
||||
</code></pre>
|
||||
|
||||
<div style="text-align: center">
|
||||
<i class="fas fa-arrow-down fa-2x"></i>
|
||||
<br/>
|
||||
<br/>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{% trans 'Line breaks are inserted by adding two spaces after the end of a line' %}<br/>
|
||||
{% trans 'or by leaving a blank line inbetween.' %}<br/><br/>
|
||||
<b>{% trans 'This text is bold' %}</b><br/>
|
||||
<i>{% trans 'This text is in italics' %}</i>
|
||||
<blockquote>
|
||||
<p>{% trans 'Blockquotes are also possible' %}</p>
|
||||
</blockquote>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
<h2>{% trans 'Lists' %}</h2>
|
||||
{% trans 'Lists can ordered or unorderd. It is <b>important to leave a blank line before the list!</b>' %}
|
||||
<pre class="intro-code code-block"><code>
|
||||
{% trans 'Ordered List' %}
|
||||
|
||||
- {% trans 'unordered list item' %}
|
||||
- {% trans 'unordered list item' %}
|
||||
- {% trans 'unordered list item' %}
|
||||
|
||||
{% trans 'Unordered List' %}
|
||||
|
||||
1. {% trans 'ordered list item' %}
|
||||
2. {% trans 'ordered list item' %}
|
||||
3. {% trans 'ordered list item' %}
|
||||
</code></pre>
|
||||
|
||||
<div style="text-align: center">
|
||||
<i class="fas fa-arrow-down fa-2x"></i>
|
||||
<br/>
|
||||
<br/>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{% trans 'Ordered List' %}
|
||||
<ul>
|
||||
<li>{% trans 'unordered list item' %}</li>
|
||||
<li>{% trans 'unordered list item' %}</li>
|
||||
<li>{% trans 'unordered list item' %}</li>
|
||||
</ul>
|
||||
{% trans 'Unordered List' %}
|
||||
<ol>
|
||||
<li>{% trans 'ordered list item' %}</li>
|
||||
<li>{% trans 'ordered list item' %}</li>
|
||||
<li>{% trans 'ordered list item' %}</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
<h2>{% trans 'Images & Links' %}</h2>
|
||||
{% trans 'Links can be formatted with Markdown. This applicaiton also allows to paste links directly into markdown fields without any formatting.' %}
|
||||
<pre class="intro-code code-block"><code>
|
||||
https://github.com/vabene1111/recipes
|
||||
[](https://github.com/vabene1111/recipes)
|
||||
[GitHub](https://github.com/vabene1111/recipes)
|
||||
|
||||

|
||||
</code></pre>
|
||||
|
||||
<div style="text-align: center">
|
||||
<i class="fas fa-arrow-down fa-2x"></i>
|
||||
<br/>
|
||||
<br/>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<a href="https://github.com/vabene1111/recipes">https://github.com/vabene1111/recipes</a> <br/>
|
||||
<a href="https://github.com/vabene1111/recipes">GitHub</a> <br/>
|
||||
<img src="{% static 'favicon.png' %}" class="img-fluid" alt="{% trans 'This will become and Image' %}"
|
||||
style="height: 3vw">
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
<h2>{% trans 'Tables' %}</h2>
|
||||
{% trans 'Markdown tables are hard to create by hand. It is recommended to use a table editor like <a href="https://www.tablesgenerator.com/markdown_tables" target="_blank">this</a> one.' %}
|
||||
<pre class="intro-code code-block"><code>
|
||||
| {% trans 'Table' %} | {% trans 'Header' %} |
|
||||
|--------|---------|
|
||||
| {% trans 'Table' %} | {% trans 'Cell' %} |
|
||||
</code></pre>
|
||||
|
||||
<div style="text-align: center">
|
||||
<i class="fas fa-arrow-down fa-2x"></i>
|
||||
<br/>
|
||||
<br/>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<table class="table table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans 'Table' %}</th>
|
||||
<th>{% trans 'Header' %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{% trans 'Table' %}</td>
|
||||
<td>{% trans 'Cell' %}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
{% endblock %}
|
||||
@@ -8,6 +8,25 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.mealplan-cell .mealplan-add-button {
|
||||
text-align: center;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.mealplan-cell .mealplan-add-button {
|
||||
visibility: hidden;
|
||||
float: right;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.mealplan-cell:hover .mealplan-add-button {
|
||||
visibility: initial;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<h3>
|
||||
{% trans 'Meal-Plan' %} <a href="{% url 'new_meal_plan' %}"><i class="fas fa-plus-circle"></i></a>
|
||||
@@ -53,10 +72,18 @@
|
||||
</tr>
|
||||
<tr>
|
||||
{% for day_key, days_value in plan_value.days.items %}
|
||||
<td>
|
||||
<td class="mealplan-cell">
|
||||
<a class="mealplan-add-button"
|
||||
href="{% url 'new_meal_plan' %}?date={{ day_key|date:'Y-m-d' }}&meal={{ plan_key }}"><i
|
||||
class="fas fa-plus"></i></a>
|
||||
{% for mp in days_value %}
|
||||
<a href="{% url 'edit_meal_plan' mp.pk %}"><i class="fas fa-edit"></i></a>
|
||||
<a href="{% url 'view_recipe' mp.recipe.id %}">{{ mp.recipe.name }}</a><br/>
|
||||
<a href="{% url 'view_plan_entry' mp.pk %}">
|
||||
{% if mp.recipe %}
|
||||
{{ mp.recipe }}
|
||||
{% else %}
|
||||
{{ mp.title }}
|
||||
{% endif %}
|
||||
</a><br/>
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
|
||||
85
cookbook/templates/meal_plan_entry.html
Normal file
85
cookbook/templates/meal_plan_entry.html
Normal file
@@ -0,0 +1,85 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% load custom_tags %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans 'Meal Plan View' %}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="{% static 'custom/css/markdown_blockquote.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-12">
|
||||
<h3>{{ plan.get_meal_name }} {{ plan.date }} <a href="{% url 'edit_meal_plan' plan.pk %}"
|
||||
class="d-print-none"><i class="fas fa-pencil-alt"></i></a>
|
||||
</h3>
|
||||
<small class="text-muted">{% trans 'Created by' %} {{ plan.created_by.get_user_name }}</small>
|
||||
{% if plan.shared.all %}
|
||||
<br/><small class="text-muted">{% trans 'Shared with' %}
|
||||
{% for x in plan.shared.all %}{{ x.get_user_name }}{% if not forloop.last %}, {% endif %} {% endfor %}</small>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
{% if plan.title %}
|
||||
<div class="row">
|
||||
<div class="col col-12">
|
||||
<h4>{{ plan.title }}</h4>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if plan.recipe %}
|
||||
<div class="row">
|
||||
<div class="col col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{% recipe_rating plan.recipe request.user as rating %}
|
||||
<h5 class="card-title"><a
|
||||
href="{% url 'view_recipe' plan.recipe.pk %}">{{ plan.recipe }}</a> {{ rating|safe }}
|
||||
</h5>
|
||||
{% recipe_last plan.recipe request.user as last_cooked %}
|
||||
{% if last_cooked %}
|
||||
{% trans 'Last cooked' %} {{ last_cooked|date }}
|
||||
{% else %}
|
||||
{% trans 'Never cooked before.' %}
|
||||
{% endif %}
|
||||
{% if plan.recipe.keywords %}
|
||||
<br/>
|
||||
<br/>
|
||||
{% for x in plan.recipe.keywords.all %}
|
||||
<span class="badge badge-pill badge-light">{{ x }}</span>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if plan.note %}
|
||||
<br/>
|
||||
<div class="row">
|
||||
<div class="col col-12">
|
||||
{{ plan.note | markdown | safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if same_day_plan %}
|
||||
<br/>
|
||||
<h4>{% trans 'Other meals on this day' %}</h4>
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for x in same_day_plan %}
|
||||
<li class="list-group-item"><a href="{% url 'view_plan_entry' x.pk %}">{{ x.get_label }}
|
||||
({{ x.get_meal_name }})</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,18 +1,26 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% load crispy_forms_tags %}
|
||||
{% load i18n %}
|
||||
{% load l10n %}
|
||||
{% load custom_tags %}
|
||||
|
||||
{% block title %}{% trans 'View' %}{% endblock %}
|
||||
{% block title %}{{ recipe.name }}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pretty-checkbox@3.0/dist/pretty-checkbox.min.css"
|
||||
integrity="sha384-ICB8i/maQ/5+tGLDUEcswB7Ch+OO9Oj8Z4Ov/Gs0gxqfTgLLkD3F43MhcEJ2x6/D" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="{% static 'css/pretty-checkbox.min.css' %}">
|
||||
|
||||
<link rel="stylesheet" href="{% static 'custom/css/markdown_blockquote.css' %}">
|
||||
|
||||
<!-- prevent weired character stuff escaping the pdf box -->
|
||||
<style>
|
||||
/* fixes print layout being disturbed by print button tooltip */
|
||||
@media print {
|
||||
.tooltip {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* prevent weired character stuff escaping the pdf box */
|
||||
.textLayer > span {
|
||||
color: transparent;
|
||||
position: absolute;
|
||||
@@ -20,46 +28,39 @@
|
||||
cursor: text;
|
||||
transform-origin: 0% 0%;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
background: #f9f9f9;
|
||||
border-left: 4px solid #ccc;
|
||||
margin: 1.5em 10px;
|
||||
padding: .5em 10px;
|
||||
quotes: none;
|
||||
}
|
||||
|
||||
blockquote:before {
|
||||
color: #ccc;
|
||||
content: open-quote;
|
||||
font-size: 4em;
|
||||
line-height: .1em;
|
||||
margin-right: .25em;
|
||||
vertical-align: -.4em;
|
||||
}
|
||||
|
||||
blockquote p {
|
||||
display: inline;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-md-9">
|
||||
<h3>{{ recipe.name }} <a href="{% url 'edit_recipe' recipe.pk %}" class="d-print-none"><i
|
||||
<div class="col col-md-8">
|
||||
{% recipe_rating recipe request.user as rating %}
|
||||
<h3>{{ recipe.name }} {{ rating|safe }} <a href="{% url 'edit_recipe' recipe.pk %}" class="d-print-none"><i
|
||||
class="fas fa-pencil-alt"></i></a></h3>
|
||||
</div>
|
||||
<div class="col col-md-3 d-print-none" style="text-align: right">
|
||||
<button class="btn btn-success" onclick="$('#bookmarkModal').modal({'show':true})"><i
|
||||
<div class="col col-md-4 d-print-none" style="text-align: right">
|
||||
<button class="btn btn-success" onclick="$('#bookmarkModal').modal({'show':true})" data-toggle="tooltip"
|
||||
data-placement="top" title="{% trans 'Add to Book' %}"><i
|
||||
class="fas fa-bookmark"></i></button>
|
||||
{% if ingredients %}
|
||||
<a class="btn btn-warning" href="{% url 'view_shopping' %}?r={{ recipe.pk }}"><i
|
||||
<a class="btn btn-secondary" href="{% url 'view_shopping' %}?r={{ recipe.pk }}" data-toggle="tooltip"
|
||||
data-placement="top" title="{% trans 'Generate shopping list' %}"><i
|
||||
class="fas fa-shopping-cart"></i></a>
|
||||
{% endif %}
|
||||
<a class="btn btn-info" href="{% url 'new_meal_plan' %}?recipe={{ recipe.pk }}"><i
|
||||
<a class="btn btn-info" href="{% url 'new_meal_plan' %}?recipe={{ recipe.pk }}" data-toggle="tooltip"
|
||||
data-placement="top" title="{% trans 'Add to Mealplan' %}"><i
|
||||
class="fas fa-calendar"></i></a>
|
||||
<button class="btn btn-warning" onclick="openCookLogModal({{ recipe.pk }})" data-toggle="tooltip"
|
||||
data-placement="top" title="{% trans 'Log Cooking' %}"><i class="fas fa-clipboard-list"></i>
|
||||
</button>
|
||||
<a class="btn btn-light" onclick="window.print()" data-toggle="tooltip"
|
||||
data-placement="top" title="{% trans 'Print' %}"><i
|
||||
class="fas fa-print"></i></a>
|
||||
<a class="btn btn-primary" href="{% url 'view_export' %}?r={{ recipe.pk }}" target="_blank"
|
||||
data-toggle="tooltip"
|
||||
data-placement="top" title="{% trans 'Export recipe' %}"><i
|
||||
class="fas fa-file-export"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -90,15 +91,19 @@
|
||||
class="badge badge-secondary"><i
|
||||
class="far fa-clock"></i> {% trans 'Waiting time ca.' %} {{ recipe.waiting_time }} min </span>
|
||||
{% endif %}
|
||||
{% recipe_last recipe request.user as last_cooked %}
|
||||
{% if last_cooked %}
|
||||
<span class="badge badge-primary">{% trans 'Last cooked' %} {{ last_cooked|date }}</span>
|
||||
{% endif %}
|
||||
|
||||
{% if recipe.waiting_time and recipe.waiting_time != 0 or recipe.working_time and recipe.working_time != 0 %}
|
||||
{% if recipe.waiting_time and recipe.waiting_time != 0 or recipe.working_time and recipe.working_time != 0 or last_cooked %}
|
||||
<br/>
|
||||
<br/>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
{% if ingredients %}
|
||||
<div class="col-lg-6 order-md-1 col-sm-12 order-sm-2 col-12 order-2">
|
||||
<div class="col-md-6 order-md-1 col-sm-12 order-sm-2 col-12 order-2">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
@@ -119,44 +124,61 @@
|
||||
<br/>
|
||||
<table class="table table-sm">
|
||||
{% for i in ingredients %}
|
||||
<tr>
|
||||
<td style="vertical-align: middle!important;">
|
||||
<div class="pretty p-default p-curve">
|
||||
<input type="checkbox"/>
|
||||
<div class="state p-success">
|
||||
<label>
|
||||
{% if i.amount != 0 %}
|
||||
<span id="ing_{{ i.pk }}">{{ i.amount.normalize }}</span>
|
||||
{{ i.unit }}
|
||||
{% else %}
|
||||
<span>⁣</span>
|
||||
{% endif %}
|
||||
</label>
|
||||
{% if i.unit.name == 'Special:Header' %}
|
||||
<tr>
|
||||
<td style="padding-top: 8px!important; ">
|
||||
<b>{{ i.note }}</b>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td style="vertical-align: middle!important;">
|
||||
<div class="pretty p-default p-curve">
|
||||
<input type="checkbox"/>
|
||||
<div class="state p-success">
|
||||
<label>
|
||||
{% if i.amount != 0 %}
|
||||
<span id="ing_{{ i.pk }}">{{ i.amount.normalize }}</span>
|
||||
{{ i.unit }}
|
||||
{% else %}
|
||||
<span>⁣</span>
|
||||
{% endif %}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</td>
|
||||
<td style="vertical-align: middle!important;">
|
||||
{% if i.ingredient.recipe %}
|
||||
<a href="{% url 'view_recipe' i.ingredient.recipe.pk %}" target="_blank">
|
||||
{% endif %}
|
||||
{{ i.ingredient.name }}
|
||||
{% if i.ingredient.recipe %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="vertical-align: middle!important;">
|
||||
{% if i.ingredient.recipe %}
|
||||
<a href="{% url 'view_recipe' i.ingredient.recipe.pk %}"
|
||||
target="_blank">
|
||||
{% endif %}
|
||||
{{ i.ingredient.name }}
|
||||
{% if i.ingredient.recipe %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
</td>
|
||||
<td style="vertical-align: middle!important;">
|
||||
{% if i.note %}
|
||||
<button class="btn btn-light btn-sm" type="button" data-container="body"
|
||||
data-toggle="popover"
|
||||
data-placement="right" data-html="true" data-trigger="focus"
|
||||
data-content="{{ i.note }}">
|
||||
<i class="fas fa-info"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</td>
|
||||
<td style="vertical-align: middle!important;">
|
||||
{% if i.note %}
|
||||
<button class="btn btn-light btn-sm d-print-none" type="button"
|
||||
data-container="body"
|
||||
data-toggle="popover"
|
||||
data-placement="right" data-html="true" data-trigger="focus"
|
||||
data-content="{{ i.note }}">
|
||||
<i class="fas fa-info"></i>
|
||||
</button>
|
||||
<div class="d-none d-print-block">
|
||||
<i class="far fa-comment-alt"></i> {{ i.note }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<!-- Bottom border -->
|
||||
<tr>
|
||||
@@ -173,7 +195,7 @@
|
||||
|
||||
{% endif %}
|
||||
{% if recipe.image %}
|
||||
<div class="col-md-6 order-md-2 col-sm-12 order-sm-1 col-12 order-1 " style="text-align: center">
|
||||
<div class="col-12 order-1 col-sm-12 order-sm-1 col-md-6 order-md-2" style="text-align: center">
|
||||
<img class="img img-fluid rounded" src="{{ recipe.image.url }}" style="max-height: 30vh;"
|
||||
alt="{% trans 'Recipe Image' %}">
|
||||
<br/>
|
||||
@@ -239,13 +261,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.3.200/pdf.min.js"
|
||||
integrity="sha256-J4Z8Fhj2MITUakMQatkqOVdtqodUlwHtQ/ey6fSsudE="
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="{% static 'js/pdf.min.js' %}"></script>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.3.200/pdf_viewer.js"
|
||||
integrity="sha256-JW7ackRikw8/UM/hHV6vKaZBYc+t2ZQ77sd3LWR8vh8="
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="{% static 'js/pdf_viewer.js' %}"></script>
|
||||
|
||||
<script type="text/javascript">
|
||||
var url = "{% url 'api_get_recipe_file' recipe_id=12345 %}".replace(/12345/, {{ recipe.id }});
|
||||
@@ -294,7 +312,8 @@
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
<h5><i class="far fa-comments"></i> {% trans 'Comments' %}</h5>
|
||||
<h5 {% if not comments %}class="d-print-none" {% endif %}><i class="far fa-comments"></i> {% trans 'Comments' %}
|
||||
</h5>
|
||||
{% for c in comments %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
@@ -352,6 +371,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'include/log_cooking.html' %}
|
||||
|
||||
<script type="text/javascript">
|
||||
|
||||
$(function () {
|
||||
@@ -362,6 +383,10 @@
|
||||
trigger: 'focus'
|
||||
});
|
||||
|
||||
function roundToTwo(num) {
|
||||
return +(Math.round(num + "e+2") + "e-2");
|
||||
}
|
||||
|
||||
function reloadIngredients() {
|
||||
factor = Number($('#in_factor').val());
|
||||
ingredients = {
|
||||
@@ -371,9 +396,13 @@
|
||||
}
|
||||
|
||||
for (var key in ingredients) {
|
||||
$('#ing_' + key).html(Math.round(ingredients[key] * factor))
|
||||
$('#ing_' + key).html(roundToTwo(ingredients[key] * factor))
|
||||
}
|
||||
}
|
||||
|
||||
$(function () {
|
||||
$('[data-toggle="tooltip"]').tooltip()
|
||||
})
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
||||
118
cookbook/templates/recipes_table.html
Normal file
118
cookbook/templates/recipes_table.html
Normal file
@@ -0,0 +1,118 @@
|
||||
{% load crispy_forms_tags %}
|
||||
{% load i18n %}
|
||||
{% load django_tables2 %}
|
||||
{% load static %}
|
||||
{% load custom_tags %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="table-container">
|
||||
{% block table %}
|
||||
<table {% render_attrs table.attrs class="table" %}>
|
||||
{% for row in table.paginated_rows %}
|
||||
<div class="card" style="margin-top: 2px;">
|
||||
<div class="row no-gutters">
|
||||
<div class="col-md-4">
|
||||
<a href="{% url 'view_recipe' row.cells.id %}">
|
||||
{% if row.cells.image|length > 1 %}
|
||||
<img src=" {{ row.cells.image }}" alt="{% trans 'Recipe Image' %}"
|
||||
class="card-img" style="object-fit: cover;height: 130px">
|
||||
{% else %}
|
||||
<img src="{% static 'recipe_no_image.svg' %}"
|
||||
alt="{% trans 'Recipe Image' %}"
|
||||
class="card-img d-none d-lg-block"
|
||||
style="object-fit: inherit; height: 130px">
|
||||
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{{ row.cells.name }}
|
||||
{% recipe_rating row.record request.user as rating %}
|
||||
{{ rating|safe }}
|
||||
</h5>
|
||||
<p class="card-text{% if not row.record.keywords %} d-none d-lg-block{% endif %}">
|
||||
{% for x in row.record.keywords.all %}
|
||||
<span class="badge badge-pill badge-light">{{ x }}</span>
|
||||
{% endfor %}
|
||||
</p>
|
||||
<p class="card-text"><small class="text-muted">
|
||||
{% if row.cells.working_time != 0 %}
|
||||
<span class="badge badge-secondary"><i
|
||||
class="fas fa-user-clock"></i> {% trans 'Preparation time ca.' %} {{ row.cells.working_time }} min </span>
|
||||
{% endif %}
|
||||
|
||||
{% if row.cells.waiting_time != 0 %}
|
||||
<span
|
||||
class="badge badge-secondary"><i
|
||||
class="far fa-clock"></i> {% trans 'Waiting time ca.' %} {{ row.cells.waiting_time }} min </span>
|
||||
{% endif %}
|
||||
{% if not row.record.internal %}
|
||||
<span class="badge badge-info">{% trans 'External' %} </span>
|
||||
{% endif %}
|
||||
{% recipe_last row.record request.user as last_cooked %}
|
||||
{% if last_cooked %}
|
||||
<span class="badge badge-primary">{% trans 'Last cooked' %} {{ last_cooked|date }}</span>
|
||||
{% endif %}
|
||||
<span class="badge badge-light">{{ row.cells.edit }}</span>
|
||||
<span class="badge badge-warning"><a href="#" style="color: inherit"
|
||||
onclick="openCookLogModal({{ row.record.pk }})">{% trans 'Log' %}</a></span>
|
||||
</small></p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endfor %}
|
||||
|
||||
</table>
|
||||
{% endblock table %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% block pagination %}
|
||||
{% if table.page and table.paginator.num_pages > 1 %}
|
||||
<nav aria-label="Table navigation">
|
||||
<ul class="pagination justify-content-center flex-wrap">
|
||||
{% if table.page.has_previous %}
|
||||
{% block pagination.previous %}
|
||||
<li class="previous page-item">
|
||||
<a href="{% querystring table.prefixed_page_field=table.page.previous_page_number %}"
|
||||
class="page-link">
|
||||
<span aria-hidden="true">«</span>
|
||||
{% trans 'previous' %}
|
||||
</a>
|
||||
</li>
|
||||
{% endblock pagination.previous %}
|
||||
{% endif %}
|
||||
{% if table.page.has_previous or table.page.has_next %}
|
||||
{% block pagination.range %}
|
||||
{% for p in table.page|table_page_range:table.paginator %}
|
||||
<li class="page-item{% if table.page.number == p %} active{% endif %}">
|
||||
<a class="page-link"
|
||||
{% if p != '...' %}href="{% querystring table.prefixed_page_field=p %}"{% endif %}>
|
||||
{{ p }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% endblock pagination.range %}
|
||||
{% endif %}
|
||||
{% if table.page.has_next %}
|
||||
{% block pagination.next %}
|
||||
<li class="next page-item">
|
||||
<a href="{% querystring table.prefixed_page_field=table.page.next_page_number %}"
|
||||
class="page-link">
|
||||
{% trans 'next' %}
|
||||
<span aria-hidden="true">»</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endblock pagination.next %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endblock pagination %}
|
||||
{% endblock content %}
|
||||
@@ -5,6 +5,11 @@
|
||||
|
||||
{% block title %}{% trans 'Settings' %}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{ preference_form.media }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h3>
|
||||
|
||||
23
cookbook/templates/setup.html
Normal file
23
cookbook/templates/setup.html
Normal file
@@ -0,0 +1,23 @@
|
||||
{% extends "base.html" %}
|
||||
{% load crispy_forms_filters %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Cookbook Setup" %}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{ form.media }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1>{% trans 'Setup' %}</h1>
|
||||
<p>{% blocktrans %}To start using this application you must first create a superuser.{% endblocktrans %}</p>
|
||||
|
||||
<form action="." method="post">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<button class="btn btn-success" type="submit"><i class="fas fa-save"></i> {% trans 'Create Superuser' %}</button>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
@@ -26,7 +26,7 @@
|
||||
<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.ingredient.name }} {% endfor %}</textarea>
|
||||
<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.ingredient.name }} {% endfor %}</textarea>
|
||||
<!--// @formatter:on-->
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,7 +34,7 @@
|
||||
<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="top" title="{% trans 'Copy list to clipboard' %}" id="id_btn_copy" onmouseout="resetTooltip()"><i
|
||||
data-placement="right" title="{% trans 'Copy list to clipboard' %}" id="id_btn_copy" onmouseout="resetTooltip()"><i
|
||||
class="far fa-copy"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-12">
|
||||
<h3>{% trans 'Statistics' %} </h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>
|
||||
{% trans 'Statistics' %}
|
||||
</h3>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
@@ -21,6 +23,10 @@
|
||||
class="badge badge-pill badge-info">{{ counts.recipes }}</span></li>
|
||||
<li class="list-group-item">{% trans 'Keywords' %} : <span
|
||||
class="badge badge-pill badge-info">{{ counts.keywords }}</span></li>
|
||||
<li class="list-group-item">{% trans 'Units' %} : <span
|
||||
class="badge badge-pill badge-info">{{ counts.units }}</span></li>
|
||||
<li class="list-group-item">{% trans 'Ingredients' %} : <span
|
||||
class="badge badge-pill badge-info">{{ counts.ingredients }}</span></li>
|
||||
<li class="list-group-item">{% trans 'Recipe Imports' %} : <span
|
||||
class="badge badge-pill badge-info">{{ counts.recipe_import }}</span></li>
|
||||
</ul>
|
||||
@@ -33,8 +39,13 @@
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">{% trans 'Recipes without Keywords' %} : <span
|
||||
class="badge badge-pill badge-info">{{ counts.recipes_no_keyword}}</span></li>
|
||||
|
||||
class="badge badge-pill badge-info">{{ counts.recipes_no_keyword }}</span></li>
|
||||
<li class="list-group-item">{% trans 'External Recipes' %} : <span
|
||||
class="badge badge-pill badge-info">{{ counts.recipes_external }}</span></li>
|
||||
<li class="list-group-item">{% trans 'Internal Recipes' %} : <span
|
||||
class="badge badge-pill badge-info">{{ counts.recipes_internal }}</span></li>
|
||||
<li class="list-group-item">{% trans 'Comments' %} : <span
|
||||
class="badge badge-pill badge-info">{{ counts.comments }}</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
|
||||
|
||||
{% extends "base.html" %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block content %}
|
||||
<form>
|
||||
<div class="form-group">
|
||||
<label for="exampleInputEmail1">Email address</label>
|
||||
<input type="email" class="form-control" id="exampleInputEmail1" aria-describedby="emailHelp"
|
||||
placeholder="Enter email">
|
||||
<small id="emailHelp" class="form-text text-muted">We'll never share your email with anyone else.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="exampleInputPassword1">Password</label>
|
||||
<input type="password" class="form-control" id="exampleInputPassword1" placeholder="Password">
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="exampleCheck1">
|
||||
<label class="form-check-label" for="exampleCheck1">Check me out</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Submit</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -1,11 +1,13 @@
|
||||
from django import template
|
||||
import markdown as md
|
||||
import bleach
|
||||
from bleach_whitelist import markdown_tags, markdown_attrs, all_styles, print_attrs
|
||||
import markdown as md
|
||||
from bleach_whitelist import markdown_tags, markdown_attrs
|
||||
from django import template
|
||||
from django.db.models import Avg
|
||||
from django.urls import reverse
|
||||
|
||||
from cookbook.helper.mdx_attributes import MarkdownFormatExtension
|
||||
from cookbook.models import get_model_name
|
||||
from cookbook.helper.mdx_urlize import UrlizeExtension
|
||||
from cookbook.models import get_model_name, CookLog
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@@ -28,5 +30,32 @@ def delete_url(model, pk):
|
||||
@register.filter()
|
||||
def markdown(value):
|
||||
tags = markdown_tags + ['pre', 'table', 'td', 'tr', 'th', 'tbody', 'style', 'thead']
|
||||
parsed_md = md.markdown(value, extensions=['markdown.extensions.fenced_code', 'tables', MarkdownFormatExtension()])
|
||||
parsed_md = md.markdown(value, extensions=['markdown.extensions.fenced_code', 'tables', UrlizeExtension(), MarkdownFormatExtension()])
|
||||
markdown_attrs['*'] = markdown_attrs['*'] + ['class']
|
||||
return bleach.clean(parsed_md, tags, markdown_attrs)
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def recipe_rating(recipe, user):
|
||||
rating = recipe.cooklog_set.filter(created_by=user).aggregate(Avg('rating'))
|
||||
if rating['rating__avg']:
|
||||
|
||||
rating_stars = ''
|
||||
for i in range(int(rating['rating__avg'])):
|
||||
rating_stars = rating_stars + '<i class="fas fa-star fa-xs"></i>'
|
||||
|
||||
if rating['rating__avg'] % 1 >= 0.5:
|
||||
rating_stars = rating_stars + '<i class="fas fa-star-half-alt fa-xs"></i>'
|
||||
|
||||
return rating_stars
|
||||
else:
|
||||
return ''
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def recipe_last(recipe, user):
|
||||
last = recipe.cooklog_set.filter(created_by=user).last()
|
||||
if last:
|
||||
return last.created_at
|
||||
else:
|
||||
return ''
|
||||
|
||||
@@ -8,41 +8,38 @@ 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:
|
||||
if not request.user.is_authenticated:
|
||||
return static('themes/flatly.min.css')
|
||||
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
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def nav_color(request):
|
||||
try:
|
||||
return request.user.userpreference.nav_color
|
||||
except AttributeError:
|
||||
if not request.user.is_authenticated:
|
||||
return 'primary'
|
||||
return request.user.userpreference.nav_color
|
||||
|
||||
|
||||
@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:
|
||||
if not request.user.is_authenticated:
|
||||
return static('tabulator/tabulator_bootstrap4.min.css')
|
||||
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
|
||||
|
||||
49
cookbook/tests/edits/test_edits_comment.py
Normal file
49
cookbook/tests/edits/test_edits_comment.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from django.contrib import auth
|
||||
from django.urls import reverse
|
||||
|
||||
from cookbook.models import Comment, Recipe
|
||||
from cookbook.tests.views.test_views import TestViews
|
||||
|
||||
|
||||
class TestEditsComment(TestViews):
|
||||
|
||||
def setUp(self):
|
||||
super(TestEditsComment, self).setUp()
|
||||
|
||||
self.recipe = Recipe.objects.create(
|
||||
internal=True,
|
||||
instructions='Do something',
|
||||
working_time=1,
|
||||
waiting_time=1,
|
||||
created_by=auth.get_user(self.user_client_1)
|
||||
)
|
||||
|
||||
self.comment = Comment.objects.create(
|
||||
text='TestStorage',
|
||||
created_by=auth.get_user(self.guest_client_1),
|
||||
recipe=self.recipe
|
||||
)
|
||||
self.url = reverse('edit_comment', args=[self.comment.pk])
|
||||
|
||||
def test_new_comment(self):
|
||||
r = self.user_client_1.post(reverse('view_recipe', args=[self.recipe.pk]), {'comment-text': 'Test Comment Text', 'comment-recipe': self.recipe.pk})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
def test_edit_comment_permissions(self):
|
||||
r = self.anonymous_client.get(self.url)
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
r = self.guest_client_1.get(self.url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
r = self.guest_client_2.get(self.url)
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
r = self.user_client_1.get(self.url)
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
r = self.admin_client_1.get(self.url)
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
r = self.superuser_client.get(self.url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
@@ -11,66 +11,66 @@ class TestEditsRecipe(TestViews):
|
||||
internal_recipe = Recipe.objects.create(
|
||||
name='Test',
|
||||
internal=True,
|
||||
created_by=auth.get_user(self.client)
|
||||
created_by=auth.get_user(self.user_client_1)
|
||||
)
|
||||
|
||||
external_recipe = Recipe.objects.create(
|
||||
name='Test',
|
||||
internal=False,
|
||||
created_by=auth.get_user(self.client)
|
||||
created_by=auth.get_user(self.user_client_1)
|
||||
)
|
||||
|
||||
url = reverse('edit_recipe', args=[internal_recipe.pk])
|
||||
r = self.client.get(url)
|
||||
r = self.user_client_1.get(url)
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
r = self.client.get(r.url)
|
||||
r = self.user_client_1.get(r.url)
|
||||
self.assertTemplateUsed(r, 'forms/edit_internal_recipe.html')
|
||||
|
||||
url = reverse('edit_recipe', args=[external_recipe.pk])
|
||||
r = self.client.get(url)
|
||||
r = self.user_client_1.get(url)
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
r = self.client.get(r.url)
|
||||
r = self.user_client_1.get(r.url)
|
||||
self.assertTemplateUsed(r, 'generic/edit_template.html')
|
||||
|
||||
def test_convert_recipe(self):
|
||||
url = reverse('edit_convert_recipe', args=[42])
|
||||
r = self.client.get(url)
|
||||
r = self.user_client_1.get(url)
|
||||
self.assertEqual(r.status_code, 404)
|
||||
|
||||
external_recipe = Recipe.objects.create(
|
||||
name='Test',
|
||||
internal=False,
|
||||
created_by=auth.get_user(self.client)
|
||||
created_by=auth.get_user(self.user_client_1)
|
||||
)
|
||||
|
||||
url = reverse('edit_convert_recipe', args=[external_recipe.pk])
|
||||
r = self.client.get(url)
|
||||
r = self.user_client_1.get(url)
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
recipe = Recipe.objects.get(pk=external_recipe.pk)
|
||||
self.assertTrue(recipe.internal)
|
||||
|
||||
url = reverse('edit_convert_recipe', args=[recipe.pk])
|
||||
r = self.client.get(url)
|
||||
r = self.user_client_1.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)
|
||||
created_by=auth.get_user(self.user_client_1)
|
||||
)
|
||||
|
||||
url = reverse('edit_internal_recipe', args=[recipe.pk])
|
||||
|
||||
r = self.client.get(url)
|
||||
r = self.user_client_1.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': '[]'})
|
||||
r = self.user_client_1.post(url, {'name': 'Changed', 'working_time': 15, 'waiting_time': 15, 'ingredients': '[]'})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
recipe = Recipe.objects.get(pk=recipe.pk)
|
||||
@@ -79,30 +79,30 @@ class TestEditsRecipe(TestViews):
|
||||
Ingredient.objects.create(name='Egg')
|
||||
Unit.objects.create(name='g')
|
||||
|
||||
r = self.client.post(url,
|
||||
r = self.user_client_1.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,5","delete":false}]'})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(2, RecipeIngredient.objects.filter(recipe=recipe).count())
|
||||
|
||||
r = self.client.post(url,
|
||||
r = self.user_client_1.post(url,
|
||||
{'name': "Test", 'working_time': "Test", 'waiting_time': 15,
|
||||
'ingredients': '[{"ingredient__name":"Tomato","unit__name":"g","amount":100,"delete":false},{"ingredient__name":"Egg","unit__name":"Piece","amount":"2,5","delete":false}]'})
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
with open('cookbook/tests/resources/image.jpg', 'rb') as file:
|
||||
r = self.client.post(url, {'name': "Changed", 'working_time': 15, 'waiting_time': 15, 'image': file})
|
||||
r = self.user_client_1.post(url, {'name': "Changed", 'working_time': 15, 'waiting_time': 15, 'image': file})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
with open('cookbook/tests/resources/image.png', 'rb') as file:
|
||||
r = self.client.post(url, {'name': "Changed", 'working_time': 15, 'waiting_time': 15, 'image': file})
|
||||
r = self.user_client_1.post(url, {'name': "Changed", 'working_time': 15, 'waiting_time': 15, 'image': file})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
def test_external_recipe_update(self):
|
||||
storage = Storage.objects.create(
|
||||
name='TestStorage',
|
||||
method=Storage.DROPBOX,
|
||||
created_by=auth.get_user(self.client),
|
||||
created_by=auth.get_user(self.user_client_1),
|
||||
token='test',
|
||||
username='test',
|
||||
password='test',
|
||||
@@ -110,19 +110,19 @@ class TestEditsRecipe(TestViews):
|
||||
|
||||
recipe = Recipe.objects.create(
|
||||
name='Test',
|
||||
created_by=auth.get_user(self.client),
|
||||
created_by=auth.get_user(self.user_client_1),
|
||||
storage=storage,
|
||||
)
|
||||
|
||||
url = reverse('edit_external_recipe', args=[recipe.pk])
|
||||
|
||||
r = self.client.get(url)
|
||||
r = self.user_client_1.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': 'Test', 'working_time': 15, 'waiting_time': 15, })
|
||||
r = self.user_client_1.post(url, {'name': 'Test', 'working_time': 15, 'waiting_time': 15, })
|
||||
recipe.refresh_from_db()
|
||||
self.assertEqual(recipe.working_time, 15)
|
||||
self.assertEqual(recipe.waiting_time, 15)
|
||||
|
||||
@@ -7,33 +7,40 @@ from cookbook.tests.views.test_views import TestViews
|
||||
|
||||
class TestEditsRecipe(TestViews):
|
||||
|
||||
def test_edit_storage(self):
|
||||
storage = Storage.objects.create(
|
||||
def setUp(self):
|
||||
super(TestEditsRecipe, self).setUp()
|
||||
|
||||
self.storage = Storage.objects.create(
|
||||
name='TestStorage',
|
||||
method=Storage.DROPBOX,
|
||||
created_by=auth.get_user(self.client),
|
||||
created_by=auth.get_user(self.admin_client_1),
|
||||
token='test',
|
||||
username='test',
|
||||
password='test',
|
||||
)
|
||||
self.url = reverse('edit_storage', args=[self.storage.pk])
|
||||
|
||||
url = reverse('edit_storage', args=[storage.pk])
|
||||
r = self.anonymous_client.get(url)
|
||||
self.assertEqual(r.status_code, 302)
|
||||
def test_edit_storage(self):
|
||||
r = self.admin_client_1.post(self.url, {'name': 'NewStorage', 'password': '1234_pw', 'token': '1234_token', 'method': Storage.DROPBOX})
|
||||
self.storage.refresh_from_db()
|
||||
self.assertEqual(self.storage.password, '1234_pw')
|
||||
self.assertEqual(self.storage.token, '1234_token')
|
||||
|
||||
r = self.another_client.get(url)
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
r = self.superuser_client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
r = self.client.post(url, {'name': 'NewStorage', 'password': '1234_pw', 'token': '1234_token', 'method': Storage.DROPBOX})
|
||||
storage.refresh_from_db()
|
||||
self.assertEqual(storage.password, '1234_pw')
|
||||
self.assertEqual(storage.token, '1234_token')
|
||||
|
||||
r = self.client.post(url, {'name': 'NewStorage', 'password': '1234_pw', 'token': '1234_token', 'method': 'not_a_valid_method'})
|
||||
r = self.admin_client_1.post(self.url, {'name': 'NewStorage', 'password': '1234_pw', 'token': '1234_token', 'method': 'not_a_valid_method'})
|
||||
self.assertFormError(r, 'form', 'method', ['Select a valid choice. not_a_valid_method is not one of the available choices.'])
|
||||
|
||||
def test_edit_storage_permissions(self):
|
||||
r = self.anonymous_client.get(self.url)
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
r = self.guest_client_1.get(self.url)
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
r = self.user_client_1.get(self.url)
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
r = self.admin_client_1.get(self.url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
r = self.superuser_client.get(self.url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
@@ -1,24 +1,38 @@
|
||||
from django.contrib import auth
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.auth.models import User, Group
|
||||
from django.test import TestCase, Client
|
||||
|
||||
|
||||
class TestBase(TestCase):
|
||||
user_client_1 = None
|
||||
user_client_2 = None
|
||||
admin_client_1 = None
|
||||
admin_client_2 = None
|
||||
guest_client_1 = None
|
||||
guest_client_2 = None
|
||||
superuser_client = None
|
||||
|
||||
def create_login_user(self, name, group):
|
||||
client = Client()
|
||||
setattr(self, name, client)
|
||||
client.force_login(User.objects.get_or_create(username=name)[0])
|
||||
user = auth.get_user(getattr(self, name))
|
||||
user.groups.add(Group.objects.get(name=group))
|
||||
self.assertTrue(user.is_authenticated)
|
||||
return user
|
||||
|
||||
def setUp(self):
|
||||
self.create_login_user('admin_client_1', 'admin')
|
||||
self.create_login_user('admin_client_2', 'admin')
|
||||
|
||||
self.create_login_user('user_client_1', 'user')
|
||||
self.create_login_user('user_client_2', 'user')
|
||||
|
||||
self.create_login_user('guest_client_1', 'guest')
|
||||
self.create_login_user('guest_client_2', 'guest')
|
||||
|
||||
self.anonymous_client = Client()
|
||||
|
||||
self.client = Client()
|
||||
self.client.force_login(User.objects.get_or_create(username='client')[0])
|
||||
user = auth.get_user(self.client)
|
||||
self.assertTrue(user.is_authenticated)
|
||||
|
||||
self.another_client = Client()
|
||||
self.another_client.force_login(User.objects.get_or_create(username='another_client')[0])
|
||||
user = auth.get_user(self.another_client)
|
||||
self.assertTrue(user.is_authenticated)
|
||||
|
||||
self.superuser_client = Client()
|
||||
self.superuser_client.force_login(User.objects.get_or_create(username='superuser_client', is_superuser=True)[0])
|
||||
user = auth.get_user(self.superuser_client)
|
||||
self.assertTrue(user.is_authenticated)
|
||||
user = self.create_login_user('superuser_client', 'admin')
|
||||
user.is_superuser = True
|
||||
user.save()
|
||||
|
||||
37
cookbook/tests/views/test_views_api.py
Normal file
37
cookbook/tests/views/test_views_api.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from django.contrib import auth
|
||||
from django.urls import reverse
|
||||
|
||||
from cookbook.models import Recipe
|
||||
from cookbook.tests.views.test_views import TestViews
|
||||
|
||||
|
||||
class TestViewsApi(TestViews):
|
||||
|
||||
def test_external_link_permission(self):
|
||||
recipe = Recipe.objects.create(
|
||||
internal=False,
|
||||
link='test',
|
||||
instructions='Do something',
|
||||
working_time=1,
|
||||
waiting_time=1,
|
||||
created_by=auth.get_user(self.user_client_1)
|
||||
)
|
||||
url = reverse('api_get_external_file_link', args=[recipe.pk])
|
||||
|
||||
self.assertEqual(self.anonymous_client.get(url).status_code, 302)
|
||||
self.assertEqual(self.guest_client_1.get(url).status_code, 302)
|
||||
self.assertEqual(self.user_client_1.get(url).status_code, 200)
|
||||
self.assertEqual(self.admin_client_1.get(url).status_code, 200)
|
||||
self.assertEqual(self.superuser_client.get(url).status_code, 200)
|
||||
|
||||
def test_file_permission(self):
|
||||
url = reverse('api_get_recipe_file', args=[1])
|
||||
|
||||
self.assertEqual(self.anonymous_client.get(url).status_code, 302)
|
||||
self.assertEqual(self.guest_client_1.get(url).status_code, 302)
|
||||
|
||||
def test_sync_permission(self):
|
||||
url = reverse('api_sync')
|
||||
|
||||
self.assertEqual(self.anonymous_client.get(url).status_code, 302)
|
||||
self.assertEqual(self.guest_client_1.get(url).status_code, 302)
|
||||
@@ -6,15 +6,15 @@ from cookbook.tests.views.test_views import TestViews
|
||||
class TestViewsGeneral(TestViews):
|
||||
|
||||
def test_index(self):
|
||||
r = self.client.get(reverse('index'))
|
||||
self.assertEqual(r.status_code, 200)
|
||||
r = self.user_client_1.get(reverse('index'))
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
r = self.anonymous_client.get(reverse('index'))
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
def test_books(self):
|
||||
url = reverse('view_books')
|
||||
r = self.client.get(url)
|
||||
r = self.user_client_1.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
r = self.anonymous_client.get(url)
|
||||
@@ -22,7 +22,7 @@ class TestViewsGeneral(TestViews):
|
||||
|
||||
def test_plan(self):
|
||||
url = reverse('view_plan')
|
||||
r = self.client.get(url)
|
||||
r = self.user_client_1.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
r = self.anonymous_client.get(url)
|
||||
@@ -30,7 +30,7 @@ class TestViewsGeneral(TestViews):
|
||||
|
||||
def test_shopping(self):
|
||||
url = reverse('view_shopping')
|
||||
r = self.client.get(url)
|
||||
r = self.user_client_1.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
r = self.anonymous_client.get(url)
|
||||
|
||||
@@ -3,15 +3,22 @@ from pydoc import locate
|
||||
from django.urls import path
|
||||
|
||||
from .views import *
|
||||
from cookbook.views import api
|
||||
from cookbook.views import api, import_export
|
||||
from cookbook.helper import dal
|
||||
|
||||
urlpatterns = [
|
||||
path('', views.index, name='index'),
|
||||
path('setup/', views.setup, name='view_setup'),
|
||||
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('settings/', views.settings, name='view_settings'),
|
||||
path('settings/', views.user_settings, name='view_settings'),
|
||||
path('history/', views.history, name='view_history'),
|
||||
|
||||
path('import/', import_export.import_recipe, name='view_import'),
|
||||
path('export/', import_export.export_recipe, name='view_export'),
|
||||
|
||||
path('view/recipe/<int:pk>', views.recipe_view, name='view_recipe'),
|
||||
|
||||
@@ -35,12 +42,14 @@ urlpatterns = [
|
||||
|
||||
path('api/get_external_file_link/<int:recipe_id>/', api.get_external_file_link, name='api_get_external_file_link'),
|
||||
path('api/get_recipe_file/<int:recipe_id>/', api.get_recipe_file, name='api_get_recipe_file'),
|
||||
|
||||
path('api/sync_all/', api.sync_all, name='api_sync'),
|
||||
path('api/log_cooking/<int:recipe_id>/', api.log_cooking, name='api_log_cooking'),
|
||||
|
||||
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'),
|
||||
|
||||
path('docs/markdown/', views.markdown_info, name='docs_markdown'),
|
||||
]
|
||||
|
||||
generic_models = (Recipe, RecipeImport, Storage, RecipeBook, MealPlan, SyncLog, Sync, Comment, RecipeBookEntry, Keyword, Ingredient)
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
from django.contrib import messages
|
||||
from django.http import HttpResponse, FileResponse
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.shortcuts import redirect
|
||||
import re
|
||||
|
||||
from cookbook.models import Recipe, Sync, Storage
|
||||
from annoying.decorators import ajax_request
|
||||
from annoying.functions import get_object_or_None
|
||||
from django.contrib import messages
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from cookbook.helper.permission_helper import group_required
|
||||
from cookbook.models import Recipe, Sync, Storage, CookLog
|
||||
from cookbook.provider.dropbox import Dropbox
|
||||
from cookbook.provider.nextcloud import Nextcloud
|
||||
|
||||
@@ -26,7 +29,7 @@ def update_recipe_links(recipe):
|
||||
recipe.save()
|
||||
|
||||
|
||||
@login_required
|
||||
@group_required('user')
|
||||
def get_external_file_link(request, recipe_id):
|
||||
recipe = Recipe.objects.get(id=recipe_id)
|
||||
if not recipe.link:
|
||||
@@ -35,7 +38,7 @@ def get_external_file_link(request, recipe_id):
|
||||
return HttpResponse(recipe.link)
|
||||
|
||||
|
||||
@login_required
|
||||
@group_required('user')
|
||||
def get_recipe_file(request, recipe_id):
|
||||
recipe = Recipe.objects.get(id=recipe_id)
|
||||
if not recipe.cors_link:
|
||||
@@ -44,7 +47,7 @@ def get_recipe_file(request, recipe_id):
|
||||
return HttpResponse(get_recipe_provider(recipe).get_base64_file(recipe))
|
||||
|
||||
|
||||
@login_required
|
||||
@group_required('user')
|
||||
def sync_all(request):
|
||||
monitors = Sync.objects.filter(active=True)
|
||||
|
||||
@@ -65,3 +68,22 @@ def sync_all(request):
|
||||
else:
|
||||
messages.add_message(request, messages.ERROR, _('Error synchronizing with Storage'))
|
||||
return redirect('list_recipe_import')
|
||||
|
||||
|
||||
@group_required('user')
|
||||
@ajax_request
|
||||
def log_cooking(request, recipe_id):
|
||||
recipe = get_object_or_None(Recipe, id=recipe_id)
|
||||
if recipe:
|
||||
log = CookLog.objects.create(created_by=request.user, recipe=recipe)
|
||||
servings = request.GET['s'] if 's' in request.GET else None
|
||||
if servings and re.match(r'^([1-9])+$', servings):
|
||||
log.servings = int(servings)
|
||||
|
||||
rating = request.GET['r'] if 'r' in request.GET else None
|
||||
if rating and re.match(r'^([1-9])+$', rating):
|
||||
log.rating = int(rating)
|
||||
log.save()
|
||||
return {'msg': 'updated successfully'}
|
||||
|
||||
return {'error': 'recipe does not exist'}
|
||||
|
||||
@@ -7,11 +7,12 @@ from django.utils.translation import ngettext
|
||||
from django_tables2 import RequestConfig
|
||||
|
||||
from cookbook.forms import SyncForm, BatchEditForm
|
||||
from cookbook.helper.permission_helper import group_required
|
||||
from cookbook.models import *
|
||||
from cookbook.tables import SyncTable
|
||||
|
||||
|
||||
@login_required
|
||||
@group_required('user')
|
||||
def sync(request):
|
||||
if request.method == "POST":
|
||||
form = SyncForm(request.POST)
|
||||
@@ -31,12 +32,12 @@ def sync(request):
|
||||
return render(request, 'batch/monitor.html', {'form': form, 'monitored_paths': monitored_paths})
|
||||
|
||||
|
||||
@login_required
|
||||
@group_required('user')
|
||||
def sync_wait(request):
|
||||
return render(request, 'batch/waiting.html')
|
||||
|
||||
|
||||
@login_required
|
||||
@group_required('user')
|
||||
def batch_import(request):
|
||||
imports = RecipeImport.objects.all()
|
||||
for new_recipe in imports:
|
||||
@@ -47,7 +48,7 @@ def batch_import(request):
|
||||
return redirect('list_recipe_import')
|
||||
|
||||
|
||||
@login_required
|
||||
@group_required('user')
|
||||
def batch_edit(request):
|
||||
if request.method == "POST":
|
||||
form = BatchEditForm(request.POST)
|
||||
@@ -86,12 +87,18 @@ class Object(object):
|
||||
pass
|
||||
|
||||
|
||||
@login_required
|
||||
@group_required('user')
|
||||
def statistics(request):
|
||||
counts = Object()
|
||||
counts.recipes = Recipe.objects.count()
|
||||
counts.keywords = Keyword.objects.count()
|
||||
counts.recipe_import = RecipeImport.objects.count()
|
||||
counts.units = Unit.objects.count()
|
||||
counts.ingredients = Ingredient.objects.count()
|
||||
counts.comments = Comment.objects.count()
|
||||
|
||||
counts.recipes_internal = Recipe.objects.filter(internal=True).count()
|
||||
counts.recipes_external = counts.recipes - counts.recipes_internal
|
||||
|
||||
counts.recipes_no_keyword = Recipe.objects.filter(keywords=None).count()
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404
|
||||
@@ -5,13 +6,15 @@ from django.urls import reverse_lazy, reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import DeleteView
|
||||
|
||||
from cookbook.helper.permission_helper import GroupRequiredMixin, OwnerRequiredMixin
|
||||
from cookbook.models import Recipe, Sync, Keyword, RecipeImport, Storage, Comment, RecipeBook, \
|
||||
RecipeBookEntry, MealPlan, Ingredient
|
||||
from cookbook.provider.dropbox import Dropbox
|
||||
from cookbook.provider.nextcloud import Nextcloud
|
||||
|
||||
|
||||
class RecipeDelete(LoginRequiredMixin, DeleteView):
|
||||
class RecipeDelete(GroupRequiredMixin, DeleteView):
|
||||
groups_required = ['user']
|
||||
template_name = "generic/delete_template.html"
|
||||
model = Recipe
|
||||
success_url = reverse_lazy('index')
|
||||
@@ -23,6 +26,7 @@ class RecipeDelete(LoginRequiredMixin, DeleteView):
|
||||
|
||||
|
||||
def delete_recipe_source(request, pk):
|
||||
group_required = ['user']
|
||||
recipe = get_object_or_404(Recipe, pk=pk)
|
||||
|
||||
if recipe.storage.method == Storage.DROPBOX:
|
||||
@@ -38,7 +42,8 @@ def delete_recipe_source(request, pk):
|
||||
return HttpResponseRedirect(reverse('edit_recipe', args=[recipe.pk]))
|
||||
|
||||
|
||||
class RecipeImportDelete(LoginRequiredMixin, DeleteView):
|
||||
class RecipeImportDelete(GroupRequiredMixin, DeleteView):
|
||||
groups_required = ['user']
|
||||
template_name = "generic/delete_template.html"
|
||||
model = RecipeImport
|
||||
success_url = reverse_lazy('list_recipe_import')
|
||||
@@ -49,7 +54,8 @@ class RecipeImportDelete(LoginRequiredMixin, DeleteView):
|
||||
return context
|
||||
|
||||
|
||||
class SyncDelete(LoginRequiredMixin, DeleteView):
|
||||
class SyncDelete(GroupRequiredMixin, DeleteView):
|
||||
groups_required = ['admin']
|
||||
template_name = "generic/delete_template.html"
|
||||
model = Sync
|
||||
success_url = reverse_lazy('data_sync')
|
||||
@@ -60,7 +66,8 @@ class SyncDelete(LoginRequiredMixin, DeleteView):
|
||||
return context
|
||||
|
||||
|
||||
class KeywordDelete(LoginRequiredMixin, DeleteView):
|
||||
class KeywordDelete(GroupRequiredMixin, DeleteView):
|
||||
groups_required = ['user']
|
||||
template_name = "generic/delete_template.html"
|
||||
model = Keyword
|
||||
success_url = reverse_lazy('list_keyword')
|
||||
@@ -71,7 +78,8 @@ class KeywordDelete(LoginRequiredMixin, DeleteView):
|
||||
return context
|
||||
|
||||
|
||||
class IngredientDelete(LoginRequiredMixin, DeleteView):
|
||||
class IngredientDelete(GroupRequiredMixin, DeleteView):
|
||||
groups_required = ['user']
|
||||
template_name = "generic/delete_template.html"
|
||||
model = Ingredient
|
||||
success_url = reverse_lazy('list_ingredient')
|
||||
@@ -82,7 +90,8 @@ class IngredientDelete(LoginRequiredMixin, DeleteView):
|
||||
return context
|
||||
|
||||
|
||||
class StorageDelete(LoginRequiredMixin, DeleteView):
|
||||
class StorageDelete(GroupRequiredMixin, DeleteView):
|
||||
groups_required = ['admin']
|
||||
template_name = "generic/delete_template.html"
|
||||
model = Storage
|
||||
success_url = reverse_lazy('list_storage')
|
||||
@@ -93,7 +102,7 @@ class StorageDelete(LoginRequiredMixin, DeleteView):
|
||||
return context
|
||||
|
||||
|
||||
class CommentDelete(LoginRequiredMixin, DeleteView):
|
||||
class CommentDelete(OwnerRequiredMixin, DeleteView):
|
||||
template_name = "generic/delete_template.html"
|
||||
model = Comment
|
||||
success_url = reverse_lazy('index')
|
||||
@@ -104,7 +113,7 @@ class CommentDelete(LoginRequiredMixin, DeleteView):
|
||||
return context
|
||||
|
||||
|
||||
class RecipeBookDelete(LoginRequiredMixin, DeleteView):
|
||||
class RecipeBookDelete(OwnerRequiredMixin, DeleteView):
|
||||
template_name = "generic/delete_template.html"
|
||||
model = RecipeBook
|
||||
success_url = reverse_lazy('view_books')
|
||||
@@ -115,18 +124,26 @@ class RecipeBookDelete(LoginRequiredMixin, DeleteView):
|
||||
return context
|
||||
|
||||
|
||||
class RecipeBookEntryDelete(LoginRequiredMixin, DeleteView):
|
||||
class RecipeBookEntryDelete(GroupRequiredMixin, DeleteView):
|
||||
groups_required = ['user']
|
||||
template_name = "generic/delete_template.html"
|
||||
model = RecipeBookEntry
|
||||
success_url = reverse_lazy('view_books')
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
obj = self.get_object()
|
||||
if not (obj.book.created_by == request.user or request.user.is_superuser):
|
||||
messages.add_message(request, messages.ERROR, _('You cannot interact with this object as its not owned by you!'))
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
return super(RecipeBookEntryDelete, self).dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super(RecipeBookEntryDelete, self).get_context_data(**kwargs)
|
||||
context['title'] = _("Bookmarks")
|
||||
return context
|
||||
|
||||
|
||||
class MealPlanDelete(LoginRequiredMixin, DeleteView):
|
||||
class MealPlanDelete(OwnerRequiredMixin, DeleteView):
|
||||
template_name = "generic/delete_template.html"
|
||||
model = MealPlan
|
||||
success_url = reverse_lazy('view_plan')
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import os
|
||||
import uuid
|
||||
from io import BytesIO
|
||||
|
||||
import simplejson
|
||||
import simplejson as json
|
||||
from PIL import Image
|
||||
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.http import HttpResponseRedirect
|
||||
@@ -15,14 +15,17 @@ from django.utils.translation import gettext as _
|
||||
from django.views.generic import UpdateView
|
||||
|
||||
from cookbook.forms import ExternalRecipeForm, KeywordForm, StorageForm, SyncForm, InternalRecipeForm, CommentForm, \
|
||||
MealPlanForm, UnitMergeForm, IngredientMergeForm, IngredientForm
|
||||
MealPlanForm, UnitMergeForm, IngredientMergeForm, IngredientForm, RecipeBookForm
|
||||
from cookbook.helper.permission_helper import group_required, GroupRequiredMixin
|
||||
|
||||
from cookbook.helper.permission_helper import OwnerRequiredMixin
|
||||
from cookbook.models import Recipe, Sync, Keyword, RecipeImport, Storage, Comment, RecipeIngredient, RecipeBook, \
|
||||
MealPlan, Unit, Ingredient
|
||||
from cookbook.provider.dropbox import Dropbox
|
||||
from cookbook.provider.nextcloud import Nextcloud
|
||||
|
||||
|
||||
@login_required
|
||||
@group_required('guest')
|
||||
def switch_recipe(request, pk):
|
||||
recipe = get_object_or_404(Recipe, pk=pk)
|
||||
if recipe.internal:
|
||||
@@ -31,7 +34,7 @@ def switch_recipe(request, pk):
|
||||
return HttpResponseRedirect(reverse('edit_external_recipe', args=[pk]))
|
||||
|
||||
|
||||
@login_required
|
||||
@group_required('user')
|
||||
def convert_recipe(request, pk):
|
||||
recipe = get_object_or_404(Recipe, pk=pk)
|
||||
if not recipe.internal:
|
||||
@@ -41,7 +44,7 @@ def convert_recipe(request, pk):
|
||||
return HttpResponseRedirect(reverse('edit_internal_recipe', args=[pk]))
|
||||
|
||||
|
||||
@login_required
|
||||
@group_required('user')
|
||||
def internal_recipe_update(request, pk):
|
||||
recipe_instance = get_object_or_404(Recipe, pk=pk)
|
||||
status = 200
|
||||
@@ -69,7 +72,7 @@ def internal_recipe_update(request, pk):
|
||||
|
||||
im_io = BytesIO()
|
||||
img.save(im_io, 'PNG', quality=70)
|
||||
recipe.image = File(im_io, name=(str(recipe.pk) + '.png'))
|
||||
recipe.image = File(im_io, name=f'{uuid.uuid4()}_{recipe.pk}.png')
|
||||
elif 'image' in form.changed_data and form.cleaned_data['image'] is False:
|
||||
recipe.image = None
|
||||
|
||||
@@ -98,7 +101,10 @@ def internal_recipe_update(request, pk):
|
||||
recipe_ingredient.ingredient = ingredient
|
||||
|
||||
if isinstance(i['amount'], str):
|
||||
recipe_ingredient.amount = float(i['amount'].replace(',', '.'))
|
||||
try:
|
||||
recipe_ingredient.amount = float(i['amount'].replace(',', '.'))
|
||||
except ValueError:
|
||||
form.add_error("ingredients", _('There was an error converting your ingredients amount to a number: ') + i['unit__name'])
|
||||
else:
|
||||
recipe_ingredient.amount = i['amount']
|
||||
|
||||
@@ -121,14 +127,15 @@ def internal_recipe_update(request, pk):
|
||||
else:
|
||||
form = InternalRecipeForm(instance=recipe_instance)
|
||||
|
||||
ingredients = RecipeIngredient.objects.select_related('unit__name', 'ingredient__name').filter(recipe=recipe_instance).values('ingredient__name', 'unit__name', 'amount', 'note')
|
||||
ingredients = RecipeIngredient.objects.select_related('unit__name', 'ingredient__name').filter(recipe=recipe_instance).values('ingredient__name', 'unit__name', 'amount', 'note').order_by('id')
|
||||
|
||||
return render(request, 'forms/edit_internal_recipe.html',
|
||||
{'form': form, 'ingredients': json.dumps(list(ingredients)),
|
||||
'view_url': reverse('view_recipe', args=[pk])}, status=status)
|
||||
|
||||
|
||||
class SyncUpdate(LoginRequiredMixin, UpdateView):
|
||||
class SyncUpdate(GroupRequiredMixin, UpdateView):
|
||||
groups_required = ['admin']
|
||||
template_name = "generic/edit_template.html"
|
||||
model = Sync
|
||||
form_class = SyncForm
|
||||
@@ -144,7 +151,8 @@ class SyncUpdate(LoginRequiredMixin, UpdateView):
|
||||
return context
|
||||
|
||||
|
||||
class KeywordUpdate(LoginRequiredMixin, UpdateView):
|
||||
class KeywordUpdate(GroupRequiredMixin, UpdateView):
|
||||
groups_required = ['user']
|
||||
template_name = "generic/edit_template.html"
|
||||
model = Keyword
|
||||
form_class = KeywordForm
|
||||
@@ -160,7 +168,8 @@ class KeywordUpdate(LoginRequiredMixin, UpdateView):
|
||||
return context
|
||||
|
||||
|
||||
class IngredientUpdate(LoginRequiredMixin, UpdateView):
|
||||
class IngredientUpdate(GroupRequiredMixin, UpdateView):
|
||||
groups_required = ['user']
|
||||
template_name = "generic/edit_template.html"
|
||||
model = Ingredient
|
||||
form_class = IngredientForm
|
||||
@@ -176,7 +185,7 @@ class IngredientUpdate(LoginRequiredMixin, UpdateView):
|
||||
return context
|
||||
|
||||
|
||||
@login_required
|
||||
@group_required('admin')
|
||||
def edit_storage(request, pk):
|
||||
instance = get_object_or_404(Storage, pk=pk)
|
||||
|
||||
@@ -202,30 +211,21 @@ def edit_storage(request, pk):
|
||||
|
||||
messages.add_message(request, messages.SUCCESS, _('Storage saved!'))
|
||||
else:
|
||||
messages.add_message(request, messages.ERROR, _('There was an error updating this storage backend.!'))
|
||||
messages.add_message(request, messages.ERROR, _('There was an error updating this storage backend!'))
|
||||
else:
|
||||
pseudo_instance = instance
|
||||
pseudo_instance.password = '__NO__CHANGE__'
|
||||
pseudo_instance.token = '__NO__CHANGE__'
|
||||
form = StorageForm(instance=pseudo_instance)
|
||||
|
||||
return render(request, 'generic/edit_template.html', {'form': form})
|
||||
return render(request, 'generic/edit_template.html', {'form': form, 'title': _('Storage')})
|
||||
|
||||
|
||||
class CommentUpdate(LoginRequiredMixin, UpdateView):
|
||||
class CommentUpdate(OwnerRequiredMixin, UpdateView):
|
||||
template_name = "generic/edit_template.html"
|
||||
model = Comment
|
||||
form_class = CommentForm
|
||||
|
||||
# TODO add msg box
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
obj = self.get_object()
|
||||
if not (obj.created_by == request.user or request.user.is_superuser):
|
||||
messages.add_message(request, messages.ERROR, _('You cannot edit this comment!'))
|
||||
return HttpResponseRedirect(reverse('view_recipe', args=[obj.recipe.pk]))
|
||||
return super(CommentUpdate, self).dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('edit_comment', kwargs={'pk': self.object.pk})
|
||||
|
||||
@@ -236,7 +236,8 @@ class CommentUpdate(LoginRequiredMixin, UpdateView):
|
||||
return context
|
||||
|
||||
|
||||
class ImportUpdate(LoginRequiredMixin, UpdateView):
|
||||
class ImportUpdate(GroupRequiredMixin, UpdateView):
|
||||
groups_required = ['user']
|
||||
template_name = "generic/edit_template.html"
|
||||
model = RecipeImport
|
||||
fields = ['name', 'path']
|
||||
@@ -252,12 +253,10 @@ class ImportUpdate(LoginRequiredMixin, UpdateView):
|
||||
return context
|
||||
|
||||
|
||||
class RecipeBookUpdate(LoginRequiredMixin, UpdateView):
|
||||
class RecipeBookUpdate(OwnerRequiredMixin, UpdateView):
|
||||
template_name = "generic/edit_template.html"
|
||||
model = RecipeBook
|
||||
fields = ['name']
|
||||
|
||||
# TODO add msg box
|
||||
form_class = RecipeBookForm
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('view_books')
|
||||
@@ -268,15 +267,13 @@ class RecipeBookUpdate(LoginRequiredMixin, UpdateView):
|
||||
return context
|
||||
|
||||
|
||||
class MealPlanUpdate(LoginRequiredMixin, UpdateView):
|
||||
class MealPlanUpdate(OwnerRequiredMixin, 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')
|
||||
return reverse('view_plan_entry', kwargs={'pk': self.object.pk})
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super(MealPlanUpdate, self).get_context_data(**kwargs)
|
||||
@@ -284,7 +281,8 @@ class MealPlanUpdate(LoginRequiredMixin, UpdateView):
|
||||
return context
|
||||
|
||||
|
||||
class ExternalRecipeUpdate(LoginRequiredMixin, UpdateView):
|
||||
class ExternalRecipeUpdate(GroupRequiredMixin, UpdateView):
|
||||
groups_required = ['user']
|
||||
model = Recipe
|
||||
form_class = ExternalRecipeForm
|
||||
template_name = "generic/edit_template.html"
|
||||
@@ -319,7 +317,7 @@ class ExternalRecipeUpdate(LoginRequiredMixin, UpdateView):
|
||||
return context
|
||||
|
||||
|
||||
@login_required
|
||||
@group_required('user')
|
||||
def edit_ingredients(request):
|
||||
if request.method == "POST":
|
||||
success = False
|
||||
|
||||
117
cookbook/views/import_export.py
Normal file
117
cookbook/views/import_export.py
Normal file
@@ -0,0 +1,117 @@
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
|
||||
from django.contrib import messages
|
||||
from django.core.files.base import ContentFile
|
||||
from django.db import IntegrityError
|
||||
from django.http import HttpResponseRedirect, JsonResponse, HttpResponse
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse_lazy
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
from cookbook.forms import ExportForm, ImportForm
|
||||
from cookbook.helper.permission_helper import group_required
|
||||
from cookbook.models import RecipeIngredient, Recipe, Unit, Ingredient, Keyword
|
||||
|
||||
|
||||
@group_required('user')
|
||||
def import_recipe(request):
|
||||
if request.method == "POST":
|
||||
form = ImportForm(request.POST)
|
||||
if form.is_valid():
|
||||
data = json.loads(form.cleaned_data['recipe'])
|
||||
|
||||
recipe = Recipe.objects.create(name=data['recipe']['name'], instructions=data['recipe']['instructions'],
|
||||
working_time=data['recipe']['working_time'], waiting_time=data['recipe']['waiting_time'],
|
||||
created_by=request.user, internal=True)
|
||||
|
||||
for k in data['keywords']:
|
||||
try:
|
||||
Keyword.objects.create(name=k['name'], icon=k['icon'], description=k['description']).save()
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
recipe.keywords.add(Keyword.objects.get(name=k['name']))
|
||||
|
||||
for u in data['units']:
|
||||
try:
|
||||
Unit.objects.create(name=u['name'], description=u['description']).save()
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
for i in data['ingredients']:
|
||||
try:
|
||||
Ingredient.objects.create(name=i['name']).save()
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
for ri in data['recipe_ingredients']:
|
||||
RecipeIngredient.objects.create(recipe=recipe, ingredient=Ingredient.objects.get(name=ri['ingredient']),
|
||||
unit=Unit.objects.get(name=ri['unit']), amount=ri['amount'], note=ri['note'])
|
||||
|
||||
if data['image']:
|
||||
fmt, img = data['image'].split(';base64,')
|
||||
ext = fmt.split('/')[-1]
|
||||
recipe.image = ContentFile(base64.b64decode(img), name=f'{recipe.pk}.{ext}')
|
||||
recipe.save()
|
||||
|
||||
messages.add_message(request, messages.SUCCESS, _('Recipe imported successfully!'))
|
||||
return HttpResponseRedirect(reverse_lazy('view_recipe', args=[recipe.pk]))
|
||||
else:
|
||||
form = ImportForm()
|
||||
|
||||
return render(request, 'import.html', {'form': form})
|
||||
|
||||
|
||||
@group_required('user')
|
||||
def export_recipe(request):
|
||||
context = {}
|
||||
if request.method == "POST":
|
||||
form = ExportForm(request.POST)
|
||||
if form.is_valid():
|
||||
recipe = form.cleaned_data['recipe']
|
||||
if recipe.internal:
|
||||
export = {
|
||||
'recipe': {'name': recipe.name, 'instructions': recipe.instructions, 'working_time': recipe.working_time, 'waiting_time': recipe.working_time},
|
||||
'units': [],
|
||||
'ingredients': [],
|
||||
'recipe_ingredients': [],
|
||||
'keywords': [],
|
||||
'image': None
|
||||
}
|
||||
|
||||
for k in recipe.keywords.all():
|
||||
export['keywords'].append({'name': k.name, 'icon': k.icon, 'description': k.description})
|
||||
|
||||
for ri in RecipeIngredient.objects.filter(recipe=recipe).all():
|
||||
if ri.unit not in export['units']:
|
||||
export['units'].append({'name': ri.unit.name, 'description': ri.unit.description})
|
||||
if ri.ingredient not in export['ingredients']:
|
||||
export['ingredients'].append({'name': ri.ingredient.name})
|
||||
|
||||
export['recipe_ingredients'].append({'ingredient': ri.ingredient.name, 'unit': ri.unit.name, 'amount': float(ri.amount), 'note': ri.note})
|
||||
|
||||
if recipe.image and form.cleaned_data['image']:
|
||||
with open(recipe.image.path, 'rb') as img_f:
|
||||
export['image'] = f'data:image/png;base64,{base64.b64encode(img_f.read()).decode("utf-8")}'
|
||||
|
||||
if form.cleaned_data['download']:
|
||||
response = HttpResponse(json.dumps(export), content_type='text/plain')
|
||||
response['Content-Disposition'] = f'attachment; filename={recipe.name}.json'
|
||||
return response
|
||||
|
||||
context['export'] = json.dumps(export, indent=4)
|
||||
else:
|
||||
form.add_error('recipe', _('External recipes cannot be exported, please share the file directly or select an internal recipe.'))
|
||||
else:
|
||||
form = ExportForm()
|
||||
recipe = request.GET.get('r')
|
||||
if recipe:
|
||||
if re.match(r'^([0-9])+$', recipe):
|
||||
if recipe := Recipe.objects.filter(pk=int(recipe)).first():
|
||||
form = ExportForm(initial={'recipe': recipe})
|
||||
|
||||
context['form'] = form
|
||||
|
||||
return render(request, 'export.html', context)
|
||||
@@ -1,16 +1,16 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models.functions import Lower
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse_lazy
|
||||
from django_tables2 import RequestConfig
|
||||
from django.utils.translation import gettext as _
|
||||
from django_tables2 import RequestConfig
|
||||
|
||||
from cookbook.filters import IngredientFilter
|
||||
from cookbook.helper.permission_helper import group_required
|
||||
from cookbook.models import Keyword, SyncLog, RecipeImport, Storage, Ingredient
|
||||
from cookbook.tables import KeywordTable, ImportLogTable, RecipeImportTable, StorageTable, IngredientTable
|
||||
|
||||
|
||||
@login_required
|
||||
@group_required('user')
|
||||
def keyword(request):
|
||||
table = KeywordTable(Keyword.objects.all())
|
||||
RequestConfig(request, paginate={'per_page': 25}).configure(table)
|
||||
@@ -18,7 +18,7 @@ def keyword(request):
|
||||
return render(request, 'generic/list_template.html', {'title': _("Keyword"), 'table': table, 'create_url': 'new_keyword'})
|
||||
|
||||
|
||||
@login_required
|
||||
@group_required('admin')
|
||||
def sync_log(request):
|
||||
table = ImportLogTable(SyncLog.objects.all().order_by(Lower('created_at').desc()))
|
||||
RequestConfig(request, paginate={'per_page': 25}).configure(table)
|
||||
@@ -26,16 +26,16 @@ def sync_log(request):
|
||||
return render(request, 'generic/list_template.html', {'title': _("Import Log"), 'table': table})
|
||||
|
||||
|
||||
@login_required
|
||||
@group_required('user')
|
||||
def recipe_import(request):
|
||||
table = RecipeImportTable(RecipeImport.objects.all())
|
||||
|
||||
RequestConfig(request, paginate={'per_page': 25}).configure(table)
|
||||
|
||||
return render(request, 'generic/list_template.html', {'title': _("Import"), 'table': table, 'import_btn': True})
|
||||
return render(request, 'generic/list_template.html', {'title': _("Discovery"), 'table': table, 'import_btn': True})
|
||||
|
||||
|
||||
@login_required
|
||||
@group_required('user')
|
||||
def ingredient(request):
|
||||
f = IngredientFilter(request.GET, queryset=Ingredient.objects.all().order_by('pk'))
|
||||
|
||||
@@ -45,7 +45,7 @@ def ingredient(request):
|
||||
return render(request, 'generic/list_template.html', {'title': _("Ingredients"), 'table': table, 'filter': f})
|
||||
|
||||
|
||||
@login_required
|
||||
@group_required('admin')
|
||||
def storage(request):
|
||||
table = StorageTable(Storage.objects.all())
|
||||
RequestConfig(request, paginate={'per_page': 25}).configure(table)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import render, redirect
|
||||
from django.urls import reverse_lazy, reverse
|
||||
@@ -11,10 +10,12 @@ from django.views.generic import CreateView
|
||||
|
||||
from cookbook.forms import ImportRecipeForm, RecipeImport, KeywordForm, Storage, StorageForm, InternalRecipeForm, \
|
||||
RecipeBookForm, MealPlanForm
|
||||
from cookbook.helper.permission_helper import GroupRequiredMixin, group_required
|
||||
from cookbook.models import Keyword, Recipe, RecipeBook, MealPlan
|
||||
|
||||
|
||||
class RecipeCreate(LoginRequiredMixin, CreateView):
|
||||
class RecipeCreate(GroupRequiredMixin, CreateView):
|
||||
groups_required = ['user']
|
||||
template_name = "generic/new_template.html"
|
||||
model = Recipe
|
||||
fields = ('name',)
|
||||
@@ -35,7 +36,8 @@ class RecipeCreate(LoginRequiredMixin, CreateView):
|
||||
return context
|
||||
|
||||
|
||||
class KeywordCreate(LoginRequiredMixin, CreateView):
|
||||
class KeywordCreate(GroupRequiredMixin, CreateView):
|
||||
groups_required = ['user']
|
||||
template_name = "generic/new_template.html"
|
||||
model = Keyword
|
||||
form_class = KeywordForm
|
||||
@@ -47,7 +49,8 @@ class KeywordCreate(LoginRequiredMixin, CreateView):
|
||||
return context
|
||||
|
||||
|
||||
class StorageCreate(LoginRequiredMixin, CreateView):
|
||||
class StorageCreate(GroupRequiredMixin, CreateView):
|
||||
groups_required = ['admin']
|
||||
template_name = "generic/new_template.html"
|
||||
model = Storage
|
||||
form_class = StorageForm
|
||||
@@ -65,7 +68,7 @@ class StorageCreate(LoginRequiredMixin, CreateView):
|
||||
return context
|
||||
|
||||
|
||||
@login_required
|
||||
@group_required('user')
|
||||
def create_new_external_recipe(request, import_id):
|
||||
if request.method == "POST":
|
||||
form = ImportRecipeForm(request.POST)
|
||||
@@ -96,7 +99,8 @@ def create_new_external_recipe(request, import_id):
|
||||
return render(request, 'forms/edit_import_recipe.html', {'form': form})
|
||||
|
||||
|
||||
class RecipeBookCreate(LoginRequiredMixin, CreateView):
|
||||
class RecipeBookCreate(GroupRequiredMixin, CreateView):
|
||||
groups_required = ['user']
|
||||
template_name = "generic/new_template.html"
|
||||
model = RecipeBook
|
||||
form_class = RecipeBookForm
|
||||
@@ -104,7 +108,7 @@ class RecipeBookCreate(LoginRequiredMixin, CreateView):
|
||||
|
||||
def form_valid(self, form):
|
||||
obj = form.save(commit=False)
|
||||
obj.user = self.request.user
|
||||
obj.created_by = self.request.user
|
||||
obj.save()
|
||||
return HttpResponseRedirect(reverse('view_books'))
|
||||
|
||||
@@ -114,15 +118,23 @@ class RecipeBookCreate(LoginRequiredMixin, CreateView):
|
||||
return context
|
||||
|
||||
|
||||
class MealPlanCreate(LoginRequiredMixin, CreateView):
|
||||
class MealPlanCreate(GroupRequiredMixin, CreateView):
|
||||
groups_required = ['user']
|
||||
template_name = "generic/new_template.html"
|
||||
model = MealPlan
|
||||
form_class = MealPlanForm
|
||||
success_url = reverse_lazy('view_plan')
|
||||
|
||||
def get_initial(self):
|
||||
return dict(
|
||||
meal=self.request.GET['meal'] if 'meal' in self.request.GET else None,
|
||||
date=datetime.strptime(self.request.GET['date'], '%Y-%m-%d') if 'date' in self.request.GET else None,
|
||||
shared=self.request.user.userpreference.plan_share.all() if self.request.user.userpreference.plan_share else None
|
||||
)
|
||||
|
||||
def form_valid(self, form):
|
||||
obj = form.save(commit=False)
|
||||
obj.user = self.request.user
|
||||
obj.created_by = self.request.user
|
||||
obj.save()
|
||||
return HttpResponseRedirect(reverse('view_plan'))
|
||||
|
||||
|
||||
@@ -1,33 +1,73 @@
|
||||
import copy
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import update_session_auth_hash
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth import update_session_auth_hash, authenticate
|
||||
from django.contrib.auth.forms import PasswordChangeForm
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Q
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.utils import timezone
|
||||
from django_tables2 import RequestConfig
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from cookbook.filters import RecipeFilter
|
||||
from cookbook.forms import *
|
||||
from cookbook.tables import RecipeTable
|
||||
from cookbook.helper.permission_helper import group_required
|
||||
from cookbook.tables import RecipeTable, RecipeTableSmall, CookLogTable, ViewLogTable
|
||||
|
||||
|
||||
def index(request):
|
||||
if not request.user.is_authenticated:
|
||||
if User.objects.count() < 1 and 'django.contrib.auth.backends.RemoteUserBackend' not in settings.AUTHENTICATION_BACKENDS:
|
||||
return HttpResponseRedirect(reverse_lazy('view_setup'))
|
||||
return HttpResponseRedirect(reverse_lazy('view_search'))
|
||||
try:
|
||||
page_map = {
|
||||
UserPreference.SEARCH: reverse_lazy('view_search'),
|
||||
UserPreference.PLAN: reverse_lazy('view_plan'),
|
||||
UserPreference.BOOKS: reverse_lazy('view_books'),
|
||||
}
|
||||
|
||||
return HttpResponseRedirect(page_map.get(request.user.userpreference.default_page))
|
||||
except UserPreference.DoesNotExist:
|
||||
return HttpResponseRedirect(reverse_lazy('view_search'))
|
||||
|
||||
|
||||
def search(request):
|
||||
if request.user.is_authenticated:
|
||||
f = RecipeFilter(request.GET, queryset=Recipe.objects.all().order_by('name'))
|
||||
|
||||
table = RecipeTable(f.qs)
|
||||
if request.user.userpreference.search_style == UserPreference.LARGE:
|
||||
table = RecipeTable(f.qs)
|
||||
else:
|
||||
table = RecipeTableSmall(f.qs)
|
||||
RequestConfig(request, paginate={'per_page': 25}).configure(table)
|
||||
|
||||
return render(request, 'index.html', {'recipes': table, 'filter': f})
|
||||
if request.GET == {} and request.user.userpreference.show_recent:
|
||||
qs = Recipe.objects.filter(viewlog__created_by=request.user).order_by('-viewlog__created_at').all()
|
||||
|
||||
recent_list = []
|
||||
for r in qs:
|
||||
if r not in recent_list:
|
||||
recent_list.append(r)
|
||||
if len(recent_list) >= 5:
|
||||
break
|
||||
|
||||
last_viewed = RecipeTable(recent_list)
|
||||
else:
|
||||
last_viewed = None
|
||||
|
||||
return render(request, 'index.html', {'recipes': table, 'filter': f, 'last_viewed': last_viewed})
|
||||
else:
|
||||
return render(request, 'index.html')
|
||||
|
||||
|
||||
@login_required
|
||||
@group_required('guest')
|
||||
def recipe_view(request, pk):
|
||||
recipe = get_object_or_404(Recipe, pk=pk)
|
||||
ingredients = RecipeIngredient.objects.filter(recipe=recipe)
|
||||
@@ -58,16 +98,20 @@ def recipe_view(request, pk):
|
||||
comment_form = CommentForm()
|
||||
bookmark_form = RecipeBookEntryForm()
|
||||
|
||||
if request.user.is_authenticated:
|
||||
if not ViewLog.objects.filter(recipe=recipe).filter(created_by=request.user).filter(created_at__gt=(timezone.now() - timezone.timedelta(minutes=5))).exists():
|
||||
ViewLog.objects.create(recipe=recipe, created_by=request.user)
|
||||
|
||||
return render(request, 'recipe_view.html',
|
||||
{'recipe': recipe, 'ingredients': ingredients, 'comments': comments, 'comment_form': comment_form,
|
||||
'bookmark_form': bookmark_form})
|
||||
|
||||
|
||||
@login_required()
|
||||
@group_required('user')
|
||||
def books(request):
|
||||
book_list = []
|
||||
|
||||
books = RecipeBook.objects.filter(user=request.user).all()
|
||||
books = RecipeBook.objects.filter(Q(created_by=request.user) | Q(shared=request.user)).distinct().all()
|
||||
|
||||
for b in books:
|
||||
book_list.append({'book': b, 'recipes': RecipeBookEntry.objects.filter(book=b).all()})
|
||||
@@ -89,7 +133,7 @@ def get_days_from_week(start, end):
|
||||
return days
|
||||
|
||||
|
||||
@login_required()
|
||||
@group_required('user')
|
||||
def meal_plan(request):
|
||||
js_week = datetime.now().strftime("%Y-W%V")
|
||||
if request.method == "POST":
|
||||
@@ -110,14 +154,27 @@ def meal_plan(request):
|
||||
plan[t[0]] = {'type_name': t[1], 'days': copy.deepcopy(days_dict)}
|
||||
|
||||
for d in days:
|
||||
plan_day = MealPlan.objects.filter(date=d).all()
|
||||
plan_day = MealPlan.objects.filter(date=d).filter(Q(created_by=request.user) | Q(shared=request.user)).distinct().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
|
||||
@group_required('user')
|
||||
def meal_plan_entry(request, pk):
|
||||
plan = MealPlan.objects.get(pk=pk)
|
||||
|
||||
if plan.created_by != request.user and plan.shared != request.user:
|
||||
messages.add_message(request, messages.ERROR, _('You do not have the required permissions to view this page!'))
|
||||
return HttpResponseRedirect(reverse_lazy('index'))
|
||||
|
||||
same_day_plan = MealPlan.objects.filter(date=plan.date).exclude(pk=plan.pk).filter(Q(created_by=request.user) | Q(shared=request.user)).order_by('meal').all()
|
||||
|
||||
return render(request, 'meal_plan_entry.html', {'plan': plan, 'same_day_plan': same_day_plan})
|
||||
|
||||
|
||||
@group_required('user')
|
||||
def shopping_list(request):
|
||||
markdown_format = True
|
||||
|
||||
@@ -143,7 +200,7 @@ def shopping_list(request):
|
||||
ingredients = []
|
||||
|
||||
for r in recipes:
|
||||
for ri in RecipeIngredient.objects.filter(recipe=r).all():
|
||||
for ri in RecipeIngredient.objects.filter(recipe=r).exclude(unit__name__contains='Special:').all():
|
||||
index = None
|
||||
for x, ig in enumerate(ingredients):
|
||||
if ri.ingredient == ig.ingredient and ri.unit == ig.unit:
|
||||
@@ -157,12 +214,9 @@ def shopping_list(request):
|
||||
return render(request, 'shopping_list.html', {'ingredients': ingredients, 'recipes': recipes, 'form': form, 'markdown_format': markdown_format})
|
||||
|
||||
|
||||
@login_required
|
||||
def settings(request):
|
||||
try:
|
||||
up = request.user.userpreference
|
||||
except UserPreference.DoesNotExist:
|
||||
up = None
|
||||
@group_required('guest')
|
||||
def user_settings(request):
|
||||
up = request.user.userpreference
|
||||
|
||||
user_name_form = UserNameForm(instance=request.user)
|
||||
password_form = PasswordChangeForm(request.user)
|
||||
@@ -175,6 +229,11 @@ def settings(request):
|
||||
up = UserPreference(user=request.user)
|
||||
up.theme = form.cleaned_data['theme']
|
||||
up.nav_color = form.cleaned_data['nav_color']
|
||||
up.default_unit = form.cleaned_data['default_unit']
|
||||
up.default_page = form.cleaned_data['default_page']
|
||||
up.show_recent = form.cleaned_data['show_recent']
|
||||
up.search_style = form.cleaned_data['search_style']
|
||||
up.plan_share.set(form.cleaned_data['plan_share'])
|
||||
up.save()
|
||||
|
||||
if 'user_name_form' in request.POST:
|
||||
@@ -196,3 +255,44 @@ def settings(request):
|
||||
preference_form = UserPreferenceForm()
|
||||
|
||||
return render(request, 'settings.html', {'preference_form': preference_form, 'user_name_form': user_name_form, 'password_form': password_form})
|
||||
|
||||
|
||||
@group_required('guest')
|
||||
def history(request):
|
||||
view_log = ViewLogTable(ViewLog.objects.filter(created_by=request.user).order_by('-created_at').all())
|
||||
cook_log = CookLogTable(CookLog.objects.filter(created_by=request.user).order_by('-created_at').all())
|
||||
return render(request, 'history.html', {'view_log': view_log, 'cook_log': cook_log})
|
||||
|
||||
|
||||
def setup(request):
|
||||
if User.objects.count() > 0 or 'django.contrib.auth.backends.RemoteUserBackend' in settings.AUTHENTICATION_BACKENDS:
|
||||
messages.add_message(request, messages.ERROR, _('The setup page can only be used to create the first user! If you have forgotten your superuser credentials please consult the django documentation on how to reset passwords.'))
|
||||
return HttpResponseRedirect(reverse('login'))
|
||||
|
||||
if request.method == 'POST':
|
||||
form = SuperUserForm(request.POST)
|
||||
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'],
|
||||
is_superuser=True
|
||||
)
|
||||
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!'))
|
||||
return HttpResponseRedirect(reverse('login'))
|
||||
except ValidationError as e:
|
||||
for m in e:
|
||||
form.add_error('password', m)
|
||||
else:
|
||||
form = SuperUserForm()
|
||||
|
||||
return render(request, 'setup.html', {'form': form})
|
||||
|
||||
|
||||
def markdown_info(request):
|
||||
return render(request, 'markdown_info.html', {})
|
||||
|
||||
@@ -4,10 +4,6 @@ server {
|
||||
|
||||
client_max_body_size 16M;
|
||||
|
||||
# serve static files
|
||||
location /static/ {
|
||||
alias /static/;
|
||||
}
|
||||
# serve media files
|
||||
location /media/ {
|
||||
alias /media/;
|
||||
|
||||
64
docs/docker/traefik-nginx/README.md
Normal file
64
docs/docker/traefik-nginx/README.md
Normal file
@@ -0,0 +1,64 @@
|
||||
This is the recommended setup to run django recipes with traefik.
|
||||
|
||||
----
|
||||
|
||||
Please refer to the traefik documentation on how to setup a docker service in traefik. Since treafik can be a little
|
||||
confusing at times, the following are examples of my traefik configuration.
|
||||
|
||||
|
||||
You need to create a network called `traefik` using `docker network create traefik`.
|
||||
## docker-compose.yml
|
||||
|
||||
```
|
||||
version: "3.3"
|
||||
|
||||
services:
|
||||
|
||||
traefik:
|
||||
image: "traefik:v2.1"
|
||||
container_name: "traefik"
|
||||
ports:
|
||||
- "443:443"
|
||||
- "80:80"
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- "./letsencrypt:/letsencrypt"
|
||||
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
||||
- "./config:/etc/traefik/"
|
||||
|
||||
|
||||
networks:
|
||||
default:
|
||||
external:
|
||||
name: traefik
|
||||
```
|
||||
|
||||
## traefik.toml
|
||||
Place this in a directory called `config` as this is mounted into the traefik container (see docer compose).
|
||||
**Change the email address accordingly**.
|
||||
```
|
||||
[api]
|
||||
insecure=true
|
||||
|
||||
[providers.docker]
|
||||
endpoint = "unix:///var/run/docker.sock"
|
||||
exposedByDefault = false
|
||||
network = "traefik"
|
||||
|
||||
#[log]
|
||||
# level = "DEBUG"
|
||||
|
||||
[entryPoints]
|
||||
[entryPoints.web]
|
||||
address = ":80"
|
||||
|
||||
[entryPoints.web_secure]
|
||||
address = ":443"
|
||||
|
||||
[certificatesResolvers.le_resolver.acme]
|
||||
|
||||
email = "you_email@mail.com"
|
||||
storage = "/letsencrypt/acme.json"
|
||||
|
||||
tlsChallenge=true
|
||||
```
|
||||
46
docs/docker/traefik-nginx/docker-compose.yml
Normal file
46
docs/docker/traefik-nginx/docker-compose.yml
Normal file
@@ -0,0 +1,46 @@
|
||||
version: "3"
|
||||
services:
|
||||
db_recipes:
|
||||
restart: always
|
||||
image: postgres:11-alpine
|
||||
volumes:
|
||||
- ./postgresql:/var/lib/postgresql/data
|
||||
env_file:
|
||||
- ./.env
|
||||
networks:
|
||||
- default
|
||||
|
||||
web_recipes:
|
||||
image: vabene1111/recipes
|
||||
restart: always
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- ./staticfiles:/opt/recipes/staticfiles
|
||||
- ./mediafiles:/opt/recipes/mediafiles
|
||||
depends_on:
|
||||
- db_recipes
|
||||
networks:
|
||||
- default
|
||||
|
||||
nginx_recipes:
|
||||
image: nginx:mainline-alpine
|
||||
restart: always
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- ./nginx/conf.d:/etc/nginx/conf.d
|
||||
- ./mediafiles:/media
|
||||
labels: # traefik example labels
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.recipes.rule=Host(`recipes.mydomain.com`, `recipes.myotherdomain.com`)"
|
||||
- "traefik.http.routers.recipes.entrypoints=web_secure"
|
||||
- "traefik.http.routers.recipes.tls.certresolver=le_resolver"
|
||||
networks:
|
||||
- default
|
||||
- traefik
|
||||
|
||||
networks:
|
||||
default:
|
||||
traefik: # This is you external traefik network
|
||||
external: true
|
||||
16
docs/docker/traefik-nginx/nginx/conf.d/Recipes.conf
Normal file
16
docs/docker/traefik-nginx/nginx/conf.d/Recipes.conf
Normal file
@@ -0,0 +1,16 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
client_max_body_size 16M;
|
||||
|
||||
# serve media files
|
||||
location /media/ {
|
||||
alias /media/;
|
||||
}
|
||||
# pass requests for dynamic content to gunicorn
|
||||
location / {
|
||||
proxy_set_header Host $host;
|
||||
proxy_pass http://web_recipes:8080;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,12 @@
|
||||
# Important Information
|
||||
Although this application allows running without any webserver in front of gunicorn it is heavily recommended by almost
|
||||
everyone **not** to do this. It is hard to find exact explanations and appears not to be a security but only
|
||||
a performance risk but that is just my personal interpretation.
|
||||
|
||||
**If you dont know what you are doing please choose the traefik-nginx config**
|
||||
|
||||
----
|
||||
|
||||
Please refer to the traefik documentation on how to setup a docker service in traefik. Since treafik can be a little
|
||||
confusing at times, the following are examples of my traefik configuration.
|
||||
|
||||
|
||||
33
docs/k8s/10-configmap.yaml
Normal file
33
docs/k8s/10-configmap.yaml
Normal file
@@ -0,0 +1,33 @@
|
||||
kind: ConfigMap
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
labels:
|
||||
app: recipes
|
||||
name: recipes-nginx-config
|
||||
data:
|
||||
nginx-config: |-
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
http {
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
client_max_body_size 16M;
|
||||
|
||||
# serve static files
|
||||
location /static/ {
|
||||
alias /static/;
|
||||
}
|
||||
# serve media files
|
||||
location /media/ {
|
||||
alias /media/;
|
||||
}
|
||||
# pass requests for dynamic content to gunicorn
|
||||
location / {
|
||||
proxy_set_header Host $host;
|
||||
proxy_pass http://localhost:8080;
|
||||
}
|
||||
}
|
||||
}
|
||||
50
docs/k8s/30-pv.yaml
Normal file
50
docs/k8s/30-pv.yaml
Normal file
@@ -0,0 +1,50 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: recipes-db
|
||||
labels:
|
||||
app: recipes
|
||||
type: local
|
||||
tier: db
|
||||
spec:
|
||||
storageClassName: manual
|
||||
capacity:
|
||||
storage: 1Gi
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
hostPath:
|
||||
path: "/data/recipes/db"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: recipes-media
|
||||
labels:
|
||||
app: recipes
|
||||
type: local
|
||||
tier: media
|
||||
spec:
|
||||
storageClassName: manual
|
||||
capacity:
|
||||
storage: 1Gi
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
hostPath:
|
||||
path: "/data/recipes/media"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: recipes-static
|
||||
labels:
|
||||
app: recipes
|
||||
type: local
|
||||
tier: static
|
||||
spec:
|
||||
storageClassName: manual
|
||||
capacity:
|
||||
storage: 1Gi
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
hostPath:
|
||||
path: "/data/recipes/static"
|
||||
52
docs/k8s/30-pvc.yaml
Normal file
52
docs/k8s/30-pvc.yaml
Normal file
@@ -0,0 +1,52 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: recipes-db
|
||||
labels:
|
||||
app: recipes
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
tier: db
|
||||
storageClassName: manual
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: recipes-media
|
||||
labels:
|
||||
app: recipes
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
tier: media
|
||||
app: recipes
|
||||
storageClassName: manual
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: recipes-static
|
||||
labels:
|
||||
app: recipes
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
tier: static
|
||||
app: recipes
|
||||
storageClassName: manual
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
102
docs/k8s/50-deployment.yaml
Normal file
102
docs/k8s/50-deployment.yaml
Normal file
@@ -0,0 +1,102 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: recipes
|
||||
labels:
|
||||
app: recipes
|
||||
environment: production
|
||||
tier: frontend
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: recipes
|
||||
environment: production
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: recipes
|
||||
environment: production
|
||||
spec:
|
||||
containers:
|
||||
- name: recipes-nginx
|
||||
image: nginx:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 80
|
||||
protocol: TCP
|
||||
name: http
|
||||
volumeMounts:
|
||||
- mountPath: '/media'
|
||||
name: media
|
||||
- mountPath: '/static'
|
||||
name: static
|
||||
- name: nginx-config
|
||||
mountPath: /etc/nginx/nginx.conf
|
||||
subPath: nginx-config
|
||||
readOnly: true
|
||||
- name: recipes
|
||||
image: 'vabene1111/recipes:latest'
|
||||
imagePullPolicy: IfNotPresent
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 8080
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 8080
|
||||
volumeMounts:
|
||||
- mountPath: '/opt/recipes/mediafiles'
|
||||
name: media
|
||||
- mountPath: '/opt/recipes/staticfiles'
|
||||
name: static
|
||||
env:
|
||||
- name: DEBUG
|
||||
value: "0"
|
||||
- name: ALLOWED_HOSTS
|
||||
value: '*'
|
||||
- name: SECRET_KEY
|
||||
value: # CHANGEME
|
||||
- name: DB_ENGINE
|
||||
value: django.db.backends.postgresql_psycopg2
|
||||
- name: POSTGRES_HOST
|
||||
value: localhost
|
||||
- name: POSTGRES_PORT
|
||||
value: "5432"
|
||||
- name: POSTGRES_USER
|
||||
value: recipes
|
||||
- name: POSTGRES_DB
|
||||
value: recipes
|
||||
- name: POSTGRES_PASSWORD
|
||||
value: # CHANGEME
|
||||
- name: recipes-db
|
||||
image: 'postgres:latest'
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
volumeMounts:
|
||||
- mountPath: '/var/lib/postgresql/data'
|
||||
name: database
|
||||
env:
|
||||
- name: POSTGRES_USER
|
||||
value: recipes
|
||||
- name: POSTGRES_DB
|
||||
value: recipes
|
||||
- name: POSTGRES_PASSWORD
|
||||
value: # CHANGEME
|
||||
volumes:
|
||||
- name: database
|
||||
persistentVolumeClaim:
|
||||
claimName: recipes-db
|
||||
- name: media
|
||||
persistentVolumeClaim:
|
||||
claimName: recipes-media
|
||||
- name: static
|
||||
persistentVolumeClaim:
|
||||
claimName: recipes-static
|
||||
- name: nginx-config
|
||||
configMap:
|
||||
name: recipes-nginx-config
|
||||
15
docs/k8s/60-service.yaml
Normal file
15
docs/k8s/60-service.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: recipes
|
||||
labels:
|
||||
app: recipes
|
||||
spec:
|
||||
selector:
|
||||
app: recipes
|
||||
environment: production
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: http
|
||||
name: http
|
||||
protocol: TCP
|
||||
25
docs/k8s/README.md
Normal file
25
docs/k8s/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Kubernetes
|
||||
|
||||
This is a basic kubernetes setup. Please note that this does not necessarily follow Kubernetes best practices and should only used as a basis to build your own setup from!
|
||||
|
||||
## Important notes
|
||||
|
||||
State (database, static files and media files) is handled via `PersistentVolumes`.
|
||||
|
||||
Note that you will most likely have to change the `PersistentVolumes` in `30-pv.yaml`. The current setup is only usable for a single-node cluster because it uses local storage on the kubernetes worker nodes under `/data/recipes/`. It should just serve as an example.
|
||||
|
||||
Currently, the deployment in `50-deployment.yaml` just pulls the `latest` tag of all containers. In a production setup, you should set this to a fixed version!
|
||||
|
||||
See env variables tagged with `CHANGEME` in `50-deployment.yaml` and make sure to change those! A better setup would use kubernetes secrets but this is not implemented yet.
|
||||
|
||||
## Updates
|
||||
|
||||
These manifests are not tested against new versions.
|
||||
|
||||
## Apply the manifets
|
||||
|
||||
To apply the manifest with `kubectl`, use the following command:
|
||||
|
||||
```
|
||||
kubectl apply -f ./docs/k8s/
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user