Compare commits

...

128 Commits
0.2.1 ... 0.6.0

Author SHA1 Message Date
vabene1111
6d5ea31f8e re added mistakingly removed command 2020-03-26 18:55:35 +01:00
vabene1111
b538761746 run container as root for now
since i want to realease this we will for now continue to run this as root inside the containerr. this can be fixed later, PR's welcome
2020-03-26 18:20:44 +01:00
vabene1111
913d858473 updated boot script to fix permission 2020-03-25 22:30:44 +01:00
vabene1111
574d088cdd Create docker-publish.yml 2020-03-24 18:04:52 +01:00
vabene1111
ed360ca1c7 updated readme 2020-03-24 17:37:16 +01:00
vabene1111
08848da4a3 Merge branch 'feature/docker-rewrite' into develop 2020-03-24 17:23:04 +01:00
vabene1111
3f1f63d7e0 remove image tag as it is default 2020-03-24 17:22:51 +01:00
vabene1111
3bd6557e59 use stable image 2020-03-24 17:18:27 +01:00
vabene1111
23cb98f631 Create docker-release-publish.yml 2020-03-24 17:16:24 +01:00
vabene1111
6e91c30245 fixed tests 2020-03-24 17:03:52 +01:00
vabene1111
d7e0fa821b updated examples 2020-03-24 16:44:19 +01:00
vabene1111
c67342df26 wip changes 2020-03-24 12:57:45 +01:00
vabene1111
e3b71d47f4 Merge pull request #39 from h4llow3En/develop
Run as alpine docker image and server static files with gunicorn
2020-03-23 20:03:09 +01:00
h4llow3En
1e3e03e4af Simplify first user creation 2020-03-20 10:50:34 +01:00
h4llow3En
391ab5ddac Don't use "latest" images 2020-03-20 10:02:59 +01:00
h4llow3En
e0c560c2d7 Remove debug output from Dockerfile 2020-03-20 09:28:33 +01:00
h4llow3En
be942bcb79 Fix "Update" description in readme 2020-03-19 18:02:28 +01:00
h4llow3En
6b27f0c8ab Cleanup and simplify deployment 2020-03-19 15:08:53 +01:00
h4llow3En
cc931189e8 Run as alpine docker image and server static files with gunicorn 2020-03-19 10:13:49 +01:00
vabene1111
1b45121385 case insenstitive import 2020-03-18 16:58:27 +01:00
vabene1111
97e2593f72 fixed single import 2020-03-18 16:50:28 +01:00
vabene1111
00539b9d1b fixed theme switching 2020-03-18 13:08:31 +01:00
vabene1111
1cadb1e85e added password change form 2020-03-18 13:06:39 +01:00
vabene1111
9e524a8f22 added user name change 2020-03-18 12:51:13 +01:00
vabene1111
a8a7d4e0f4 complete bottom border "hack" 2020-03-18 12:30:35 +01:00
vabene1111
d0cf396f68 ingredient mobile friendly 2020-03-18 12:29:40 +01:00
vabene1111
e45f3f3343 updated translations 2020-03-18 12:20:45 +01:00
vabene1111
13ea2ecd7d display ingredient note 2020-03-18 12:12:03 +01:00
vabene1111
25ba62e87c improved ingredient editing 2020-03-18 12:11:15 +01:00
vabene1111
48107b918d added ingredient list page 2020-03-18 11:36:39 +01:00
vabene1111
0b56e22af9 nav improvements 2020-03-18 11:36:24 +01:00
vabene1111
47128fbb79 properly align nav icon vertically 2020-03-18 11:19:17 +01:00
vabene1111
12f6aa6df7 changed docker base image for python 3.8 2020-03-17 23:55:16 +01:00
vabene1111
c2dc038ac9 note optional 2020-03-17 22:49:53 +01:00
vabene1111
0c2b3d2d03 added ingredient notes + removed null constraints 2020-03-17 22:47:17 +01:00
vabene1111
1d562452df changed behavior of delete original 2020-03-17 18:54:44 +01:00
vabene1111
4c90664aa2 fixed translation mistaek 2020-03-17 18:47:24 +01:00
vabene1111
90dbc36402 added ability to link recipes to ingredients 2020-03-17 18:44:11 +01:00
vabene1111
a60b09e491 fixed confirm message on unit/ingredeitn merge 2020-03-17 18:28:53 +01:00
vabene1111
deeda425a8 added tests for storage edit 2020-03-17 18:22:13 +01:00
vabene1111
6fcbc9f0cd some basic testing for external recipe edits 2020-03-17 17:48:23 +01:00
vabene1111
7518d8c6b1 fixed several rewrite issues 2020-03-17 17:36:05 +01:00
vabene1111
eb25a9163f fixed import log badge 2020-03-17 17:31:26 +01:00
vabene1111
e2f6e07e42 Merge branch 'develop' of https://github.com/vabene1111/recipes into develop 2020-03-17 17:23:45 +01:00
vabene1111
17b9519fa9 refactor generic url creation 2020-03-17 17:23:39 +01:00
vabene1111
adcef1d887 Update main.yml 2020-03-17 16:41:21 +01:00
vabene1111
7398304d16 Update main.yml 2020-03-17 16:40:59 +01:00
vabene1111
09ff7e82f1 django admin cleanup 2020-03-17 16:16:04 +01:00
vabene1111
47072763ee cleand up search ui 2020-03-17 15:34:17 +01:00
vabene1111
86f2c9d89c cleand up and fixed ingredient edit table 2020-03-16 23:08:52 +01:00
vabene1111
c8eaa2a349 removed js test code 2020-03-16 22:50:52 +01:00
vabene1111
9853cecabb fixed keywords without icons 2020-03-16 14:48:04 +01:00
vabene1111
7b65252d47 documentation 2020-03-12 21:48:09 +01:00
vabene1111
f62ec51c91 ignore compose 2020-03-12 21:03:34 +01:00
vabene1111
9f8b93732f removed root docker compose 2020-03-12 20:57:27 +01:00
vabene1111
bf07fc7437 restructure 2020-03-12 20:48:52 +01:00
vabene1111
08837032ce ignore docker compose 2020-03-12 20:38:48 +01:00
vabene1111
abd655ce62 moved delete to seperate file 2020-03-01 12:34:59 +01:00
vabene1111
e755068a31 added test for png's 2020-03-01 12:13:36 +01:00
vabene1111
b5e35115fa fixed png's not working in recipes 2020-03-01 12:13:06 +01:00
vabene1111
c8cc140a78 improved recipe edit tests and fixed bugs 2020-02-29 00:00:13 +01:00
vabene1111
8c7a171d56 added more tests and test structure 2020-02-28 23:17:04 +01:00
vabene1111
df62717806 removed non internal recipes from shopping 2020-02-28 22:21:03 +01:00
vabene1111
a1e6bd5441 fixed text outside of colum 2020-02-28 22:12:18 +01:00
vabene1111
034e2c612b added internal recipe filter 2020-02-28 22:12:02 +01:00
vabene1111
45c85b9de8 fixed markdown blockquotes not really rendering 2020-02-28 22:09:16 +01:00
vabene1111
a9952b8f57 improved markdown rendering of tables and images 2020-02-28 21:53:27 +01:00
vabene1111
b8f16b50a7 simplified viewer + fixed characters escaping 2020-02-19 20:19:08 +01:00
vabene1111
752df5a1d2 cleaned up viewer 2020-02-19 19:39:32 +01:00
vabene1111
fe6e351349 testing pdf viewerr 2020-02-19 19:12:11 +01:00
vabene1111
8cc9273268 basic viewer working 2020-02-19 19:02:47 +01:00
vabene1111
0c1763b347 pdf display working 2020-02-19 18:13:11 +01:00
vabene1111
88dc713683 WIP pdf embedding 2020-02-19 16:55:13 +01:00
vabene1111
fc1cc70870 basic pdf embedding 2020-02-19 00:08:32 +01:00
vabene1111
42e09fcae9 updated translations 2020-02-18 23:26:14 +01:00
vabene1111
4843568d10 added searching for ingredients 2020-02-18 23:16:05 +01:00
vabene1111
2e7e4b23dd highlight active tab 2020-02-18 23:03:37 +01:00
vabene1111
8192a8dc8f improved shopping list ui 2020-02-18 23:00:23 +01:00
vabene1111
c98dbd065e allow choosing output format of shopping list 2020-02-18 22:41:37 +01:00
vabene1111
43d03ed17d group ingredients in shopping list 2020-02-18 22:33:31 +01:00
vabene1111
8ba34414a1 added shoping to nav 2020-02-18 22:28:22 +01:00
vabene1111
46dffe2f63 added ingredient merging 2020-02-18 22:25:21 +01:00
vabene1111
04cbe6cb2c removed empty script tag when no default is given 2020-02-18 22:17:27 +01:00
vabene1111
8fd6dcc81c updated translations 2020-02-17 23:34:17 +01:00
vabene1111
b89e96476a fixed status badge
since GH actions currently dont triggert on fast forward merge pushs the badge is changed to show passing status of develop
2020-02-17 00:33:21 +01:00
vabene1111
67b4ec8215 fixed shopping ingredient list 2020-02-17 00:24:33 +01:00
vabene1111
db2e67dd71 incresed instruction font size 2020-02-17 00:22:36 +01:00
vabene1111
de355abd19 fixed tests to reflect new name 2020-02-17 00:17:51 +01:00
vabene1111
41bfa95cb2 nav color theming 2020-02-17 00:11:15 +01:00
vabene1111
ad9944dd01 fixed broken tabulator on default theme 2020-02-16 23:59:16 +01:00
vabene1111
a1160c310c fixed fix of migration 2020-02-16 23:54:45 +01:00
vabene1111
0444286d11 hotkeys for recipe editing 2020-02-16 23:50:58 +01:00
vabene1111
7d4630e3af normalized ingredients 2020-02-16 23:22:44 +01:00
vabene1111
f77aa7c8f0 migrating ingredients 2020-02-16 23:12:16 +01:00
vabene1111
81677a74bb made flatly default theme + fixed preview image 2020-02-16 22:58:24 +01:00
vabene1111
2cc385ceac preview image + readme update 2020-02-16 22:52:42 +01:00
vabene1111
b4cdc92207 catch non existing relation 2020-02-14 00:40:17 +01:00
vabene1111
ffdcbff540 dark theming tabulator + select 2020-02-14 00:35:52 +01:00
vabene1111
cc7422a503 theming refactor
moved server side for a better page loading experience and less javascript mess
2020-02-13 23:47:24 +01:00
vabene1111
c08e30c5a9 case insensitive filter 2020-02-12 23:38:05 +01:00
vabene1111
60477cdb9e settings button 2020-02-04 22:26:40 +01:00
vabene1111
bc066d29f6 dark mode reverted + meal plan button 2020-02-04 22:18:10 +01:00
vabene1111
c96159e15c dark mode 2020-02-04 22:00:47 +01:00
vabene1111
2d70680214 recipe buttons wip 2020-02-03 11:33:44 +01:00
vabene1111
00fdab1678 unit merging 2020-02-03 11:00:11 +01:00
vabene1111
6ccafe3c2f Update README.md 2020-02-02 23:03:46 +01:00
vabene1111
4080301dbc Testing GitHub actions as CI 2020-02-02 23:01:31 +01:00
vabene1111
e7227f84ca some more recipe edit cleanup 2020-02-02 22:52:25 +01:00
vabene1111
6753a2c0b5 basic recipe edit test 2020-02-02 22:46:37 +01:00
vabene1111
56e841879b login template enhancements 2020-02-02 22:12:04 +01:00
vabene1111
19f5b44e50 some basic tests 2020-02-02 22:09:30 +01:00
vabene1111
305a4949fb cleanup recipe edit 2020-02-02 16:06:24 +01:00
vabene1111
07502fecc0 fixed possible markdown xss 2020-02-02 16:06:12 +01:00
vabene1111
4da1293898 fixed it 2020-02-01 21:11:26 +01:00
vabene1111
ab2ce26d9d nearly working 2020-01-30 12:45:55 +01:00
vabene1111
a2348f531b basics of ingredient unit normalization 2020-01-30 12:26:47 +01:00
vabene1111
227d90d49d basic shopping view 2020-01-30 00:28:01 +01:00
vabene1111
6a61c934cd update readme 2020-01-19 14:40:06 +01:00
vabene1111
6f4a40acdd meal plan prev+next week buttons 2020-01-19 14:34:50 +01:00
vabene1111
becdcdc6a4 small meal plan fixes 2020-01-17 18:31:46 +01:00
vabene1111
afa69c647d basic meal plan working 2020-01-17 17:47:23 +01:00
vabene1111
7449380434 meal plan WIP 2020-01-17 16:02:14 +01:00
vabene1111
2afec837a4 fixed missing locale middleware 2020-01-17 14:17:10 +01:00
vabene1111
127eb3181b updated readme 2020-01-17 09:51:05 +01:00
vabene1111
0590826742 Merge pull request #22 from ntindle/patch-3
Update README.md
2020-01-17 09:45:52 +01:00
Nicholas Tindle
ac2c13743c Update README.md 2020-01-16 17:27:17 -06:00
vabene1111
7d4da4c19b Merge pull request #17 from ntindle/patch-2
Update README.md
2020-01-14 06:49:23 +01:00
Nicholas Tindle
763a4a66a2 Update README.md
Minor changes
2020-01-13 21:08:57 -06:00
111 changed files with 3972 additions and 733 deletions

View File

@@ -7,4 +7,12 @@ docker-compose*
.gitignore
README.md
LICENSE
.vscode
.vscode
.env
.env.template
.github
.idea
LICENSE.md
docs
nginx
update.sh

View File

@@ -1,7 +1,3 @@
VIRTUAL_HOST=
LETSENCRYPT_HOST=
LETSENCRYPT_EMAIL=
DEBUG=1
ALLOWED_HOSTS=*
SECRET_KEY=

13
.github/workflows/docker-publish.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
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 }}

View File

@@ -0,0 +1,18 @@
name: Deploy Docker Image
on:
push:
tags:
- '*'
jobs:
build-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Publish to Registry
uses: AhnSeongHyun/action-tag-docker-build-push@v1.0.0
with:
repo_name: vabene1111/recipes
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}

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

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

24
.gitignore vendored
View File

@@ -63,35 +63,17 @@ venv/
mediafiles/
*.sqlite3
*.sqlite3*
\.idea/workspace\.xml
\.idea/misc\.xml
\.idea/recipes\.iml
# Deployment
\.env
staticfiles/
postgresql/
postgresql/
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
/docker-compose.override.yml

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

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

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptLibraryMappings">
<file url="file://$PROJECT_DIR$" libraries="{pretty-checkbox}" />
<file url="file://$PROJECT_DIR$" libraries="{jquery-3.4.1, pdf, pdf_viewer, pretty-checkbox}" />
</component>
</project>

35
.idea/recipes.iml generated Normal file
View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="FacetManager">
<facet type="django" name="Django">
<configuration>
<option name="rootFolder" value="$MODULE_DIR$" />
<option name="settingsModule" value="recipes/settings.py" />
<option name="manageScript" value="$MODULE_DIR$/manage.py" />
<option name="environment" value="&lt;map/&gt;" />
<option name="doNotUseTestRunner" value="false" />
<option name="trackFilePattern" value="migrations" />
</configuration>
</facet>
</component>
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.8 (recipes)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="jquery-3.4.1" level="application" />
<orderEntry type="library" name="pretty-checkbox" level="application" />
<orderEntry type="library" name="pdf" level="application" />
<orderEntry type="library" name="pdf_viewer" level="application" />
</component>
<component name="TemplatesService">
<option name="TEMPLATE_CONFIGURATION" value="Django" />
<option name="TEMPLATE_FOLDERS">
<list>
<option value="$MODULE_DIR$/templates" />
<option value="$MODULE_DIR$/cookbook/templates" />
</list>
</option>
</component>
</module>

View File

@@ -1,24 +1,18 @@
FROM ubuntu:18.04
RUN mkdir /Recipes
WORKDIR /Recipes
ADD . /Recipes/
RUN apt-get update
RUN apt-get -y upgrade
RUN apt-get install -y \
python3 \
python3-pip \
postgresql-client \
gettext
RUN pip3 install --upgrade pip
RUN pip3 install -r requirements.txt
RUN apt-get autoremove -y
FROM python:3.8-alpine
RUN apk add --no-cache postgresql-libs gettext zlib libjpeg libxml2-dev libxslt-dev
ENV PYTHONUNBUFFERED 1
EXPOSE 8080
EXPOSE 8080
RUN mkdir /opt/recipes
WORKDIR /opt/recipes
COPY . ./
RUN chmod +x boot.sh setup.sh
RUN ln -s /opt/recipes/setup.sh /usr/local/bin/createsuperuser
RUN apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev zlib-dev jpeg-dev && \
python -m venv venv && \
venv/bin/pip install -r requirements.txt --no-cache-dir &&\
apk --purge del .build-deps
ENTRYPOINT ["/opt/recipes/boot.sh"]

View File

@@ -1,30 +1,62 @@
# Recipes
Recipes is a django application to manage, tag and search recipes using either built in models or external storage providers hosting PDF's, Images or other files.
# Recipes ![CI](https://github.com/vabene1111/recipes/workflows/Continous%20Integration/badge.svg?branch=develop)
Recipes is a Django application to manage, tag and search recipes using either built in models or external storage providers hosting PDF's, Images or other files.
![Preview](preview.png)
![Preview](docs/preview.png)
### Features
- :package: **Sync** files with Dropbox and Nextcloud (more can easily be added)
- :mag: Powerful **search** with djangos [TrigramSimilarity](https://docs.djangoproject.com/en/3.0/ref/contrib/postgres/search/#trigram-similarity)
- :mag: Powerful **search** with Djangos [TrigramSimilarity](https://docs.djangoproject.com/en/3.0/ref/contrib/postgres/search/#trigram-similarity)
- :label: Create and search for **tags**, assign them in batch to all files matching certain filters
- :page_facing_up: **Create recipes** locally within a nice, standardized web interface
- :iphone: Optimized for use on **mobile** devices like phones and tablets
- :shopping_cart: Generate **shopping** lists from recipes
- :calendar: Create a **Plan** on what to eat when
- :person_with_blond_hair: **Share** recipes with friends and comment on them to suggest or remember changes you made
- :whale: Easy setup with **Docker**
- :art: Customize your interface with **themes**
- :heavy_plus_sign: Many more like recipe scaling, image compression, cookbooks, printing views, ...
This application is meant for people with a collection of recipes they want to share with family and friends or simply store them in a nicely organized way. A basic permission system exists but this application is not meant to be run as a public page.
# 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. 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`.
### Manual
Copy `.env.template` to `.env` and fill in the missing values accordingly.
Make sure all variables are available to whatever serves your application.
Otherwise simply follow the instructions for any django based deployment
(for example [this one](http://uwsgi-docs.readthedocs.io/en/latest/tutorials/Django_and_nginx.html)).
## 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!**
1. Stop the container using `docker-compose down`
2. Pull the latest image using `docker-compose pull`
3. Start the container again using `docker-compose up -d`
# Documentation
Most things should be straight forward but there are some more complicated things.
##### Storage Backends
A `Storage Backend` is a remote storage location where files are stored. To add a new backend klick on `Storage Data` and then on `Storage Backends`. There click the plus button.
A `Storage Backend` is a remote storage location where 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 Path's
##### Adding Synced Paths
To add a new path from your Storage backend to the sync list, go to `Storage Data >> Configure Sync` and select the storage backend you want to use.
Then enter the path you want to monitor starting at the storage root (e.g. `/Folder/RecipesFolder`) and save it.
@@ -33,37 +65,15 @@ To sync the recipes app with the storage backends press `Sync now` under `Storag
##### Import Recipes
All files found by the sync can be found under `Manage Data >> Import recipes`. There you can either import all at once without modifying them or import one by one, adding tags while importing.
##### Batch Edit
If you have many untagged recipes you may want to edit them all at once. For this go to
If you have many untagged recipes, you may want to edit them all at once. To do so, go to
`Storage Data >> Batch Edit`. Enter a word which should be contained in the recipe name and select the tags you want to apply.
When clicking submit every recipe containing the word will be updated (tags are added).
When clicking submit, every recipe containing the word will be updated (tags are added).
> Currently the only option is word contains, maybe some more SQL like operators will be added later.
## Installation
### Docker-Compose
When cloning this repository a simple docker-compose file is included. It is made for setups already running an nginx-reverse proxy network with lets encrypt companion but can be changed easily. Copy `.env.template` to `.env` and fill in the missing values accordingly.
Now simply start the containers and run the `update.sh` script which will apply all migrations and collect static files.
Create a default user by executing into the container with `docker-compose exec web_recipes sh` and run `python3 manage.py createsuperuser`.
### Manual
Copy `.env.template` to `.env` and fill in the missing values accordingly.
You can leave out the docker specific variables (VIRTUAL_HOST, LETSENCRYPT_HOST, LETSENCRYPT_EMAIL).
Make sure all variables are available to whatever servers your application.
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).
To start developing:
1. Clone the repository using your preferred method
2. Install requirements from `requirements.txt` either globally or in a virtual environment
3. Run migrations with `manage.py migrate`
4. Create a first user with `manage.py createsuperuser`
5. Start development server with `manage.py runserver`
## 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/).
## License
This project is licensed under the MIT license. Even though it is not required to publish derivatives i highly encourage pushing changes upstream and letting people profit from any work done on this project.
This project is licensed under the MIT license. Even though it is not required to publish derivatives, I highly encourage pushing changes upstream and letting people profit from any work done on this project.

9
boot.sh Normal file
View File

@@ -0,0 +1,9 @@
#!/bin/sh
source venv/bin/activate
echo "Updating database"
python manage.py migrate
python manage.py collectstatic --noinput
echo "Done"
exec gunicorn -b :8080 --access-logfile - --error-logfile - recipes.wsgi

View File

@@ -2,10 +2,103 @@ from django.contrib import admin
from .models import *
admin.site.register(Recipe)
class UserPreferenceAdmin(admin.ModelAdmin):
list_display = ('name', 'theme', 'nav_color')
@staticmethod
def name(obj):
return obj.user.get_user_name()
admin.site.register(UserPreference, UserPreferenceAdmin)
class StorageAdmin(admin.ModelAdmin):
list_display = ('name', 'method')
admin.site.register(Storage, StorageAdmin)
class SyncAdmin(admin.ModelAdmin):
list_display = ('storage', 'path', 'active', 'last_checked')
admin.site.register(Sync, SyncAdmin)
class SyncLogAdmin(admin.ModelAdmin):
list_display = ('sync', 'status', 'msg', 'created_at')
admin.site.register(SyncLog, SyncLogAdmin)
admin.site.register(Keyword)
admin.site.register(Sync)
admin.site.register(SyncLog)
admin.site.register(RecipeImport)
admin.site.register(Storage)
class RecipeAdmin(admin.ModelAdmin):
list_display = ('name', 'internal', 'created_by', 'storage')
@staticmethod
def created_by(obj):
return obj.created_by.get_user_name()
admin.site.register(Recipe, RecipeAdmin)
admin.site.register(Unit)
admin.site.register(Ingredient)
class RecipeIngredientAdmin(admin.ModelAdmin):
list_display = ('recipe', 'ingredient', 'amount', 'unit')
admin.site.register(RecipeIngredient, RecipeIngredientAdmin)
class CommentAdmin(admin.ModelAdmin):
list_display = ('recipe', 'name', 'created_at')
@staticmethod
def name(obj):
return obj.created_by.get_user_name()
admin.site.register(Comment, CommentAdmin)
class RecipeImportAdmin(admin.ModelAdmin):
list_display = ('name', 'storage', 'file_path')
admin.site.register(RecipeImport, RecipeImportAdmin)
class RecipeBookAdmin(admin.ModelAdmin):
list_display = ('name', 'user_name')
@staticmethod
def user_name(obj):
return obj.user.get_user_name()
admin.site.register(RecipeBook, RecipeBookAdmin)
class RecipeBookEntryAdmin(admin.ModelAdmin):
list_display = ('book', 'recipe')
admin.site.register(RecipeBookEntry, RecipeBookEntryAdmin)
class MealPlanAdmin(admin.ModelAdmin):
list_display = ('user', 'recipe', 'meal', 'date')
@staticmethod
def user(obj):
return obj.user.get_user_name()
admin.site.register(MealPlan, MealPlanAdmin)

View File

@@ -2,14 +2,17 @@ import django_filters
from django.contrib.postgres.search import TrigramSimilarity
from django.db.models import Q
from cookbook.forms import MultiSelectWidget
from cookbook.models import Recipe, Keyword
from cookbook.models import Recipe, Keyword, Ingredient
from django.conf import settings
from django.utils.translation import gettext as _
class RecipeFilter(django_filters.FilterSet):
name = django_filters.CharFilter(method='filter_name')
keywords = django_filters.ModelMultipleChoiceFilter(queryset=Keyword.objects.all(), widget=MultiSelectWidget,
method='filter_keywords')
ingredients = django_filters.ModelMultipleChoiceFilter(queryset=Ingredient.objects.all(), widget=MultiSelectWidget,
method='filter_ingredients', label=_('Ingredients'))
@staticmethod
def filter_keywords(queryset, name, value):
@@ -19,6 +22,14 @@ class RecipeFilter(django_filters.FilterSet):
queryset = queryset.filter(keywords=x)
return queryset
@staticmethod
def filter_ingredients(queryset, name, value):
if not name == 'ingredients':
return queryset
for x in value:
queryset = queryset.filter(recipeingredient__ingredient=x).distinct()
return queryset
@staticmethod
def filter_name(queryset, name, value):
if not name == 'name':
@@ -32,14 +43,12 @@ class RecipeFilter(django_filters.FilterSet):
class Meta:
model = Recipe
fields = ['name', 'keywords']
fields = ['name', 'keywords', 'ingredients', 'internal']
class QuickRecipeFilter(django_filters.FilterSet):
name = django_filters.CharFilter(lookup_expr='contains')
keywords = django_filters.ModelMultipleChoiceFilter(queryset=Keyword.objects.all(), widget=MultiSelectWidget,
method='filter_keywords')
class IngredientFilter(django_filters.FilterSet):
name = django_filters.CharFilter(lookup_expr='icontains')
class Meta:
model = Recipe
fields = ['name', 'keywords']
model = Ingredient
fields = ['name']

View File

@@ -1,3 +1,4 @@
from dal import autocomplete
from django import forms
from django.forms import widgets
from django.utils.translation import gettext as _
@@ -6,11 +7,49 @@ from emoji_picker.widgets import EmojiPickerTextInput
from .models import *
class SelectWidget(widgets.Select):
class Media:
js = ('custom/js/form_select.js',)
class MultiSelectWidget(widgets.SelectMultiple):
class Media:
js = ('custom/js/form_multiselect.js',)
# yes there are some stupid browsers that still dont support this but i dont support people using these browsers
class DateWidget(forms.DateInput):
input_type = 'date'
def __init__(self, **kwargs):
kwargs["format"] = "%Y-%m-%d"
super().__init__(**kwargs)
class UserPreferenceForm(forms.ModelForm):
prefix = 'preference'
class Meta:
model = UserPreference
fields = ('theme', 'nav_color')
help_texts = {
'nav_color': _('Color of the top navigation bar. Not all colors work with all themes, just try them out!')
}
class UserNameForm(forms.ModelForm):
prefix = 'name'
class Meta:
model = User
fields = ('first_name', 'last_name')
help_texts = {
'first_name': _('Both fields are optional. If none are given the username will be displayed instead')
}
class ExternalRecipeForm(forms.ModelForm):
file_path = forms.CharField(disabled=True, required=False)
storage = forms.ModelChoiceField(queryset=Storage.objects.all(), disabled=True, required=False)
@@ -32,6 +71,8 @@ class ExternalRecipeForm(forms.ModelForm):
class InternalRecipeForm(forms.ModelForm):
ingredients = forms.CharField(widget=forms.HiddenInput(), required=False)
class Meta:
model = Recipe
fields = ('name', 'instructions', 'image', 'working_time', 'waiting_time', 'keywords')
@@ -46,6 +87,52 @@ class InternalRecipeForm(forms.ModelForm):
widgets = {'keywords': MultiSelectWidget}
class ShoppingForm(forms.Form):
recipe = forms.ModelMultipleChoiceField(
queryset=Recipe.objects.filter(internal=True).all(),
widget=MultiSelectWidget
)
markdown_format = forms.BooleanField(
help_text=_('Include <code>- [ ]</code> in list for easier usage in markdown based documents.'),
required=False,
initial=False
)
class UnitMergeForm(forms.Form):
prefix = 'unit'
new_unit = forms.ModelChoiceField(
queryset=Unit.objects.all(),
widget=SelectWidget,
label=_('New Unit'),
help_text=_('New unit that other gets replaced by.'),
)
old_unit = forms.ModelChoiceField(
queryset=Unit.objects.all(),
widget=SelectWidget,
label=_('Old Unit'),
help_text=_('Unit that should be replaced.'),
)
class IngredientMergeForm(forms.Form):
prefix = 'ingredient'
new_ingredient = forms.ModelChoiceField(
queryset=Ingredient.objects.all(),
widget=SelectWidget,
label=_('New Ingredient'),
help_text=_('New ingredient that other gets replaced by.'),
)
old_ingredient = forms.ModelChoiceField(
queryset=Ingredient.objects.all(),
widget=SelectWidget,
label=_('Old Ingredient'),
help_text=_('Ingredient that should be replaced.'),
)
class CommentForm(forms.ModelForm):
prefix = 'comment'
@@ -68,6 +155,13 @@ class KeywordForm(forms.ModelForm):
widgets = {'icon': EmojiPickerTextInput}
class IngredientForm(forms.ModelForm):
class Meta:
model = Ingredient
fields = ('name', 'recipe')
widgets = {'recipe': SelectWidget}
class StorageForm(forms.ModelForm):
username = forms.CharField(widget=forms.TextInput(attrs={'autocomplete': 'new-password'}), required=False)
password = forms.CharField(widget=forms.TextInput(attrs={'autocomplete': 'new-password', 'type': 'password'}),
@@ -87,12 +181,6 @@ class StorageForm(forms.ModelForm):
}
class RecipeBookForm(forms.ModelForm):
class Meta:
model = RecipeBook
fields = ('name',)
class RecipeBookEntryForm(forms.ModelForm):
prefix = 'bookmark'
@@ -125,3 +213,17 @@ class ImportRecipeForm(forms.ModelForm):
'file_uid': _('File ID'),
}
widgets = {'keywords': MultiSelectWidget}
class RecipeBookForm(forms.ModelForm):
class Meta:
model = RecipeBook
fields = ('name',)
class MealPlanForm(forms.ModelForm):
class Meta:
model = MealPlan
fields = ('recipe', 'meal', 'note', 'date')
widgets = {'recipe': SelectWidget, 'date': DateWidget}

View File

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

View File

@@ -0,0 +1,24 @@
import markdown
from markdown.treeprocessors import Treeprocessor
class StyleTreeprocessor(Treeprocessor):
def run_processor(self, node):
for child in node:
if child.tag == "table":
child.set("class", "table table-bordered")
if child.tag == "img":
child.set("class", "img-fluid")
self.run_processor(child)
return node
def run(self, root):
self.run_processor(root)
return root
class MarkdownFormatExtension(markdown.Extension):
def extendMarkdown(self, md, md_globals):
md.treeprocessors.register(StyleTreeprocessor(), 'StyleTreeprocessor', 10)

View File

@@ -3,167 +3,265 @@
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-01-13 12:08+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"POT-Creation-Date: 2020-03-18 12:13+0100\n"
"PO-Revision-Date: 2020-03-18 12:19+0100\n"
"Language: de\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"Last-Translator: \n"
"Language-Team: \n"
"X-Generator: Poedit 2.3\n"
#: cookbook/forms.py:24 cookbook/forms.py:40 cookbook/forms.py:122
#: .\cookbook\filters.py:15 .\cookbook\templates\base.html:98
#: .\cookbook\templates\forms\edit_internal_recipe.html:28
#: .\cookbook\templates\forms\ingredients.html:34
#: .\cookbook\templates\recipe_view.html:104 .\cookbook\views\lists.py:45
msgid "Ingredients"
msgstr "Zutaten"
#: .\cookbook\forms.py:35
msgid ""
"Color of the top navigation bar. Not all colors work with all themes, just "
"try them out!"
msgstr ""
"Farbe der oberen Navigationsleiste. Nicht alle Farben passen, daher einfach "
"mal ausprobieren!"
#: .\cookbook\forms.py:49 .\cookbook\forms.py:67 .\cookbook\forms.py:196
msgid "Name"
msgstr "Name"
#: cookbook/forms.py:25 cookbook/forms.py:41 cookbook/forms.py:123
#: cookbook/templates/stats.html:22
#: .\cookbook\forms.py:50 .\cookbook\forms.py:68 .\cookbook\forms.py:197
#: .\cookbook\templates\stats.html:22
msgid "Keywords"
msgstr "Schlagwörter"
#: cookbook/forms.py:26 cookbook/forms.py:43
#: .\cookbook\forms.py:51 .\cookbook\forms.py:70
msgid "Preparation time in minutes"
msgstr "Zubereitungszeit in Minuten"
#: cookbook/forms.py:27 cookbook/forms.py:44
#: .\cookbook\forms.py:52 .\cookbook\forms.py:71
msgid "Waiting time (cooking/baking) in minutes"
msgstr "Wartezeit (kochen/backen) in Minuten"
#: cookbook/forms.py:28 cookbook/forms.py:124
#: .\cookbook\forms.py:53 .\cookbook\forms.py:198
msgid "Path"
msgstr "Pfad"
#: cookbook/forms.py:29
#: .\cookbook\forms.py:54
msgid "Storage UID"
msgstr "Speicher ID"
#: cookbook/forms.py:42
#: .\cookbook\forms.py:69
msgid "Instructions"
msgstr "Anleitung"
#: cookbook/forms.py:57
#: .\cookbook\forms.py:82
msgid ""
"Include <code>- [ ]</code> in list for easier usage in markdown based "
"documents."
msgstr ""
"Füge <code>- [ ]</code> vor den Zutaten ein um sie besser in einem Markdown "
"Dokument zu verwenden."
#: .\cookbook\forms.py:94
msgid "New Unit"
msgstr "Neue Einheit"
#: .\cookbook\forms.py:95
msgid "New unit that other gets replaced by."
msgstr "Neue Einheit die die alte ersetzt."
#: .\cookbook\forms.py:100
msgid "Old Unit"
msgstr "Alte Einheit"
#: .\cookbook\forms.py:101
msgid "Unit that should be replaced."
msgstr "Einheit die ersetzt werden soll."
#: .\cookbook\forms.py:111
msgid "New Ingredient"
msgstr "Neue Zutat"
#: .\cookbook\forms.py:112
msgid "New ingredient that other gets replaced by."
msgstr "Neue Zutat die die alte ersetzt."
#: .\cookbook\forms.py:117
msgid "Old Ingredient"
msgstr "Alte Zutat"
#: .\cookbook\forms.py:118
msgid "Ingredient that should be replaced."
msgstr "Zutat die ersetzt werden soll."
#: .\cookbook\forms.py:130
msgid "Add your comment: "
msgstr "Schreibe einen Kommentar:"
#: cookbook/forms.py:75
#: .\cookbook\forms.py:155
msgid "Leave empty for dropbox and enter app password for nextcloud."
msgstr "Für Dropbox leer lassen, bei Nextcloud App-Passwort eingeben."
#: cookbook/forms.py:78
#: .\cookbook\forms.py:158
msgid "Leave empty for nextcloud and enter api token for dropbox."
msgstr "Bei Nextcloud leer lassen, bei Dropbox API Token eingeben"
msgstr "Bei Nextcloud leer lassen, bei Dropbox API Token eingeben."
#: cookbook/forms.py:86
#: .\cookbook\forms.py:166
msgid ""
"Leave empty for dropbox and enter only base url for nextcloud (<code>/remote."
"php/webdav/</code> is added automatically)"
msgstr "Bei Dropbox leer lassen, bei Nextcloud Server URL angeben (<code>/remote."
"php/webdav/</code> wird automatisch hinzugefügt)"
msgstr ""
"Bei Dropbox leer lassen, bei Nextcloud Server URL angeben (<code>/remote.php/"
"webdav/</code> wird automatisch hinzugefügt)"
#: cookbook/forms.py:111
#: .\cookbook\forms.py:185
msgid "Search String"
msgstr "Such Wort"
#: cookbook/forms.py:125
#: .\cookbook\forms.py:199
msgid "File ID"
msgstr "Datei ID"
#: cookbook/tables.py:75 cookbook/templates/forms/edit_internal_recipe.html:39
#: cookbook/templates/forms/edit_internal_recipe.html:98
#: cookbook/templates/generic/delete_template.html:5
#: cookbook/templates/generic/delete_template.html:13
#: cookbook/templates/generic/edit_template.html:25
#: .\cookbook\models.py:190
msgid "Breakfast"
msgstr "Frühstück"
#: .\cookbook\models.py:190
msgid "Lunch"
msgstr "Mittagessen"
#: .\cookbook\models.py:190
msgid "Dinner"
msgstr "Abendessen"
#: .\cookbook\models.py:190
msgid "Other"
msgstr "Andere"
#: .\cookbook\tables.py:83
#: .\cookbook\templates\forms\edit_internal_recipe.html:49
#: .\cookbook\templates\forms\edit_internal_recipe.html:160
#: .\cookbook\templates\generic\delete_template.html:5
#: .\cookbook\templates\generic\delete_template.html:13
#: .\cookbook\templates\generic\edit_template.html:25
msgid "Delete"
msgstr "Löschen"
#: cookbook/templates/base.html:64 cookbook/templates/base.html:72
#: cookbook/templates/index.html:7
#: .\cookbook\templates\base.html:70 .\cookbook\templates\base.html:78
#: .\cookbook\templates\forms\ingredients.html:7
#: .\cookbook\templates\index.html:7 .\cookbook\templates\shopping_list.html:7
msgid "Cookbook"
msgstr "Kochbuch"
#: cookbook/templates/base.html:76
#: .\cookbook\templates\base.html:85
msgid "Utensils"
msgstr "Utensilien"
#: .\cookbook\templates\base.html:89
msgid "Books"
msgstr "Bücher"
#: cookbook/templates/base.html:81
#: .\cookbook\templates\base.html:92 .\cookbook\templates\meal_plan.html:4
#: .\cookbook\templates\meal_plan.html:13 .\cookbook\views\delete.py:136
#: .\cookbook\views\edit.py:283 .\cookbook\views\new.py:130
msgid "Meal-Plan"
msgstr "Plan"
#: .\cookbook\templates\base.html:95
msgid "Shopping"
msgstr "Einkaufsliste"
#: .\cookbook\templates\base.html:105
msgid "Tags"
msgstr "Schlagwörter"
#: cookbook/templates/base.html:85 cookbook/views/edit.py:130
#: cookbook/views/edit.py:331 cookbook/views/lists.py:17
#: cookbook/views/new.py:44
#: .\cookbook\templates\base.html:109 .\cookbook\views\delete.py:70
#: .\cookbook\views\edit.py:159 .\cookbook\views\lists.py:18
#: .\cookbook\views\new.py:46
msgid "Keyword"
msgstr "Schlagwort"
#: cookbook/templates/base.html:87
#: .\cookbook\templates\base.html:111
msgid "Batch Edit"
msgstr "Massenbearbeitung"
#: cookbook/templates/base.html:92
#: .\cookbook\templates\base.html:116
msgid "Storage Data"
msgstr "Datenquellen"
#: cookbook/templates/base.html:96
#: .\cookbook\templates\base.html:120
msgid "Storage Backends"
msgstr "Speicher Quellen"
#: cookbook/templates/base.html:98
#: .\cookbook\templates\base.html:122
msgid "Configure Sync"
msgstr "Sync Einstellen"
#: cookbook/templates/base.html:100
#: .\cookbook\templates\base.html:124
msgid "Import Recipes"
msgstr "Importierte Rezepte"
#: cookbook/templates/base.html:102 cookbook/views/lists.py:25
#: .\cookbook\templates\base.html:126 .\cookbook\views\lists.py:26
msgid "Import Log"
msgstr "Import Log"
#: cookbook/templates/base.html:104 cookbook/templates/stats.html:10
#: .\cookbook\templates\base.html:128 .\cookbook\templates\stats.html:10
msgid "Statistics"
msgstr "Statistiken"
#: cookbook/templates/base.html:112
#: .\cookbook\templates\base.html:130
msgid "Units & Ingredients"
msgstr "Einheiten & Zutaten"
#: .\cookbook\templates\base.html:145 .\cookbook\templates\settings.html:6
#: .\cookbook\templates\settings.html:11
msgid "Settings"
msgstr "Einstellungen"
#: .\cookbook\templates\base.html:148
msgid "Admin"
msgstr "Admin"
#: cookbook/templates/base.html:116
#: .\cookbook\templates\base.html:152
msgid "Logout"
msgstr "Ausloggen"
#: cookbook/templates/base.html:119
#: .\cookbook\templates\base.html:157
#: .\cookbook\templates\registration\login.html:44
msgid "Login"
msgstr "Einloggen"
#: cookbook/templates/batch/edit.html:6
#: .\cookbook\templates\batch\edit.html:6
msgid "Batch edit Category"
msgstr "Kategorie massenbearbeitung"
#: cookbook/templates/batch/edit.html:15
#: .\cookbook\templates\batch\edit.html:15
msgid "Batch edit Recipes"
msgstr "Rezept massenbearbeitung"
#: cookbook/templates/batch/edit.html:20
#: .\cookbook\templates\batch\edit.html:20
msgid "Add the specified keywords to all recipes containing a word"
msgstr ""
"Ausgewählte Schlagwörter zu allen Rezepten die das Suchwort enthalten "
"hinzufügen"
#: cookbook/templates/batch/monitor.html:6 cookbook/views/edit.py:114
#: .\cookbook\templates\batch\monitor.html:6 .\cookbook\views\edit.py:143
msgid "Sync"
msgstr "Synchronisieren"
#: cookbook/templates/batch/monitor.html:10
#: .\cookbook\templates\batch\monitor.html:10
msgid "Manage watched Folders"
msgstr "Überwachte Ordner verwalten"
#: cookbook/templates/batch/monitor.html:14
#: .\cookbook\templates\batch\monitor.html:14
msgid ""
"On this Page you can manage all storage folder locations that should be "
"monitored and synced"
@@ -171,20 +269,20 @@ msgstr ""
"Auf dieser Seite kannst du alle Ordner verwalten die überwacht und "
"synchronisiert werden sollen"
#: cookbook/templates/batch/monitor.html:16
#: .\cookbook\templates\batch\monitor.html:16
msgid "The path must be in the following format"
msgstr "Der Pfad muss in folgendem Format sein"
#: cookbook/templates/batch/monitor.html:27
#: .\cookbook\templates\batch\monitor.html:27
msgid "Sync Now!"
msgstr "Jetzt Synchronisieren!"
#: cookbook/templates/batch/waiting.html:4
#: cookbook/templates/batch/waiting.html:10
#: .\cookbook\templates\batch\waiting.html:4
#: .\cookbook\templates\batch\waiting.html:10
msgid "Importing Recipes"
msgstr "Rezept werden importiert"
#: cookbook/templates/batch/waiting.html:23
#: .\cookbook\templates\batch\waiting.html:23
msgid ""
"This can take a few minutes, depending on the number of recipes in sync, "
"please wait."
@@ -192,120 +290,176 @@ msgstr ""
"Abhängig von der Anzahl der Rezepte kann dieser Vorgang einige Minuten "
"dauern, bitte warten."
#: cookbook/templates/books.html:4 cookbook/templates/books.html:10
#: .\cookbook\templates\books.html:4 .\cookbook\templates\books.html:10
msgid "Recipe Books"
msgstr "Rezept Bücher"
#: cookbook/templates/books.html:14
#: .\cookbook\templates\books.html:14
msgid "New Book"
msgstr "Neues Buch"
#: cookbook/templates/books.html:53
#: .\cookbook\templates\books.html:53
msgid "There are no recipes in this book yet."
msgstr "In diesem Buch sind bisher keine Rezepte."
#: cookbook/templates/forms/edit_import_recipe.html:5
#: cookbook/templates/forms/edit_import_recipe.html:9
#: .\cookbook\templates\forms\edit_import_recipe.html:5
#: .\cookbook\templates\forms\edit_import_recipe.html:9
msgid "Import new Recipe"
msgstr "Rezept Importieren"
#: cookbook/templates/forms/edit_import_recipe.html:14
#: cookbook/templates/forms/edit_internal_recipe.html:37
#: cookbook/templates/generic/edit_template.html:23
#: cookbook/templates/generic/new_template.html:23
#: cookbook/templates/recipe_view.html:207
#: .\cookbook\templates\forms\edit_import_recipe.html:14
#: .\cookbook\templates\forms\edit_internal_recipe.html:47
#: .\cookbook\templates\generic\edit_template.html:23
#: .\cookbook\templates\generic\new_template.html:23
#: .\cookbook\templates\recipe_view.html:340
#: .\cookbook\templates\settings.html:33 .\cookbook\templates\settings.html:47
msgid "Save"
msgstr "Speichern"
#: cookbook/templates/forms/edit_internal_recipe.html:7
#: cookbook/templates/forms/edit_internal_recipe.html:16
#: .\cookbook\templates\forms\edit_internal_recipe.html:8
#: .\cookbook\templates\forms\edit_internal_recipe.html:18
msgid "Edit Recipe"
msgstr "Rezept bearbeiten"
#: cookbook/templates/forms/edit_internal_recipe.html:26
#: cookbook/templates/recipe_view.html:63
msgid "Ingredients"
msgstr "Zutaten"
#: .\cookbook\templates\forms\edit_internal_recipe.html:37
msgid ""
"Use <b>Ctrl</b>+<b>Space</b> to insert new Ingredient!<br/>You can also save "
"the recipe using <b>Ctrl</b>+<b>Shift</b>+<b>S</b>."
msgstr ""
"Benutze <b>Strg</b>+<b>Leertaste</b> um eine neue Zutat einzufügen!<br/"
">Rezepte können mit<b>Strg</b>+<b>Shift</b>+<b>S</b> gespeichert werden."
#: cookbook/templates/forms/edit_internal_recipe.html:41
#: cookbook/templates/generic/edit_template.html:27
#: cookbook/templates/recipe_view.html:7
#: .\cookbook\templates\forms\edit_internal_recipe.html:51
#: .\cookbook\templates\generic\edit_template.html:27
#: .\cookbook\templates\recipe_view.html:7
msgid "View"
msgstr "Angucken"
#: cookbook/templates/forms/edit_internal_recipe.html:45
#: cookbook/templates/generic/edit_template.html:30
#: .\cookbook\templates\forms\edit_internal_recipe.html:55
#: .\cookbook\templates\generic\edit_template.html:30
msgid "Delete original file"
msgstr "Original löschen"
#: cookbook/templates/forms/edit_internal_recipe.html:90
#: cookbook/templates/forms/edit_internal_recipe.html:127
#: .\cookbook\templates\forms\edit_internal_recipe.html:142
#: .\cookbook\templates\forms\edit_internal_recipe.html:189
#: .\cookbook\views\delete.py:81 .\cookbook\views\edit.py:175
msgid "Ingredient"
msgstr "Zutat"
#: cookbook/templates/forms/edit_internal_recipe.html:95
#: .\cookbook\templates\forms\edit_internal_recipe.html:147
msgid "Amount"
msgstr "Menge"
#: cookbook/templates/forms/edit_internal_recipe.html:96
#: .\cookbook\templates\forms\edit_internal_recipe.html:149
msgid "Unit"
msgstr "Einheit"
#: cookbook/templates/generic/delete_template.html:18
#: .\cookbook\templates\forms\edit_internal_recipe.html:154
msgid "Note"
msgstr "Notiz "
#: .\cookbook\templates\forms\edit_internal_recipe.html:163
msgid "Are you sure that you want to delete this ingredient?"
msgstr "Bist du sicher das du diese Zutat löschen willst?"
#: .\cookbook\templates\forms\ingredients.html:15
msgid "Edit Ingredients"
msgstr "Zutaten Bearbeiten"
#: .\cookbook\templates\forms\ingredients.html:16
msgid ""
"\n"
" The following form can be used if, accidentally, two (or more) units "
"or ingredients where created that should be\n"
" the same.\n"
" It merges two units or ingredients and updates all recipes using "
"them.\n"
" "
msgstr ""
"\n"
" Dieses Formular kann genutzt werden wenn versehentlich zwei (oder "
"mehr) Einheitenoder Zutaten erstellt wurden die eigentlich identisch\n"
" sein sollen.\n"
" Es vereint zwei Zutaten oder Einheiten und aktualisiert alle "
"entsprechenden Rezepte.\n"
" "
#: .\cookbook\templates\forms\ingredients.html:24
msgid "Units"
msgstr "Einheiten"
#: .\cookbook\templates\forms\ingredients.html:26
msgid "Are you sure that you want to merge these two units ?"
msgstr "Bist du sicher diese beiden Einheiten zusammengeführt werden sollen ?"
#: .\cookbook\templates\forms\ingredients.html:31
#: .\cookbook\templates\forms\ingredients.html:40
msgid "Merge"
msgstr "Zusammenführen"
#: .\cookbook\templates\forms\ingredients.html:36
msgid "Are you sure that you want to merge these two ingredients ?"
msgstr "Bist du sicher diese beiden Zutaten zusammengeführt werden sollen ?"
#: .\cookbook\templates\generic\delete_template.html:18
#, python-format
msgid "Are you sure you want to delete the %(title)s: <b>%(object)s</b> "
msgstr "Bist du sicher das %(title)s: <b>%(object)s</b> gelöscht werden soll"
#: cookbook/templates/generic/delete_template.html:21
#: .\cookbook\templates\generic\delete_template.html:21
msgid "Confirm"
msgstr ""
msgstr "Bestätigen"
#: cookbook/templates/generic/edit_template.html:6
#: cookbook/templates/generic/edit_template.html:14
#: .\cookbook\templates\generic\edit_template.html:6
#: .\cookbook\templates\generic\edit_template.html:14
msgid "Edit"
msgstr "Bearbeiten"
#: cookbook/templates/generic/list_template.html:6
#: cookbook/templates/generic/list_template.html:12
#: .\cookbook\templates\generic\list_template.html:6
#: .\cookbook\templates\generic\list_template.html:12
msgid "List"
msgstr "Liste"
#: cookbook/templates/generic/list_template.html:19
#: .\cookbook\templates\generic\list_template.html:25
msgid "Filter"
msgstr "Filter"
#: .\cookbook\templates\generic\list_template.html:30
msgid "Import all"
msgstr "Alle importieren"
#: cookbook/templates/generic/new_template.html:6
#: cookbook/templates/generic/new_template.html:14
#: .\cookbook\templates\generic\new_template.html:6
#: .\cookbook\templates\generic\new_template.html:14
msgid "New"
msgstr "Neu"
#: cookbook/templates/generic/table_template.html:76
#: .\cookbook\templates\generic\table_template.html:76
msgid "previous"
msgstr "vorherige"
#: cookbook/templates/generic/table_template.html:98
#: .\cookbook\templates\generic\table_template.html:98
msgid "next"
msgstr "nächste"
#: cookbook/templates/include/recipe_open_modal.html:28
#: cookbook/views/edit.py:258 cookbook/views/edit.py:278
#: cookbook/views/edit.py:298 cookbook/views/new.py:32
#: .\cookbook\templates\include\recipe_open_modal.html:28
#: .\cookbook\views\delete.py:21 .\cookbook\views\edit.py:315
#: .\cookbook\views\new.py:34
msgid "Recipe"
msgstr "Rezept"
#: cookbook/templates/include/recipe_open_modal.html:39
#: .\cookbook\templates\include\recipe_open_modal.html:39
msgid "Close"
msgstr "Schließen"
#: cookbook/templates/include/recipe_open_modal.html:56
#: .\cookbook\templates\include\recipe_open_modal.html:53
msgid "Open Recipe"
msgstr "Rezept öffnen"
#: cookbook/templates/include/storage_backend_warning.html:4
#: .\cookbook\templates\include\storage_backend_warning.html:4
msgid "Security Warning"
msgstr "Sicherheitswarnung"
#: cookbook/templates/include/storage_backend_warning.html:5
#: .\cookbook\templates\include\storage_backend_warning.html:5
msgid ""
"\n"
" The <b>Password and Token</b> field are stored as <b>plain text</b> "
@@ -327,56 +481,78 @@ msgstr ""
"oder Accounts mit limitiertem Zugriff verwendet werden.\n"
" "
#: cookbook/templates/index.html:21
#: .\cookbook\templates\index.html:27
msgid "Search recipe ..."
msgstr "Suche Rezept ..."
#: cookbook/templates/index.html:40
#: .\cookbook\templates\index.html:41
msgid "New Recipe"
msgstr "Neues Rezept"
#: .\cookbook\templates\index.html:46
msgid "Advanced Search"
msgstr "Erweiterte Suche"
#: cookbook/templates/index.html:59
#: .\cookbook\templates\index.html:50
msgid "Reset Search"
msgstr "Suche zurücksetzen"
#: .\cookbook\templates\index.html:78
msgid "Log in to view Recipies"
msgstr "Bitte einloggen um Rezepte zu sehen"
#: cookbook/templates/recipe_view.html:27
#: .\cookbook\templates\meal_plan.html:20
msgid "Week"
msgstr "Woche"
#: .\cookbook\templates\recipe_view.html:67
msgid "in"
msgstr "in"
#: cookbook/templates/recipe_view.html:32
#: cookbook/templates/recipe_view.html:174
#: .\cookbook\templates\recipe_view.html:72
#: .\cookbook\templates\recipe_view.html:293
msgid "by"
msgstr "von"
#: cookbook/templates/recipe_view.html:43
#: .\cookbook\templates\recipe_view.html:84
msgid "Preparation time ca."
msgstr "Zubereitungszeit ca."
#: cookbook/templates/recipe_view.html:48
#: .\cookbook\templates\recipe_view.html:89
msgid "Waiting time ca."
msgstr "Zubereitungszeit ca."
msgstr "Wartezeit ca."
#: cookbook/templates/recipe_view.html:110
#: .\cookbook\templates\recipe_view.html:170
msgid "Recipe Image"
msgstr "Rezept Bild"
#: cookbook/templates/recipe_view.html:126
#: .\cookbook\templates\recipe_view.html:193
#: .\cookbook\templates\recipe_view.html:227
msgid "View external recipe"
msgstr "Externes Rezept ansehen"
#: cookbook/templates/recipe_view.html:137
#: .\cookbook\templates\recipe_view.html:205
msgid "Cloud not show a file preview. Maybe its not a PDF ?"
msgstr ""
"Datei konnte nicht angezeigt werden. Direkte anzeige funktioniert nur mit "
"PDF Dateien."
#: .\cookbook\templates\recipe_view.html:212
msgid "External recipe"
msgstr "Externes Rezept"
#: cookbook/templates/recipe_view.html:139
#: .\cookbook\templates\recipe_view.html:214
msgid ""
"\n"
" This is an external recipe, which means you can only "
"view it by opening the link above.\n"
" You can convert this recipe to a fancy recipe by "
"pressing the convert button. The original file\n"
" will still be accessible.\n"
" "
" This is an external recipe, which means "
"you can only view it by opening the link\n"
" above.\n"
" You can convert this recipe to a fancy "
"recipe by pressing the convert button. The\n"
" original\n"
" file\n"
" will still be accessible.\n"
" "
msgstr ""
"\n"
" Dies ist ein externes Rezept. Das bedeutet das es "
@@ -386,56 +562,81 @@ msgstr ""
"bleibt weiterhin verfügbar.\n"
" "
#: cookbook/templates/recipe_view.html:147
#: .\cookbook\templates\recipe_view.html:225
msgid "Convert now!"
msgstr "Jetzt umwandeln!"
#: cookbook/templates/recipe_view.html:156
#: .\cookbook\templates\recipe_view.html:289
msgid "Comments"
msgstr "Kommentare"
#: cookbook/templates/recipe_view.html:165 cookbook/views/edit.py:191
#: cookbook/views/edit.py:353
#: .\cookbook\templates\recipe_view.html:309 .\cookbook\views\delete.py:103
#: .\cookbook\views\edit.py:234
msgid "Comment"
msgstr "Kommentar"
#: cookbook/templates/registration/login.html:8
#: .\cookbook\templates\registration\login.html:8
msgid "Your username and password didn't match. Please try again."
msgstr "Nutzername oder Passwort falsch. Bitte versuch es erneut."
#: cookbook/templates/stats.html:4
#: .\cookbook\templates\settings.html:17
msgid "Language"
msgstr "Sprache"
#: .\cookbook\templates\settings.html:42
msgid "Style"
msgstr "Stil"
#: .\cookbook\templates\shopping_list.html:15
msgid "Shopping List"
msgstr "Einkaufsliste"
#: .\cookbook\templates\shopping_list.html:20
msgid "Load"
msgstr "Laden"
#: .\cookbook\templates\shopping_list.html:37
#: .\cookbook\templates\shopping_list.html:55
msgid "Copy list to clipboard"
msgstr "Kopiere Liste in Zwischenablage"
#: .\cookbook\templates\shopping_list.html:48
msgid "Copied!"
msgstr "Kopiert!"
#: .\cookbook\templates\stats.html:4
msgid "Stats"
msgstr "Statistiken"
#: cookbook/templates/stats.html:17
#: .\cookbook\templates\stats.html:17
msgid "Number of objects"
msgstr "Anzahl der Objekte"
#: cookbook/templates/stats.html:20
#: .\cookbook\templates\stats.html:20
msgid "Recipes"
msgstr "Rezepte"
#: cookbook/templates/stats.html:24
#: .\cookbook\templates\stats.html:24
msgid "Recipe Imports"
msgstr "Rezept Importe"
#: cookbook/templates/stats.html:32
#: .\cookbook\templates\stats.html:32
msgid "Objects stats"
msgstr "Objekt Statistiken"
#: cookbook/templates/stats.html:35
#: .\cookbook\templates\stats.html:35
msgid "Recipes without Keywords"
msgstr "Rezepte ohne Schlagwort"
#: cookbook/views/api.py:63
#: .\cookbook\views\api.py:63
msgid "Sync successful!"
msgstr "Synchronisation erfolgreich!"
#: cookbook/views/api.py:66
#: .\cookbook\views\api.py:66
msgid "Error synchronizing with Storage"
msgstr "Fehler beim Synchronisieren"
#: cookbook/views/data.py:71
#: .\cookbook\views\data.py:71
#, python-format
msgid "Batch edit done. %(count)d recipe was updated."
msgid_plural "Batch edit done. %(count)d Recipes where updated."
@@ -443,65 +644,81 @@ msgstr[0] "Massenbearbeitung erfolgreich. %(count)d Rezept wurde aktualisiert."
msgstr[1] ""
"Massenbearbeitung erfolgreich. %(count)d Rezepte wurden aktualisiert."
#: cookbook/views/edit.py:88
msgid "Recipe saved!"
msgstr "Rezept gespeichert"
#: cookbook/views/edit.py:91 cookbook/views/new.py:87
msgid "There was an error importing this recipe!"
msgstr "Beim importieren des Rezeptes ist ein Fehler aufgetreten"
#: cookbook/views/edit.py:139 cookbook/views/edit.py:182
msgid "You cannot edit this comment!"
msgstr "Du kannst diesen Kommentar nicht bearbeiten!"
#: cookbook/views/edit.py:158
msgid "Storage saved!"
msgstr "Speicherquelle gespeichert"
#: cookbook/views/edit.py:161
msgid "There was an error updating this storage backend.!"
msgstr "Es gab einen Fehler beim aktualisierung dieser Speicher Quelle"
#: cookbook/views/edit.py:208 cookbook/views/edit.py:309
#: cookbook/views/lists.py:34
#: .\cookbook\views\delete.py:48 .\cookbook\views\edit.py:251
#: .\cookbook\views\lists.py:35
msgid "Import"
msgstr "Rezept Importieren"
#: cookbook/views/edit.py:224 cookbook/views/edit.py:364
#: cookbook/views/new.py:110
msgid "Recipe Book"
msgstr "Rezeptbuch"
#: cookbook/views/edit.py:246
msgid "Changes saved!"
msgstr "Änderungen gespeichert"
#: cookbook/views/edit.py:250
msgid "Error saving changes!"
msgstr "Fehler beim Speichern der Daten."
#: cookbook/views/edit.py:320
#: .\cookbook\views\delete.py:59
msgid "Monitor"
msgstr "Monitor"
#: cookbook/views/edit.py:342 cookbook/views/lists.py:42
#: cookbook/views/new.py:62
#: .\cookbook\views\delete.py:92 .\cookbook\views\lists.py:53
#: .\cookbook\views\new.py:64
msgid "Storage Backend"
msgstr "Speicher Quelle"
#: cookbook/views/edit.py:375
#: .\cookbook\views\delete.py:114 .\cookbook\views\edit.py:267
#: .\cookbook\views\new.py:112
msgid "Recipe Book"
msgstr "Rezeptbuch"
#: .\cookbook\views\delete.py:125
msgid "Bookmarks"
msgstr "Lesezeichen"
#: cookbook/views/new.py:84
#: .\cookbook\views\edit.py:117
msgid "Recipe saved!"
msgstr "Rezept gespeichert"
#: .\cookbook\views\edit.py:119
msgid "There was an error saving this recipe!"
msgstr "Es gab einen Fehler beim Speichern des Rezepts"
#: .\cookbook\views\edit.py:184
msgid "You cannot edit this storage!"
msgstr "Du kannst diese Speicherquelle nicht bearbeiten!"
#: .\cookbook\views\edit.py:203
msgid "Storage saved!"
msgstr "Speicherquelle gespeichert"
#: .\cookbook\views\edit.py:205
msgid "There was an error updating this storage backend.!"
msgstr "Es gab einen Fehler beim aktualisierung dieser Speicher Quelle"
#: .\cookbook\views\edit.py:225
msgid "You cannot edit this comment!"
msgstr "Du kannst diesen Kommentar nicht bearbeiten!"
#: .\cookbook\views\edit.py:303
msgid "Changes saved!"
msgstr "Änderungen gespeichert"
#: .\cookbook\views\edit.py:307
msgid "Error saving changes!"
msgstr "Fehler beim Speichern der Daten."
#: .\cookbook\views\edit.py:337
msgid "Units merged!"
msgstr "Einheiten zusammengeführt"
#: .\cookbook\views\edit.py:350
msgid "Ingredients merged!"
msgstr "Zutaten zusammengeführt"
#: .\cookbook\views\new.py:86
msgid "Imported new recipe!"
msgstr "Importier neue Rezepte"
#: cookbook/views/views.py:42
#: .\cookbook\views\new.py:89
msgid "There was an error importing this recipe!"
msgstr "Beim importieren des Rezeptes ist ein Fehler aufgetreten"
#: .\cookbook\views\views.py:44
msgid "Comment saved!"
msgstr "Kommentar gespeichert"
#: cookbook/views/views.py:52
#: .\cookbook\views\views.py:54
msgid "Bookmark saved!"
msgstr "Lesezeichen gespeichert"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
# Generated by Django 3.0.2 on 2020-02-13 22:34
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('cookbook', '0014_auto_20200213_2332'),
]
operations = [
migrations.AlterField(
model_name='userpreference',
name='user',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 3.0.2 on 2020-02-13 22:35
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('cookbook', '0015_auto_20200213_2334'),
]
operations = [
migrations.RemoveField(
model_name='userpreference',
name='id',
),
migrations.AlterField(
model_name='userpreference',
name='user',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL),
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
# Generated by Django 3.0.2 on 2020-02-19 15:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0025_userpreference_nav_color'),
]
operations = [
migrations.AddField(
model_name='recipe',
name='cors_link',
field=models.CharField(blank=True, max_length=1024, null=True),
),
migrations.AlterField(
model_name='recipe',
name='link',
field=models.CharField(blank=True, max_length=512, null=True),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 3.0.4 on 2020-03-17 17:31
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0026_auto_20200219_1605'),
]
operations = [
migrations.AddField(
model_name='ingredient',
name='recipe',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='cookbook.Recipe'),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 3.0.4 on 2020-03-17 18:01
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0027_ingredient_recipe'),
]
operations = [
migrations.AlterField(
model_name='recipeingredient',
name='ingredient',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='cookbook.Ingredient'),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 3.0.4 on 2020-03-17 18:01
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0028_auto_20200317_1901'),
]
operations = [
migrations.AlterField(
model_name='recipeingredient',
name='unit',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='cookbook.Unit'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.0.4 on 2020-03-17 18:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0029_auto_20200317_1901'),
]
operations = [
migrations.AddField(
model_name='recipeingredient',
name='note',
field=models.CharField(blank=True, max_length=64, null=True),
),
]

View File

@@ -1,8 +1,54 @@
from django.contrib.auth.models import User
import re
from django.contrib import auth
from django.contrib.auth.models import User
from django.utils.translation import gettext as _
from django.db import models
def get_user_name(self):
if not (name := f"{self.first_name} {self.last_name}") == " ":
return name
else:
return self.username
auth.models.User.add_to_class('get_user_name', get_user_name)
def get_model_name(model):
return ('_'.join(re.findall('[A-Z][^A-Z]*', model.__name__))).lower()
class UserPreference(models.Model):
# Themes
BOOTSTRAP = 'BOOTSTRAP'
DARKLY = 'DARKLY'
FLATLY = 'FLATLY'
SUPERHERO = 'SUPERHERO'
THEMES = ((BOOTSTRAP, 'Bootstrap'), (DARKLY, 'Darkly'), (FLATLY, 'Flatly'), (SUPERHERO, 'Superhero'))
# Nav colors
PRIMARY = 'PRIMARY'
SECONDARY = 'SECONDARY'
SUCCESS = 'SUCCESS'
INFO = 'INFO'
WARNING = 'WARNING'
DANGER = 'DANGER'
LIGHT = 'LIGHT'
DARK = 'DARK'
COLORS = ((PRIMARY, 'Primary'), (SECONDARY, 'Secondary'), (SUCCESS, 'Success'), (INFO, 'Info'), (WARNING, 'Warning'), (DANGER, 'Danger'), (LIGHT, 'Light'), (DARK, 'Dark'))
user = models.OneToOneField(User, on_delete=models.CASCADE, primary_key=True)
theme = models.CharField(choices=THEMES, max_length=128, default=FLATLY)
nav_color = models.CharField(choices=COLORS, max_length=128, default=PRIMARY)
def __str__(self):
return self.user
class Storage(models.Model):
DROPBOX = 'DB'
NEXTCLOUD = 'NEXTCLOUD'
@@ -38,6 +84,9 @@ class SyncLog(models.Model):
msg = models.TextField(default="")
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.created_at}:{self.sync} - {self.status}"
class Keyword(models.Model):
name = models.CharField(max_length=64, unique=True)
@@ -48,7 +97,10 @@ class Keyword(models.Model):
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return "{0} {1}".format(self.icon, self.name)
if self.icon:
return f"{self.icon} {self.name}"
else:
return f"{self.name}"
class Recipe(models.Model):
@@ -58,7 +110,8 @@ class Recipe(models.Model):
storage = models.ForeignKey(Storage, on_delete=models.PROTECT, blank=True, null=True)
file_uid = models.CharField(max_length=256, default="")
file_path = models.CharField(max_length=512, default="")
link = models.CharField(max_length=512, default="")
link = models.CharField(max_length=512, null=True, blank=True)
cors_link = models.CharField(max_length=1024, null=True, blank=True)
keywords = models.ManyToManyField(Keyword, blank=True)
working_time = models.IntegerField(default=0)
waiting_time = models.IntegerField(default=0)
@@ -72,14 +125,34 @@ class Recipe(models.Model):
@property
def all_tags(self):
return ' '.join([(x.icon + x.name) for x in self.keywords.all()])
return ' '.join([(str(x)) for x in self.keywords.all()])
class RecipeIngredients(models.Model):
name = models.CharField(max_length=128)
class Unit(models.Model):
name = models.CharField(unique=True, max_length=128)
description = models.TextField(blank=True, null=True)
def __str__(self):
return self.name
class Ingredient(models.Model):
name = models.CharField(unique=True, max_length=128)
recipe = models.ForeignKey(Recipe, null=True, blank=True, on_delete=models.SET_NULL)
def __str__(self):
return self.name
class RecipeIngredient(models.Model):
ingredient = models.ForeignKey(Ingredient, on_delete=models.PROTECT)
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
unit = models.CharField(max_length=128)
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)
def __str__(self):
return str(self.amount) + ' ' + str(self.unit) + ' ' + str(self.ingredient)
class Comment(models.Model):
@@ -89,6 +162,9 @@ class Comment(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.text
class RecipeImport(models.Model):
name = models.CharField(max_length=128)
@@ -115,3 +191,20 @@ class RecipeBookEntry(models.Model):
def __str__(self):
return self.recipe.name
class MealPlan(models.Model):
BREAKFAST = 'BREAKFAST'
LUNCH = 'LUNCH'
DINNER = 'DINNER'
OTHER = 'OTHER'
MEAL_TYPES = ((BREAKFAST, _('Breakfast')), (LUNCH, _('Lunch')), (DINNER, _('Dinner')), (OTHER, _('Other')),)
user = models.ForeignKey(User, on_delete=models.CASCADE)
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
meal = models.CharField(choices=MEAL_TYPES, max_length=128, default=BREAKFAST)
note = models.TextField(blank=True)
date = models.DateField()
def __str__(self):
return self.meal + ' (' + str(self.date) + ') ' + str(self.recipe)

View File

@@ -1,3 +1,4 @@
import base64
import os
from datetime import datetime
@@ -34,7 +35,7 @@ class Dropbox(Provider):
import_count = 0
for recipe in recipes['entries']: # TODO check if has_more is set and import that as well
path = recipe['path_lower']
if not Recipe.objects.filter(file_path=path).exists() and not RecipeImport.objects.filter(
if not Recipe.objects.filter(file_path__iexact=path).exists() and not RecipeImport.objects.filter(
file_path=path).exists():
name = os.path.splitext(recipe['name'])[0]
new_recipe = RecipeImport(name=name, file_path=path, storage=monitor.storage, file_uid=recipe['id'])
@@ -88,6 +89,16 @@ class Dropbox(Provider):
response = Dropbox.create_share_link(recipe)
return response['url']
@staticmethod
def get_base64_file(recipe):
if not recipe.link:
recipe.link = Dropbox.get_share_link(recipe)
recipe.save()
response = requests.get(recipe.link.replace('www.dropbox.', 'dl.dropboxusercontent.'))
return base64.b64encode(response.content)
@staticmethod
def rename_file(recipe, new_name):
url = "https://api.dropboxapi.com/2/files/move_v2"

View File

@@ -1,8 +1,10 @@
import base64
import os
import tempfile
from datetime import datetime
import webdav3.client as wc
import requests
from io import BytesIO
from requests.auth import HTTPBasicAuth
from cookbook.models import Recipe, RecipeImport, SyncLog
@@ -30,7 +32,7 @@ class Nextcloud(Provider):
import_count = 0
for file in files:
path = monitor.path + '/' + file
if not Recipe.objects.filter(file_path=path).exists() and not RecipeImport.objects.filter(
if not Recipe.objects.filter(file_path__iexact=path).exists() and not RecipeImport.objects.filter(
file_path=path).exists():
name = os.path.splitext(file)[0]
new_recipe = RecipeImport(name=name, file_path=path, storage=monitor.storage)
@@ -81,6 +83,20 @@ class Nextcloud(Provider):
return Nextcloud.create_share_link(recipe)
@staticmethod
def get_base64_file(recipe):
client = Nextcloud.get_client(recipe.storage)
tmp_file_path = tempfile.gettempdir() + '/' + recipe.name + '.pdf'
client.download_file(remote_path=recipe.file_path, local_path=tmp_file_path)
val = base64.b64encode(open(tmp_file_path, 'rb').read())
os.remove(tmp_file_path)
return val
@staticmethod
def rename_file(recipe, new_name):
client = Nextcloud.get_client(recipe.storage)

View File

@@ -11,10 +11,14 @@ class Provider:
def get_share_link(recipe):
raise Exception('Method not implemented in storage provider')
@staticmethod
def get_base64_file(recipe):
raise Exception('Method not implemented in storage provider')
@staticmethod
def rename_file(recipe, new_name):
raise Exception('Method not implemented in storage provider')
@staticmethod
def delete_file(recipe, new_name):
def delete_file(recipe):
raise Exception('Method not implemented in storage provider')

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -8,8 +8,7 @@ from .models import *
class RecipeTable(tables.Table):
id = tables.LinkColumn('edit_recipe', args=[A('id')])
name = tables.TemplateColumn(
"<a href='#' onClick='openRecipe({{record.id}})'>{{record.name}}</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'}})
@@ -28,6 +27,15 @@ class KeywordTable(tables.Table):
fields = ('id', 'icon', 'name')
class IngredientTable(tables.Table):
id = tables.LinkColumn('edit_ingredient', args=[A('id')])
class Meta:
model = Keyword
template_name = 'generic/table_template.html'
fields = ('id', 'name')
class StorageTable(tables.Table):
id = tables.LinkColumn('edit_storage', args=[A('id')])
@@ -45,7 +53,7 @@ class ImportLogTable(tables.Table):
if value == 'SUCCESS':
return format_html('<span class="badge badge-success">%s</span>' % value)
else:
return format_html('<span class="badge badge-error">%s</span>' % value)
return format_html('<span class="badge badge-danger">%s</span>' % value)
class Meta:
model = SyncLog
@@ -72,7 +80,7 @@ class SyncTable(tables.Table):
class RecipeImportTable(tables.Table):
id = tables.LinkColumn('new_recipe_import', args=[A('id')])
delete = tables.TemplateColumn("<a href='{% url 'delete_import' record.id %}' >" + _('Delete') + "</a>")
delete = tables.TemplateColumn("<a href='{% url 'delete_recipe_import' record.id %}' >" + _('Delete') + "</a>")
class Meta:
model = RecipeImport

View File

@@ -1,5 +1,6 @@
{% load static %}
{% load i18n %}
{% load theming_tags %}
<html>
<head>
@@ -18,11 +19,11 @@
<meta name="msapplication-TileImage" content="/mstile-144x144.png">
<!-- Bootstrap 4 -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css"
integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
<script src="https://code.jquery.com/jquery-3.4.1.slim.min.js"
integrity="sha384-J6qa4849blE2+poT4WnyKhv5vZF5SrPo0iEjwBvKU7imGFAV0wwj1yYfoRSJoZ+n"
<link id="id_main_css" href="{% theme_url request %}" rel="stylesheet">
<script src="https://code.jquery.com/jquery-3.4.1.js"
integrity="sha256-WpOohJOqMqqyKL9FccASB9O0KwACQJpFTUBLTYOVvVU="
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js"
integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo"
crossorigin="anonymous"></script>
@@ -31,13 +32,18 @@
crossorigin="anonymous"></script>
<!-- Select2 for use with django autocomplete light -->
<link href="https://cdn.jsdelivr.net/npm/select2@4.0.12/dist/css/select2.min.css" rel="stylesheet"/>
<script src="https://cdn.jsdelivr.net/npm/select2@4.0.12/dist/js/select2.min.js"></script>
<link href="https://cdn.jsdelivr.net/npm/select2@4.0.13/dist/css/select2.min.css" rel="stylesheet"/>
<script src="https://cdn.jsdelivr.net/npm/select2@4.0.13/dist/js/select2.min.js"></script>
<!-- Bootstrap theme for select2 -->
<link rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/select2-bootstrap-theme/0.1.0-beta.10/select2-bootstrap.css"
integrity="sha256-zFnNbsU+u3l0K+MaY92RvJI6AdAVAxK3/QrBApHvlH8=" crossorigin="anonymous"/>
<link rel="stylesheet"
href="{% static 'themes/select2-bootstrap-theme.css' %}"
crossorigin="anonymous"/>
<script type="text/javascript">
$.fn.select2.defaults.set("theme", "bootstrap");
</script>
@@ -60,7 +66,7 @@
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<nav class="navbar navbar-expand-lg navbar-dark bg-{% nav_color request %}" id="id_main_nav">
<!--<a class="navbar-brand" href="{% url 'index' %}">{% trans 'Cookbook' %}</a>-->
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText"
aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
@@ -68,59 +74,90 @@
</button>
<div class="collapse navbar-collapse" id="navbarText">
<ul class="navbar-nav mr-auto">
<li class="nav-item active">
<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
class="sr-only">(current)</span></a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'view_books' %}"><i class="fas fa-bookmark"></i> {% trans 'Books' %}</a>
<li class="nav-item dropdown {% if request.resolver_match.url_name in 'view_books,view_plan,view_shopping,list_ingredient' %}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' %}
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdownMenuLink">
<a class="dropdown-item" href="{% url 'view_books' %}"><i
class="fas fa-bookmark fa-fw"></i> {% trans 'Books' %}
</a>
<a class="dropdown-item" href="{% url 'view_plan' %}"><i
class="fas fa-calendar fa-fw"></i> {% trans 'Meal-Plan' %}
</a>
<a class="dropdown-item" href="{% url 'view_shopping' %}"><i
class="fas fa-shopping-cart fa-fw"></i> {% trans 'Shopping' %}
</a>
<a class="dropdown-item" href="{% url 'list_ingredient' %}"><i
class="fas fa-leaf fa-fw"></i> {% trans 'Ingredients' %}
</a>
</div>
</li>
<li class="nav-item dropdown">
<li class="nav-item dropdown {% if request.resolver_match.url_name in 'list_keyword,data_batch_edit' %}active{% endif %}">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdownMenuLink" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false">
<i class="fas fa-tags"></i> {% trans 'Tags' %}
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdownMenuLink">
<a class="dropdown-item" href="{% url 'list_keyword' %}"><i
class="fas fa-tags"></i> {% trans 'Keyword' %}</a>
class="fas fa-tags fa-fw"></i> {% trans 'Keyword' %}</a>
<a class="dropdown-item" href="{% url 'data_batch_edit' %}"><i
class="fas fa-edit"></i> {% trans 'Batch Edit' %}</a>
class="fas fa-edit fa-fw"></i> {% trans 'Batch Edit' %}</a>
</div>
</li>
<li class="nav-item dropdown">
<li class="nav-item dropdown {% if request.resolver_match.url_name in 'list_storage,data_sync,list_recipe_import,list_sync_log,data_stats,edit_ingredient' %}active{% endif %}">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdownMenuLink" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false"><i class="fas fa-database"></i> {% trans 'Storage Data' %}
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdownMenuLink">
<a class="dropdown-item" href="{% url 'list_storage' %}"><i
class="fas fa-database"></i> {% trans 'Storage Backends' %}</a>
class="fas fa-database fa-fw"></i> {% trans 'Storage Backends' %}</a>
<a class="dropdown-item" href="{% url 'data_sync' %}"><i
class="fas fa-sync-alt"></i> {% trans 'Configure Sync' %}</a>
<a class="dropdown-item" href="{% url 'list_import' %}"><i
class="far fa-file-alt"></i> {% trans 'Import Recipes' %}</a>
<a class="dropdown-item" href="{% url 'list_import_log' %}"><i
class="fas fa-history"></i> {% trans 'Import Log' %}</a>
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>
<a class="dropdown-item" href="{% url 'list_sync_log' %}"><i
class="fas fa-history fa-fw"></i> {% trans 'Import Log' %}</a>
<a class="dropdown-item" href="{% url 'data_stats' %}"><i
class="fas fa-chart-line"></i> {% trans 'Statistics' %}</a>
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>
</div>
</li>
</ul>
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" href="{% url 'admin:index' %}"><i
class="fas fa-user-shield"></i> {% trans 'Admin' %}</a>
</li>
<li class="nav-item">
{% if user.is_authenticated %}
<a class="nav-link" href="{% url 'logout' %}">{% trans 'Logout' %} {{ user.get_username }} <i
class="fas fa-sign-out-alt"></i></a>
{% else %}
<a class="nav-link" href="{% url 'login' %}">{% trans 'Login' %} <i class="fas fa-sign-in-alt"></i></a>
{% endif %}
</li>
</ul>
{% if user.is_authenticated %}
<li class="nav-item dropdown {% if request.resolver_match.url_name in 'view_settings' %}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 }}
</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>
{% 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 'logout' %}"><i
class="fas fa-sign-out-alt fa-fw"></i> {% trans 'Logout' %}</a>
</div>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{% url 'login' %}">{% trans 'Login' %} <i class="fas fa-sign-in-alt"></i></a>
</li>
{% endif %}
</ul>
</div>
</nav>
<br/>
@@ -141,5 +178,8 @@
{% endblock %}
</div>
{% block script %}
{% endblock script %}
</body>
</html>

View File

@@ -10,7 +10,7 @@
<h2>{% trans 'Recipe Books' %}</h2>
</div>
<div class="col col-md-3" style="text-align: right">
<a href="{% url 'new_book' %}" class="btn btn-success"><i
<a href="{% url 'new_recipe_book' %}" class="btn btn-success"><i
class="fas fa-plus-circle"></i> {% trans 'New Book' %}</a>
</div>
</div>
@@ -41,7 +41,7 @@
{% for r in b.recipes %}
<div class="row">
<div class="col col-md-10">
<li><a href="#" onClick='openRecipe({{ r.recipe.pk }})'>{{ r.recipe.name }}</a></li>
<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>
@@ -58,5 +58,4 @@
<br/>
{% endfor %}
{% include 'include/recipe_open_modal.html' %}
{% endblock %}

View File

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

View File

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

View File

@@ -13,7 +13,7 @@
<h3>{% trans 'Edit' %} {{ title }}</h3>
{% if form.Meta.model|get_class == 'Storage' %}
{% if form.Meta.model|get_class_name == 'Storage' %}
{% include 'include/storage_backend_warning.html' %}
{% endif %}
@@ -21,8 +21,8 @@
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-success" type="submit"><i class="fas fa-save"></i> {% trans 'Save' %}</button>
<a href="{% url 'redirect_delete' form.instance|get_class|lower form.instance.pk %}"
class="btn btn-danger">{% trans 'Delete' %}</a>
<a href="{% delete_url form.instance|get_class form.instance.pk %}"
class="btn btn-danger"><i class="fas fa-trash-alt"></i> {% trans 'Delete' %}</a>
{% if view_url %}
<a href="{{ view_url }}" class="btn btn-info"><i class="far fa-eye"></i> {% trans 'View' %}</a>
{% endif %}

View File

@@ -15,6 +15,17 @@
</a>
{% endif %}
</h3>
{% if filter %}
<br/>
<br/>
<form action="." method="get">
{% csrf_token %}
{{ filter.form|crispy }}
<button type="submit" class="btn btn-success">{% trans 'Filter' %}</button>
</form>
{% endif %}
{% if import_btn %}
<a href="{% url 'data_batch_import' %}" class="btn btn-warning">{% trans 'Import all' %}</a>
<br/>

View File

@@ -13,7 +13,7 @@
<h3>{% trans 'New' %} {{ title }} </h3>
{% if form.Meta.model|get_class == 'Storage' %}
{% if form.Meta.model|get_class_name == 'Storage' %}
{% include 'include/storage_backend_warning.html' %}
{% endif %}
@@ -23,4 +23,15 @@
<button class="btn btn-success" type="submit"><i class="fas fa-save"></i> {% trans 'Save' %}</button>
</form>
{% if default_recipe %}
<script type="text/javascript">
$(document).ready(function () {
$('#id_recipe').val({{ default_recipe.pk }}).trigger('change');
});
</script>
{% endif %}
{% endblock %}

View File

@@ -43,15 +43,12 @@
</div>
<script type="text/javascript">
function openRecipe(id, force_external = false) {
function openRecipe(id) {
var link = $('#a_recipe_open');
link.hide();
$('#div_loader').show();
var url = "{% url 'api_get_file_link' recipe_id=12345 %}".replace(/12345/, id);
if (force_external) {
url = "{% url 'api_get_external_file_link' recipe_id=12345 %}".replace(/12345/, id);
}
var url = "{% url 'api_get_external_file_link' recipe_id=12345 %}".replace(/12345/, id);
link.text("{% trans 'Open Recipe' %}");
$('#modal_recipe').modal('show');

View File

@@ -8,6 +8,12 @@
{% block extra_head %}
{{ filter.form.media }}
<style>
.dropdown-toggle-no-arrow::after {
display: none;
}
</style>
{% endblock %}
{% block content %}
@@ -21,31 +27,44 @@
<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" id="button-addon4">
<button class="btn btn-primary" type="submit"><i class="fas fa-search"></i></button>
<button class="btn btn-warning" type="button" onclick="window.location = window.location.pathname;"><i class="fas fa-backspace"></i></button>
<button class="btn btn-success" type="button" onclick="location.href='{% url 'new_recipe' %}'"><i class="fas fa-plus-circle"></i></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 class="row ">
<div class="col-md-2 offset-md-10" style="text-align: right">
<a class="" data-toggle="collapse" href="#collapse_adv_search" role="button"
aria-expanded="false"
aria-controls="collapse_adv_search"><i
class="fas fa-search"></i> {% trans 'Advanced Search' %}</a>
</div>
</div>
<div class="row">
<div class="collapse col-md-12" id="collapse_adv_search">
<div>
<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>
</form>
@@ -60,6 +79,4 @@
</div>
{% endif %}
{% include 'include/recipe_open_modal.html' %}
{% endblock %}

View File

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

View File

@@ -9,6 +9,40 @@
{% 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">
<!-- prevent weired character stuff escaping the pdf box -->
<style>
.textLayer > span {
color: transparent;
position: absolute;
white-space: pre;
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 %}
@@ -20,6 +54,12 @@
<div class="col col-md-3 d-print-none" style="text-align: right">
<button class="btn btn-success" onclick="$('#bookmarkModal').modal({'show':true})"><i
class="fas fa-bookmark"></i></button>
{% if ingredients %}
<a class="btn btn-warning" href="{% url 'view_shopping' %}?r={{ recipe.pk }}"><i
class="fas fa-shopping-cart"></i></a>
{% endif %}
<a class="btn btn-info" href="{% url 'new_meal_plan' %}?recipe={{ recipe.pk }}"><i
class="fas fa-calendar"></i></a>
</div>
</div>
@@ -29,23 +69,26 @@
{% endif %}
{% if recipe.internal %}
<small>{% trans 'by' %} {{ recipe.created_by.username }}<br/></small>
<br/>
<small>{% trans 'by' %} {{ recipe.created_by.get_user_name }}<br/></small>
{% endif %}
{% if recipe.all_tags %}
{{ recipe.all_tags }}
{% if recipe.keywords %}
{% for x in recipe.keywords.all %}
<span class="badge badge-pill badge-light">{{ x }}</span>
{% endfor %}
<br/>
<br/>
{% endif %}
{% if recipe.working_time and recipe.working_time != 0 %}
<span class="badge badge-secondary">{% trans 'Preparation time ca.' %} {{ recipe.working_time }} min </span>
<span class="badge badge-secondary"><i
class="fas fa-user-clock"></i> {% trans 'Preparation time ca.' %} {{ recipe.working_time }} min </span>
{% endif %}
{% if recipe.waiting_time and recipe.waiting_time != 0 %}
<span
class="badge badge-secondary">{% trans 'Waiting time ca.' %} {{ recipe.waiting_time }} min </span>
class="badge badge-secondary"><i
class="far fa-clock"></i> {% trans 'Waiting time ca.' %} {{ recipe.waiting_time }} min </span>
{% endif %}
{% if recipe.waiting_time and recipe.waiting_time != 0 or recipe.working_time and recipe.working_time != 0 %}
@@ -55,7 +98,7 @@
<div class="row">
{% if ingredients %}
<div class="col-md-6 order-md-1 col-sm-12 order-sm-2 col-12 order-2">
<div class="col-lg-6 order-md-1 col-sm-12 order-sm-2 col-12 order-2">
<div class="card">
<div class="card-body">
<div class="row">
@@ -74,10 +117,10 @@
</div>
</div>
<br/>
<table class="">
<table class="table table-sm">
{% for i in ingredients %}
<tr>
<td style="font-size: large">
<td style="vertical-align: middle!important;">
<div class="pretty p-default p-curve">
<input type="checkbox"/>
<div class="state p-success">
@@ -93,11 +136,36 @@
</div>
</td>
<td style="font-size: large">{{ i.name }}</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>
{% endfor %}
<!-- Bottom border -->
<tr>
<td></td>
<td></td>
<td></td>
</tr>
</table>
<br/>
</div>
</div>
@@ -113,47 +181,131 @@
</div>
{% endif %}
</div>
{% if recipe.ingredients or recipe.image %}
{% if ingredients or recipe.image %}
<br/>
<br/>
{% endif %}
{% if recipe.instructions %}
{{ recipe.instructions | markdown | safe }}
{% endif %}
<div style="font-size: large">
{% if recipe.instructions %}
{{ recipe.instructions | markdown | safe }}
{% endif %}
</div>
{% if recipe.storage %}
<a href='#' onClick='openRecipe({{ recipe.id }}, true)' class="d-print-none">{% trans 'View external recipe' %}
<i
class="fas fa-external-link-alt"></i></a>
{% endif %}
<div class="row">
{% if recipe.internal %}
<div class="col col-12" style="margin-top: 2vh">
<a href='#' onClick='openRecipe({{ recipe.id }})'
class="d-print-none">{% trans 'View external recipe' %} <i class="fas fa-external-link-alt"></i></a>
</div>
{% else %}
{% if not recipe.internal %}
<br/>
<br/>
<br/>
<div class="card border-info">
<div class="card-body text-info">
<h5 class="card-title">{% trans 'External recipe' %}</h5>
<p class="card-text">
{% blocktrans %}
This is an external recipe, which means you can only view it by opening the link above.
You can convert this recipe to a fancy recipe by pressing the convert button. The original file
will still be accessible.
{% endblocktrans %}.
<br/>
<br/>
<a href="{% url 'edit_convert_recipe' recipe.pk %}"
class="card-link btn btn-info">{% trans 'Convert now!' %}</a>
</p>
</div>
<div class="col col-12" style="margin-top: 2vh">
<div class="loader" id="id_loader"></div>
<div id="viewerContainer" class="border">
<div id="viewer" class="pdfViewer"></div>
</div>
<div class="alert alert-warning" role="alert" id="id_warning_no_preview" style="display: none">
{% trans 'Cloud not show a file preview. Maybe its not a PDF ?' %}
</div>
</div>
<div class="col col-12" style="margin-top: 2vh">
<div class="card border-info">
<div class="card-body text-info">
<h5 class="card-title">{% trans 'External recipe' %}</h5>
<p class="card-text">
{% blocktrans %}
This is an external recipe, which means you can only view it by opening the link
above.
You can convert this recipe to a fancy recipe by pressing the convert button. The
original
file
will still be accessible.
{% endblocktrans %}.
<br/>
<br/>
<a href="{% url 'edit_convert_recipe' recipe.pk %}"
class="card-link btn btn-info">{% trans 'Convert now!' %}</a>
<a href='#' onClick='openRecipe({{ recipe.id }})'
class="d-print-none btn btn-warning">{% trans 'View external recipe' %} <i
class="fas fa-external-link-alt"></i></a>
</p>
</div>
</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="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.3.200/pdf_viewer.js"
integrity="sha256-JW7ackRikw8/UM/hHV6vKaZBYc+t2ZQ77sd3LWR8vh8="
crossorigin="anonymous"></script>
<script type="text/javascript">
var url = "{% url 'api_get_recipe_file' recipe_id=12345 %}".replace(/12345/, {{ recipe.id }});
$('#viewerContainer').hide();
var xhttp = new XMLHttpRequest();
xhttp.onreadystatechange = function () {
if (this.readyState === 4 && this.status === 200) {
var base64Pdf = atob(this.responseText);
$('#id_loader').hide();
$('#viewerContainer').show();
var container = document.getElementById("viewerContainer");
var pdfViewer = new pdfjsViewer.PDFViewer({
container: container,
});
document.addEventListener("pagesinit", function () {
// We can use pdfViewer now, e.g. let's change default scale.
pdfViewer.currentScaleValue = "page-width";
});
var loadingTask = pdfjsLib.getDocument({
data: base64Pdf
});
loadingTask.promise.then(function (pdfDocument) {
// Document loaded, specifying document for the viewer and
// the (optional) linkService.
pdfViewer.setDocument(pdfDocument);
});
}
};
xhttp.open("GET", url, true);
xhttp.send();
</script>
{% endif %}
</div>
{% endif %}
<br/>
<br/>
<h5>{% trans 'Comments' %}</h5>
<h5><i class="far fa-comments"></i> {% trans 'Comments' %}</h5>
{% for c in comments %}
<div class="card">
<div class="card-body">
<small class="card-title">{{ c.updated_at }} {% trans 'by' %} {{ c.created_by.username }}</small> <a
href="{% url 'edit_comment' c.pk %}" class="d-print-none"><i class="fas fa-pencil-alt"></i></a><br/>
{{ c.text }}
</div>
</div>
<br/>
{% endfor %}
<div class="d-print-none">
<form method="POST" class="post-form">
@@ -168,17 +320,6 @@
</form>
</div>
{% for c in comments %}
<div class="card">
<div class="card-body">
<small class="card-title">{{ c.updated_at }} {% trans 'by' %} {{ c.created_by.username }}</small> <a
href="{% url 'edit_comment' c.pk %}" class="d-print-none"><i class="fas fa-pencil-alt"></i></a><br/>
{{ c.text }}
</div>
</div>
<br/>
{% endfor %}
{% if recipe.storage %}
{% include 'include/recipe_open_modal.html' %}
{% endif %}
@@ -213,6 +354,14 @@
<script type="text/javascript">
$(function () {
$('[data-toggle="popover"]').popover()
});
$('.popover-dismiss').popover({
trigger: 'focus'
});
function reloadIngredients() {
factor = Number($('#in_factor').val());
ingredients = {

View File

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

View File

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

View File

@@ -0,0 +1,64 @@
{% extends "base.html" %}
{% load django_tables2 %}
{% load crispy_forms_tags %}
{% load static %}
{% load i18n %}
{% block title %}{% trans "Cookbook" %}{% endblock %}
{% block extra_head %}
{{ form.media }}
{% endblock %}
{% block content %}
<h2><i class="fas fa-shopping-cart"></i> {% trans 'Shopping List' %}</h2>
<form action="{% url 'view_shopping' %}" method="post">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-success" type="submit"><i class="fas fa-sync-alt"></i> {% trans 'Load' %}</button>
</form>
<br/>
<br/>
<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 }}&#10;{% endfor %}</textarea>
<!--// @formatter:on-->
</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="top" title="{% trans 'Copy list to clipboard' %}" id="id_btn_copy" onmouseout="resetTooltip()"><i
class="far fa-copy"></i></button>
</div>
</div>
<script type="text/javascript">
function copy() {
let list = $('#id_list');
list.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>
{% endblock %}

View File

@@ -1,14 +1,32 @@
from django import template
import markdown as md
import bleach
from bleach_whitelist import markdown_tags, markdown_attrs, all_styles, print_attrs
from django.urls import reverse
from cookbook.helper.mdx_attributes import MarkdownFormatExtension
from cookbook.models import get_model_name
register = template.Library()
@register.filter(name='get_class')
def get_class(value):
@register.filter()
def get_class_name(value):
return value.__class__.__name__
@register.filter()
def get_class(value):
return value.__class__
@register.simple_tag
def delete_url(model, pk):
return reverse(f'delete_{get_model_name(model)}', args=[pk])
@register.filter()
def markdown(value):
return md.markdown(value, extensions=['markdown.extensions.fenced_code'])
tags = markdown_tags + ['pre', 'table', 'td', 'tr', 'th', 'tbody', 'style', 'thead']
parsed_md = md.markdown(value, extensions=['markdown.extensions.fenced_code', 'tables', MarkdownFormatExtension()])
return bleach.clean(parsed_md, tags, print_attrs, markdown_attrs)

View File

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

View File

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

View File

View File

View File

@@ -0,0 +1,128 @@
from django.contrib import auth
from django.urls import reverse
from cookbook.models import Recipe, RecipeIngredient, Ingredient, Unit, Storage
from cookbook.tests.views.test_views import TestViews
class TestEditsRecipe(TestViews):
def test_switch_recipe(self):
internal_recipe = Recipe.objects.create(
name='Test',
internal=True,
created_by=auth.get_user(self.client)
)
external_recipe = Recipe.objects.create(
name='Test',
internal=False,
created_by=auth.get_user(self.client)
)
url = reverse('edit_recipe', args=[internal_recipe.pk])
r = self.client.get(url)
self.assertEqual(r.status_code, 302)
r = self.client.get(r.url)
self.assertTemplateUsed(r, 'forms/edit_internal_recipe.html')
url = reverse('edit_recipe', args=[external_recipe.pk])
r = self.client.get(url)
self.assertEqual(r.status_code, 302)
r = self.client.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)
self.assertEqual(r.status_code, 404)
external_recipe = Recipe.objects.create(
name='Test',
internal=False,
created_by=auth.get_user(self.client)
)
url = reverse('edit_convert_recipe', args=[external_recipe.pk])
r = self.client.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)
self.assertEqual(r.status_code, 302)
def test_internal_recipe_update(self):
recipe = Recipe.objects.create(
name='Test',
created_by=auth.get_user(self.client)
)
url = reverse('edit_internal_recipe', args=[recipe.pk])
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
r = self.anonymous_client.get(url)
self.assertEqual(r.status_code, 302)
r = self.client.post(url, {'name': 'Changed', 'working_time': 15, 'waiting_time': 15, 'ingredients': '[]'})
self.assertEqual(r.status_code, 200)
recipe = Recipe.objects.get(pk=recipe.pk)
self.assertEqual('Changed', recipe.name)
Ingredient.objects.create(name='Egg')
Unit.objects.create(name='g')
r = self.client.post(url,
{'name': 'Changed', 'working_time': 15, 'waiting_time': 15,
'ingredients': '[{"ingredient__name":"Tomato","unit__name":"g","amount":100,"delete":false},{"ingredient__name":"Egg","unit__name":"Piece","amount":"2,5","delete":false}]'})
self.assertEqual(r.status_code, 200)
self.assertEqual(2, RecipeIngredient.objects.filter(recipe=recipe).count())
r = self.client.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})
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})
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),
token='test',
username='test',
password='test',
)
recipe = Recipe.objects.create(
name='Test',
created_by=auth.get_user(self.client),
storage=storage,
)
url = reverse('edit_external_recipe', args=[recipe.pk])
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
r = self.anonymous_client.get(url)
self.assertEqual(r.status_code, 302)
r = self.client.post(url, {'name': 'Test', 'working_time': 15, 'waiting_time': 15, })
recipe.refresh_from_db()
self.assertEqual(recipe.working_time, 15)
self.assertEqual(recipe.waiting_time, 15)

View File

@@ -0,0 +1,39 @@
from django.contrib import auth
from django.urls import reverse
from cookbook.models import Storage
from cookbook.tests.views.test_views import TestViews
class TestEditsRecipe(TestViews):
def test_edit_storage(self):
storage = Storage.objects.create(
name='TestStorage',
method=Storage.DROPBOX,
created_by=auth.get_user(self.client),
token='test',
username='test',
password='test',
)
url = reverse('edit_storage', args=[storage.pk])
r = self.anonymous_client.get(url)
self.assertEqual(r.status_code, 302)
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'})
self.assertFormError(r, 'form', 'method', ['Select a valid choice. not_a_valid_method is not one of the available choices.'])

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,24 @@
from django.contrib import auth
from django.contrib.auth.models import User
from django.test import TestCase, Client
class TestBase(TestCase):
def setUp(self):
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)

View File

View File

@@ -0,0 +1,5 @@
from cookbook.tests.test_setup import TestBase
class TestViews(TestBase):
pass

View File

@@ -0,0 +1,37 @@
from django.urls import reverse
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.anonymous_client.get(reverse('index'))
self.assertEqual(r.status_code, 200)
def test_books(self):
url = reverse('view_books')
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
r = self.anonymous_client.get(url)
self.assertEqual(r.status_code, 302)
def test_plan(self):
url = reverse('view_plan')
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
r = self.anonymous_client.get(url)
self.assertEqual(r.status_code, 302)
def test_shopping(self):
url = reverse('view_shopping')
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
r = self.anonymous_client.get(url)
self.assertEqual(r.status_code, 302)

View File

@@ -1,3 +1,5 @@
from pydoc import locate
from django.urls import path
from .views import *
@@ -6,46 +8,24 @@ from cookbook.helper import dal
urlpatterns = [
path('', views.index, name='index'),
path('books', views.books, name='view_books'),
path('books/', views.books, name='view_books'),
path('plan/', views.meal_plan, name='view_plan'),
path('shopping/', views.shopping_list, name='view_shopping'),
path('settings/', views.settings, name='view_settings'),
path('view/recipe/<int:pk>', views.recipe_view, name='view_recipe'),
path('new/recipe/', new.RecipeCreate.as_view(), name='new_recipe'),
path('new/recipe_import/<int:import_id>/', new.create_new_external_recipe, name='new_recipe_import'),
path('new/keyword/', new.KeywordCreate.as_view(), name='new_keyword'),
path('new/storage/', new.StorageCreate.as_view(), name='new_storage'),
path('new/book/', new.RecipeBookCreate.as_view(), name='new_book'),
path('list/keyword', lists.keyword, name='list_keyword'),
path('list/import_log', lists.sync_log, name='list_import_log'),
path('list/import', lists.recipe_import, name='list_import'),
path('list/storage', lists.storage, name='list_storage'),
path('edit/recipe/<int:pk>/', edit.switch_recipe, name='edit_recipe'),
path('edit/recipe/internal/<int:pk>/', edit.internal_recipe_update, name='edit_internal_recipe'),
# for internal use only
path('edit/recipe/external/<int:pk>/', edit.RecipeUpdate.as_view(), name='edit_external_recipe'),
# for internal use only
path('edit/recipe/internal/<int:pk>/', edit.internal_recipe_update, name='edit_internal_recipe'), # for internal use only
path('edit/recipe/external/<int:pk>/', edit.ExternalRecipeUpdate.as_view(), name='edit_external_recipe'), # for internal use only
path('edit/recipe/convert/<int:pk>/', edit.convert_recipe, name='edit_convert_recipe'), # for internal use only
path('edit/keyword/<int:pk>/', edit.KeywordUpdate.as_view(), name='edit_keyword'),
path('edit/sync/<int:pk>/', edit.SyncUpdate.as_view(), name='edit_sync'),
path('edit/import/<int:pk>/', edit.ImportUpdate.as_view(), name='edit_import'),
path('edit/storage/<int:pk>/', edit.edit_storage, name='edit_storage'),
path('edit/comment/<int:pk>/', edit.CommentUpdate.as_view(), name='edit_comment'),
path('edit/recipe-book/<int:pk>/', edit.RecipeBookUpdate.as_view(), name='edit_recipe_book'),
path('edit/ingredient/', edit.edit_ingredients, name='edit_ingredient'),
path('redirect/delete/<slug:name>/<int:pk>/', edit.delete_redirect, name='redirect_delete'),
path('delete/recipe/<int:pk>/', edit.RecipeDelete.as_view(), name='delete_recipe'),
path('delete/recipe-source/<int:pk>/', edit.RecipeSourceDelete.as_view(), name='delete_recipe_source'),
path('delete/keyword/<int:pk>/', edit.KeywordDelete.as_view(), name='delete_keyword'),
path('delete/sync/<int:pk>/', edit.MonitorDelete.as_view(), name='delete_sync'),
path('delete/import/<int:pk>/', edit.ImportDelete.as_view(), name='delete_import'),
path('delete/storage/<int:pk>/', edit.StorageDelete.as_view(), name='delete_storage'),
path('delete/comment/<int:pk>/', edit.CommentDelete.as_view(), name='delete_comment'),
path('delete/recipe-book/<int:pk>/', edit.RecipeBookDelete.as_view(), name='delete_recipe_book'),
path('delete/recipe-book-entry/<int:pk>/', edit.RecipeBookEntryDelete.as_view(), name='delete_recipe_book_entry'),
path('delete/recipe-source/<int:pk>/', delete.delete_recipe_source, name='delete_recipe_source'),
path('data/sync', data.sync, name='data_sync'), # TODO move to generic "new" view
path('data/batch/edit', data.batch_edit, name='data_batch_edit'),
@@ -53,12 +33,30 @@ urlpatterns = [
path('data/sync/wait', data.sync_wait, name='data_sync_wait'),
path('data/statistics', data.statistics, name='data_stats'),
path('api/get_file_link/<int:recipe_id>/', api.get_file_link, name='api_get_file_link'),
path('api/get_external_file_link/<int:recipe_id>/', api.get_external_file_link, name='api_get_external_file_link'),
path('api/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('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'),
]
generic_models = (Recipe, RecipeImport, Storage, RecipeBook, MealPlan, SyncLog, Sync, Comment, RecipeBookEntry, Keyword, Ingredient)
for m in generic_models:
py_name = get_model_name(m)
url_name = py_name.replace('_', '-')
if c := locate(f'cookbook.views.new.{m.__name__}Create'):
urlpatterns.append(path(f'new/{url_name}/', c.as_view(), name=f'new_{py_name}'))
if c := locate(f'cookbook.views.edit.{m.__name__}Update'):
urlpatterns.append(path(f'edit/{url_name}/<int:pk>/', c.as_view(), name=f'edit_{py_name}'))
if c := getattr(lists, py_name, None):
urlpatterns.append(path(f'list/{url_name}/', c, name=f'list_{py_name}'))
if c := locate(f'cookbook.views.delete.{m.__name__}Delete'):
urlpatterns.append(path(f'delete/{url_name}/<int:pk>/', c.as_view(), name=f'delete_{py_name}'))

View File

@@ -3,4 +3,5 @@ from cookbook.views.api import *
from cookbook.views.data import *
from cookbook.views.edit import *
from cookbook.views.new import *
from cookbook.views.lists import *
from cookbook.views.lists import *
from cookbook.views.delete import *

View File

@@ -1,5 +1,5 @@
from django.contrib import messages
from django.http import HttpResponse
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
@@ -10,40 +10,40 @@ from cookbook.provider.dropbox import Dropbox
from cookbook.provider.nextcloud import Nextcloud
@login_required
def get_file_link(request, recipe_id):
recipe = Recipe.objects.get(id=recipe_id)
def get_recipe_provider(recipe):
if recipe.storage.method == Storage.DROPBOX:
return Dropbox
elif recipe.storage.method == Storage.NEXTCLOUD:
return Nextcloud
else:
raise Exception('Provider not implemented')
if recipe.internal:
return HttpResponse(reverse('view_recipe', args=[recipe_id]))
if recipe.storage.method == Storage.DROPBOX: # TODO move to central location (as all provider related functions)
if recipe.link == "":
recipe.link = Dropbox.get_share_link(recipe) # TODO response validation
recipe.save()
if recipe.storage.method == Storage.NEXTCLOUD:
if recipe.link == "":
recipe.link = Nextcloud.get_share_link(recipe) # TODO response validation
recipe.save()
return HttpResponse(recipe.link)
def update_recipe_links(recipe):
if not recipe.link:
recipe.link = get_recipe_provider(recipe).get_share_link(recipe) # TODO response validation in apis
recipe.save()
@login_required
def get_external_file_link(request, recipe_id):
recipe = Recipe.objects.get(id=recipe_id)
if recipe.storage.method == Storage.DROPBOX: # TODO move to central location (as all provider related functions)
if recipe.link == "":
recipe.link = Dropbox.get_share_link(recipe) # TODO response validation
recipe.save()
if recipe.storage.method == Storage.NEXTCLOUD:
if recipe.link == "":
recipe.link = Nextcloud.get_share_link(recipe) # TODO response validation
recipe.save()
if not recipe.link:
update_recipe_links(recipe)
return HttpResponse(recipe.link)
@login_required
def get_recipe_file(request, recipe_id):
recipe = Recipe.objects.get(id=recipe_id)
if not recipe.cors_link:
update_recipe_links(recipe)
return HttpResponse(get_recipe_provider(recipe).get_base64_file(recipe))
@login_required
def sync_all(request):
monitors = Sync.objects.filter(active=True)
@@ -61,7 +61,7 @@ def sync_all(request):
if not error:
messages.add_message(request, messages.SUCCESS, _('Sync successful!'))
return redirect('list_import')
return redirect('list_recipe_import')
else:
messages.add_message(request, messages.ERROR, _('Error synchronizing with Storage'))
return redirect('list_import')
return redirect('list_recipe_import')

View File

@@ -44,7 +44,7 @@ def batch_import(request):
recipe.save()
new_recipe.delete()
return redirect('list_import')
return redirect('list_recipe_import')
@login_required

137
cookbook/views/delete.py Normal file
View File

@@ -0,0 +1,137 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.urls import reverse_lazy, reverse
from django.utils.translation import gettext as _
from django.views.generic import DeleteView
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):
template_name = "generic/delete_template.html"
model = Recipe
success_url = reverse_lazy('index')
def get_context_data(self, **kwargs):
context = super(RecipeDelete, self).get_context_data(**kwargs)
context['title'] = _("Recipe")
return context
def delete_recipe_source(request, pk):
recipe = get_object_or_404(Recipe, pk=pk)
if recipe.storage.method == Storage.DROPBOX:
Dropbox.delete_file(recipe) # TODO central location to handle storage type switches
if recipe.storage.method == Storage.NEXTCLOUD:
Nextcloud.delete_file(recipe)
recipe.storage = None
recipe.file_path = ''
recipe.file_uid = ''
recipe.save()
return HttpResponseRedirect(reverse('edit_recipe', args=[recipe.pk]))
class RecipeImportDelete(LoginRequiredMixin, DeleteView):
template_name = "generic/delete_template.html"
model = RecipeImport
success_url = reverse_lazy('list_recipe_import')
def get_context_data(self, **kwargs):
context = super(RecipeImportDelete, self).get_context_data(**kwargs)
context['title'] = _("Import")
return context
class SyncDelete(LoginRequiredMixin, DeleteView):
template_name = "generic/delete_template.html"
model = Sync
success_url = reverse_lazy('data_sync')
def get_context_data(self, **kwargs):
context = super(SyncDelete, self).get_context_data(**kwargs)
context['title'] = _("Monitor")
return context
class KeywordDelete(LoginRequiredMixin, DeleteView):
template_name = "generic/delete_template.html"
model = Keyword
success_url = reverse_lazy('list_keyword')
def get_context_data(self, **kwargs):
context = super(KeywordDelete, self).get_context_data(**kwargs)
context['title'] = _("Keyword")
return context
class IngredientDelete(LoginRequiredMixin, DeleteView):
template_name = "generic/delete_template.html"
model = Ingredient
success_url = reverse_lazy('list_ingredient')
def get_context_data(self, **kwargs):
context = super(IngredientDelete, self).get_context_data(**kwargs)
context['title'] = _("Ingredient")
return context
class StorageDelete(LoginRequiredMixin, DeleteView):
template_name = "generic/delete_template.html"
model = Storage
success_url = reverse_lazy('list_storage')
def get_context_data(self, **kwargs):
context = super(StorageDelete, self).get_context_data(**kwargs)
context['title'] = _("Storage Backend")
return context
class CommentDelete(LoginRequiredMixin, DeleteView):
template_name = "generic/delete_template.html"
model = Comment
success_url = reverse_lazy('index')
def get_context_data(self, **kwargs):
context = super(CommentDelete, self).get_context_data(**kwargs)
context['title'] = _("Comment")
return context
class RecipeBookDelete(LoginRequiredMixin, DeleteView):
template_name = "generic/delete_template.html"
model = RecipeBook
success_url = reverse_lazy('view_books')
def get_context_data(self, **kwargs):
context = super(RecipeBookDelete, self).get_context_data(**kwargs)
context['title'] = _("Recipe Book")
return context
class RecipeBookEntryDelete(LoginRequiredMixin, DeleteView):
template_name = "generic/delete_template.html"
model = RecipeBookEntry
success_url = reverse_lazy('view_books')
def get_context_data(self, **kwargs):
context = super(RecipeBookEntryDelete, self).get_context_data(**kwargs)
context['title'] = _("Bookmarks")
return context
class MealPlanDelete(LoginRequiredMixin, DeleteView):
template_name = "generic/delete_template.html"
model = MealPlan
success_url = reverse_lazy('view_plan')
def get_context_data(self, **kwargs):
context = super(MealPlanDelete, self).get_context_data(**kwargs)
context['title'] = _("Meal-Plan")
return context

View File

@@ -1,21 +1,23 @@
import os
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
from django.shortcuts import redirect, get_object_or_404, render
from django.urls import reverse_lazy, reverse
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.utils.translation import gettext as _
from django.views.generic import UpdateView, DeleteView
from django.views.generic import UpdateView
from cookbook.forms import ExternalRecipeForm, KeywordForm, StorageForm, SyncForm, InternalRecipeForm, CommentForm
from cookbook.models import Recipe, Sync, Keyword, RecipeImport, Storage, Comment, RecipeIngredients, RecipeBook, \
RecipeBookEntry
from cookbook.forms import ExternalRecipeForm, KeywordForm, StorageForm, SyncForm, InternalRecipeForm, CommentForm, \
MealPlanForm, UnitMergeForm, IngredientMergeForm, IngredientForm
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
@@ -35,15 +37,19 @@ def convert_recipe(request, pk):
if not recipe.internal:
recipe.internal = True
recipe.save()
return HttpResponseRedirect(reverse('edit_internal_recipe', args=[pk]))
return HttpResponseRedirect(reverse('edit_internal_recipe', args=[pk]))
@login_required
def internal_recipe_update(request, pk):
recipe_instance = get_object_or_404(Recipe, pk=pk)
status = 200
if request.method == "POST":
form = InternalRecipeForm(request.POST, request.FILES)
form.instance = recipe_instance
if form.is_valid():
recipe = recipe_instance
recipe.name = form.cleaned_data['name']
@@ -62,41 +68,64 @@ def internal_recipe_update(request, pk):
img = img.resize((basewidth, hsize), Image.ANTIALIAS)
im_io = BytesIO()
img.save(im_io, 'JPEG', quality=70)
recipe.image = File(im_io, name=(str(recipe.pk) + '.jpeg'))
img.save(im_io, 'PNG', quality=70)
recipe.image = File(im_io, name=(str(recipe.pk) + '.png'))
elif 'image' in form.changed_data and form.cleaned_data['image'] is False:
recipe.image = None
recipe.save()
form_ingredients = json.loads(form.data['ingredients'])
RecipeIngredients.objects.filter(recipe=recipe_instance).delete()
try:
form_ingredients = json.loads(form.cleaned_data['ingredients'])
except simplejson.errors.JSONDecodeError:
form_ingredients = []
RecipeIngredient.objects.filter(recipe=recipe_instance).delete()
for i in form_ingredients:
ingredient = RecipeIngredients()
ingredient.recipe = recipe_instance
ingredient.name = i['name']
if isinstance(i['amount'], str):
ingredient.amount = float(i['amount'].replace(',', '.'))
recipe_ingredient = RecipeIngredient()
recipe_ingredient.recipe = recipe_instance
if 'note' in i:
recipe_ingredient.note = i['note']
if Ingredient.objects.filter(name=i['ingredient__name']).exists():
recipe_ingredient.ingredient = Ingredient.objects.get(name=i['ingredient__name'])
else:
ingredient.amount = i['amount']
ingredient.unit = i['unit']
ingredient.save()
ingredient = Ingredient()
ingredient.name = i['ingredient__name']
ingredient.save()
recipe_ingredient.ingredient = ingredient
if isinstance(i['amount'], str):
recipe_ingredient.amount = float(i['amount'].replace(',', '.'))
else:
recipe_ingredient.amount = i['amount']
if Unit.objects.filter(name=i['unit__name']).exists():
recipe_ingredient.unit = Unit.objects.get(name=i['unit__name'])
else:
unit = Unit()
unit.name = i['unit__name']
unit.save()
recipe_ingredient.unit = unit
recipe_ingredient.save()
recipe.keywords.set(form.cleaned_data['keywords'])
messages.add_message(request, messages.SUCCESS, _('Recipe saved!'))
return HttpResponseRedirect(reverse('edit_internal_recipe', args=[pk]))
else:
messages.add_message(request, messages.ERROR, _('There was an error importing this recipe!'))
messages.add_message(request, messages.ERROR, _('There was an error saving this recipe!'))
status = 403
else:
form = InternalRecipeForm(instance=recipe_instance)
ingredients = RecipeIngredients.objects.filter(recipe=recipe_instance)
ingredients = RecipeIngredient.objects.select_related('unit__name', 'ingredient__name').filter(recipe=recipe_instance).values('ingredient__name', 'unit__name', 'amount', 'note')
return render(request, 'forms/edit_internal_recipe.html',
{'form': form, 'ingredients': json.dumps(list(ingredients.values())),
'view_url': reverse('view_recipe', args=[pk])})
{'form': form, 'ingredients': json.dumps(list(ingredients)),
'view_url': reverse('view_recipe', args=[pk])}, status=status)
class SyncUpdate(LoginRequiredMixin, UpdateView):
@@ -131,16 +160,32 @@ class KeywordUpdate(LoginRequiredMixin, UpdateView):
return context
class IngredientUpdate(LoginRequiredMixin, UpdateView):
template_name = "generic/edit_template.html"
model = Ingredient
form_class = IngredientForm
# TODO add msg box
def get_success_url(self):
return reverse('edit_ingredient', kwargs={'pk': self.object.pk})
def get_context_data(self, **kwargs):
context = super(IngredientUpdate, self).get_context_data(**kwargs)
context['title'] = _("Ingredient")
return context
@login_required
def edit_storage(request, pk):
instance = get_object_or_404(Storage, pk=pk)
if not (instance.created_by == request.user or request.user.is_superuser):
messages.add_message(request, messages.ERROR, _('You cannot edit this comment!'))
messages.add_message(request, messages.ERROR, _('You cannot edit this storage!'))
return HttpResponseRedirect(reverse('list_storage'))
if request.method == "POST":
form = StorageForm(request.POST)
form = StorageForm(request.POST, instance=instance)
if form.is_valid():
instance.name = form.cleaned_data['name']
instance.method = form.cleaned_data['method']
@@ -156,7 +201,6 @@ def edit_storage(request, pk):
instance.save()
messages.add_message(request, messages.SUCCESS, _('Storage saved!'))
return HttpResponseRedirect(reverse('edit_storage', args=[pk]))
else:
messages.add_message(request, messages.ERROR, _('There was an error updating this storage backend.!'))
else:
@@ -165,8 +209,7 @@ def edit_storage(request, pk):
pseudo_instance.token = '__NO__CHANGE__'
form = StorageForm(instance=pseudo_instance)
return render(request, 'generic/edit_template.html',
{'form': form, 'view_url': reverse('view_recipe', args=[pk])})
return render(request, 'generic/edit_template.html', {'form': form})
class CommentUpdate(LoginRequiredMixin, UpdateView):
@@ -225,7 +268,23 @@ class RecipeBookUpdate(LoginRequiredMixin, UpdateView):
return context
class RecipeUpdate(LoginRequiredMixin, UpdateView):
class MealPlanUpdate(LoginRequiredMixin, UpdateView):
template_name = "generic/edit_template.html"
model = MealPlan
form_class = MealPlanForm
# TODO add msg box
def get_success_url(self):
return reverse('view_plan')
def get_context_data(self, **kwargs):
context = super(MealPlanUpdate, self).get_context_data(**kwargs)
context['title'] = _("Meal-Plan")
return context
class ExternalRecipeUpdate(LoginRequiredMixin, UpdateView):
model = Recipe
form_class = ExternalRecipeForm
template_name = "generic/edit_template.html"
@@ -235,26 +294,24 @@ class RecipeUpdate(LoginRequiredMixin, UpdateView):
old_recipe = Recipe.objects.get(pk=self.object.pk)
if not old_recipe.name == self.object.name:
if self.object.storage.method == Storage.DROPBOX:
Dropbox.rename_file(old_recipe,
self.object.name) # TODO central location to handle storage type switches
Dropbox.rename_file(old_recipe, self.object.name) # TODO central location to handle storage type switches
if self.object.storage.method == Storage.NEXTCLOUD:
Nextcloud.rename_file(old_recipe, self.object.name)
self.object.file_path = os.path.dirname(self.object.file_path) + '/' + self.object.name + \
os.path.splitext(self.object.file_path)[1]
self.object.file_path = os.path.dirname(self.object.file_path) + '/' + self.object.name + os.path.splitext(self.object.file_path)[1]
messages.add_message(self.request, messages.SUCCESS, _('Changes saved!'))
return super(RecipeUpdate, self).form_valid(form)
return super(ExternalRecipeUpdate, self).form_valid(form)
def form_invalid(self, form):
messages.add_message(self.request, messages.ERROR, _('Error saving changes!'))
return super(RecipeUpdate, self).form_valid(form)
return super(ExternalRecipeUpdate, self).form_valid(form)
def get_success_url(self):
return reverse('edit_recipe', kwargs={'pk': self.object.pk})
def get_context_data(self, **kwargs):
context = super(RecipeUpdate, self).get_context_data(**kwargs)
context = super(ExternalRecipeUpdate, self).get_context_data(**kwargs)
context['title'] = _("Recipe")
context['view_url'] = reverse('view_recipe', args=[self.object.pk])
if self.object.storage:
@@ -262,115 +319,41 @@ class RecipeUpdate(LoginRequiredMixin, UpdateView):
return context
# Generic Delete views
@login_required
def edit_ingredients(request):
if request.method == "POST":
success = False
units_form = UnitMergeForm(request.POST, prefix=UnitMergeForm.prefix)
if units_form.is_valid():
new_unit = units_form.cleaned_data['new_unit']
old_unit = units_form.cleaned_data['old_unit']
recipe_ingredients = RecipeIngredient.objects.filter(unit=old_unit).all()
for i in recipe_ingredients:
i.unit = new_unit
i.save()
def delete_redirect(request, name, pk):
return redirect(('delete_' + name), pk)
old_unit.delete()
success = True
messages.add_message(request, messages.SUCCESS, _('Units merged!'))
ingredients_form = IngredientMergeForm(request.POST, prefix=IngredientMergeForm.prefix)
if ingredients_form.is_valid():
new_ingredient = ingredients_form.cleaned_data['new_ingredient']
old_ingredient = ingredients_form.cleaned_data['old_ingredient']
recipe_ingredients = RecipeIngredient.objects.filter(ingredient=old_ingredient).all()
for i in recipe_ingredients:
i.ingredient = new_ingredient
i.save()
class RecipeDelete(LoginRequiredMixin, DeleteView):
template_name = "generic/delete_template.html"
model = Recipe
success_url = reverse_lazy('index')
old_ingredient.delete()
success = True
messages.add_message(request, messages.SUCCESS, _('Ingredients merged!'))
def get_context_data(self, **kwargs):
context = super(RecipeDelete, self).get_context_data(**kwargs)
context['title'] = _("Recipe")
return context
if success:
units_form = UnitMergeForm()
ingredients_form = IngredientMergeForm()
else:
units_form = UnitMergeForm()
ingredients_form = IngredientMergeForm()
class RecipeSourceDelete(LoginRequiredMixin, DeleteView):
template_name = "generic/delete_template.html"
model = Recipe
success_url = reverse_lazy('index')
def delete(self, request, *args, **kwargs):
self.object = self.get_object()
if self.object.storage.method == Storage.DROPBOX:
Dropbox.delete_file(self.object) # TODO central location to handle storage type switches
if self.object.storage.method == Storage.NEXTCLOUD:
Nextcloud.delete_file(self.object)
return super(RecipeSourceDelete, self).delete(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super(RecipeSourceDelete, self).get_context_data(**kwargs)
context['title'] = _("Recipe")
return context
class ImportDelete(LoginRequiredMixin, DeleteView):
template_name = "generic/delete_template.html"
model = RecipeImport
success_url = reverse_lazy('list_import')
def get_context_data(self, **kwargs):
context = super(ImportDelete, self).get_context_data(**kwargs)
context['title'] = _("Import")
return context
class MonitorDelete(LoginRequiredMixin, DeleteView):
template_name = "generic/delete_template.html"
model = Sync
success_url = reverse_lazy('data_sync')
def get_context_data(self, **kwargs):
context = super(MonitorDelete, self).get_context_data(**kwargs)
context['title'] = _("Monitor")
return context
class KeywordDelete(LoginRequiredMixin, DeleteView):
template_name = "generic/delete_template.html"
model = Keyword
success_url = reverse_lazy('list_keyword')
def get_context_data(self, **kwargs):
context = super(KeywordDelete, self).get_context_data(**kwargs)
context['title'] = _("Keyword")
return context
class StorageDelete(LoginRequiredMixin, DeleteView):
template_name = "generic/delete_template.html"
model = Storage
success_url = reverse_lazy('list_storage')
def get_context_data(self, **kwargs):
context = super(StorageDelete, self).get_context_data(**kwargs)
context['title'] = _("Storage Backend")
return context
class CommentDelete(LoginRequiredMixin, DeleteView):
template_name = "generic/delete_template.html"
model = Comment
success_url = reverse_lazy('index')
def get_context_data(self, **kwargs):
context = super(CommentDelete, self).get_context_data(**kwargs)
context['title'] = _("Comment")
return context
class RecipeBookDelete(LoginRequiredMixin, DeleteView):
template_name = "generic/delete_template.html"
model = RecipeBook
success_url = reverse_lazy('view_books')
def get_context_data(self, **kwargs):
context = super(RecipeBookDelete, self).get_context_data(**kwargs)
context['title'] = _("Recipe Book")
return context
class RecipeBookEntryDelete(LoginRequiredMixin, DeleteView):
template_name = "generic/delete_template.html"
model = RecipeBookEntry
success_url = reverse_lazy('view_books')
def get_context_data(self, **kwargs):
context = super(RecipeBookEntryDelete, self).get_context_data(**kwargs)
context['title'] = _("Bookmarks")
return context
return render(request, 'forms/ingredients.html', {'units_form': units_form, 'ingredients_form': ingredients_form})

View File

@@ -5,8 +5,9 @@ from django.urls import reverse_lazy
from django_tables2 import RequestConfig
from django.utils.translation import gettext as _
from cookbook.models import Keyword, SyncLog, RecipeImport, Storage
from cookbook.tables import KeywordTable, ImportLogTable, RecipeImportTable, StorageTable
from cookbook.filters import IngredientFilter
from cookbook.models import Keyword, SyncLog, RecipeImport, Storage, Ingredient
from cookbook.tables import KeywordTable, ImportLogTable, RecipeImportTable, StorageTable, IngredientTable
@login_required
@@ -34,6 +35,16 @@ def recipe_import(request):
return render(request, 'generic/list_template.html', {'title': _("Import"), 'table': table, 'import_btn': True})
@login_required
def ingredient(request):
f = IngredientFilter(request.GET, queryset=Ingredient.objects.all().order_by('pk'))
table = IngredientTable(f.qs)
RequestConfig(request, paginate={'per_page': 25}).configure(table)
return render(request, 'generic/list_template.html', {'title': _("Ingredients"), 'table': table, 'filter': f})
@login_required
def storage(request):
table = StorageTable(Storage.objects.all())

View File

@@ -1,3 +1,5 @@
import re
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
@@ -8,8 +10,8 @@ from django.utils.translation import gettext as _
from django.views.generic import CreateView
from cookbook.forms import ImportRecipeForm, RecipeImport, KeywordForm, Storage, StorageForm, InternalRecipeForm, \
RecipeBookForm
from cookbook.models import Keyword, Recipe, RecipeBook
RecipeBookForm, MealPlanForm
from cookbook.models import Keyword, Recipe, RecipeBook, MealPlan
class RecipeCreate(LoginRequiredMixin, CreateView):
@@ -74,6 +76,7 @@ def create_new_external_recipe(request, import_id):
recipe.name = form.cleaned_data['name']
recipe.file_path = form.cleaned_data['file_path']
recipe.file_uid = form.cleaned_data['file_uid']
recipe.created_by = request.user
recipe.save()
@@ -82,7 +85,7 @@ def create_new_external_recipe(request, import_id):
RecipeImport.objects.get(id=import_id).delete()
messages.add_message(request, messages.SUCCESS, _('Imported new recipe!'))
return redirect('list_import')
return redirect('list_recipe_import')
else:
messages.add_message(request, messages.ERROR, _('There was an error importing this recipe!'))
else:
@@ -109,3 +112,28 @@ class RecipeBookCreate(LoginRequiredMixin, CreateView):
context = super(RecipeBookCreate, self).get_context_data(**kwargs)
context['title'] = _("Recipe Book")
return context
class MealPlanCreate(LoginRequiredMixin, CreateView):
template_name = "generic/new_template.html"
model = MealPlan
form_class = MealPlanForm
success_url = reverse_lazy('view_plan')
def form_valid(self, form):
obj = form.save(commit=False)
obj.user = self.request.user
obj.save()
return HttpResponseRedirect(reverse('view_plan'))
def get_context_data(self, **kwargs):
context = super(MealPlanCreate, self).get_context_data(**kwargs)
context['title'] = _("Meal-Plan")
recipe = self.request.GET.get('recipe')
if recipe:
if re.match(r'^([0-9])+$', recipe):
if Recipe.objects.filter(pk=int(recipe)).exists():
context['default_recipe'] = Recipe.objects.get(pk=int(recipe))
return context

View File

@@ -1,8 +1,12 @@
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.http import HttpResponseRedirect
from django.contrib.auth.forms import PasswordChangeForm
from django.shortcuts import render, get_object_or_404
from django.urls import reverse
from django_tables2 import RequestConfig
from django.utils.translation import gettext as _
@@ -26,7 +30,7 @@ def index(request):
@login_required
def recipe_view(request, pk):
recipe = get_object_or_404(Recipe, pk=pk)
ingredients = RecipeIngredients.objects.filter(recipe=recipe)
ingredients = RecipeIngredient.objects.filter(recipe=recipe)
comments = Comment.objects.filter(recipe=recipe)
if request.method == "POST":
@@ -69,3 +73,125 @@ def books(request):
book_list.append({'book': b, 'recipes': RecipeBookEntry.objects.filter(book=b).all()})
return render(request, 'books.html', {'book_list': book_list})
def get_start_end_from_week(p_year, p_week):
first_day_of_week = datetime.strptime(f'{p_year}-W{int(p_week) - 1}-1', "%Y-W%W-%w").date()
last_day_of_week = first_day_of_week + timedelta(days=6.9)
return first_day_of_week, last_day_of_week
def get_days_from_week(start, end):
delta = end - start
days = []
for i in range(delta.days + 1):
days.append(start + timedelta(days=i))
return days
@login_required()
def meal_plan(request):
js_week = datetime.now().strftime("%Y-W%V")
if request.method == "POST":
js_week = request.POST['week']
year, week = js_week.split('-')
first_day, last_day = get_start_end_from_week(year, week.replace('W', ''))
surrounding_weeks = {'next': (last_day + timedelta(3)).strftime("%Y-W%V"), 'prev': (first_day - timedelta(3)).strftime("%Y-W%V")}
days = get_days_from_week(first_day, last_day)
days_dict = {}
for d in days:
days_dict[d] = []
plan = {}
for t in MealPlan.MEAL_TYPES:
plan[t[0]] = {'type_name': t[1], 'days': copy.deepcopy(days_dict)}
for d in days:
plan_day = MealPlan.objects.filter(date=d).all()
for p in plan_day:
plan[p.meal]['days'][d].append(p)
return render(request, 'meal_plan.html', {'js_week': js_week, 'plan': plan, 'days': days, 'surrounding_weeks': surrounding_weeks})
@login_required
def shopping_list(request):
markdown_format = True
if request.method == "POST":
form = ShoppingForm(request.POST)
if form.is_valid():
recipes = form.cleaned_data['recipe']
markdown_format = form.cleaned_data['markdown_format']
else:
recipes = []
else:
raw_list = request.GET.getlist('r')
recipes = []
for r in raw_list:
if re.match(r'^([1-9])+$', r):
if Recipe.objects.filter(pk=int(r)).exists():
recipes.append(int(r))
form = ShoppingForm(initial={'recipe': recipes})
ingredients = []
for r in recipes:
for ri in RecipeIngredient.objects.filter(recipe=r).all():
index = None
for x, ig in enumerate(ingredients):
if ri.ingredient == ig.ingredient and ri.unit == ig.unit:
index = x
if index:
ingredients[index].amount = ingredients[index].amount + ri.amount
else:
ingredients.append(ri)
return render(request, 'shopping_list.html', {'ingredients': ingredients, 'recipes': recipes, 'form': form, 'markdown_format': markdown_format})
@login_required
def settings(request):
try:
up = request.user.userpreference
except UserPreference.DoesNotExist:
up = None
user_name_form = UserNameForm(instance=request.user)
password_form = PasswordChangeForm(request.user)
if request.method == "POST":
if 'preference_form' in request.POST:
form = UserPreferenceForm(request.POST, prefix='preference')
if form.is_valid():
if not up:
up = UserPreference(user=request.user)
up.theme = form.cleaned_data['theme']
up.nav_color = form.cleaned_data['nav_color']
up.save()
if 'user_name_form' in request.POST:
user_name_form = UserNameForm(request.POST, prefix='name')
if user_name_form.is_valid():
request.user.first_name = user_name_form.cleaned_data['first_name']
request.user.last_name = user_name_form.cleaned_data['last_name']
request.user.save()
if 'password_form' in request.POST:
password_form = PasswordChangeForm(request.user, request.POST)
if password_form.is_valid():
user = password_form.save()
update_session_auth_hash(request, user)
if up:
preference_form = UserPreferenceForm(instance=up)
else:
preference_form = UserPreferenceForm()
return render(request, 'settings.html', {'preference_form': preference_form, 'user_name_form': user_name_form, 'password_form': password_form})

View File

@@ -1,43 +0,0 @@
version: "3"
services:
db_recipes:
restart: always
image: "postgres:11-alpine"
volumes:
- ./postgresql:/var/lib/postgresql/data
env_file:
- ./.env
networks:
- default
web_recipes:
build: .
restart: always
env_file:
- ./.env
command: "gunicorn --bind 0.0.0.0:8080 recipes.wsgi"
volumes:
- .:/Recipes
depends_on:
- db_recipes
networks:
- default
nginx_recipes:
image: "nginx"
restart: always
env_file:
- ./.env
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d
- ./staticfiles:/static
- ./mediafiles:/media
networks:
- default
- nginx-proxy
networks:
default:
nginx-proxy:
external:
name: nginx-proxy

View File

@@ -0,0 +1,11 @@
This is a docker compose example when using [jwilder's nginx reverse proxy](https://github.com/jwilder/docker-gen)
in combination with [jrcs's letsencrypt companion](https://hub.docker.com/r/jrcs/letsencrypt-nginx-proxy-companion/).
Please refer to the appropriate documentation on how to setup the reverse proxy and networks.
Remember to add the appropriate environment variables to `.env` file:
```
VIRTUAL_HOST=
LETSENCRYPT_HOST=
LETSENCRYPT_EMAIL=
```

View File

@@ -0,0 +1,43 @@
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
- ./staticfiles:/static
- ./mediafiles:/media
networks:
- default
- nginx-proxy
networks:
default:
nginx-proxy:
external:
name: nginx-proxy

View File

@@ -0,0 +1,5 @@
This is the most basic configuration to run this image with docker compose.
> **NOTE**: There is no proxy included in this configuration and gunicorn is directly exposed as the webserver which is
> not recommended by according to the [gunicorn devs](https://serverfault.com/questions/331256/why-do-i-need-nginx-and-something-like-gunicorn).
> It is higly recommended to configure an additional proxy (nginx, ...) in front of this.

View File

@@ -0,0 +1,29 @@
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
ports:
- 80:8080
depends_on:
- db_recipes
networks:
- default
networks:
default:

View File

@@ -0,0 +1,60 @@
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
```

Some files were not shown because too many files have changed in this diff Show More