mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2025-12-30 13:40:01 -05:00
Compare commits
261 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b90c70b2a3 | ||
|
|
bcf50f30bc | ||
|
|
065ed6c437 | ||
|
|
285e09f40a | ||
|
|
0398f36949 | ||
|
|
ea30eb96cd | ||
|
|
b787ae49bb | ||
|
|
f8e2283a69 | ||
|
|
13d51a7b46 | ||
|
|
e74ae06b64 | ||
|
|
aa495250c9 | ||
|
|
f8ee48c23b | ||
|
|
320246b18b | ||
|
|
00992da998 | ||
|
|
2b9ad2feed | ||
|
|
257127bd4e | ||
|
|
b1df118140 | ||
|
|
da6b437b20 | ||
|
|
6fe4c79b0d | ||
|
|
1793753cb4 | ||
|
|
9ed1aff0d2 | ||
|
|
51c3ec5762 | ||
|
|
5feeabb498 | ||
|
|
c4aa3eb019 | ||
|
|
e8b9f473a6 | ||
|
|
279b4dc025 | ||
|
|
6a1226ca26 | ||
|
|
b9ee7d53fa | ||
|
|
ace7ee4274 | ||
|
|
16968db1cf | ||
|
|
2b24155dd2 | ||
|
|
5a7c914fe7 | ||
|
|
f822e03be0 | ||
|
|
1bdf14dbf9 | ||
|
|
6ef173d82d | ||
|
|
1e471ad40d | ||
|
|
4ff1a6bc93 | ||
|
|
7d1f47edc5 | ||
|
|
f69d7898d5 | ||
|
|
9692e2386b | ||
|
|
93b2e2d7e4 | ||
|
|
8b2833f353 | ||
|
|
643dbbc294 | ||
|
|
c4273a4c3f | ||
|
|
95461316a5 | ||
|
|
1775b64ba4 | ||
|
|
5a9270373f | ||
|
|
37f98ce9fe | ||
|
|
fa556c9a7f | ||
|
|
29e1d1286c | ||
|
|
f489043077 | ||
|
|
bdd004518c | ||
|
|
840f5ec60d | ||
|
|
566eea1d75 | ||
|
|
bb48655acb | ||
|
|
d723165b1c | ||
|
|
592bd4f11e | ||
|
|
0aec23fcdd | ||
|
|
a23dc717aa | ||
|
|
d364994ed7 | ||
|
|
a38ed28512 | ||
|
|
1f5c02bcc3 | ||
|
|
f4afdfbc07 | ||
|
|
f753b63b13 | ||
|
|
6f3068a28c | ||
|
|
aa57b47d18 | ||
|
|
113e9ef1e3 | ||
|
|
5899527621 | ||
|
|
0a40de0f14 | ||
|
|
bc31f013c0 | ||
|
|
e7fc15dc72 | ||
|
|
79396cec9e | ||
|
|
5e07c6130f | ||
|
|
94e1fdfbff | ||
|
|
a0d414c83f | ||
|
|
1441368465 | ||
|
|
6f301c4771 | ||
|
|
ec31d251ea | ||
|
|
289625923f | ||
|
|
a42a76a2cf | ||
|
|
fd1216cd22 | ||
|
|
3f6a342026 | ||
|
|
f72fc699f8 | ||
|
|
cdcca80196 | ||
|
|
400cd2f6a0 | ||
|
|
37a4821d01 | ||
|
|
d165075a96 | ||
|
|
a062173ebd | ||
|
|
806963c396 | ||
|
|
851853740d | ||
|
|
7ca88f3c0a | ||
|
|
ac2e9dd6cb | ||
|
|
b2a34ce59a | ||
|
|
6124501f5a | ||
|
|
4ec313f752 | ||
|
|
dd07c56ede | ||
|
|
77fae46aee | ||
|
|
ff573b0358 | ||
|
|
247eab2a4f | ||
|
|
dc46502667 | ||
|
|
ac58f1959d | ||
|
|
f4543f8d65 | ||
|
|
d0ef5e27df | ||
|
|
9ea90f1c87 | ||
|
|
e7922a7e47 | ||
|
|
d3bc440c83 | ||
|
|
910b28fe2d | ||
|
|
89cd8bc2d2 | ||
|
|
d2e9ad2ae6 | ||
|
|
56c9edd328 | ||
|
|
7732aa7646 | ||
|
|
9863447bac | ||
|
|
53b00cc4c8 | ||
|
|
4f34ec1be8 | ||
|
|
e444ba91f0 | ||
|
|
fa3513eb65 | ||
|
|
d323778f1d | ||
|
|
53cb5afef6 | ||
|
|
0349301919 | ||
|
|
a5d2bd75d6 | ||
|
|
26499ad431 | ||
|
|
2eb72953f0 | ||
|
|
6fd9cf0d8c | ||
|
|
1e800889e4 | ||
|
|
422113a745 | ||
|
|
e687d0e569 | ||
|
|
76c1529ec1 | ||
|
|
d30b2b7ec8 | ||
|
|
c4fbad614e | ||
|
|
4c92a4b39c | ||
|
|
3dad5132bb | ||
|
|
7d7890445e | ||
|
|
cea015f23d | ||
|
|
3e8610912e | ||
|
|
ba80ca42e6 | ||
|
|
c413db5460 | ||
|
|
0e319ff293 | ||
|
|
88e3b22dcd | ||
|
|
19e2094ecd | ||
|
|
215989682b | ||
|
|
2f038edf8c | ||
|
|
a754002f4e | ||
|
|
1af2211010 | ||
|
|
724d57ecd7 | ||
|
|
4b04fada51 | ||
|
|
4ad7043f91 | ||
|
|
4dfda4439c | ||
|
|
591d185b9d | ||
|
|
8d582548bd | ||
|
|
209924e5b3 | ||
|
|
7e3e2aadaf | ||
|
|
0930e615f0 | ||
|
|
21c759b127 | ||
|
|
7d1a83440d | ||
|
|
2d75b303fd | ||
|
|
a1b15d46b8 | ||
|
|
69a6edee99 | ||
|
|
0ac23b4e3a | ||
|
|
085e777ee0 | ||
|
|
c31df3f7a6 | ||
|
|
98e2c0acaf | ||
|
|
1509b8243b | ||
|
|
e427d8b714 | ||
|
|
89b8dbe57f | ||
|
|
f2a17fe3bb | ||
|
|
14e0dae6e3 | ||
|
|
733c281dc8 | ||
|
|
c542f3154e | ||
|
|
c6f40db7e3 | ||
|
|
31dabd4757 | ||
|
|
7a89015ac5 | ||
|
|
2b1cde2efc | ||
|
|
cb3b8c931e | ||
|
|
72bea14c3a | ||
|
|
cd46203d55 | ||
|
|
368d631602 | ||
|
|
5c1cecb7e7 | ||
|
|
526cf13b8d | ||
|
|
3c21baf876 | ||
|
|
2d2c38517c | ||
|
|
163b259bd1 | ||
|
|
24ced66c69 | ||
|
|
6c1982cccb | ||
|
|
3bae7283d1 | ||
|
|
0b458f7565 | ||
|
|
675f30126c | ||
|
|
25b051323c | ||
|
|
697de3d9fc | ||
|
|
7bc09dfe89 | ||
|
|
711dfbe55f | ||
|
|
76108c66c6 | ||
|
|
db3c390d03 | ||
|
|
138fb14107 | ||
|
|
17ebdd7711 | ||
|
|
fc9a42029a | ||
|
|
7d942d551a | ||
|
|
78c94f2b64 | ||
|
|
b317d7ba29 | ||
|
|
71b8ddd1bf | ||
|
|
23de4d4239 | ||
|
|
4641b81f70 | ||
|
|
a9bad5e5f9 | ||
|
|
9f7106a325 | ||
|
|
73f13f56e1 | ||
|
|
312c364797 | ||
|
|
ad9b10c9c1 | ||
|
|
678cfaca12 | ||
|
|
9b36f51d16 | ||
|
|
fa8389d783 | ||
|
|
30d766be77 | ||
|
|
5e2dba7b04 | ||
|
|
70df7c5307 | ||
|
|
f91d9fcfe2 | ||
|
|
086a4aea47 | ||
|
|
148ce2faef | ||
|
|
4827364e37 | ||
|
|
da958faf33 | ||
|
|
f5117abcfb | ||
|
|
df79c8f889 | ||
|
|
0ff65d35dc | ||
|
|
8239dc3604 | ||
|
|
4a4d4b4486 | ||
|
|
34733a427f | ||
|
|
7f68bbd25d | ||
|
|
392ee73719 | ||
|
|
2a0a85018a | ||
|
|
62868cd2b2 | ||
|
|
4e92be3bbc | ||
|
|
14c94bf7ab | ||
|
|
ce3148ac89 | ||
|
|
652b4bf2af | ||
|
|
bc39b53aad | ||
|
|
984192e479 | ||
|
|
3c73b084cf | ||
|
|
fc073124d4 | ||
|
|
f6fb07926e | ||
|
|
90dddd34f3 | ||
|
|
0b948618f3 | ||
|
|
78be002134 | ||
|
|
7acd72ff3a | ||
|
|
c5edeb7e8f | ||
|
|
5d5c5a8597 | ||
|
|
03bdcdf9b4 | ||
|
|
16d755fd76 | ||
|
|
587426e3d3 | ||
|
|
be55e034bf | ||
|
|
8055754455 | ||
|
|
82497c734a | ||
|
|
b39a55ee94 | ||
|
|
9d837cd633 | ||
|
|
968206a7ab | ||
|
|
a769fe6906 | ||
|
|
21cf4c5d70 | ||
|
|
6c02912dad | ||
|
|
c0756d87a6 | ||
|
|
2471c7982d | ||
|
|
b36e440920 | ||
|
|
a8b1ee9765 | ||
|
|
782d276724 | ||
|
|
9510562576 | ||
|
|
363a4b6ff7 |
@@ -14,5 +14,4 @@ LICENSE
|
||||
.idea
|
||||
LICENSE.md
|
||||
docs
|
||||
nginx
|
||||
update.sh
|
||||
@@ -8,28 +8,45 @@ ALLOWED_HOSTS=*
|
||||
# random secret key, use for example base64 /dev/urandom | head -c50 to generate one
|
||||
SECRET_KEY=
|
||||
|
||||
# your default timezone
|
||||
TIMEZONE=Europe/Berlin
|
||||
|
||||
# add only a database password if you want to run with the default postgres, otherwise change settings accordingly
|
||||
DB_ENGINE=django.db.backends.postgresql_psycopg2
|
||||
DB_ENGINE=django.db.backends.postgresql
|
||||
POSTGRES_HOST=db_recipes
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_USER=djangodb
|
||||
POSTGRES_PASSWORD=
|
||||
POSTGRES_DB=djangodb
|
||||
|
||||
# the default value for the user preference 'fractions' (enable/disable fraction support)
|
||||
# when unset: 0 (disabled)
|
||||
FRACTION_PREF_DEFAULT=0
|
||||
|
||||
# the default value for the user preference 'comments' (enable/disable commenting system)
|
||||
# when unset: 1 (true)
|
||||
COMMENT_PREF_DEFAULT=1
|
||||
|
||||
# Users can set a amount of time after which the shopping list is refreshed when they are in viewing mode
|
||||
# This is the minimum interval users can set. Setting this to low will allow users to refresh very frequently which
|
||||
# might cause high load on the server. (Technically they can obviously refresh as often as they want with their own scripts)
|
||||
SHOPPING_MIN_AUTOSYNC_INTERVAL=5
|
||||
|
||||
# If staticfiles are stored at a different location uncomment and change accordingly
|
||||
# STATIC_URL=/static/
|
||||
|
||||
# If mediafiles are stored at a different location uncomment and change accordingly
|
||||
# MEDIA_URL=/media/
|
||||
|
||||
# Serve mediafiles directly using gunicorn. Basically everyone recommends not doing this. Please use any of the examples
|
||||
# provided that include an additional nxginx container to handle media file serving.
|
||||
# If you know what you are doing turn this back on (1) to serve media files using djangos serve() method.
|
||||
# when unset: 1 (true) - this is temporary until an appropriate amount of time has passed for everyone to migrate
|
||||
GUNICORN_MEDIA=0
|
||||
|
||||
|
||||
# allow authentication via reverse proxy (e.g. authelia), leave of if you dont know what you are doing
|
||||
# docs: https://github.com/vabene1111/recipes/tree/develop/docs/docker/nginx-proxy%20with%20proxy%20authentication
|
||||
# when unset: 0 (false)
|
||||
REVERSE_PROXY_AUTH=0
|
||||
|
||||
|
||||
# the default value for the user preference 'comments' (enable/disable commenting system)
|
||||
# when unset: 1 (true)
|
||||
COMMENT_PREF_DEFAULT=1
|
||||
|
||||
|
||||
11
.github/dependabot.yml
vendored
Normal file
11
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "pip" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "daily"
|
||||
2
.github/workflows/docker-publish-release.yml
vendored
2
.github/workflows/docker-publish-release.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
name: Build image job
|
||||
steps:
|
||||
- name: Checkout master
|
||||
uses: actions/checkout@master#
|
||||
uses: actions/checkout@master
|
||||
- name: Get version number
|
||||
id: get_version
|
||||
run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//}
|
||||
|
||||
17
.github/workflows/docs.yml
vendored
Normal file
17
.github/workflows/docs.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: Make Docs
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.x
|
||||
- run: pip install mkdocs-material
|
||||
- run: mkdocs gh-deploy --force
|
||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
@@ -0,0 +1,5 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
||||
</state>
|
||||
</component>
|
||||
1
.idea/dictionaries/vabene1111_PC.xml
generated
1
.idea/dictionaries/vabene1111_PC.xml
generated
@@ -1,6 +1,7 @@
|
||||
<component name="ProjectDictionaryState">
|
||||
<dictionary name="vabene1111-PC">
|
||||
<words>
|
||||
<w>autosync</w>
|
||||
<w>csrftoken</w>
|
||||
<w>gunicorn</w>
|
||||
<w>ical</w>
|
||||
|
||||
63
CONTRIBUTERS.md
Normal file
63
CONTRIBUTERS.md
Normal file
@@ -0,0 +1,63 @@
|
||||
Many thanks to everyone who contributed to this project! If you add something or help out feel free to add yourself
|
||||
to this list.
|
||||
|
||||
## Code/Features
|
||||
Please have a look at the [list of pull requests](https://github.com/vabene1111/recipes/pulls) for
|
||||
a complete list of contributions.
|
||||
Below are some of the larger contributions made yet.
|
||||
|
||||
|
||||
- @tourn provided the serving feature and **several** other improvements!
|
||||
- @l0c4lh057 provided a much improved ingredient text parser in [#277](https://github.com/vabene1111/recipes/pull/277)
|
||||
- @sebimarkgraf added nutritional information [#199](https://github.com/vabene1111/recipes/pull/199)
|
||||
- @cazier added reverse proxy authentication [#88](https://github.com/vabene1111/recipes/pull/88)
|
||||
|
||||
## Translations
|
||||
|
||||
### Catalan
|
||||
[Rubenix](https://www.transifex.com/user/profile/rubenix/)
|
||||
|
||||
### Dutch
|
||||
[D0T1X](https://www.transifex.com/user/profile/D0T1X/)
|
||||
[ikbenfrank](https://www.transifex.com/user/profile/ikbenfrank/)
|
||||
[kampsj](https://www.transifex.com/user/profile/kampsj/)
|
||||
|
||||
### French
|
||||
[jt117](https://www.transifex.com/user/profile/jt117/)
|
||||
[nerdinator](https://www.transifex.com/user/profile/nerdinator/)
|
||||
[agaume](https://www.transifex.com/user/profile/agaume/)
|
||||
|
||||
### German
|
||||
[eTaurus](https://www.transifex.com/user/profile/eTaurus/)
|
||||
[l0c4lh057](https://www.transifex.com/user/profile/l0c4lh057/)
|
||||
|
||||
### Hungarian
|
||||
[igazka](https://www.transifex.com/user/profile/igazka/)
|
||||
|
||||
### Italian
|
||||
[SK3LA](https://www.transifex.com/user/profile/SK3LA/)
|
||||
[auanasgheps](https://www.transifex.com/user/profile/auanasgheps/)
|
||||
|
||||
### Latvian
|
||||
[melkypie](https://github.com/melkypie)
|
||||
|
||||
### Portuguese
|
||||
|
||||
[hds](https://www.transifex.com/user/profile/hds/)
|
||||
[mlopezifu](https://www.transifex.com/user/profile/mlopezifu/)
|
||||
[stormsz](https://www.transifex.com/user/profile/stormsz/)
|
||||
|
||||
### Spanish
|
||||
|
||||
[albertocp](https://www.transifex.com/user/profile/albertocp/)
|
||||
[alfa5](https://www.transifex.com/user/profile/alfa5/)
|
||||
[mlopezifu](https://www.transifex.com/user/profile/mlopezifu/)
|
||||
[sergio.laya](https://www.transifex.com/user/profile/sergio.laya/)
|
||||
|
||||
### Turkish
|
||||
|
||||
[batmanisnaked](https://www.transifex.com/user/profile/batmanisnaked/)
|
||||
|
||||
### Vietnamese
|
||||
|
||||
[vuongtrunghieu](https://www.transifex.com/user/profile/vuongtrunghieu/)
|
||||
83
README.md
83
README.md
@@ -1,81 +1,82 @@
|
||||
# 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
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
[More Screenshots](https://imgur.com/a/V01151p)
|
||||
|
||||
### Features
|
||||
## Features
|
||||
|
||||
- :package: **Sync** files with Dropbox and Nextcloud (more can easily be added)
|
||||
- :mag: Powerful **search** with Djangos [TrigramSimilarity](https://docs.djangoproject.com/en/3.0/ref/contrib/postgres/search/#trigram-similarity)
|
||||
- :label: Create and search for **tags**, assign them in batch to all files matching certain filters
|
||||
- :page_facing_up: **Create recipes** locally within a nice, standardized web interface
|
||||
- :page_facing_up: **Create recipes** locally within a nice, standardized web interface
|
||||
- :arrow_down: **Import recipes** from thousands of websites supporting [ld+json or microdata](https://schema.org/Recipe)
|
||||
- :iphone: Optimized for use on **mobile** devices like phones and tablets
|
||||
- :shopping_cart: Generate **shopping** lists from recipes
|
||||
- :calendar: Create a **Plan** on what to eat when
|
||||
- :family: **Share** recipes with friends and comment on them to suggest or remember changes you made
|
||||
- :heavy_division_sign: automatically convert decimal units to **fractions** for those who like this
|
||||
- :whale: Easy setup with **Docker**
|
||||
- :art: Customize your interface with **themes**
|
||||
- :envelope: Export and import recipes from other users
|
||||
- :earth_africa: localized in many languages thanks to the awesome community
|
||||
- :heavy_plus_sign: Many more like recipe scaling, image compression, cookbooks, printing views, ...
|
||||
|
||||
This application is meant for people with a collection of recipes they want to share with family and friends or simply
|
||||
store them in a nicely organized way. A basic permission system exists but this application is not meant to be run as a public page.
|
||||
This application is meant for people with a collection of recipes they want to share with family and friends or simply
|
||||
store them in a nicely organized way. A basic permission system exists but this application is not meant to be run as
|
||||
a public page.
|
||||
Some Documentation can be found [here](https://github.com/vabene1111/recipes/wiki)
|
||||
# Installation
|
||||
|
||||
The docker image (`vabene1111/recipes`) simply exposes the application on port `8080`. You may choose any preferred installation method, the following are just examples to make it easier.
|
||||
While this application has been around for a while and is actively used by many (including myself) it is still considered
|
||||
**beta** software that has a lot of rough edges and unpolished parts.
|
||||
|
||||
### Docker-Compose
|
||||
|
||||
2. Choose one of the included configurations [here](docs/docker).
|
||||
2. Download the environment (config) file template and fill it out `wget https://raw.githubusercontent.com/vabene1111/recipes/develop/.env.template -O .env `
|
||||
3. Start the container `docker-compose up -d`
|
||||
4. Open the page to create the first user. Alternatively use `docker-compose exec web_recipes createsuperuser`
|
||||
|
||||
### Manual
|
||||
**Python >= 3.8** is required to run this!
|
||||
|
||||
Copy `.env.template` to `.env` and fill in the missing values accordingly.
|
||||
Make sure all variables are available to whatever serves your application.
|
||||
|
||||
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`
|
||||
|
||||
## Kubernetes
|
||||
|
||||
You can find a basic kubernetes setup [here](docs/k8s/). Please see the README in the folder for more detail.
|
||||
## Installation
|
||||
Please refer to the Installation section of the [Documentation](https://vabene1111.github.io/recipes/).
|
||||
|
||||
## 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/).
|
||||
|
||||
**If you want feel free to open an issue or pull request to add yourself to the list of awesome contributors.**
|
||||
|
||||
### Getting Started
|
||||
This application is developed using the django framework for Python. They have excellent
|
||||
[documentation](https://www.djangoproject.com/start/) on how to get started, so I will only give you the basics here
|
||||
|
||||
1. Clone this repository wherever you like and install the Python language for your OS (at least version 3.8)
|
||||
2. Open it in your favorite editor/IDE (e.g. PyCharm)
|
||||
1. if you want, create a virutal environment for all your packages.
|
||||
3. Install all required packages by running `pip install -r requirements.txt`
|
||||
4. Run the migrations `python manage.py migrate`
|
||||
5. Start the development server `python manage.py runserver`
|
||||
|
||||
There is **no** need to set any environment variables. By default, a simple sqlite database is used and all settings are
|
||||
populated from default values.
|
||||
|
||||
### Translating
|
||||
|
||||
There is a [transifex project](https://www.transifex.com/django-recipes/django-cookbook/) project to enable community driven translations. If you want to contribute a new language or help maintain an already existing one feel free to create a transifex account (using the link above) and request to join the project.
|
||||
|
||||
It is also possible to provide the translations directly by creating a new language using `manage.py makemessages -l <language_code> -i venv`. Once finished simply open a PR with the changed files.
|
||||
|
||||
## License
|
||||
|
||||
Beginning with version 0.10.0 the code in this repository is licensed under the [GNU AGPL v3](https://www.gnu.org/licenses/agpl-3.0.de.html) license with an
|
||||
[common clause](https://commonsclause.com/) selling exception. See [LICENSE.md](https://github.com/vabene1111/recipes/blob/develop/LICENSE.md) for details.
|
||||
|
||||
**Reasoning**
|
||||
**This software and *all* its features are and will always be free for everyone to use and enjoy.**
|
||||
|
||||
#### This software and **all** its features are and will always be free for everyone to use and enjoy.
|
||||
|
||||
The reason for the selling exception is that a significant amount of time was spend over multiple years to develop this software.
|
||||
The reason for the selling exception is that a significant amount of time was spend over multiple years to develop this software.
|
||||
A payed hosted version which will be identical in features and code base to the software offered in this repository will
|
||||
likely be released in the future (including all features needed to sell a hosted version as they might also be useful for personal use).
|
||||
This will not only benefit me personally but also everyone who self-hosts this software as any profits made trough selling the hosted option
|
||||
This will not only benefit me personally but also everyone who self-hosts this software as any profits made trough selling the hosted option
|
||||
allow me to spend more time developing and improving the software for everyone. Selling exceptions are [approved by Richard Stallman](http://www.gnu.org/philosophy/selling-exceptions.en.html) and the
|
||||
common clause license is very permissive (see the [FAQ](https://commonsclause.com/)).
|
||||
common clause license is very permissive (see the [FAQ](https://commonsclause.com/)).
|
||||
|
||||
10
SECURITY.md
Normal file
10
SECURITY.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Since this software is still considered beta/WIP support is always only given for the latest version. There are no backports of security or any other fixes.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please open a normal public issue if you have any security related concerns. If you feel like the issue should not be discussed in
|
||||
public just open a generic issue and we will discuss further communitcation there (since GitHub does not allow everyone to create a security advisory :/).
|
||||
@@ -2,6 +2,13 @@ from django.contrib import admin
|
||||
from .models import *
|
||||
|
||||
|
||||
class SpaceAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'message')
|
||||
|
||||
|
||||
admin.site.register(Space, SpaceAdmin)
|
||||
|
||||
|
||||
class UserPreferenceAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'theme', 'nav_color', 'default_page', 'search_style', 'comments')
|
||||
|
||||
@@ -125,6 +132,13 @@ class ViewLogAdmin(admin.ModelAdmin):
|
||||
admin.site.register(ViewLog, ViewLogAdmin)
|
||||
|
||||
|
||||
class InviteLinkAdmin(admin.ModelAdmin):
|
||||
list_display = ('username', 'group', 'valid_until', 'created_by', 'created_at', 'used_by')
|
||||
|
||||
|
||||
admin.site.register(InviteLink, InviteLinkAdmin)
|
||||
|
||||
|
||||
class CookLogAdmin(admin.ModelAdmin):
|
||||
list_display = ('recipe', 'created_by', 'created_at', 'rating', 'servings')
|
||||
|
||||
@@ -132,8 +146,36 @@ class CookLogAdmin(admin.ModelAdmin):
|
||||
admin.site.register(CookLog, CookLogAdmin)
|
||||
|
||||
|
||||
class ShoppingListRecipeAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'recipe', 'servings')
|
||||
|
||||
|
||||
admin.site.register(ShoppingListRecipe, ShoppingListRecipeAdmin)
|
||||
|
||||
|
||||
class ShoppingListEntryAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'food', 'unit', 'list_recipe', 'checked')
|
||||
|
||||
|
||||
admin.site.register(ShoppingListEntry, ShoppingListEntryAdmin)
|
||||
|
||||
|
||||
class ShoppingListAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'created_by', 'created_at')
|
||||
|
||||
|
||||
admin.site.register(ShoppingList, ShoppingListAdmin)
|
||||
|
||||
|
||||
class ShareLinkAdmin(admin.ModelAdmin):
|
||||
list_display = ('recipe', 'created_by', 'uuid', 'created_at',)
|
||||
|
||||
|
||||
admin.site.register(ShareLink, ShareLinkAdmin)
|
||||
|
||||
|
||||
class NutritionInformationAdmin(admin.ModelAdmin):
|
||||
list_display = ('id',)
|
||||
|
||||
|
||||
admin.site.register(NutritionInformation, NutritionInformationAdmin)
|
||||
|
||||
@@ -2,7 +2,7 @@ import django_filters
|
||||
from django.contrib.postgres.search import TrigramSimilarity
|
||||
from django.db.models import Q
|
||||
from cookbook.forms import MultiSelectWidget
|
||||
from cookbook.models import Recipe, Keyword, Food
|
||||
from cookbook.models import Recipe, Keyword, Food, ShoppingList
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
@@ -52,3 +52,16 @@ class IngredientFilter(django_filters.FilterSet):
|
||||
class Meta:
|
||||
model = Food
|
||||
fields = ['name']
|
||||
|
||||
|
||||
class ShoppingListFilter(django_filters.FilterSet):
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
if data is not None:
|
||||
data = data.copy()
|
||||
data.setdefault("finished", False)
|
||||
super(ShoppingListFilter, self).__init__(data, *args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
model = ShoppingList
|
||||
fields = ['finished']
|
||||
|
||||
@@ -31,15 +31,19 @@ class UserPreferenceForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = UserPreference
|
||||
fields = ('default_unit', 'theme', 'nav_color', 'default_page', 'show_recent', 'search_style', 'plan_share', 'ingredient_decimals', 'comments')
|
||||
fields = ('default_unit', 'use_fractions', 'theme', 'nav_color', 'default_page', 'show_recent', 'search_style', 'plan_share', 'ingredient_decimals', 'shopping_auto_sync', 'comments')
|
||||
|
||||
help_texts = {
|
||||
'nav_color': _('Color of the top navigation bar. Not all colors work with all themes, just try them out!'),
|
||||
'default_unit': _('Default Unit to be used when inserting a new ingredient into a recipe.'),
|
||||
'plan_share': _('Default user to share newly created meal plan entries with.'),
|
||||
'use_fractions': _('Enables support for fractions in ingredient amounts (e.g. convert decimals to fractions automatically)'),
|
||||
'plan_share': _('Users with whom newly created meal plan/shopping list entries should be shared by default.'),
|
||||
'show_recent': _('Show recently viewed recipes on search page.'),
|
||||
'ingredient_decimals': _('Number of decimals to round ingredients.'),
|
||||
'comments': _('If you want to be able to create and see comments underneath recipes.')
|
||||
'comments': _('If you want to be able to create and see comments underneath recipes.'),
|
||||
'shopping_auto_sync': _(
|
||||
'Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but might use a little bit '
|
||||
'of mobile data. If lower than instance limit it is reset when saving.')
|
||||
}
|
||||
|
||||
widgets = {
|
||||
@@ -84,13 +88,14 @@ class InternalRecipeForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = ('name', 'image', 'working_time', 'waiting_time', 'keywords')
|
||||
fields = ('name', 'image', 'working_time', 'waiting_time', 'servings', 'keywords')
|
||||
|
||||
labels = {
|
||||
'name': _('Name'),
|
||||
'keywords': _('Keywords'),
|
||||
'working_time': _('Preparation time in minutes'),
|
||||
'waiting_time': _('Waiting time (cooking/baking) in minutes'),
|
||||
'servings': _('Number of servings'),
|
||||
}
|
||||
widgets = {'keywords': MultiSelectWidget}
|
||||
|
||||
@@ -261,7 +266,7 @@ class MealPlanForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = MealPlan
|
||||
fields = ('recipe', 'title', 'meal_type', 'note', 'date', 'shared')
|
||||
fields = ('recipe', 'title', 'meal_type', 'note', 'servings', 'date', 'shared')
|
||||
|
||||
help_texts = {
|
||||
'shared': _('You can list default users to share recipes with in the settings.'),
|
||||
@@ -271,7 +276,16 @@ class MealPlanForm(forms.ModelForm):
|
||||
widgets = {'recipe': SelectWidget, 'date': DateWidget, 'shared': MultiSelectWidget}
|
||||
|
||||
|
||||
class SuperUserForm(forms.Form):
|
||||
name = forms.CharField()
|
||||
class InviteLinkForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = InviteLink
|
||||
fields = ('username', 'group', 'valid_until')
|
||||
help_texts = {
|
||||
'username': _('A username is not required, if left blank the new user can choose one.')
|
||||
}
|
||||
|
||||
|
||||
class UserCreateForm(forms.Form):
|
||||
name = forms.CharField(label='Username')
|
||||
password = forms.CharField(widget=forms.TextInput(attrs={'autocomplete': 'new-password', 'type': 'password'}))
|
||||
password_confirm = forms.CharField(widget=forms.TextInput(attrs={'autocomplete': 'new-password', 'type': 'password'}))
|
||||
|
||||
131
cookbook/helper/ingredient_parser.py
Normal file
131
cookbook/helper/ingredient_parser.py
Normal file
@@ -0,0 +1,131 @@
|
||||
import unicodedata
|
||||
import string
|
||||
|
||||
def parse_fraction(x):
|
||||
if len(x) == 1 and 'fraction' in unicodedata.decomposition(x):
|
||||
frac_split = unicodedata.decomposition(x[-1:]).split()
|
||||
return float((frac_split[1]).replace('003', '')) / float((frac_split[3]).replace('003', ''))
|
||||
else:
|
||||
frac_split = x.split('/')
|
||||
if not len(frac_split) == 2:
|
||||
raise ValueError
|
||||
try:
|
||||
return int(frac_split[0]) / int(frac_split[1])
|
||||
except ZeroDivisionError:
|
||||
raise ValueError
|
||||
|
||||
def parse_amount(x):
|
||||
amount = 0
|
||||
unit = ''
|
||||
|
||||
did_check_frac = False
|
||||
end = 0
|
||||
while end < len(x) and (x[end] in string.digits or ((x[end] == '.' or x[end] == ',') and end + 1 < len(x) and x[end+1] in string.digits)):
|
||||
end += 1
|
||||
if end > 0:
|
||||
amount = float(x[:end].replace(',', '.'))
|
||||
else:
|
||||
amount = parse_fraction(x[0])
|
||||
end += 1
|
||||
did_check_frac = True
|
||||
if end < len(x):
|
||||
if did_check_frac:
|
||||
unit = x[end:]
|
||||
else:
|
||||
try:
|
||||
amount += parse_fraction(x[end])
|
||||
unit = x[end+1:]
|
||||
except ValueError:
|
||||
unit = x[end:]
|
||||
return amount, unit
|
||||
|
||||
def parse_ingredient_with_comma(tokens):
|
||||
ingredient = ''
|
||||
note = ''
|
||||
start = 0
|
||||
# search for first occurence of an argument ending in a comma
|
||||
while start < len(tokens) and not tokens[start].endswith(','):
|
||||
start += 1
|
||||
if start == len(tokens):
|
||||
# no token ending in a comma found -> use everything as ingredient
|
||||
ingredient = ' '.join(tokens)
|
||||
else:
|
||||
ingredient = ' '.join(tokens[:start+1])[:-1]
|
||||
note = ' '.join(tokens[start+1:])
|
||||
return ingredient, note
|
||||
|
||||
def parse_ingredient(tokens):
|
||||
ingredient = ''
|
||||
note = ''
|
||||
if tokens[-1].endswith(')'):
|
||||
# last argument ends with closing bracket -> look for opening bracket
|
||||
start = len(tokens) - 1
|
||||
while not tokens[start].startswith('(') and not start == 0:
|
||||
start -= 1
|
||||
if start == 0:
|
||||
# the whole list is wrapped in brackets -> assume it is an error (e.g. assumed first argument was the unit)
|
||||
raise ValueError
|
||||
elif start < 0:
|
||||
# no opening bracket anywhere -> just ignore the last bracket
|
||||
ingredient, note = parse_ingredient_with_comma(tokens)
|
||||
else:
|
||||
# opening bracket found -> split in ingredient and note, remove brackets from note
|
||||
note = ' '.join(tokens[start:])[1:-1]
|
||||
ingredient = ' '.join(tokens[:start])
|
||||
else:
|
||||
ingredient, note = parse_ingredient_with_comma(tokens)
|
||||
return ingredient, note
|
||||
|
||||
def parse(x):
|
||||
# initialize default values
|
||||
amount = 0
|
||||
unit = ''
|
||||
ingredient = ''
|
||||
note = ''
|
||||
|
||||
tokens = x.split()
|
||||
if len(tokens) == 1:
|
||||
# there only is one argument, that must be the ingredient
|
||||
ingredient = tokens[0]
|
||||
else:
|
||||
try:
|
||||
# try to parse first argument as amount
|
||||
amount, unit = parse_amount(tokens[0])
|
||||
# only try to parse second argument as amount if there are at least three arguments
|
||||
# if it already has a unit there can't be a fraction for the amount
|
||||
if len(tokens) > 2:
|
||||
try:
|
||||
if not unit == '':
|
||||
# a unit is already found, no need to try the second argument for a fraction
|
||||
# probably not the best method to do it, but I didn't want to make an if check and paste the exact same thing in the else as already is in the except
|
||||
raise ValueError
|
||||
# try to parse second argument as amount and add that, in case of '2 1/2' or '2 ½'
|
||||
amount += parse_fraction(tokens[1])
|
||||
# assume that units can't end with a comma
|
||||
if len(tokens) > 3 and not tokens[2].endswith(','):
|
||||
# try to use third argument as unit and everything else as ingredient, use everything as ingredient if it fails
|
||||
try:
|
||||
ingredient, note = parse_ingredient(tokens[3:])
|
||||
unit = tokens[2]
|
||||
except ValueError:
|
||||
ingredient, note = parse_ingredient(tokens[2:])
|
||||
else:
|
||||
ingredient, note = parse_ingredient(tokens[2:])
|
||||
except ValueError:
|
||||
# assume that units can't end with a comma
|
||||
if not tokens[1].endswith(','):
|
||||
# try to use second argument as unit and everything else as ingredient, use everything as ingredient if it fails
|
||||
try:
|
||||
ingredient, note = parse_ingredient(tokens[2:])
|
||||
unit = tokens[1]
|
||||
except ValueError:
|
||||
ingredient, note = parse_ingredient(tokens[1:])
|
||||
else:
|
||||
ingredient, note = parse_ingredient(tokens[1:])
|
||||
else:
|
||||
# only two arguments, first one is the amount which means this is the ingredient
|
||||
ingredient = tokens[1]
|
||||
except ValueError:
|
||||
# can't parse first argument as amount -> no unit -> parse everything as ingredient
|
||||
ingredient, note = parse_ingredient(tokens)
|
||||
return amount, unit.strip(), ingredient.strip(), note.strip()
|
||||
@@ -67,9 +67,28 @@ def is_object_owner(user, obj):
|
||||
return owner == user
|
||||
if owner := getattr(obj, 'user', None):
|
||||
return owner == user
|
||||
if getattr(obj, 'get_owner', None):
|
||||
return obj.get_owner() == user
|
||||
return False
|
||||
|
||||
|
||||
def is_object_shared(user, obj):
|
||||
"""
|
||||
Tests if a given user is shared for a given object
|
||||
test performed by checking user against the objects shared table
|
||||
superusers bypass all checks, unauthenticated users cannot own anything
|
||||
:param user django auth user object
|
||||
:param obj any object that should be tested
|
||||
:return: true if user is shared for object, false otherwise
|
||||
"""
|
||||
# TODO this could be improved/cleaned up by adding share checks for relevant objects
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
if user.is_superuser:
|
||||
return True
|
||||
return user in obj.shared.all()
|
||||
|
||||
|
||||
def share_link_valid(recipe, share):
|
||||
"""
|
||||
Verifies the validity of a share uuid
|
||||
@@ -122,7 +141,7 @@ class OwnerRequiredMixin(object):
|
||||
return HttpResponseRedirect(reverse_lazy('login'))
|
||||
else:
|
||||
if not is_object_owner(request.user, self.get_object()):
|
||||
messages.add_message(request, messages.ERROR, _('You cannot interact with this object as its not owned by you!'))
|
||||
messages.add_message(request, messages.ERROR, _('You cannot interact with this object as it is not owned by you!'))
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
|
||||
return super(OwnerRequiredMixin, self).dispatch(request, *args, **kwargs)
|
||||
@@ -136,7 +155,7 @@ class CustomIsOwner(permissions.BasePermission):
|
||||
verifies user has ownership over object
|
||||
(either user or created_by or user is request user)
|
||||
"""
|
||||
message = _('You cannot interact with this object as its not owned by you!')
|
||||
message = _('You cannot interact with this object as it is not owned by you!')
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return request.user.is_authenticated
|
||||
@@ -145,6 +164,20 @@ class CustomIsOwner(permissions.BasePermission):
|
||||
return is_object_owner(request.user, obj)
|
||||
|
||||
|
||||
class CustomIsShared(permissions.BasePermission): # TODO function duplicate/too similar name
|
||||
"""
|
||||
Custom permission class for django rest framework views
|
||||
verifies user is shared for the object he is trying to access
|
||||
"""
|
||||
message = _('You cannot interact with this object as it is not owned by you!')
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return request.user.is_authenticated
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
return is_object_shared(request.user, obj)
|
||||
|
||||
|
||||
class CustomIsGuest(permissions.BasePermission):
|
||||
"""
|
||||
Custom permission class for django rest framework views
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
import unicodedata
|
||||
from json import JSONDecodeError
|
||||
|
||||
import microdata
|
||||
@@ -10,6 +11,7 @@ from django.utils.dateparse import parse_duration
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from cookbook.models import Keyword
|
||||
from cookbook.helper.ingredient_parser import parse as parse_ingredient
|
||||
|
||||
|
||||
def get_from_html(html_text, url):
|
||||
@@ -18,7 +20,7 @@ def get_from_html(html_text, url):
|
||||
# first try finding ld+json as its most common
|
||||
for ld in soup.find_all('script', type='application/ld+json'):
|
||||
try:
|
||||
ld_json = json.loads(ld.string)
|
||||
ld_json = json.loads(ld.string.replace('\n', ''))
|
||||
if type(ld_json) != list:
|
||||
ld_json = [ld_json]
|
||||
|
||||
@@ -31,8 +33,8 @@ def get_from_html(html_text, url):
|
||||
|
||||
if '@type' in ld_json_item and ld_json_item['@type'] == 'Recipe':
|
||||
return find_recipe_json(ld_json_item, url)
|
||||
except JSONDecodeError:
|
||||
JsonResponse({'error': True, 'msg': _('The requested site provided malformed data and cannot be read.')}, status=400)
|
||||
except JSONDecodeError as e:
|
||||
return JsonResponse({'error': True, 'msg': _('The requested site provided malformed data and cannot be read.')}, status=400)
|
||||
|
||||
# now try to find microdata
|
||||
items = microdata.get_items(html_text)
|
||||
@@ -69,31 +71,12 @@ def find_recipe_json(ld_json, url):
|
||||
ingredients = []
|
||||
|
||||
for x in ld_json['recipeIngredient']:
|
||||
ingredient_split = x.split()
|
||||
ingredient = None
|
||||
amount = 0
|
||||
unit = ''
|
||||
if len(ingredient_split) > 2:
|
||||
ingredient = " ".join(ingredient_split[2:])
|
||||
unit = ingredient_split[1]
|
||||
try:
|
||||
amount = float(ingredient_split[0].replace(',', '.'))
|
||||
except ValueError:
|
||||
amount = 0
|
||||
ingredient = " ".join(ingredient_split)
|
||||
if len(ingredient_split) == 2:
|
||||
ingredient = " ".join(ingredient_split[1:])
|
||||
unit = ''
|
||||
try:
|
||||
amount = float(ingredient_split[0].replace(',', '.'))
|
||||
except ValueError:
|
||||
amount = 0
|
||||
ingredient = " ".join(ingredient_split)
|
||||
if len(ingredient_split) == 1:
|
||||
ingredient = " ".join(ingredient_split)
|
||||
|
||||
if ingredient:
|
||||
ingredients.append({'amount': amount, 'unit': {'text': unit, 'id': random.randrange(10000, 99999)}, 'ingredient': {'text': ingredient, 'id': random.randrange(10000, 99999)}, 'original': x})
|
||||
try:
|
||||
amount, unit, ingredient, note = parse_ingredient(x)
|
||||
if ingredient:
|
||||
ingredients.append({'amount': amount, 'unit': {'text': unit, 'id': random.randrange(10000, 99999)}, 'ingredient': {'text': ingredient, 'id': random.randrange(10000, 99999)}, "note": note, 'original': x})
|
||||
except:
|
||||
pass
|
||||
|
||||
ld_json['recipeIngredient'] = ingredients
|
||||
else:
|
||||
@@ -149,7 +132,7 @@ def find_recipe_json(ld_json, url):
|
||||
else:
|
||||
ld_json['recipeInstructions'] = ''
|
||||
|
||||
ld_json['recipeInstructions'] += '\n\n' + _('Imported from ') + url
|
||||
ld_json['recipeInstructions'] += '\n\n' + _('Imported from') + ' ' + url
|
||||
|
||||
if 'image' in ld_json:
|
||||
# check if list of images is returned, take first if so
|
||||
|
||||
BIN
cookbook/locale/ca/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/ca/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
2027
cookbook/locale/ca/LC_MESSAGES/django.po
Normal file
2027
cookbook/locale/ca/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
BIN
cookbook/locale/es/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/es/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
1969
cookbook/locale/es/LC_MESSAGES/django.po
Normal file
1969
cookbook/locale/es/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
BIN
cookbook/locale/hu_HU/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/hu_HU/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
1841
cookbook/locale/hu_HU/LC_MESSAGES/django.po
Normal file
1841
cookbook/locale/hu_HU/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
BIN
cookbook/locale/it/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/it/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
1949
cookbook/locale/it/LC_MESSAGES/django.po
Normal file
1949
cookbook/locale/it/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
BIN
cookbook/locale/lv/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/lv/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
1927
cookbook/locale/lv/LC_MESSAGES/django.po
Normal file
1927
cookbook/locale/lv/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
BIN
cookbook/locale/pt/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/pt/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
1841
cookbook/locale/pt/LC_MESSAGES/django.po
Normal file
1841
cookbook/locale/pt/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
BIN
cookbook/locale/tr/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/tr/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
1841
cookbook/locale/tr/LC_MESSAGES/django.po
Normal file
1841
cookbook/locale/tr/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
BIN
cookbook/locale/zh_CN/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/zh_CN/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
1841
cookbook/locale/zh_CN/LC_MESSAGES/django.po
Normal file
1841
cookbook/locale/zh_CN/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
# Generated by Django 3.0.7 on 2020-08-11 10:14
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('cookbook', '0074_remove_keyword_created_by'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ShoppingListRecipe',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('multiplier', models.IntegerField(default=1)),
|
||||
('recipe', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.Recipe')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ShoppingListEntry',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('amount', models.IntegerField(default=1)),
|
||||
('order', models.IntegerField(default=0)),
|
||||
('checked', models.BooleanField(default=False)),
|
||||
('food', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.Food')),
|
||||
('list_recipe', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.ShoppingListRecipe')),
|
||||
('unit', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.Unit')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ShoppingList',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4)),
|
||||
('note', models.TextField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
('recipes', models.ManyToManyField(blank=True, to='cookbook.ShoppingListRecipe')),
|
||||
('shared', models.ManyToManyField(blank=True, related_name='list_share', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
||||
18
cookbook/migrations/0076_shoppinglist_entries.py
Normal file
18
cookbook/migrations/0076_shoppinglist_entries.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.0.7 on 2020-08-26 18:46
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0075_shoppinglist_shoppinglistentry_shoppinglistrecipe'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='shoppinglist',
|
||||
name='entries',
|
||||
field=models.ManyToManyField(blank=True, to='cookbook.ShoppingListEntry'),
|
||||
),
|
||||
]
|
||||
29
cookbook/migrations/0077_invitelink.py
Normal file
29
cookbook/migrations/0077_invitelink.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 3.0.7 on 2020-09-01 11:31
|
||||
|
||||
import datetime
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('cookbook', '0076_shoppinglist_entries'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='InviteLink',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4)),
|
||||
('username', models.CharField(blank=True, max_length=64)),
|
||||
('valid_until', models.DateField(default=datetime.date(2020, 9, 15))),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
||||
21
cookbook/migrations/0078_invitelink_used_by.py
Normal file
21
cookbook/migrations/0078_invitelink_used_by.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 3.0.7 on 2020-09-01 11:39
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('cookbook', '0077_invitelink'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invitelink',
|
||||
name='used_by',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='used_by', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
21
cookbook/migrations/0079_invitelink_group.py
Normal file
21
cookbook/migrations/0079_invitelink_group.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 3.0.7 on 2020-09-01 12:54
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('auth', '0011_update_proxy_permissions'),
|
||||
('cookbook', '0078_invitelink_used_by'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invitelink',
|
||||
name='group',
|
||||
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='auth.Group'),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
24
cookbook/migrations/0080_auto_20200921_2331.py
Normal file
24
cookbook/migrations/0080_auto_20200921_2331.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 3.0.7 on 2020-09-21 21:31
|
||||
|
||||
import datetime
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0079_invitelink_group'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='shopping_auto_sync',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='invitelink',
|
||||
name='valid_until',
|
||||
field=models.DateField(default=datetime.date(2020, 10, 5)),
|
||||
),
|
||||
]
|
||||
18
cookbook/migrations/0081_auto_20200921_2349.py
Normal file
18
cookbook/migrations/0081_auto_20200921_2349.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.0.7 on 2020-09-21 21:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0080_auto_20200921_2331'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='userpreference',
|
||||
name='shopping_auto_sync',
|
||||
field=models.IntegerField(default=5),
|
||||
),
|
||||
]
|
||||
24
cookbook/migrations/0082_auto_20200922_1143.py
Normal file
24
cookbook/migrations/0082_auto_20200922_1143.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 3.0.7 on 2020-09-22 09:43
|
||||
|
||||
import datetime
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0081_auto_20200921_2349'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='invitelink',
|
||||
name='valid_until',
|
||||
field=models.DateField(default=datetime.date(2020, 10, 6)),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='shoppinglistentry',
|
||||
name='amount',
|
||||
field=models.DecimalField(decimal_places=16, default=0, max_digits=32),
|
||||
),
|
||||
]
|
||||
21
cookbook/migrations/0083_space.py
Normal file
21
cookbook/migrations/0083_space.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 3.0.7 on 2020-09-22 10:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0082_auto_20200922_1143'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Space',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(default='Default', max_length=128)),
|
||||
('message', models.CharField(default='', max_length=512)),
|
||||
],
|
||||
),
|
||||
]
|
||||
21
cookbook/migrations/0084_auto_20200922_1233.py
Normal file
21
cookbook/migrations/0084_auto_20200922_1233.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 3.0.7 on 2020-09-22 10:33
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def create_default_space(apps, schema_editor):
|
||||
Space = apps.get_model('cookbook', 'Space')
|
||||
Space.objects.create(
|
||||
name='Default',
|
||||
message=''
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('cookbook', '0083_space'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(create_default_space),
|
||||
]
|
||||
18
cookbook/migrations/0085_auto_20200922_1235.py
Normal file
18
cookbook/migrations/0085_auto_20200922_1235.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.0.7 on 2020-09-22 10:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0084_auto_20200922_1233'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='space',
|
||||
name='message',
|
||||
field=models.CharField(blank=True, default='', max_length=512),
|
||||
),
|
||||
]
|
||||
24
cookbook/migrations/0086_auto_20200929_1143.py
Normal file
24
cookbook/migrations/0086_auto_20200929_1143.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 3.0.7 on 2020-09-29 09:43
|
||||
|
||||
import datetime
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0085_auto_20200922_1235'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='mealplan',
|
||||
name='recipe_multiplier',
|
||||
field=models.IntegerField(default=1),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='invitelink',
|
||||
name='valid_until',
|
||||
field=models.DateField(default=datetime.date(2020, 10, 13)),
|
||||
),
|
||||
]
|
||||
23
cookbook/migrations/0087_auto_20200929_1152.py
Normal file
23
cookbook/migrations/0087_auto_20200929_1152.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 3.0.7 on 2020-09-29 09:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0086_auto_20200929_1143'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='mealplan',
|
||||
name='recipe_multiplier',
|
||||
field=models.DecimalField(decimal_places=4, default=1, max_digits=8),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='shoppinglistrecipe',
|
||||
name='multiplier',
|
||||
field=models.DecimalField(decimal_places=4, default=1, max_digits=8),
|
||||
),
|
||||
]
|
||||
18
cookbook/migrations/0088_shoppinglist_finished.py
Normal file
18
cookbook/migrations/0088_shoppinglist_finished.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.1.1 on 2020-09-29 11:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0087_auto_20200929_1152'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='shoppinglist',
|
||||
name='finished',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
36
cookbook/migrations/0089_auto_20201117_2222.py
Normal file
36
cookbook/migrations/0089_auto_20201117_2222.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 3.1.1 on 2020-11-17 21:22
|
||||
|
||||
import datetime
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0088_shoppinglist_finished'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='NutritionInformation',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('fats', models.DecimalField(decimal_places=16, default=0, max_digits=32)),
|
||||
('carbohydrates', models.DecimalField(decimal_places=16, default=0, max_digits=32)),
|
||||
('proteins', models.DecimalField(decimal_places=16, default=0, max_digits=32)),
|
||||
('calories', models.DecimalField(decimal_places=16, default=0, max_digits=32)),
|
||||
('source', models.CharField(blank=True, default='', max_length=512, null=True)),
|
||||
],
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='invitelink',
|
||||
name='valid_until',
|
||||
field=models.DateField(default=datetime.date(2020, 12, 1)),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='recipe',
|
||||
name='nutrition',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.nutritioninformation'),
|
||||
),
|
||||
]
|
||||
24
cookbook/migrations/0090_auto_20201214_1359.py
Normal file
24
cookbook/migrations/0090_auto_20201214_1359.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 3.1.3 on 2020-12-14 12:59
|
||||
|
||||
import datetime
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0089_auto_20201117_2222'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='use_fractions',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='invitelink',
|
||||
name='valid_until',
|
||||
field=models.DateField(default=datetime.date(2020, 12, 28)),
|
||||
),
|
||||
]
|
||||
26
cookbook/migrations/0091_auto_20201226_1551.py
Normal file
26
cookbook/migrations/0091_auto_20201226_1551.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 3.1.4 on 2020-12-26 14:51
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def migrate_empty_units(apps, schema_editor):
|
||||
Unit = apps.get_model('cookbook', 'Unit')
|
||||
Ingredient = apps.get_model('cookbook', 'Ingredient')
|
||||
|
||||
empty_units = Unit.objects.filter(name='').all()
|
||||
for x in empty_units:
|
||||
for i in Ingredient.objects.all():
|
||||
if i.unit == x:
|
||||
i.unit = None
|
||||
i.save()
|
||||
x.delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('cookbook', '0090_auto_20201214_1359'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(migrate_empty_units),
|
||||
]
|
||||
18
cookbook/migrations/0092_recipe_servings.py
Normal file
18
cookbook/migrations/0092_recipe_servings.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.0.7 on 2020-08-30 13:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0091_auto_20201226_1551'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='recipe',
|
||||
name='servings',
|
||||
field=models.IntegerField(default=1),
|
||||
),
|
||||
]
|
||||
30
cookbook/migrations/0093_auto_20201231_1236.py
Normal file
30
cookbook/migrations/0093_auto_20201231_1236.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 3.1.4 on 2020-12-31 11:36
|
||||
|
||||
import datetime
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0092_recipe_servings'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='mealplan',
|
||||
old_name='recipe_multiplier',
|
||||
new_name='servings',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='invitelink',
|
||||
name='valid_until',
|
||||
field=models.DateField(default=datetime.date(2021, 1, 14)),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='unit',
|
||||
name='name',
|
||||
field=models.CharField(max_length=128, unique=True, validators=[django.core.validators.MinLengthValidator(1)]),
|
||||
),
|
||||
]
|
||||
18
cookbook/migrations/0094_auto_20201231_1238.py
Normal file
18
cookbook/migrations/0094_auto_20201231_1238.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.1.4 on 2020-12-31 11:38
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0093_auto_20201231_1236'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='shoppinglistrecipe',
|
||||
old_name='multiplier',
|
||||
new_name='servings',
|
||||
),
|
||||
]
|
||||
@@ -1,12 +1,16 @@
|
||||
import re
|
||||
import uuid
|
||||
from datetime import date, timedelta
|
||||
|
||||
from annoying.fields import AutoOneToOneField
|
||||
from django.contrib import auth
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.auth.models import User, Group
|
||||
from django.core.validators import MinLengthValidator
|
||||
from django.utils.translation import gettext as _
|
||||
from django.db import models
|
||||
from django_random_queryset import RandomManager
|
||||
|
||||
from recipes.settings import COMMENT_PREF_DEFAULT
|
||||
from recipes.settings import COMMENT_PREF_DEFAULT, FRACTION_PREF_DEFAULT
|
||||
|
||||
|
||||
def get_user_name(self):
|
||||
@@ -23,6 +27,11 @@ def get_model_name(model):
|
||||
return ('_'.join(re.findall('[A-Z][^A-Z]*', model.__name__))).lower()
|
||||
|
||||
|
||||
class Space(models.Model):
|
||||
name = models.CharField(max_length=128, default='Default')
|
||||
message = models.CharField(max_length=512, default='', blank=True)
|
||||
|
||||
|
||||
class UserPreference(models.Model):
|
||||
# Themes
|
||||
BOOTSTRAP = 'BOOTSTRAP'
|
||||
@@ -61,12 +70,14 @@ class UserPreference(models.Model):
|
||||
theme = models.CharField(choices=THEMES, max_length=128, default=FLATLY)
|
||||
nav_color = models.CharField(choices=COLORS, max_length=128, default=PRIMARY)
|
||||
default_unit = models.CharField(max_length=32, default='g')
|
||||
use_fractions = models.BooleanField(default=FRACTION_PREF_DEFAULT)
|
||||
default_page = models.CharField(choices=PAGES, max_length=64, default=SEARCH)
|
||||
search_style = models.CharField(choices=SEARCH_STYLE, max_length=64, default=LARGE)
|
||||
show_recent = models.BooleanField(default=True)
|
||||
plan_share = models.ManyToManyField(User, blank=True, related_name='plan_share_default')
|
||||
ingredient_decimals = models.IntegerField(default=2)
|
||||
comments = models.BooleanField(default=COMMENT_PREF_DEFAULT)
|
||||
shopping_auto_sync = models.IntegerField(default=5)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.user)
|
||||
@@ -126,7 +137,7 @@ class Keyword(models.Model):
|
||||
|
||||
|
||||
class Unit(models.Model):
|
||||
name = models.CharField(unique=True, max_length=128)
|
||||
name = models.CharField(unique=True, max_length=128, validators=[MinLengthValidator(1)])
|
||||
description = models.TextField(blank=True, null=True)
|
||||
|
||||
def __str__(self):
|
||||
@@ -173,8 +184,20 @@ class Step(models.Model):
|
||||
ordering = ['order', 'pk']
|
||||
|
||||
|
||||
class NutritionInformation(models.Model):
|
||||
fats = models.DecimalField(default=0, decimal_places=16, max_digits=32)
|
||||
carbohydrates = models.DecimalField(default=0, decimal_places=16, max_digits=32)
|
||||
proteins = models.DecimalField(default=0, decimal_places=16, max_digits=32)
|
||||
calories = models.DecimalField(default=0, decimal_places=16, max_digits=32)
|
||||
source = models.CharField(max_length=512, default="", null=True, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return f'Nutrition'
|
||||
|
||||
|
||||
class Recipe(models.Model):
|
||||
name = models.CharField(max_length=128)
|
||||
servings = models.IntegerField(default=1)
|
||||
image = models.ImageField(upload_to='recipes/', blank=True, null=True)
|
||||
storage = models.ForeignKey(Storage, on_delete=models.PROTECT, blank=True, null=True)
|
||||
file_uid = models.CharField(max_length=256, default="", blank=True)
|
||||
@@ -186,10 +209,13 @@ class Recipe(models.Model):
|
||||
working_time = models.IntegerField(default=0)
|
||||
waiting_time = models.IntegerField(default=0)
|
||||
internal = models.BooleanField(default=False)
|
||||
nutrition = models.ForeignKey(NutritionInformation, blank=True, null=True, on_delete=models.CASCADE)
|
||||
created_by = models.ForeignKey(User, on_delete=models.PROTECT)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
objects = RandomManager()
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@@ -246,6 +272,7 @@ class MealType(models.Model):
|
||||
|
||||
class MealPlan(models.Model):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, blank=True, null=True)
|
||||
servings = models.DecimalField(default=1, max_digits=8, decimal_places=4)
|
||||
title = models.CharField(max_length=64, blank=True, default='')
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
shared = models.ManyToManyField(User, blank=True, related_name='plan_share')
|
||||
@@ -265,12 +292,74 @@ class MealPlan(models.Model):
|
||||
return f'{self.get_label()} - {self.date} - {self.meal_type.name}'
|
||||
|
||||
|
||||
class ShoppingListRecipe(models.Model):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, null=True, blank=True)
|
||||
servings = models.DecimalField(default=1, max_digits=8, decimal_places=4)
|
||||
|
||||
def __str__(self):
|
||||
return f'Shopping list recipe {self.id} - {self.recipe}'
|
||||
|
||||
def get_owner(self):
|
||||
try:
|
||||
return self.shoppinglist_set.first().created_by
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
|
||||
class ShoppingListEntry(models.Model):
|
||||
list_recipe = models.ForeignKey(ShoppingListRecipe, on_delete=models.CASCADE, null=True, blank=True)
|
||||
food = models.ForeignKey(Food, on_delete=models.CASCADE)
|
||||
unit = models.ForeignKey(Unit, on_delete=models.CASCADE, null=True, blank=True)
|
||||
amount = models.DecimalField(default=0, decimal_places=16, max_digits=32)
|
||||
order = models.IntegerField(default=0)
|
||||
checked = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self):
|
||||
return f'Shopping list entry {self.id}'
|
||||
|
||||
def get_owner(self):
|
||||
try:
|
||||
return self.shoppinglist_set.first().created_by
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
|
||||
class ShoppingList(models.Model):
|
||||
uuid = models.UUIDField(default=uuid.uuid4)
|
||||
note = models.TextField(blank=True, null=True)
|
||||
recipes = models.ManyToManyField(ShoppingListRecipe, blank=True)
|
||||
entries = models.ManyToManyField(ShoppingListEntry, blank=True)
|
||||
shared = models.ManyToManyField(User, blank=True, related_name='list_share')
|
||||
finished = models.BooleanField(default=False)
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return f'Shopping list {self.id}'
|
||||
|
||||
|
||||
class ShareLink(models.Model):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
|
||||
uuid = models.UUIDField(default=uuid.uuid4)
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.recipe} - {self.uuid}'
|
||||
|
||||
|
||||
class InviteLink(models.Model):
|
||||
uuid = models.UUIDField(default=uuid.uuid4)
|
||||
username = models.CharField(blank=True, max_length=64)
|
||||
group = models.ForeignKey(Group, on_delete=models.CASCADE)
|
||||
valid_until = models.DateField(default=date.today() + timedelta(days=14))
|
||||
used_by = models.ForeignKey(User, null=True, on_delete=models.CASCADE, related_name='used_by')
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.uuid}'
|
||||
|
||||
|
||||
class CookLog(models.Model):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from drf_writable_nested import WritableNestedModelSerializer, UniqueFieldsMixin
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import APIException, ValidationError
|
||||
from rest_framework.fields import CurrentUserDefault
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from cookbook.models import MealPlan, MealType, Recipe, ViewLog, UserPreference, Storage, Sync, SyncLog, Keyword, Unit, Ingredient, Comment, RecipeImport, RecipeBook, RecipeBookEntry, ShareLink, CookLog, Food, Step
|
||||
from cookbook.models import MealPlan, MealType, Recipe, ViewLog, UserPreference, Storage, Sync, SyncLog, Keyword, Unit, Ingredient, Comment, RecipeImport, RecipeBook, RecipeBookEntry, ShareLink, CookLog, Food, Step, ShoppingList, \
|
||||
ShoppingListEntry, ShoppingListRecipe, NutritionInformation
|
||||
from cookbook.templatetags.custom_tags import markdown
|
||||
|
||||
|
||||
@@ -14,19 +16,24 @@ class CustomDecimalField(serializers.Field):
|
||||
"""
|
||||
|
||||
def to_representation(self, value):
|
||||
return value.normalize()
|
||||
if isinstance(value, Decimal):
|
||||
return value.normalize()
|
||||
else:
|
||||
return Decimal(value).normalize()
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if type(data) == int or type(data) == float:
|
||||
return data
|
||||
elif type(data) == str:
|
||||
if data == '':
|
||||
return 0
|
||||
try:
|
||||
return float(data.replace(',', ''))
|
||||
except ValueError:
|
||||
raise ValidationError('A valid number is required')
|
||||
|
||||
|
||||
class UserNameSerializer(serializers.ModelSerializer):
|
||||
class UserNameSerializer(WritableNestedModelSerializer):
|
||||
username = serializers.SerializerMethodField('get_user_label')
|
||||
|
||||
def get_user_label(self, obj):
|
||||
@@ -106,6 +113,9 @@ class FoodSerializer(UniqueFieldsMixin, serializers.ModelSerializer):
|
||||
obj, created = Food.objects.get_or_create(**validated_data)
|
||||
return obj
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
return super(FoodSerializer, self).update(instance, validated_data)
|
||||
|
||||
class Meta:
|
||||
model = Food
|
||||
fields = ('id', 'name', 'recipe')
|
||||
@@ -130,15 +140,26 @@ class StepSerializer(WritableNestedModelSerializer):
|
||||
fields = ('id', 'name', 'type', 'instruction', 'ingredients', 'time', 'order', 'show_as_header')
|
||||
|
||||
|
||||
class NutritionInformationSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = NutritionInformation
|
||||
fields = ('carbohydrates', 'fats', 'proteins', 'calories', 'source')
|
||||
|
||||
|
||||
class RecipeSerializer(WritableNestedModelSerializer):
|
||||
nutrition = NutritionInformationSerializer(allow_null=True, required=False)
|
||||
steps = StepSerializer(many=True)
|
||||
keywords = KeywordSerializer(many=True)
|
||||
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = ['id', 'name', 'image', 'keywords', 'steps', 'working_time', 'waiting_time', 'created_by', 'created_at', 'updated_at', 'internal']
|
||||
fields = ['id', 'name', 'image', 'keywords', 'steps', 'working_time', 'waiting_time', 'created_by', 'created_at', 'updated_at', 'internal', 'nutrition', 'servings']
|
||||
read_only_fields = ['image', 'created_by', 'created_at']
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['created_by'] = self.context['request']._user
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class RecipeImageSerializer(WritableNestedModelSerializer):
|
||||
class Meta:
|
||||
@@ -181,13 +202,61 @@ class MealPlanSerializer(serializers.ModelSerializer):
|
||||
recipe_name = serializers.ReadOnlyField(source='recipe.name')
|
||||
meal_type_name = serializers.ReadOnlyField(source='meal_type.name')
|
||||
note_markdown = serializers.SerializerMethodField('get_note_markdown')
|
||||
servings = CustomDecimalField()
|
||||
|
||||
def get_note_markdown(self, obj):
|
||||
return markdown(obj.note)
|
||||
|
||||
class Meta:
|
||||
model = MealPlan
|
||||
fields = ('id', 'title', 'recipe', 'note', 'note_markdown', 'date', 'meal_type', 'created_by', 'shared', 'recipe_name', 'meal_type_name')
|
||||
fields = ('id', 'title', 'recipe', 'servings', 'note', 'note_markdown', 'date', 'meal_type', 'created_by', 'shared', 'recipe_name', 'meal_type_name')
|
||||
|
||||
|
||||
class ShoppingListRecipeSerializer(serializers.ModelSerializer):
|
||||
recipe_name = serializers.ReadOnlyField(source='recipe.name')
|
||||
servings = CustomDecimalField()
|
||||
|
||||
class Meta:
|
||||
model = ShoppingListRecipe
|
||||
fields = ('id', 'recipe', 'recipe_name', 'servings')
|
||||
read_only_fields = ('id',)
|
||||
|
||||
|
||||
class ShoppingListEntrySerializer(WritableNestedModelSerializer):
|
||||
food = FoodSerializer(allow_null=True)
|
||||
unit = UnitSerializer(allow_null=True)
|
||||
amount = CustomDecimalField()
|
||||
|
||||
class Meta:
|
||||
model = ShoppingListEntry
|
||||
fields = ('id', 'list_recipe', 'food', 'unit', 'amount', 'order', 'checked')
|
||||
read_only_fields = ('id',)
|
||||
|
||||
|
||||
class ShoppingListEntryCheckedSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ShoppingListEntry
|
||||
fields = ('id', 'checked')
|
||||
|
||||
|
||||
class ShoppingListSerializer(WritableNestedModelSerializer):
|
||||
recipes = ShoppingListRecipeSerializer(many=True, allow_null=True)
|
||||
entries = ShoppingListEntrySerializer(many=True, allow_null=True)
|
||||
shared = UserNameSerializer(many=True)
|
||||
|
||||
class Meta:
|
||||
model = ShoppingList
|
||||
fields = ('id', 'uuid', 'note', 'recipes', 'entries', 'shared', 'finished', 'created_by', 'created_at',)
|
||||
read_only_fields = ('id',)
|
||||
|
||||
|
||||
class ShoppingListAutoSyncSerializer(WritableNestedModelSerializer):
|
||||
entries = ShoppingListEntryCheckedSerializer(many=True, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = ShoppingList
|
||||
fields = ('id', 'entries',)
|
||||
read_only_fields = ('id',)
|
||||
|
||||
|
||||
class ShareLinkSerializer(serializers.ModelSerializer):
|
||||
|
||||
43
cookbook/static/js/frac.js
Normal file
43
cookbook/static/js/frac.js
Normal file
@@ -0,0 +1,43 @@
|
||||
/* frac.js (C) 2012-present SheetJS -- http://sheetjs.com */
|
||||
/*https://developer.aliyun.com/mirror/npm/package/frac/v/0.3.0 Apache license*/
|
||||
var frac = function frac(x, D, mixed) {
|
||||
var n1 = Math.floor(x), d1 = 1;
|
||||
var n2 = n1+1, d2 = 1;
|
||||
if(x !== n1) while(d1 <= D && d2 <= D) {
|
||||
var m = (n1 + n2) / (d1 + d2);
|
||||
if(x === m) {
|
||||
if(d1 + d2 <= D) { d1+=d2; n1+=n2; d2=D+1; }
|
||||
else if(d1 > d2) d2=D+1;
|
||||
else d1=D+1;
|
||||
break;
|
||||
}
|
||||
else if(x < m) { n2 = n1+n2; d2 = d1+d2; }
|
||||
else { n1 = n1+n2; d1 = d1+d2; }
|
||||
}
|
||||
if(d1 > D) { d1 = d2; n1 = n2; }
|
||||
if(!mixed) return [0, n1, d1];
|
||||
var q = Math.floor(n1/d1);
|
||||
return [q, n1 - q*d1, d1];
|
||||
};
|
||||
frac.cont = function cont(x, D, mixed) {
|
||||
var sgn = x < 0 ? -1 : 1;
|
||||
var B = x * sgn;
|
||||
var P_2 = 0, P_1 = 1, P = 0;
|
||||
var Q_2 = 1, Q_1 = 0, Q = 0;
|
||||
var A = Math.floor(B);
|
||||
while(Q_1 < D) {
|
||||
A = Math.floor(B);
|
||||
P = A * P_1 + P_2;
|
||||
Q = A * Q_1 + Q_2;
|
||||
if((B - A) < 0.00000005) break;
|
||||
B = 1 / (B - A);
|
||||
P_2 = P_1; P_1 = P;
|
||||
Q_2 = Q_1; Q_1 = Q;
|
||||
}
|
||||
if(Q > D) { if(Q_1 > D) { Q = Q_2; P = P_2; } else { Q = Q_1; P = P_1; } }
|
||||
if(!mixed) return [0, sgn * P, Q];
|
||||
var q = Math.floor(sgn * P/Q);
|
||||
return [q, sgn*P - q*Q, Q];
|
||||
};
|
||||
// eslint-disable-next-line no-undef
|
||||
if(typeof module !== 'undefined' && typeof DO_NOT_EXPORT_FRAC === 'undefined') module.exports = frac;
|
||||
146
cookbook/static/js/vue-cookies.js
Normal file
146
cookbook/static/js/vue-cookies.js
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Vue Cookies v1.7.4
|
||||
* https://github.com/cmp-cc/vue-cookies
|
||||
*
|
||||
* Copyright 2016, cmp-cc
|
||||
* Released under the MIT license
|
||||
*/
|
||||
|
||||
(function () {
|
||||
|
||||
var defaultConfig = {
|
||||
expires: '1d',
|
||||
path: '; path=/',
|
||||
domain: '',
|
||||
secure: '',
|
||||
sameSite: '; SameSite=Lax'
|
||||
};
|
||||
|
||||
var VueCookies = {
|
||||
// install of Vue
|
||||
install: function (Vue) {
|
||||
Vue.prototype.$cookies = this;
|
||||
Vue.$cookies = this;
|
||||
},
|
||||
config: function (expireTimes, path, domain, secure, sameSite) {
|
||||
defaultConfig.expires = expireTimes ? expireTimes : '1d';
|
||||
defaultConfig.path = path ? '; path=' + path : '; path=/';
|
||||
defaultConfig.domain = domain ? '; domain=' + domain : '';
|
||||
defaultConfig.secure = secure ? '; Secure' : '';
|
||||
defaultConfig.sameSite = sameSite ? '; SameSite=' + sameSite : '; SameSite=Lax';
|
||||
},
|
||||
get: function (key) {
|
||||
var value = decodeURIComponent(document.cookie.replace(new RegExp('(?:(?:^|.*;)\\s*' + encodeURIComponent(key).replace(/[\-\.\+\*]/g, '\\$&') + '\\s*\\=\\s*([^;]*).*$)|^.*$'), '$1')) || null;
|
||||
|
||||
if (value && value.substring(0, 1) === '{' && value.substring(value.length - 1, value.length) === '}') {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch (e) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
},
|
||||
set: function (key, value, expireTimes, path, domain, secure, sameSite) {
|
||||
if (!key) {
|
||||
throw new Error('Cookie name is not find in first argument.');
|
||||
} else if (/^(?:expires|max\-age|path|domain|secure|SameSite)$/i.test(key)) {
|
||||
throw new Error('Cookie key name illegality, Cannot be set to ["expires","max-age","path","domain","secure","SameSite"]\t current key name: ' + key);
|
||||
}
|
||||
// support json object
|
||||
if (value && value.constructor === Object) {
|
||||
value = JSON.stringify(value);
|
||||
}
|
||||
var _expires = '';
|
||||
expireTimes = expireTimes == undefined ? defaultConfig.expires : expireTimes;
|
||||
if (expireTimes && expireTimes != 0) {
|
||||
switch (expireTimes.constructor) {
|
||||
case Number:
|
||||
if (expireTimes === Infinity || expireTimes === -1) _expires = '; expires=Fri, 31 Dec 9999 23:59:59 GMT';
|
||||
else _expires = '; max-age=' + expireTimes;
|
||||
break;
|
||||
case String:
|
||||
if (/^(?:\d+(y|m|d|h|min|s))$/i.test(expireTimes)) {
|
||||
// get capture number group
|
||||
var _expireTime = expireTimes.replace(/^(\d+)(?:y|m|d|h|min|s)$/i, '$1');
|
||||
// get capture type group , to lower case
|
||||
switch (expireTimes.replace(/^(?:\d+)(y|m|d|h|min|s)$/i, '$1').toLowerCase()) {
|
||||
// Frequency sorting
|
||||
case 'm':
|
||||
_expires = '; max-age=' + +_expireTime * 2592000;
|
||||
break; // 60 * 60 * 24 * 30
|
||||
case 'd':
|
||||
_expires = '; max-age=' + +_expireTime * 86400;
|
||||
break; // 60 * 60 * 24
|
||||
case 'h':
|
||||
_expires = '; max-age=' + +_expireTime * 3600;
|
||||
break; // 60 * 60
|
||||
case 'min':
|
||||
_expires = '; max-age=' + +_expireTime * 60;
|
||||
break; // 60
|
||||
case 's':
|
||||
_expires = '; max-age=' + _expireTime;
|
||||
break;
|
||||
case 'y':
|
||||
_expires = '; max-age=' + +_expireTime * 31104000;
|
||||
break; // 60 * 60 * 24 * 30 * 12
|
||||
default:
|
||||
new Error('unknown exception of "set operation"');
|
||||
}
|
||||
} else {
|
||||
_expires = '; expires=' + expireTimes;
|
||||
}
|
||||
break;
|
||||
case Date:
|
||||
_expires = '; expires=' + expireTimes.toUTCString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
document.cookie =
|
||||
encodeURIComponent(key) + '=' + encodeURIComponent(value) +
|
||||
_expires +
|
||||
(domain ? '; domain=' + domain : defaultConfig.domain) +
|
||||
(path ? '; path=' + path : defaultConfig.path) +
|
||||
(secure == undefined ? defaultConfig.secure : secure ? '; Secure' : '') +
|
||||
(sameSite == undefined ? defaultConfig.sameSite : (sameSite ? '; SameSite=' + sameSite : ''));
|
||||
return this;
|
||||
},
|
||||
remove: function (key, path, domain) {
|
||||
if (!key || !this.isKey(key)) {
|
||||
return false;
|
||||
}
|
||||
document.cookie = encodeURIComponent(key) +
|
||||
'=; expires=Thu, 01 Jan 1970 00:00:00 GMT' +
|
||||
(domain ? '; domain=' + domain : defaultConfig.domain) +
|
||||
(path ? '; path=' + path : defaultConfig.path) +
|
||||
'; SameSite=Lax';
|
||||
return this;
|
||||
},
|
||||
isKey: function (key) {
|
||||
return (new RegExp('(?:^|;\\s*)' + encodeURIComponent(key).replace(/[\-\.\+\*]/g, '\\$&') + '\\s*\\=')).test(document.cookie);
|
||||
},
|
||||
keys: function () {
|
||||
if (!document.cookie) return [];
|
||||
var _keys = document.cookie.replace(/((?:^|\s*;)[^\=]+)(?=;|$)|^\s*|\s*(?:\=[^;]*)?(?:\1|$)/g, '').split(/\s*(?:\=[^;]*)?;\s*/);
|
||||
for (var _index = 0; _index < _keys.length; _index++) {
|
||||
_keys[_index] = decodeURIComponent(_keys[_index]);
|
||||
}
|
||||
return _keys;
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof exports == 'object') {
|
||||
module.exports = VueCookies;
|
||||
} else if (typeof define == 'function' && define.amd) {
|
||||
define([], function () {
|
||||
return VueCookies;
|
||||
});
|
||||
} else if (window.Vue) {
|
||||
Vue.use(VueCookies);
|
||||
}
|
||||
// vue-cookies can exist independently,no dependencies library
|
||||
if (typeof window !== 'undefined') {
|
||||
window.$cookies = VueCookies;
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -108,6 +108,25 @@ class RecipeImportTable(tables.Table):
|
||||
fields = ('id', 'name', 'file_path')
|
||||
|
||||
|
||||
class ShoppingListTable(tables.Table):
|
||||
id = tables.LinkColumn('view_shopping', args=[A('id')])
|
||||
|
||||
class Meta:
|
||||
model = ShoppingList
|
||||
template_name = 'generic/table_template.html'
|
||||
fields = ('id', 'finished', 'created_by', 'created_at')
|
||||
|
||||
|
||||
class InviteLinkTable(tables.Table):
|
||||
link = tables.TemplateColumn("<a href='{% url 'view_signup' record.uuid %}' >" + _('Link') + "</a>")
|
||||
delete = tables.TemplateColumn("<a href='{% url 'delete_invite_link' record.id %}' >" + _('Delete') + "</a>")
|
||||
|
||||
class Meta:
|
||||
model = InviteLink
|
||||
template_name = 'generic/table_template.html'
|
||||
fields = ('username', 'group', 'valid_until', 'created_by', 'created_at')
|
||||
|
||||
|
||||
class ViewLogTable(tables.Table):
|
||||
recipe = tables.LinkColumn('view_recipe', args=[A('recipe_id')])
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load theming_tags %}
|
||||
{% load custom_tags %}
|
||||
|
||||
<html>
|
||||
<head>
|
||||
@@ -72,7 +73,7 @@
|
||||
<a class="dropdown-item" href="{% url 'view_plan' %}"><i
|
||||
class="fas fa-calendar fa-fw"></i> {% trans 'Meal-Plan' %}
|
||||
</a>
|
||||
<a class="dropdown-item" href="{% url 'view_shopping' %}"><i
|
||||
<a class="dropdown-item" href="{% url 'list_shopping_list' %}"><i
|
||||
class="fas fa-shopping-cart fa-fw"></i> {% trans 'Shopping' %}
|
||||
</a>
|
||||
<a class="dropdown-item" href="{% url 'list_food' %}"><i
|
||||
@@ -83,7 +84,7 @@
|
||||
<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' %}
|
||||
<i class="fas fa-tags"></i> {% trans 'Keywords' %}
|
||||
</a>
|
||||
<div class="dropdown-menu" aria-labelledby="navbarDropdownMenuLink">
|
||||
<a class="dropdown-item" href="{% url 'list_keyword' %}"><i
|
||||
@@ -138,7 +139,7 @@
|
||||
{% endif %}
|
||||
<div class="dropdown-divider"></div>
|
||||
<a class="dropdown-item" href="{% url 'docs_markdown' %}"><i
|
||||
class="fab fa-markdown fa-fw"></i> {% trans 'Markdown Help' %}</a>
|
||||
class="fab fa-markdown fa-fw"></i> {% trans 'Markdown Guide' %}</a>
|
||||
<a class="dropdown-item" href="https://github.com/vabene1111/recipes"><i
|
||||
class="fab fa-github fa-fw"></i> {% trans 'GitHub' %}</a>
|
||||
<a class="dropdown-item" href="{% url 'docs_api' %}"><i
|
||||
@@ -158,6 +159,14 @@
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{% message_of_the_day as message_of_the_day %}
|
||||
{% if message_of_the_day %}
|
||||
<div class="bg-warning" style=" width: 100%; text-align: center!important; color: #ffffff; padding: 8px">
|
||||
{{ message_of_the_day }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
<div class="card border-info">
|
||||
<div class="card-body text-info">
|
||||
<p class="card-text">
|
||||
{% trans 'On this Page you can manage all storage folder locations that should be monitored and synced' %}
|
||||
{% trans 'On this Page you can manage all storage folder locations that should be monitored and synced.' %}
|
||||
<br/>
|
||||
{% trans 'The path must be in the following format' %} <code>/Folder/RecipesFolder</code>
|
||||
{% trans 'The path must be in the following format' %}: <code>/Folder/RecipesFolder</code>
|
||||
</p>
|
||||
<form method="POST" class="post-form">{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
|
||||
@@ -56,12 +56,15 @@
|
||||
<input type="file" @change="imageChanged">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="id_name"> {% trans 'Preperation Time' %}</label>
|
||||
<label for="id_name"> {% trans 'Preparation Time' %}</label>
|
||||
<input class="form-control" id="id_prep_time" v-model="recipe.working_time">
|
||||
<br/>
|
||||
<label for="id_name"> {% trans 'Waiting Time' %}</label>
|
||||
<input class="form-control" id="id_wait_time" v-model="recipe.waiting_time">
|
||||
<br/>
|
||||
<label for="id_name"> {% trans 'Servings' %}</label>
|
||||
<input class="form-control" id="id_servings" v-model="recipe.servings">
|
||||
<br/>
|
||||
<label for="id_name"> {% trans 'Keywords' %}</label>
|
||||
<multiselect
|
||||
v-model="recipe.keywords"
|
||||
@@ -80,6 +83,35 @@
|
||||
</multiselect>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="recipe !== undefined">
|
||||
<div class="row" v-if="recipe.nutrition" style="margin-top: 1vh">
|
||||
<div class="col-md-12">
|
||||
<div class="card border-grey">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">{% trans 'Nutrition' %}</h4>
|
||||
<div class="dropdown-menu dropdown-menu-right"
|
||||
aria-labelledby="dropdownMenuLink">
|
||||
<button class="dropdown-item" @click="removeStep(step)"><i
|
||||
class="fa fa-trash fa-fw"></i> {% trans 'Delete Step' %}</button>
|
||||
|
||||
</div>
|
||||
|
||||
<label for="id_name"> {% trans 'Calories' %}</label>
|
||||
<input class="form-control" id="id_calories" v-model="recipe.nutrition.calories">
|
||||
|
||||
<label for="id_name"> {% trans 'Carbohydrates' %}</label>
|
||||
<input class="form-control" id="id_carbohydrates" v-model="recipe.nutrition.carbohydrates">
|
||||
|
||||
<label for="id_name"> {% trans 'Fats' %}</label>
|
||||
<input class="form-control" id="id_fats" v-model="recipe.nutrition.fats">
|
||||
<label for="id_name"> {% trans 'Proteins' %}</label>
|
||||
<input class="form-control" id="id_proteins" v-model="recipe.nutrition.proteins">
|
||||
<br/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<draggable :list="recipe.steps" group="steps"
|
||||
@@ -318,8 +350,8 @@
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<label :for="'id_instruction_' + step.id">{% trans 'Instructions' %}</label>
|
||||
<b-form-textarea class="form-control" rows="2" max-rows="8" v-model="step.instruction"
|
||||
:id="'id_instruction_' + step.id"></b-form-textarea>
|
||||
<b-form-textarea class="form-control" rows="2" max-rows="20" v-model="step.instruction"
|
||||
:id="'id_instruction_' + step.id"></b-form-textarea>
|
||||
<small class="text-muted">{% trans 'You can use markdown to format this field. See the <a href="/docs/markdown/">docs here</a>' %}</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -327,7 +359,7 @@
|
||||
</div>
|
||||
</draggable>
|
||||
|
||||
<div class="row" style="margin-top: 1vh; margin-bottom: 2vh">
|
||||
<div class="row" style="margin-top: 1vh; margin-bottom: 8vh" v-if="recipe !== undefined">
|
||||
<div class="col-12">
|
||||
<button type="button" @click="updateRecipe(true)"
|
||||
class="btn btn-success shadow-none">{% trans 'Save & View' %}</button>
|
||||
@@ -335,6 +367,11 @@
|
||||
class="btn btn-info shadow-none">{% trans 'Save' %}</button>
|
||||
<button type="button" @click="addStep()"
|
||||
class="btn btn-primary shadow-none">{% trans 'Add Step' %}</button>
|
||||
<button type="button" @click="addNutrition()"
|
||||
class="btn btn-primary shadow-none"
|
||||
v-if="recipe.nutrition === null">{% trans 'Add Nutrition' %}</button>
|
||||
<button type="button" @click="removeNutrition()" v-if="recipe.nutrition !== null"
|
||||
class="btn btn-warning shadow-none">{% trans 'Remove Nutrition' %}</button>
|
||||
<a href="{% url 'view_recipe' recipe.pk %}" @click="addStep()"
|
||||
class="btn btn-secondary shadow-none">{% trans 'View Recipe' %}</a>
|
||||
<a href="{% url 'delete_recipe' recipe.pk %}"
|
||||
@@ -349,7 +386,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content_xl_right %}
|
||||
<div class="sticky-top" style="top: 2vh; z-index: 100;">
|
||||
<div class="sticky-top" style="top: 2vh; z-index: 100;" v-if="recipe !== undefined">
|
||||
<div class="row">
|
||||
<div class="col-md-11">
|
||||
<button type="button" @click="updateRecipe(true)"
|
||||
@@ -361,6 +398,12 @@
|
||||
<button type="button" @click="addStep()"
|
||||
class="btn btn-primary btn-block shadow-none">{% trans 'Add Step' %}</button>
|
||||
|
||||
<button type="button" @click="addNutrition()"
|
||||
class="btn btn-primary btn-block shadow-none"
|
||||
v-if="recipe.nutrition === null">{% trans 'Add Nutrition' %}</button>
|
||||
<button type="button" @click="removeNutrition()" v-if="recipe.nutrition !== null"
|
||||
class="btn btn-warning btn-block shadow-none">{% trans 'Remove Nutrition' %}</button>
|
||||
|
||||
<a href="{% url 'view_recipe' recipe.pk %}"
|
||||
class="btn btn-secondary btn-block shadow-none">{% trans 'View Recipe' %}</a>
|
||||
<a href="{% url 'delete_recipe' recipe.pk %}"
|
||||
@@ -399,6 +442,8 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
|
||||
<script src="{% url 'javascript-catalog' %}"></script>
|
||||
<script type="application/javascript">
|
||||
let csrftoken = Cookies.get('csrftoken');
|
||||
Vue.http.headers.common['X-CSRFToken'] = csrftoken;
|
||||
@@ -455,8 +500,8 @@
|
||||
e.preventDefault(); // present "Save Page" from getting triggered.
|
||||
|
||||
for (el of e.path) {
|
||||
if(el.id !== undefined && el.id.includes('id_card_step_')) {
|
||||
let step = this.recipe.steps[el.id.replace('id_card_step_','')]
|
||||
if (el.id !== undefined && el.id.includes('id_card_step_')) {
|
||||
let step = this.recipe.steps[el.id.replace('id_card_step_', '')]
|
||||
this.addIngredient(step)
|
||||
}
|
||||
}
|
||||
@@ -491,7 +536,7 @@
|
||||
}).catch((err) => {
|
||||
this.loading = false
|
||||
console.log(err)
|
||||
this.makeToast('{% trans 'Error' %}', '{% trans 'There was an error loading the recipe!' %}' + err.bodyText, 'danger')
|
||||
this.makeToast(gettext('Error'), gettext('There was an error loading the recipe!') + err.bodyText, 'danger')
|
||||
})
|
||||
},
|
||||
updateRecipe: function (view_after) {
|
||||
@@ -502,14 +547,14 @@
|
||||
this.$http.put("{% url 'api:recipe-detail' recipe.pk %}", this.recipe,
|
||||
{}).then((response) => {
|
||||
console.log(response)
|
||||
this.makeToast('{% trans 'Updated' %}', '{% trans 'Changes saved successfully!' %}', 'success')
|
||||
this.makeToast(gettext('Updated'), gettext('Changes saved successfully!'), 'success')
|
||||
this.recipe_changed = false
|
||||
if (view_after) {
|
||||
location.href = "{% url 'view_recipe' 12345 %}".replace(/12345/, this.recipe.id);
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
this.makeToast('{% trans 'Error' %}', '{% trans 'There was an error updating the recipe!' %}' + err.bodyText, 'danger')
|
||||
this.makeToast(gettext('Error'), gettext('There was an error updating the recipe!') + err.bodyText, 'danger')
|
||||
})
|
||||
},
|
||||
imageChanged: function (event) {
|
||||
@@ -519,11 +564,11 @@
|
||||
this.$http.put("{% url 'api:recipe-detail' recipe.pk %}" + 'image/', fd,
|
||||
{headers: {'Content-Type': 'multipart/form-data'}}).then((response) => {
|
||||
console.log(response)
|
||||
this.makeToast('{% trans 'Updated' %}', '{% trans 'Changes saved successfully!' %}', 'success')
|
||||
this.makeToast(gettext('Updated'), gettext('Changes saved successfully!'), 'success')
|
||||
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
this.makeToast('{% trans 'Error' %}', '{% trans 'There was an error updating the recipe!' %}' + err.body.image, 'danger')
|
||||
this.makeToast(gettext('Error'), gettext('There was an error updating the recipe!') + err.body.image, 'danger')
|
||||
})
|
||||
|
||||
let reader = new FileReader();
|
||||
@@ -567,12 +612,12 @@
|
||||
|
||||
},
|
||||
removeIngredient: function (step, ingredient) {
|
||||
if (confirm('{% trans 'Are you sure that you want to delete this ingredient?' %}')) {
|
||||
if (confirm(gettext('Are you sure that you want to delete this ingredient?'))) {
|
||||
step.ingredients = step.ingredients.filter(item => item !== ingredient)
|
||||
}
|
||||
},
|
||||
removeStep: function (step) {
|
||||
if (confirm('{% trans 'Are you sure that you want to delete this step?' %}')) {
|
||||
if (confirm(gettext('Are you sure that you want to delete this step?'))) {
|
||||
this.recipe.steps = this.recipe.steps.filter(item => item !== step)
|
||||
}
|
||||
},
|
||||
@@ -594,7 +639,7 @@
|
||||
|
||||
let new_unit = this.recipe.steps[step].ingredients[id]
|
||||
new_unit.unit = {'name': tag}
|
||||
this.foods.push(new_unit.unit)
|
||||
this.units.push(new_unit.unit)
|
||||
this.recipe.steps[step].ingredients[id] = new_unit
|
||||
},
|
||||
searchKeywords: function (query) {
|
||||
@@ -604,7 +649,7 @@
|
||||
this.keywords_loading = false
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
this.makeToast('{% trans 'Error' %}', '{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
|
||||
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
|
||||
})
|
||||
},
|
||||
searchUnits: function (query) {
|
||||
@@ -623,7 +668,7 @@
|
||||
}
|
||||
this.units_loading = false
|
||||
}).catch((err) => {
|
||||
this.makeToast('{% trans 'Error' %}', '{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
|
||||
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
|
||||
})
|
||||
},
|
||||
searchFoods: function (query) {
|
||||
@@ -643,12 +688,18 @@
|
||||
|
||||
this.foods_loading = false
|
||||
}).catch((err) => {
|
||||
this.makeToast('{% trans 'Error' %}', '{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
|
||||
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
|
||||
})
|
||||
},
|
||||
scrollToStep: function (step_index) {
|
||||
document.getElementById('id_step_' + step_index).scrollIntoView({behavior: 'smooth'});
|
||||
},
|
||||
addNutrition: function () {
|
||||
this.recipe.nutrition = {}
|
||||
},
|
||||
removeNutrition: function () {
|
||||
this.recipe.nutrition = null
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
<h4>{% trans 'Units' %}</h4>
|
||||
<form action="{% url 'edit_food' %}" method="post"
|
||||
onsubmit="return confirm('{% trans 'Are you sure that you want to merge these two units ?' %}')">
|
||||
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"
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
<h4>{% trans 'Ingredients' %}</h4>
|
||||
<form action="{% url 'edit_food' %}" method="post"
|
||||
onsubmit="return confirm('{% trans 'Are you sure that you want to merge these two ingredients ?' %}')">
|
||||
onsubmit="return confirm('{% trans 'Are you sure that you want to merge these two ingredients?' %}')">
|
||||
{% csrf_token %}
|
||||
{{ food_form|crispy }}
|
||||
<button class="btn btn-danger" type="submit">
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
{% block content %}
|
||||
|
||||
<div class="table-container">
|
||||
<h3>{{ title }} {% trans 'List' %}
|
||||
<h3 style="margin-bottom: 2vh">{{ title }} {% trans 'List' %}
|
||||
{% if create_url %}
|
||||
<a href="{% url create_url %}"> <i class="fas fa-plus-circle"></i>
|
||||
</a>
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="collapse col-md-12" id="collapse_adv_search">
|
||||
<div class="collapse col-md-12{% if filter.data.keywords or filter.data.foods or filter.data.internal and not filter.data.internal == "unknown" %} show{% endif %}" id="collapse_adv_search">
|
||||
<div style="margin-top: 1vh">
|
||||
{{ filter.form.keywords | as_crispy_field }}
|
||||
</div>
|
||||
@@ -91,7 +91,7 @@
|
||||
{% render_table recipes %}
|
||||
{% else %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
{% trans "Log in to view Recipies" %}
|
||||
{% trans "Log in to view recipes" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
{% blocktrans %}
|
||||
Markdown is lightweight markup language that can be used to format plain text easily.
|
||||
This site uses the <a href="https://python-markdown.github.io/" target="_blank">Python Markdown</a> library to
|
||||
convert your text into nice looking html. Its full markdown documentation can be found
|
||||
convert your text into nice looking HTML. Its full markdown documentation can be found
|
||||
<a href="https://daringfireball.net/projects/markdown/syntax" target="_blank">here</a>.
|
||||
An incomplete but most likely sufficient documentation can be found below.
|
||||
{% endblocktrans %}
|
||||
@@ -57,7 +57,7 @@
|
||||
{% trans 'or by leaving a blank line inbetween.' %}
|
||||
|
||||
**{% trans 'This text is bold' %}**
|
||||
*{% trans 'This text is in italics' %}*
|
||||
*{% trans 'This text is italic' %}*
|
||||
> {% trans 'Blockquotes are also possible' %}
|
||||
</code></pre>
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
{% trans 'Line breaks are inserted by adding two spaces after the end of a line' %}<br/>
|
||||
{% trans 'or by leaving a blank line inbetween.' %}<br/><br/>
|
||||
<b>{% trans 'This text is bold' %}</b><br/>
|
||||
<i>{% trans 'This text is in italics' %}</i>
|
||||
<i>{% trans 'This text is italic' %}</i>
|
||||
<blockquote>
|
||||
<p>{% trans 'Blockquotes are also possible' %}</p>
|
||||
</blockquote>
|
||||
@@ -123,13 +123,13 @@
|
||||
|
||||
<br/>
|
||||
<h2>{% trans 'Images & Links' %}</h2>
|
||||
{% trans 'Links can be formatted with Markdown. This applicaiton also allows to paste links directly into markdown fields without any formatting.' %}
|
||||
{% trans 'Links can be formatted with Markdown. This application also allows to paste links directly into markdown fields without any formatting.' %}
|
||||
<pre class="intro-code code-block"><code>
|
||||
https://github.com/vabene1111/recipes
|
||||
[](https://github.com/vabene1111/recipes)
|
||||
[GitHub](https://github.com/vabene1111/recipes)
|
||||
|
||||

|
||||

|
||||
</code></pre>
|
||||
|
||||
<div style="text-align: center">
|
||||
@@ -142,7 +142,7 @@
|
||||
<div class="card-body">
|
||||
<a href="https://github.com/vabene1111/recipes">https://github.com/vabene1111/recipes</a> <br/>
|
||||
<a href="https://github.com/vabene1111/recipes">GitHub</a> <br/>
|
||||
<img src="{% static 'favicon.png' %}" class="img-fluid" alt="{% trans 'This will become and Image' %}"
|
||||
<img src="{% static 'favicon.png' %}" class="img-fluid" alt="{% trans 'This will become and image' %}"
|
||||
style="height: 3vw">
|
||||
</div>
|
||||
|
||||
@@ -150,7 +150,7 @@
|
||||
|
||||
<br/>
|
||||
<h2>{% trans 'Tables' %}</h2>
|
||||
{% trans 'Markdown tables are hard to create by hand. It is recommended to use a table editor like <a href="https://www.tablesgenerator.com/markdown_tables" target="_blank">this</a> one.' %}
|
||||
{% trans 'Markdown tables are hard to create by hand. It is recommended to use a table editor like <a href="https://www.tablesgenerator.com/markdown_tables" rel="noreferrer noopener" target="_blank">this one.</a>' %}
|
||||
<pre class="intro-code code-block"><code>
|
||||
| {% trans 'Table' %} | {% trans 'Header' %} |
|
||||
|--------|---------|
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
<script src="{% static 'js/Sortable.min.js' %}"></script>
|
||||
<script src="{% static 'js/vuedraggable.umd.min.js' %}"></script>
|
||||
<script src="{% static 'js/vue-cookies.js' %}"></script>
|
||||
|
||||
<script src="{% static 'js/js.cookie.min.js' %}"></script>
|
||||
|
||||
@@ -24,14 +25,15 @@
|
||||
<div class="col-md-4 offset-md-4">
|
||||
<div class="input-group" style="margin-top: 8px; margin-bottom: 8px">
|
||||
<div class="input-group-prepend">
|
||||
<button class="btn btn-outline-secondary shadow-none" @click="changeWeek(-1)">
|
||||
<button class="btn btn-outline-secondary shadow-none"
|
||||
@click="changeStartDate(number_of_days * -1)">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
</button>
|
||||
</div>
|
||||
<input name="week" id="id_week" class="form-control" type="week" v-model="week"
|
||||
<input name="date" id="id_date" class="form-control" type="date" v-model="start_date"
|
||||
@change="updatePlan()">
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-outline-secondary shadow-none" @click="changeWeek(1)">
|
||||
<button class="btn btn-outline-secondary shadow-none" @click="changeStartDate(number_of_days)">
|
||||
<i class="fas fa-arrow-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -41,10 +43,10 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<table class="table table-sm table-striped table-responsive-sm">
|
||||
<table class="table table-sm table-striped table-responsive-sm" style=" table-layout:fixed;">
|
||||
<thead class="thead-dark">
|
||||
<tr>
|
||||
<th v-for="d in days" style="width: 14.2%; text-align: center">[[d]]<br/>[[formatDateDay(d)]].
|
||||
<th v-for="d in dates" style="width: 14.2%; text-align: center">[[formatDateDayname(d)]]<br/>[[formatDateDay(d)]].
|
||||
<button class="btn btn-sm btn-outline-secondary shadow-none" @click="addDayToShopping(d)"><i
|
||||
class="fas fa-cart-plus fa-sm"></i></button>
|
||||
</th>
|
||||
@@ -52,7 +54,7 @@
|
||||
</thead>
|
||||
<tbody v-for="t in meal_types">
|
||||
<tr v-if="meal_plan[t.name] !== undefined">
|
||||
<td colspan="7" style="text-align: center">
|
||||
<td :colspan="number_of_days" style="text-align: center">
|
||||
[[ meal_plan[t.name].name]]
|
||||
<template
|
||||
v-if="t.created_by !== {{ request.user.pk }} && user_names[t.created_by] !== undefined">
|
||||
@@ -66,18 +68,21 @@
|
||||
@change="dragChanged(d.date, t, $event)"
|
||||
:empty-insert-threshold="10" handle=".handle">
|
||||
<div class="" v-for="(element, index) in d.items" :key="element.id">
|
||||
<!-- small layout with handle -->
|
||||
<div class="d-block d-md-none">
|
||||
<div class="col-">
|
||||
<i class="fas fa-arrows-alt handle input-group-text"
|
||||
style="width: 100%"></i>
|
||||
</div>
|
||||
<div class="list-group-item">
|
||||
<div class="list-group-item" style="word-wrap: break-word;">
|
||||
<a href="#" @click="plan_detail = element" data-toggle="modal"
|
||||
data-target="#id_plan_detail_modal">[[ planElementName(element)]]</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-group-item handle d-md-block d-none">
|
||||
<div class="col-md-12">
|
||||
<!-- big layout -->
|
||||
<div class="list-group-item handle d-md-block d-none"
|
||||
style="word-wrap: break-word; padding: 2;margin-bottom: 4">
|
||||
<div class="col-md-12" style="padding: 0">
|
||||
<a href="#" @click="plan_detail = element" data-toggle="modal"
|
||||
data-target="#id_plan_detail_modal">[[ planElementName(element)]]</a>
|
||||
</div>
|
||||
@@ -103,14 +108,25 @@
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<input type="text" class="form-control" v-model="recipe_query" @keyup="getRecipes"
|
||||
placeholder="{% trans 'Search Recipe' %}" style="margin-bottom: 8px">
|
||||
<div class="input-group mb-3">
|
||||
<input type="text" class="form-control" v-model="recipe_query" @keyup="getRecipes"
|
||||
placeholder="{% trans 'Search Recipe' %}">
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-outline-secondary" type="button"
|
||||
@click="getRandomRecipes">
|
||||
<i class="fas fa-dice"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<draggable class="list-group" :list="recipes"
|
||||
:group="{ name: 'plan', pull: 'clone', put: false }" :clone="cloneRecipe">
|
||||
<div class="list-group-item" v-for="(element, index) in recipes" :key="element.id">
|
||||
<i class="fas fa-arrows-alt"></i> [[element.name]]
|
||||
<div class="list-group-item d-flex align-items-center justify-content-between" v-for="(element, index) in recipes" :key="element.id">
|
||||
<span>
|
||||
<i class="fas fa-arrows-alt"></i> [[element.name]]
|
||||
</span>
|
||||
<span class="badge badge-light badge-pill">[[element.servings]]</span>
|
||||
</div>
|
||||
</draggable>
|
||||
</div>
|
||||
@@ -126,6 +142,9 @@
|
||||
class="text-muted">{% trans 'You can use markdown to format this field. See the <a href="/docs/markdown/" target="_blank" rel="noopener noreferrer">docs here</a>' %}</span></small>
|
||||
<br/>
|
||||
<br/>
|
||||
<input type="number" class="form-control" v-model="new_note_servings"
|
||||
placeholder="{% trans 'Serving Count' %}" style="margin-bottom: 8px">
|
||||
<br/>
|
||||
<draggable :list="pseudo_note_list"
|
||||
:group="{ name: 'plan', pull: 'clone', put: false }" :clone="cloneNote">
|
||||
<div class="list-group-item" v-for="(element, index) in pseudo_note_list"
|
||||
@@ -149,7 +168,7 @@
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<template v-if="shopping_list.length < 1">{% trans 'Shopping List currently empty' %}</template>
|
||||
<template v-if="shopping_list.length < 1">{% trans 'Shopping list currently empty' %}</template>
|
||||
<template v-else>
|
||||
<a v-bind:href="getShoppingUrl()" class="btn btn-success"
|
||||
target="_blank">{% trans 'Open Shopping List' %}</a>
|
||||
@@ -170,6 +189,29 @@
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label>
|
||||
{% trans 'Number of Days' %}
|
||||
<input class="form-control" type="number" v-model="number_of_days"
|
||||
@change="updatePlan(); $cookies.set('number_of_days',number_of_days)">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label>
|
||||
{% trans 'Weekday offset' %}
|
||||
<input class="form-control" type="number" v-model="start_offset"
|
||||
@change="updatePlan(); $cookies.set('start_offset',start_offset)">
|
||||
<small class="text-muted">{% trans 'Number of days starting from the first day of the week to offset the default view.' %}</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<a href="#" data-toggle="modal"
|
||||
data-target="#id_plan_types_modal">{% trans 'Edit plan types' %}</a> <br/>
|
||||
<a href="#" data-toggle="modal"
|
||||
@@ -204,6 +246,9 @@
|
||||
<small class="text-muted">{% trans 'Recipe' %}</small><br/>
|
||||
<a v-bind:href="planDetailRecipeUrl()" target="_blank">[[ plan_detail.recipe_name ]]</a>
|
||||
<br/>
|
||||
<br/>
|
||||
<small class="text-muted">{% trans 'Serving Count' %}</small><br/>
|
||||
<span>[[ plan_detail.servings ]]</span>
|
||||
</template>
|
||||
|
||||
<template v-if="plan_detail.note !== ''">
|
||||
@@ -296,12 +341,12 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{% blocktrans %}
|
||||
<p>The meal plan module allows planning of meals both with recipes or just notes.</p>
|
||||
<p>The meal plan module allows planning of meals both with recipes and notes.</p>
|
||||
<p>Simply select a recipe from the list of recently viewed recipes or search the one you
|
||||
want and drag it to the desired plan position. You can also add a note and a title and
|
||||
then drag the recipe to create a plan entry with a custom title and note. Creating only
|
||||
Notes is possible by dragging the create note box into the plan.</p>
|
||||
<p>Click on a recipe in order to open the detail view. Here you can also add it to the
|
||||
<p>Click on a recipe in order to open the detailed view. There you can also add it to the
|
||||
shopping list. You can also add all recipes of a day to the shopping list by
|
||||
clicking the shopping cart at the top of the table.</p>
|
||||
<p>Since a common use case is to plan meals together you can define
|
||||
@@ -324,7 +369,8 @@
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<script src="{% url 'javascript-catalog' %}"></script>
|
||||
<script type="application/javascript">
|
||||
moment.locale('{{request.LANGUAGE_CODE}}');
|
||||
|
||||
@@ -335,8 +381,10 @@
|
||||
delimiters: ['[[', ']]'],
|
||||
el: '#app',
|
||||
data: {
|
||||
week: moment().format('YYYY-[W]WW'),
|
||||
days: moment.weekdays(true),
|
||||
start_date: undefined,
|
||||
start_offset: 0,
|
||||
dates: [],
|
||||
number_of_days: $cookies.isKey('number_of_days') ? $cookies.get('number_of_days') : 7,
|
||||
plan_entries: [],
|
||||
meal_types: [],
|
||||
meal_types_edit: [],
|
||||
@@ -349,6 +397,7 @@
|
||||
],
|
||||
new_note_title: '',
|
||||
new_note_text: '',
|
||||
new_note_servings: '',
|
||||
default_shared_users: [],
|
||||
user_id_update: [],
|
||||
user_names: {},
|
||||
@@ -363,11 +412,14 @@
|
||||
this.$set(this.user_names, {{ request.user.pk }}, '{{ request.user.get_user_name }}')
|
||||
this.user_id_update = Array.from(this.default_shared_users)
|
||||
|
||||
this.start_offset = $cookies.isKey('start_offset') ? $cookies.get('start_offset') : 0;
|
||||
this.start_date = moment().weekday(0).add(this.start_offset, 'days').format('YYYY-MM-DD')
|
||||
|
||||
this.updatePlan();
|
||||
this.getRecipes();
|
||||
},
|
||||
methods: {
|
||||
makeToast: function(title, message, variant=null) {
|
||||
makeToast: function (title, message, variant = null) {
|
||||
//TODO remove duplicate function in favor of central one
|
||||
this.$bvToast.toast(message, {
|
||||
title: title,
|
||||
@@ -377,6 +429,11 @@
|
||||
})
|
||||
},
|
||||
updatePlan: function () {
|
||||
this.dates = [];
|
||||
for (var i = 0; i <= (this.number_of_days - 1); i++) {
|
||||
this.dates.push(moment(this.start_date).add(i, 'days'));
|
||||
}
|
||||
|
||||
let planEntryPromise = this.getPlanEntries();
|
||||
let planTypePromise = this.getPlanTypes();
|
||||
|
||||
@@ -385,11 +442,11 @@
|
||||
})
|
||||
},
|
||||
getPlanEntries: function () {
|
||||
return this.$http.get("{% url 'api:mealplan-list' %}?html_week=" + this.week).then((response) => {
|
||||
return this.$http.get("{% url 'api:mealplan-list' %}?from_date=" + this.dates[0].format('YYYY-MM-DD') + "&to_date=" + this.dates[this.dates.length - 1].format('YYYY-MM-DD')).then((response) => {
|
||||
this.plan_entries = response.data;
|
||||
}).catch((err) => {
|
||||
console.log("getPlanEntries error: ", err);
|
||||
this.makeToast('{% trans 'Error' %}','{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
|
||||
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
|
||||
})
|
||||
},
|
||||
getPlanTypes: function () {
|
||||
@@ -401,7 +458,7 @@
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.log("getPlanTypes error: ", err);
|
||||
this.makeToast('{% trans 'Error' %}','{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
|
||||
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
|
||||
})
|
||||
},
|
||||
buildGrid: function () {
|
||||
@@ -420,11 +477,10 @@
|
||||
meal_type: t.id,
|
||||
days: {}
|
||||
})
|
||||
for (let d of this.days) {
|
||||
let date = moment(this.week).weekday(this.days.indexOf(d)).format('YYYY-MM-DD')
|
||||
this.$set(this.meal_plan[t.name].days, date, {
|
||||
name: d,
|
||||
date: date,
|
||||
for (let d of this.dates) {
|
||||
this.$set(this.meal_plan[t.name].days, d.format('YYYY-MM-DD'), {
|
||||
name: this.formatDateDayname(d),
|
||||
date: d.format('YYYY-MM-DD'),
|
||||
items: []
|
||||
})
|
||||
}
|
||||
@@ -441,17 +497,23 @@
|
||||
|
||||
this.updateUserNames()
|
||||
},
|
||||
getRandomRecipes: function () {
|
||||
this.$set(this, 'recipe_query', '');
|
||||
this.getRecipes();
|
||||
},
|
||||
getRecipes: function () {
|
||||
let url = "{% url 'api:recipe-list' %}?limit=5"
|
||||
if (this.recipe_query !== '') {
|
||||
url += '&query=' + this.recipe_query;
|
||||
} else {
|
||||
url += '&random=True'
|
||||
}
|
||||
|
||||
this.$http.get(url).then((response) => {
|
||||
this.recipes = response.data;
|
||||
}).catch((err) => {
|
||||
console.log("getRecipes error: ", err);
|
||||
this.makeToast('{% trans 'Error' %}','{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
|
||||
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
|
||||
})
|
||||
},
|
||||
getMdNote: function () {
|
||||
@@ -464,18 +526,18 @@
|
||||
this.recipes = response.data;
|
||||
}).catch((err) => {
|
||||
console.log("getRecipes error: ", err);
|
||||
this.makeToast('{% trans 'Error' %}','{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
|
||||
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
|
||||
})
|
||||
},
|
||||
updateUserNames: function () {
|
||||
return this.$http.get("{% url 'api:username-list' %}?filter_list=[" + this.user_id_update + ']').then((response) => {
|
||||
for (let u of response.data) {
|
||||
this.$set(this.user_names, u.id, u.username);
|
||||
this.$set(this.user_names, u.id, u.username);
|
||||
}
|
||||
|
||||
}).catch((err) => {
|
||||
console.log("updateUserNames error: ", err);
|
||||
this.makeToast('{% trans 'Error' %}','{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
|
||||
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
|
||||
})
|
||||
},
|
||||
dragChanged: function (date, meal_type, evt) {
|
||||
@@ -501,7 +563,7 @@
|
||||
this.$http.put(`{% url 'api:mealplan-list' %}${plan_entry.id}/`, plan_entry).then((response) => {
|
||||
}).catch((err) => {
|
||||
console.log("dragChanged update error", err);
|
||||
this.makeToast('{% trans 'Error' %}','{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
|
||||
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -512,7 +574,7 @@
|
||||
this.meal_plan[entry.meal_type_name].days[entry.date].items = this.meal_plan[entry.meal_type_name].days[entry.date].items.filter(item => item !== entry)
|
||||
}).catch((err) => {
|
||||
console.log("deleteEntry error: ", err);
|
||||
this.makeToast('{% trans 'Error' %}','{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
|
||||
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
|
||||
})
|
||||
},
|
||||
updatePlanTypes: function () {
|
||||
@@ -526,14 +588,14 @@
|
||||
promise_list.push(this.$http.post("{% url 'api:mealtype-list' %}", x).then((response) => {
|
||||
}).catch((err) => {
|
||||
console.log("updatePlanTypes create error: ", err);
|
||||
this.makeToast('{% trans 'Error' %}','{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
|
||||
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
|
||||
}))
|
||||
} else if (x.delete) {
|
||||
if (x.id !== undefined) {
|
||||
promise_list.push(this.$http.delete(`{% url 'api:mealtype-list' %}${x.id}/`, x).then((response) => {
|
||||
}).catch((err) => {
|
||||
console.log("updatePlanTypes delete error: ", err);
|
||||
this.makeToast('{% trans 'Error' %}','{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
|
||||
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
|
||||
}))
|
||||
}
|
||||
} else {
|
||||
@@ -541,7 +603,7 @@
|
||||
|
||||
}).catch((err) => {
|
||||
console.log("updatePlanTypes update error: ", err);
|
||||
this.makeToast('{% trans 'Error' %}','{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
|
||||
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -551,34 +613,43 @@
|
||||
})
|
||||
},
|
||||
markTypeDelete: function (element) {
|
||||
if (confirm('{% trans 'When deleting a meal type all entries using that type will be deleted as well. Deletion will apply when configuration is saved. Do you want to proceed?' %}')) {
|
||||
if (confirm(gettext('When deleting a meal type all entries using that type will be deleted as well. Deletion will apply when configuration is saved. Do you want to proceed?'))) {
|
||||
element.delete = true
|
||||
}
|
||||
},
|
||||
cloneRecipe: function (recipe) {
|
||||
return {
|
||||
let r = {
|
||||
id: Math.round(Math.random() * 1000) + 10000,
|
||||
recipe: recipe.id,
|
||||
recipe_name: recipe.name,
|
||||
servings: (this.new_note_servings > 1) ? this.new_note_servings : recipe.servings,
|
||||
title: this.new_note_title,
|
||||
note: this.new_note_text,
|
||||
is_new: true
|
||||
}
|
||||
|
||||
this.new_note_title = ''
|
||||
this.new_note_text = ''
|
||||
this.new_note_servings = ''
|
||||
|
||||
return r
|
||||
},
|
||||
cloneNote: function () {
|
||||
let new_entry = {
|
||||
id: Math.round(Math.random() * 1000) + 10000,
|
||||
title: this.new_note_title,
|
||||
note: this.new_note_text,
|
||||
servings: 1,
|
||||
is_new: true,
|
||||
}
|
||||
|
||||
if (new_entry.title === '') {
|
||||
new_entry.title = '{% trans 'Title' %}'
|
||||
new_entry.title = gettext('Title')
|
||||
}
|
||||
|
||||
this.new_note_title = ''
|
||||
this.new_note_text = ''
|
||||
this.new_note_servings = ''
|
||||
return new_entry
|
||||
},
|
||||
planElementName: function (element) {
|
||||
@@ -606,11 +677,14 @@
|
||||
formatLocalDate: function (date) {
|
||||
return moment(date).format('LL')
|
||||
},
|
||||
formatDateDay: function (day) {
|
||||
return moment(this.week).weekday(this.days.indexOf(day)).format('D')
|
||||
formatDateDay: function (date) {
|
||||
return moment(date).format('D')
|
||||
},
|
||||
changeWeek: function (change) {
|
||||
this.week = moment(this.week).add(change, 'w').format('YYYY-[W]WW')
|
||||
formatDateDayname: function (date) {
|
||||
return moment(date).format('dddd')
|
||||
},
|
||||
changeStartDate: function (change) {
|
||||
this.start_date = moment(this.start_date).add(change, 'days').format('YYYY-MM-DD')
|
||||
this.updatePlan();
|
||||
},
|
||||
getShoppingUrl: function () {
|
||||
@@ -618,22 +692,23 @@
|
||||
let first = true
|
||||
for (let se of this.shopping_list) {
|
||||
if (first) {
|
||||
url += `?r=${se.recipe}`
|
||||
url += `?r=[${se.recipe},${se.servings}]`
|
||||
first = false
|
||||
} else {
|
||||
url += `&r=${se.recipe}`
|
||||
url += `&r=[${se.recipe},${se.servings}]`
|
||||
}
|
||||
}
|
||||
return url
|
||||
},
|
||||
getIcalUrl: function () {
|
||||
return "{% url 'api_get_plan_ical' 12345 %}".replace(/12345/, this.week);
|
||||
if (this.dates.length === 0) {
|
||||
return ""
|
||||
}
|
||||
return "{% url 'api_get_plan_ical' 12345 6789 %}".replace(/12345/, this.dates[0].format('YYYY-MM-DD')).replace(/6789/, this.dates[this.dates.length - 1].format('YYYY-MM-DD'));
|
||||
},
|
||||
addDayToShopping: function (day) {
|
||||
let date = moment(this.week).weekday(this.days.indexOf(day)).format('YYYY-MM-DD')
|
||||
|
||||
addDayToShopping: function (date) {
|
||||
for (let t of this.meal_types) {
|
||||
for (let i of this.meal_plan[t.name].days[date].items) {
|
||||
for (let i of this.meal_plan[t.name].days[date.format('YYYY-MM-DD')].items) {
|
||||
if (!this.shopping_list.includes(i)) {
|
||||
this.shopping_list.push(i)
|
||||
}
|
||||
|
||||
@@ -12,19 +12,11 @@
|
||||
{% include 'include/vue_base.html' %}
|
||||
<script src="{% static 'js/moment-with-locales.min.js' %}"></script>
|
||||
|
||||
<script src="{% static 'js/frac.js' %}"></script>
|
||||
|
||||
<link rel="stylesheet" href="{% static 'css/pretty-checkbox.min.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'custom/css/markdown_blockquote.css' %}">
|
||||
|
||||
<style>
|
||||
/* fixes print layout being disturbed by print button tooltip */
|
||||
@media print {
|
||||
.tooltip {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@@ -46,10 +38,10 @@
|
||||
class="fas fa-pencil-alt fa-fw"></i> {% trans 'Edit' %}</a>
|
||||
<button class="dropdown-item" onclick="$('#bookmarkModal').modal({'show':true})">
|
||||
<i class="fas fa-bookmark fa-fw"></i> {% trans 'Add to Book' %}</button>
|
||||
{% if ingredients %}
|
||||
<a class="dropdown-item" href="{% url 'view_shopping' %}?r={{ recipe.pk }}">
|
||||
<i class="fas fa-shopping-cart fa-fw"></i> {% trans 'Add to Shopping' %}</a>
|
||||
{% endif %}
|
||||
|
||||
<a class="dropdown-item" v-bind:href="shopping_url" v-if="has_ingredients">
|
||||
<i class="fas fa-shopping-cart fa-fw"></i> {% trans 'Add to Shopping' %}</a>
|
||||
|
||||
<a class="dropdown-item" href="{% url 'new_meal_plan' %}?recipe={{ recipe.pk }}"><i
|
||||
class="fas fa-calendar fa-fw"></i> {% trans 'Add to Plan' %}</a>
|
||||
<button class="dropdown-item" onclick="openCookLogModal({{ recipe.pk }})"><i
|
||||
@@ -87,13 +79,13 @@
|
||||
|
||||
{% if recipe.working_time and recipe.working_time != 0 %}
|
||||
<span class="badge badge-secondary"><i
|
||||
class="fas fa-user-clock"></i> {% trans 'Preparation time ca.' %} {{ recipe.working_time }} min </span>
|
||||
class="fas fa-user-clock"></i> {% trans 'Preparation time ~' %} {{ recipe.working_time }} min </span>
|
||||
{% endif %}
|
||||
|
||||
{% if recipe.waiting_time and recipe.waiting_time != 0 %}
|
||||
<span
|
||||
class="badge badge-secondary"><i
|
||||
class="far fa-clock"></i> {% trans 'Waiting time ca.' %} {{ recipe.waiting_time }} min </span>
|
||||
class="far fa-clock"></i> {% trans 'Waiting time ~' %} {{ recipe.waiting_time }} min </span>
|
||||
{% endif %}
|
||||
{% recipe_last recipe request.user as last_cooked %}
|
||||
{% if last_cooked %}
|
||||
@@ -105,10 +97,9 @@
|
||||
<br/>
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
||||
<div class="row" v-if="recipe && has_ingredients">
|
||||
<div class="col-md-6 order-md-1 col-sm-12 order-sm-2 col-12 order-2">
|
||||
<div class="row">
|
||||
<div class="col-md-6 order-md-1 col-sm-12 order-sm-2 col-12 order-2" v-if="recipe && has_ingredients">
|
||||
<!-- TODO duplicate code remove -->
|
||||
<div class="card border-primary">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
@@ -118,8 +109,8 @@
|
||||
<div class="col col-md-3">
|
||||
|
||||
<div class="input-group d-print-none">
|
||||
<input type="number" value="1" maxlength="3" class="form-control"
|
||||
v-model="ingredient_factor"/>
|
||||
<input type="number" value="1" maxlength="3" class="form-control" style="min-width: 2vw"
|
||||
v-model="servings"/>
|
||||
<div class="input-group-append">
|
||||
<span class="input-group-text"><i class="fas fa-calculator"></i></span>
|
||||
</div>
|
||||
@@ -160,9 +151,12 @@
|
||||
<template v-if="i.no_amount">
|
||||
<span>⁣</span>
|
||||
</template>
|
||||
<template v-if="!i.no_amount && i.unit">
|
||||
<span>[[roundDecimals(i.amount * ingredient_factor)]]</span>
|
||||
[[i.unit.name]]
|
||||
<template v-if="!i.no_amount">
|
||||
<span v-html="calculateAmount(i.amount)"></span>
|
||||
{# Allow for amounts without units, such as "2 eggs" #}
|
||||
<template v-if="i.unit">
|
||||
[[i.unit.name]]
|
||||
</template>
|
||||
</template>
|
||||
</label>
|
||||
</div>
|
||||
@@ -180,12 +174,10 @@
|
||||
</td>
|
||||
<td style="vertical-align: middle!important;">
|
||||
<template v-if="i.note">
|
||||
<a class="btn btn-light btn-sm d-print-none" tabindex="-1"
|
||||
data-toggle="popover"
|
||||
data-placement="right" data-html="true" data-trigger="focus"
|
||||
v-bind:data-content="i.note">
|
||||
<i class="fas fa-info"></i>
|
||||
</a>
|
||||
<b-button v-b-popover.hover="i.note"
|
||||
class="btn btn-sm d-print-none"><i
|
||||
class="fas fa-info"></i></b-button>
|
||||
|
||||
<div class="d-none d-print-block">
|
||||
<i class="far fa-comment-alt"></i> [[i.note]]
|
||||
</div>
|
||||
@@ -218,6 +210,60 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if recipe.nutrition %}
|
||||
<div class="row mt-5">
|
||||
<div class="col-md-6 order-md-1 col-sm-12 order-sm-2 col-12 order-2">
|
||||
<div class="card border-primary">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">{% trans 'Nutrition' %}</h4>
|
||||
<table class="table table-sm">
|
||||
<tr>
|
||||
<td style="padding-top: 8px!important; ">
|
||||
<b>{% trans 'Calories' %}</b>
|
||||
</td>
|
||||
<td style="text-align: right">{{ recipe.nutrition.calories|floatformat:2 }}</td>
|
||||
<td>kcal</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<tr>
|
||||
<td style="padding-top: 8px!important; ">
|
||||
<b>{% trans 'Carbohydrates' %}</b>
|
||||
</td>
|
||||
<td style="text-align: right">{{ recipe.nutrition.carbohydrates|floatformat:2 }}</td>
|
||||
<td>g</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<tr>
|
||||
<td style="padding-top: 8px!important; ">
|
||||
<b>{% trans 'Fats' %}</b>
|
||||
</td>
|
||||
<td style="text-align: right">{{ recipe.nutrition.fats|floatformat:2 }}</td>
|
||||
<td>g</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<tr>
|
||||
<td style="padding-top: 8px!important; ">
|
||||
<b>{% trans 'Proteins' %}</b>
|
||||
</td>
|
||||
<td style="text-align: right">{{ recipe.nutrition.proteins|floatformat:2 }}</td>
|
||||
<td>g</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</table>
|
||||
{% if recipe.nutrition.source %}
|
||||
Source: {{ recipe.nutrition.source }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<div v-if="recipe !== undefined && recipe.steps.length > 0">
|
||||
<hr>
|
||||
<h3>{% trans 'Instructions' %}</h3>
|
||||
@@ -257,6 +303,7 @@
|
||||
<div class="col-md-6">
|
||||
<table class="table table-sm">
|
||||
<template v-for="i in recipe.steps[{{ forloop.counter0 }}].ingredients">
|
||||
<!-- TODO duplicate code remove -->
|
||||
|
||||
<template v-if="i.is_header">
|
||||
<tr>
|
||||
@@ -279,9 +326,12 @@
|
||||
<template v-if="i.no_amount">
|
||||
<span>⁣</span>
|
||||
</template>
|
||||
<template v-if="!i.no_amount && i.unit">
|
||||
<span>[[roundDecimals(i.amount * ingredient_factor)]]</span>
|
||||
[[i.unit.name]]
|
||||
<template v-if="!i.no_amount">
|
||||
<span v-html="calculateAmount(i.amount)"></span>
|
||||
{# Allow for amounts without units, such as "2 eggs" #}
|
||||
<template v-if="i.unit">
|
||||
[[i.unit.name]]
|
||||
</template>
|
||||
</template>
|
||||
</label>
|
||||
</div>
|
||||
@@ -299,12 +349,9 @@
|
||||
</td>
|
||||
<td style="vertical-align: middle!important;">
|
||||
<template v-if="i.note">
|
||||
<a class="btn btn-light btn-sm d-print-none" tabindex="-1"
|
||||
data-toggle="popover"
|
||||
data-placement="right" data-html="true" data-trigger="focus"
|
||||
v-bind:data-content="i.note">
|
||||
<i class="fas fa-info"></i>
|
||||
</a>
|
||||
<b-button v-b-popover.hover="i.note"
|
||||
class="btn btn-sm d-print-none"><i
|
||||
class="fas fa-info"></i></b-button>
|
||||
<div class="d-none d-print-block">
|
||||
<i class="far fa-comment-alt"></i> [[i.note]]
|
||||
</div>
|
||||
@@ -469,6 +516,13 @@
|
||||
let csrftoken = Cookies.get('csrftoken');
|
||||
Vue.http.headers.common['X-CSRFToken'] = csrftoken;
|
||||
|
||||
{% if user_servings %}
|
||||
const recipe_servings = {{ user_servings|floatformat:0 }}
|
||||
{% else %}
|
||||
const recipe_servings = {{ recipe.servings }}
|
||||
{% endif %}
|
||||
|
||||
|
||||
let app = new Vue({
|
||||
delimiters: ['[[', ']]'],
|
||||
el: '#id_base_container',
|
||||
@@ -476,9 +530,16 @@
|
||||
recipe: undefined,
|
||||
has_ingredients: false,
|
||||
has_times: false,
|
||||
ingredient_factor: 1,
|
||||
servings: recipe_servings,
|
||||
},
|
||||
computed: {
|
||||
ingredient_factor: function () {
|
||||
return this.servings / recipe_servings
|
||||
},
|
||||
shopping_url: function () {
|
||||
return `{% url 'view_shopping' %}?r=[${this.recipe.id},${this.servings}]`
|
||||
},
|
||||
},
|
||||
|
||||
mounted: function () {
|
||||
this.loadRecipe()
|
||||
},
|
||||
@@ -487,7 +548,7 @@
|
||||
this.$http.get("{% url 'api:recipe-detail' recipe.pk %}" {% if share %}
|
||||
+ "?share={{ share }}"{% endif %}).then((response) => {
|
||||
this.recipe = response.data;
|
||||
this.loading = false
|
||||
this.loading = false;
|
||||
|
||||
for (let step of this.recipe.steps) {
|
||||
if (step.ingredients.length > 0) {
|
||||
@@ -496,25 +557,25 @@
|
||||
if (step.time !== 0) {
|
||||
this.has_times = true
|
||||
}
|
||||
this.$set(step, 'time_finished', undefined)
|
||||
this.$set(step, 'time_finished', undefined);
|
||||
for (let i of step.ingredients) {
|
||||
this.$set(i, 'checked', false)
|
||||
}
|
||||
}
|
||||
|
||||
}).catch((err) => {
|
||||
this.error = err.data
|
||||
this.loading = false
|
||||
this.error = err.data;
|
||||
this.loading = false;
|
||||
console.log(err)
|
||||
})
|
||||
},
|
||||
roundDecimals: function (num) {
|
||||
let decimals = {% if request.user.userpreference.ingredient_decimals %}
|
||||
{{ request.user.userpreference.ingredient_decimals }} {% else %} 2 {% endif %}
|
||||
{{ request.user.userpreference.ingredient_decimals }} {% else %} 2; {% endif %}
|
||||
return +(Math.round(num + `e+${decimals}`) + `e-${decimals}`);
|
||||
},
|
||||
updateTimes: function (step) {
|
||||
let time_diff_first = 0
|
||||
let time_diff_first = 0;
|
||||
for (let s of this.recipe.steps) {
|
||||
if (this.recipe.steps.indexOf(s) < this.recipe.steps.indexOf(step)) {
|
||||
time_diff_first += s.time
|
||||
@@ -523,27 +584,34 @@
|
||||
|
||||
this.recipe.steps[0].time_finished = moment(step.time_finished).subtract(time_diff_first, 'minutes').format(moment.HTML5_FMT.DATETIME_LOCAL);
|
||||
|
||||
let time_diff = 0
|
||||
let time_diff = 0;
|
||||
for (let s of this.recipe.steps) {
|
||||
s.time_finished = moment(this.recipe.steps[0].time_finished).add(time_diff, 'minutes').format(moment.HTML5_FMT.DATETIME_LOCAL);
|
||||
time_diff += s.time
|
||||
}
|
||||
|
||||
}
|
||||
},
|
||||
calculateAmount: function (amount) {
|
||||
{% if request.user.userpreference.use_fractions %}
|
||||
let return_string = ''
|
||||
let fraction = frac.cont((amount * this.ingredient_factor), 9, true)
|
||||
|
||||
if (fraction[0] > 0) {
|
||||
return_string += fraction[0]
|
||||
}
|
||||
|
||||
if (fraction[1] > 0) {
|
||||
return_string += ` <sup>${(fraction[1])}</sup>⁄<sub>${(fraction[2])}</sub>`
|
||||
}
|
||||
|
||||
return return_string
|
||||
{% else %}
|
||||
return this.roundDecimals(amount * this.ingredient_factor)
|
||||
{% endif %}
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
// Bootstrap component functions
|
||||
$(function () {
|
||||
$('[data-toggle="popover"]').popover()
|
||||
});
|
||||
|
||||
$('.popover-dismiss').popover({
|
||||
trigger: 'focus'
|
||||
});
|
||||
|
||||
$(function () {
|
||||
$('[data-toggle="tooltip"]').tooltip()
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,6 +1,8 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans 'Login' %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% if form.errors %}
|
||||
|
||||
18
cookbook/templates/registration/signup.html
Normal file
18
cookbook/templates/registration/signup.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{% extends "base.html" %}
|
||||
{% load crispy_forms_filters %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans 'Register' %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h3>{% trans 'Create your Account' %}</h3>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<button class="btn btn-success" type="submit"><i class="fas fa-save"></i> {% trans 'Create User' %}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
@@ -12,12 +12,12 @@
|
||||
{% block content %}
|
||||
|
||||
<h1>{% trans 'Setup' %}</h1>
|
||||
<p>{% blocktrans %}To start using this application you must first create a superuser.{% endblocktrans %}</p>
|
||||
<p>{% blocktrans %}To start using this application you must first create a superuser account.{% endblocktrans %}</p>
|
||||
|
||||
<form action="." method="post">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<button class="btn btn-success" type="submit"><i class="fas fa-save"></i> {% trans 'Create Superuser' %}</button>
|
||||
<button class="btn btn-success" type="submit"><i class="fas fa-save"></i> {% trans 'Create Superuser account' %}</button>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
@@ -4,61 +4,702 @@
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Cookbook" %}{% endblock %}
|
||||
{% block title %}{% trans "Shopping List" %}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{ form.media }}
|
||||
{% include 'include/vue_base.html' %}
|
||||
|
||||
<link rel="stylesheet" href="{% static 'css/vue-multiselect-bs4.min.css' %}">
|
||||
<script src="{% static 'js/vue-multiselect.min.js' %}"></script>
|
||||
|
||||
<script src="{% static 'js/Sortable.min.js' %}"></script>
|
||||
<script src="{% static 'js/vuedraggable.umd.min.js' %}"></script>
|
||||
|
||||
<link rel="stylesheet" href="{% static 'css/pretty-checkbox.min.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h2><i class="fas fa-shopping-cart"></i> {% trans 'Shopping List' %}</h2>
|
||||
|
||||
<form action="{% url 'view_shopping' %}" method="post">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<button class="btn btn-success" type="submit"><i class="fas fa-sync-alt"></i> {% trans 'Load' %}</button>
|
||||
</form>
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<!--// @formatter:off-->
|
||||
<textarea id="id_list" class="form-control" rows="{{ ingredients|length|add:1 }}">{% for i in ingredients %}{% if markdown_format %}- [ ] {% endif %}{{ i.amount.normalize }} {{ i.unit }} {{ i.food.name }} {% endfor %}</textarea>
|
||||
<!--// @formatter:on-->
|
||||
<div class="col col-md-9">
|
||||
<h2>{% trans 'Shopping List' %}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<br/>
|
||||
<div class="row">
|
||||
<div class="col col-md-12 text-center">
|
||||
<button class="btn btn-success" onclick="copy()" style="width: 15vw" data-toggle="tooltip"
|
||||
data-placement="right" title="{% trans 'Copy list to clipboard' %}" id="id_btn_copy" onmouseout="resetTooltip()"><i
|
||||
class="far fa-copy"></i></button>
|
||||
<div class="col col-mdd-3 text-right">
|
||||
<b-form-checkbox switch size="lg" v-model="edit_mode"
|
||||
@change="$forceUpdate()">{% trans 'Edit' %}</b-form-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
function copy() {
|
||||
let list = $('#id_list');
|
||||
<template v-if="shopping_list !== undefined">
|
||||
|
||||
list.select();
|
||||
<div class="text-center" v-if="loading">
|
||||
<i class="fas fa-spinner fa-spin fa-8x"></i>
|
||||
</div>
|
||||
<div v-else-if="edit_mode">
|
||||
<div class="row">
|
||||
<div class="col col-md-6">
|
||||
|
||||
$('#id_btn_copy').attr('data-original-title','{% trans 'Copied!' %}').tooltip('show');
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="fa fa-search"></i> {% trans 'Search' %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<input type="text" class="form-control" v-model="recipe_query" @keyup="getRecipes"
|
||||
placeholder="{% trans 'Search Recipe' %}">
|
||||
<ul class="list-group" style="margin-top: 8px">
|
||||
<li class="list-group-item" v-for="x in recipes">
|
||||
<div class="row flex-row" style="padding-left: 0.5vw; padding-right: 0.5vw">
|
||||
<div class="flex-column flex-fill my-auto"><a v-bind:href="getRecipeUrl(x.id)"
|
||||
target="_blank"
|
||||
rel="nofollow norefferer">[[x.name]]</a>
|
||||
</div>
|
||||
<div class="flex-column align-self-end">
|
||||
<button class="btn btn-outline-primary shadow-none"
|
||||
@click="addRecipeToList(x)"><i
|
||||
class="fa fa-plus"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
document.execCommand("copy");
|
||||
}
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="fa fa-shopping-cart"></i> {% trans 'Shopping Recipes' %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<template v-if="shopping_list.recipes.length < 1">
|
||||
{% trans 'No recipes selected' %}
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="row flex-row my-auto" v-for="x in shopping_list.recipes"
|
||||
style="margin-top: 1vh!important;">
|
||||
<div class="flex-column align-self-start " style="margin-right: 0.4vw">
|
||||
<button class="btn btn-outline-danger" @click="removeRecipeFromList(x)"><i
|
||||
class="fa fa-trash"></i></button>
|
||||
</div>
|
||||
<div class="flex-grow-1 flex-column my-auto"><a v-bind:href="getRecipeUrl(x.recipe)"
|
||||
target="_blank"
|
||||
rel="nofollow norefferer">[[x.recipe_name]]</a>
|
||||
</div>
|
||||
<div class="flex-column align-self-end ">
|
||||
<div class="input-group input-group-sm my-auto">
|
||||
<div class="input-group-prepend">
|
||||
<button class="text-muted btn btn-outline-primary shadow-none"
|
||||
@click="((x.servings - 1) > 0) ? x.servings -= 1 : 1">-
|
||||
</button>
|
||||
</div>
|
||||
<input class="form-control" type="number" v-model="x.servings">
|
||||
<div class="input-group-append">
|
||||
<button class="text-muted btn btn-outline-primary shadow-none"
|
||||
@click="x.servings += 1">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
function resetTooltip() {
|
||||
setTimeout(function () {
|
||||
$('#id_btn_copy').attr('data-original-title','{% trans 'Copy list to clipboard' %}');
|
||||
}, 300);
|
||||
}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
$(function () {
|
||||
$('[data-toggle="tooltip"]').tooltip()
|
||||
})
|
||||
<table class="table table-sm table-striped" style="margin-top: 1vh">
|
||||
|
||||
<tbody is="draggable" group="people" :list="display_entries" tag="tbody" :empty-insert-threshold="10"
|
||||
handle=".handle" @sort="sortEntries()">
|
||||
|
||||
<tr v-for="(element, index) in display_entries" :key="element.id">
|
||||
<!--<td class="handle"><i class="fas fa-sort"></i></td>-->
|
||||
<td>[[element.amount]]</td>
|
||||
<td>[[element.unit.name]]</td>
|
||||
<td>[[element.food.name]]</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-danger" v-if="element.list_recipe === null"
|
||||
@click="shopping_list.entries = shopping_list.entries.filter(item => item.id !== element.id)">
|
||||
<i class="fa fa-trash"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-md-3">
|
||||
<input class="form-control" type="number" placeholder="{% trans 'Amount' %}"
|
||||
v-model="new_entry.amount" ref="new_entry_amount">
|
||||
</div>
|
||||
<div class="col col-md-4">
|
||||
<multiselect
|
||||
v-tabindex
|
||||
ref="unit"
|
||||
v-model="new_entry.unit"
|
||||
:options="units"
|
||||
:close-on-select="true"
|
||||
:clear-on-select="true"
|
||||
:allow-empty="true"
|
||||
:preserve-search="true"
|
||||
placeholder="{% trans 'Select Unit' %}"
|
||||
tag-placeholder="{% trans 'Create' %}"
|
||||
select-label="{% trans 'Select' %}"
|
||||
:taggable="true"
|
||||
@tag="addUnitType"
|
||||
label="name"
|
||||
track-by="name"
|
||||
:multiple="false"
|
||||
:loading="units_loading"
|
||||
@search-change="searchUnits">
|
||||
</multiselect>
|
||||
</div>
|
||||
<div class="col col-md-4">
|
||||
<multiselect
|
||||
v-tabindex
|
||||
ref="food"
|
||||
v-model="new_entry.food"
|
||||
:options="foods"
|
||||
:close-on-select="true"
|
||||
:clear-on-select="true"
|
||||
:allow-empty="true"
|
||||
:preserve-search="true"
|
||||
placeholder="{% trans 'Select Food' %}"
|
||||
tag-placeholder="{% trans 'Create' %}"
|
||||
select-label="{% trans 'Select' %}"
|
||||
:taggable="true"
|
||||
@tag="addFoodType"
|
||||
label="name"
|
||||
track-by="name"
|
||||
:multiple="false"
|
||||
:loading="foods_loading"
|
||||
@search-change="searchFoods">
|
||||
</multiselect>
|
||||
</div>
|
||||
|
||||
<div class="col col-md-1 my-auto text-right">
|
||||
<button class="btn btn-success btn-lg" @click="addEntry()"><i class="fa fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col" style="text-align: right; margin-top: 1vh">
|
||||
|
||||
<div class="form-group form-check form-group-lg">
|
||||
<input class="form-check-input" style="zoom:1.3;" type="checkbox"
|
||||
v-model="shopping_list.finished" id="id_finished">
|
||||
<label class="form-check-label" style="zoom:1.3;"
|
||||
for="id_finished"> {% trans 'Finished' %}</label>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col" style="margin-top: 1vh">
|
||||
<multiselect
|
||||
v-tabindex
|
||||
|
||||
v-model="shopping_list.shared"
|
||||
:options="users"
|
||||
:close-on-select="true"
|
||||
:clear-on-select="true"
|
||||
:allow-empty="true"
|
||||
:preserve-search="true"
|
||||
placeholder="{% trans 'Select User' %}"
|
||||
select-label="{% trans 'Select' %}"
|
||||
label="username"
|
||||
track-by="id"
|
||||
:multiple="true"
|
||||
:loading="users_loading"
|
||||
@search-change="searchUsers">
|
||||
</multiselect>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div v-else>
|
||||
|
||||
{% if request.user.userpreference.shopping_auto_sync > 0 %}
|
||||
<div class="row" v-if="!onLine">
|
||||
<div class="col col-md-12">
|
||||
<div class="alert alert-warning" role="alert">
|
||||
{% trans 'You are offline, shopping list might not syncronize.' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row" style="margin-top: 8px">
|
||||
<div class="col col-md-12">
|
||||
<table class="table">
|
||||
<tr v-for="x in display_entries">
|
||||
<template v-if="!x.checked">
|
||||
<td><input type="checkbox" style="zoom:1.4;" v-model="x.checked"
|
||||
@change="entryChecked(x)">
|
||||
</td>
|
||||
<td>[[x.amount]]</td>
|
||||
<td>[[x.unit.name]]</td>
|
||||
<td>[[x.food.name]]</td>
|
||||
</template>
|
||||
</tr>
|
||||
|
||||
<tr v-for="x in display_entries" class="text-muted">
|
||||
<template v-if="x.checked">
|
||||
<td><input type="checkbox" style="zoom:1.4;" v-model="x.checked"
|
||||
@change="entryChecked(x)">
|
||||
</td>
|
||||
<td>[[x.amount]]</td>
|
||||
<td>[[x.unit.name]]</td>
|
||||
<td>[[x.food.name]]</td>
|
||||
</template>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="row" style="margin-top: 2vh">
|
||||
<div class="col" style="text-align: right">
|
||||
<b-button class="btn btn-info" v-b-modal.id_modal_export><i
|
||||
class="fas fa-file-export"></i> {% trans 'Export' %}</b-button>
|
||||
<button class="btn btn-success" @click="updateShoppingList()" v-if="edit_mode"><i
|
||||
class="fas fa-save"></i> {% trans 'Save' %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<b-modal id="id_modal_export" title="{% trans 'Copy/Export' %}">
|
||||
<div class="row">
|
||||
<div class="col col-12">
|
||||
<label>
|
||||
{% trans 'List Prefix' %}
|
||||
<input class="form-control" v-model="export_text_prefix">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-12">
|
||||
<b-form-textarea class="form-control" max-rows="8" v-model="export_text">
|
||||
|
||||
</b-form-textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</b-modal>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
|
||||
<script src="{% url 'javascript-catalog' %}"></script>
|
||||
<script type="application/javascript">
|
||||
let csrftoken = Cookies.get('csrftoken');
|
||||
Vue.http.headers.common['X-CSRFToken'] = csrftoken;
|
||||
|
||||
Vue.component('vue-multiselect', window.VueMultiselect.default)
|
||||
|
||||
let app = new Vue({
|
||||
components: {
|
||||
Multiselect: window.VueMultiselect.default
|
||||
},
|
||||
delimiters: ['[[', ']]'],
|
||||
el: '#id_base_container',
|
||||
data: {
|
||||
shopping_list_id: {% if shopping_list_id %}{{ shopping_list_id }}{% else %}null{% endif %},
|
||||
loading: true,
|
||||
edit_mode: false,
|
||||
export_text_prefix: '', //TODO add userpreference
|
||||
recipe_query: '',
|
||||
recipes: [],
|
||||
shopping_list: undefined,
|
||||
new_entry: {
|
||||
unit: undefined,
|
||||
amount: undefined,
|
||||
food: undefined,
|
||||
},
|
||||
foods: [],
|
||||
foods_loading: false,
|
||||
units: [],
|
||||
units_loading: false,
|
||||
users: [],
|
||||
users_loading: false,
|
||||
onLine: navigator.onLine,
|
||||
},
|
||||
directives: {
|
||||
tabindex: {
|
||||
inserted(el) {
|
||||
el.setAttribute('tabindex', 0);
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
servings_cache() {
|
||||
let cache = {}
|
||||
this.shopping_list.recipes.forEach((r) => {
|
||||
cache[r.id] = r.servings;
|
||||
})
|
||||
return cache
|
||||
},
|
||||
display_entries() {
|
||||
let entries = []
|
||||
|
||||
//TODO merge multiple ingredients of same unit
|
||||
|
||||
this.shopping_list.entries.forEach(element => {
|
||||
let item = {}
|
||||
Object.assign(item, element);
|
||||
if (item.list_recipe !== null) {
|
||||
item.amount = item.amount * this.servings_cache[item.list_recipe]
|
||||
}
|
||||
item.unit = ((element.unit !== undefined && element.unit !== null) ? element.unit : {'name': ''})
|
||||
entries.push(item)
|
||||
});
|
||||
|
||||
return entries
|
||||
},
|
||||
export_text() {
|
||||
let text = ''
|
||||
for (let e of this.display_entries.filter(item => item.checked === false)) {
|
||||
text += `${this.export_text_prefix}${e.amount} ${e.unit.name} ${e.food.name} \n`
|
||||
}
|
||||
return text
|
||||
}
|
||||
},
|
||||
/*
|
||||
watch: {
|
||||
recipe: {
|
||||
deep: true,
|
||||
handler() {
|
||||
this.recipe_changed = this.recipe_changed !== undefined;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
window.addEventListener('beforeunload', this.warnPageLeave)
|
||||
},
|
||||
*/
|
||||
mounted: function () {
|
||||
this.loadShoppingList()
|
||||
|
||||
{% if recipes %}
|
||||
this.loading = true
|
||||
this.edit_mode = true
|
||||
let loadingRecipes = []
|
||||
{% for r in recipes %}
|
||||
loadingRecipes.push(this.loadInitialRecipe({{ r.recipe }}, {{ r.servings }}))
|
||||
{% endfor %}
|
||||
|
||||
Promise.allSettled(loadingRecipes).then(() => {
|
||||
this.loading = false
|
||||
})
|
||||
{% endif %}
|
||||
|
||||
{% if request.user.userpreference.shopping_auto_sync > 0 %}
|
||||
setInterval(() => {
|
||||
if ((this.shopping_list_id !== null) && !this.edit_mode && window.navigator.onLine) {
|
||||
this.loadShoppingList(true)
|
||||
}
|
||||
}, {% widthratio request.user.userpreference.shopping_auto_sync 1 1000 %})
|
||||
|
||||
window.addEventListener('online', this.updateOnlineStatus);
|
||||
window.addEventListener('offline', this.updateOnlineStatus);
|
||||
{% endif %}
|
||||
|
||||
this.searchUsers('')
|
||||
},
|
||||
methods: {
|
||||
updateOnlineStatus(e) {
|
||||
const {
|
||||
type
|
||||
} = e;
|
||||
this.onLine = type === 'online';
|
||||
},
|
||||
/*
|
||||
warnPageLeave: function (event) {
|
||||
if (this.recipe_changed) {
|
||||
event.returnValue = ''
|
||||
return ''
|
||||
}
|
||||
},
|
||||
*/
|
||||
makeToast: function (title, message, variant = null) {
|
||||
//TODO remove duplicate function in favor of central one
|
||||
this.$bvToast.toast(message, {
|
||||
title: title,
|
||||
variant: variant,
|
||||
toaster: 'b-toaster-top-center',
|
||||
solid: true
|
||||
})
|
||||
},
|
||||
loadInitialRecipe: function (recipe, servings) {
|
||||
return this.$http.get('{% url 'api:recipe-detail' 123456 %}'.replace('123456', recipe)).then((response) => {
|
||||
this.addRecipeToList(response.data, servings)
|
||||
}).catch((err) => {
|
||||
console.log("getRecipes error: ", err);
|
||||
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
|
||||
})
|
||||
},
|
||||
loadShoppingList: function (autosync = false) {
|
||||
|
||||
if (this.shopping_list_id) {
|
||||
this.$http.get("{% url 'api:shoppinglist-detail' 123456 %}".replace('123456', this.shopping_list_id) + ((autosync) ? '?autosync=true' : '')).then((response) => {
|
||||
if (!autosync) {
|
||||
this.shopping_list = response.body
|
||||
this.loading = false
|
||||
} else {
|
||||
let check_map = {}
|
||||
for (let e of response.body.entries) {
|
||||
check_map[e.id] = {checked: e.checked}
|
||||
}
|
||||
|
||||
for (let se of this.shopping_list.entries) {
|
||||
if (check_map[se.id] !== undefined) {
|
||||
se.checked = check_map[se.id].checked
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.shopping_list.entries.length === 0) {
|
||||
this.edit_mode = true
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
|
||||
})
|
||||
} else {
|
||||
this.shopping_list = {
|
||||
"recipes": [],
|
||||
"entries": [],
|
||||
"entries_display": [],
|
||||
"shared": [{% for u in request.user.userpreference.plan_share.all %}
|
||||
{'id': {{ u.pk }}, 'username': '{{ u.get_user_name }}'},
|
||||
{% endfor %}],
|
||||
"created_by": 1
|
||||
}
|
||||
this.loading = false
|
||||
|
||||
if (this.shopping_list.entries.length === 0) {
|
||||
this.edit_mode = true
|
||||
}
|
||||
}
|
||||
},
|
||||
updateShoppingList: function () {
|
||||
this.loading = true
|
||||
let recipe_promises = []
|
||||
|
||||
for (let i in this.shopping_list.recipes) {
|
||||
if (this.shopping_list.recipes[i].created) {
|
||||
console.log('updating recipe', this.shopping_list.recipes[i])
|
||||
recipe_promises.push(this.$http.post("{% url 'api:shoppinglistrecipe-list' %}", this.shopping_list.recipes[i], {}).then((response) => {
|
||||
let old_id = this.shopping_list.recipes[i].id
|
||||
console.log("list recipe create respose ", response.body)
|
||||
this.$set(this.shopping_list.recipes, i, response.body)
|
||||
for (let e of this.shopping_list.entries.filter(item => item.list_recipe === old_id)) {
|
||||
console.log("found recipe updating ID")
|
||||
e.list_recipe = this.shopping_list.recipes[i].id
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
this.makeToast(gettext('Error'), gettext('There was an error updating a resource!') + err.bodyText, 'danger')
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
Promise.allSettled(recipe_promises).then(() => {
|
||||
console.log("proceeding to update shopping list", this.shopping_list)
|
||||
|
||||
if (this.shopping_list_id === null) {
|
||||
return this.$http.post("{% url 'api:shoppinglist-list' %}", this.shopping_list, {}).then((response) => {
|
||||
console.log(response)
|
||||
this.makeToast(gettext('Updated'), gettext('Object created successfully!'), 'success')
|
||||
this.loading = false
|
||||
|
||||
this.shopping_list = response.body
|
||||
this.shopping_list_id = this.shopping_list.id
|
||||
|
||||
window.history.pushState('shopping_list', '{% trans 'Shopping List' %}', "{% url 'view_shopping' 123456 %}".replace('123456', this.shopping_list_id));
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
this.makeToast(gettext('Error'), '{% trans 'There was an error creating a resource!' %}' + err.bodyText, 'danger')
|
||||
this.loading = false
|
||||
})
|
||||
} else {
|
||||
return this.$http.put("{% url 'api:shoppinglist-detail' shopping_list_id %}", this.shopping_list, {}).then((response) => {
|
||||
console.log(response)
|
||||
this.shopping_list = response.body
|
||||
this.makeToast(gettext('Updated'), gettext('Changes saved successfully!'), 'success')
|
||||
this.loading = false
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
this.makeToast(gettext('Error'), gettext('There was an error updating a resource!') + err.bodyText, 'danger')
|
||||
this.loading = false
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
})
|
||||
},
|
||||
sortEntries: function () {
|
||||
this.display_entries.forEach((item, index) => {
|
||||
|
||||
})
|
||||
console.log("IMPLEMENT ME", this.display_entries)
|
||||
},
|
||||
entryChecked: function (entry) {
|
||||
console.log("checked entry: ", entry)
|
||||
this.shopping_list.entries.forEach((item) => {
|
||||
if (item.id === entry.id) { //TODO unwrap once same entries are merged
|
||||
item.checked = entry.checked
|
||||
this.$http.put("{% url 'api:shoppinglistentry-detail' 123456 %}".replace('123456', item.id), item, {}).then((response) => {
|
||||
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
this.makeToast(gettext('Error'), gettext('There was an error updating a resource!') + err.bodyText, 'danger')
|
||||
this.loading = false
|
||||
})
|
||||
|
||||
}
|
||||
})
|
||||
},
|
||||
addEntry: function () {
|
||||
if (this.new_entry.food !== undefined) {
|
||||
this.shopping_list.entries.push({
|
||||
'list_recipe': null,
|
||||
'food': this.new_entry.food,
|
||||
'unit': this.new_entry.unit,
|
||||
'amount': parseFloat(this.new_entry.amount),
|
||||
'order': 0,
|
||||
'checked': false
|
||||
})
|
||||
|
||||
this.new_entry = {
|
||||
unit: undefined,
|
||||
amount: undefined,
|
||||
food: undefined,
|
||||
}
|
||||
|
||||
this.$refs.new_entry_amount.focus();
|
||||
} else {
|
||||
this.makeToast(gettext('Error'), gettext('Please enter a valid food'), 'danger')
|
||||
}
|
||||
},
|
||||
getRecipes: function () {
|
||||
let url = "{% url 'api:recipe-list' %}?limit=5&internal=true"
|
||||
if (this.recipe_query !== '') {
|
||||
url += '&query=' + this.recipe_query;
|
||||
} else {
|
||||
this.recipes = []
|
||||
return
|
||||
}
|
||||
|
||||
this.$http.get(url).then((response) => {
|
||||
this.recipes = response.data;
|
||||
}).catch((err) => {
|
||||
console.log("getRecipes error: ", err);
|
||||
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
|
||||
})
|
||||
},
|
||||
getRecipeUrl: function (id) { //TODO generic function that can be reused else were
|
||||
return '{% url 'view_recipe' 123456 %}'.replace('123456', id)
|
||||
},
|
||||
addRecipeToList: function (recipe, servings = 1) {
|
||||
let slr = {
|
||||
"created": true,
|
||||
"id": Math.random() * 1000,
|
||||
"recipe": recipe.id,
|
||||
"recipe_name": recipe.name,
|
||||
"servings": servings,
|
||||
}
|
||||
|
||||
this.shopping_list.recipes.push(slr)
|
||||
|
||||
for (let s of recipe.steps) {
|
||||
for (let i of s.ingredients) {
|
||||
if (!i.is_header && i.food !== null) {
|
||||
this.shopping_list.entries.push({
|
||||
'list_recipe': slr.id,
|
||||
'food': i.food,
|
||||
'unit': i.unit,
|
||||
'amount': i.amount,
|
||||
'order': 0
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
removeRecipeFromList: function (slr) {
|
||||
this.shopping_list.entries = this.shopping_list.entries.filter(item => item.list_recipe !== slr.id)
|
||||
this.shopping_list.recipes = this.shopping_list.recipes.filter(item => item !== slr)
|
||||
},
|
||||
searchKeywords: function (query) {
|
||||
this.keywords_loading = true
|
||||
this.$http.get("{% url 'api:keyword-list' %}" + '?query=' + query + '&limit=10').then((response) => {
|
||||
this.keywords = response.data;
|
||||
this.keywords_loading = false
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
|
||||
})
|
||||
},
|
||||
|
||||
searchUnits: function (query) { //TODO move to central component
|
||||
this.units_loading = true
|
||||
this.$http.get("{% url 'api:unit-list' %}" + '?query=' + query + '&limit=10').then((response) => {
|
||||
this.units = response.data;
|
||||
this.units_loading = false
|
||||
}).catch((err) => {
|
||||
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
|
||||
})
|
||||
},
|
||||
searchFoods: function (query) { //TODO move to central component
|
||||
this.foods_loading = true
|
||||
this.$http.get("{% url 'api:food-list' %}" + '?query=' + query + '&limit=10').then((response) => {
|
||||
this.foods = response.data
|
||||
this.foods_loading = false
|
||||
}).catch((err) => {
|
||||
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
|
||||
})
|
||||
},
|
||||
addFoodType: function (tag, index) { //TODO move to central component
|
||||
let new_food = {'name': tag}
|
||||
this.foods.push(new_food)
|
||||
this.new_entry.food = new_food
|
||||
},
|
||||
addUnitType: function (tag, index) { //TODO move to central component
|
||||
let new_unit = {'name': tag}
|
||||
this.units.push(new_unit)
|
||||
this.new_entry.unit = new_unit
|
||||
},
|
||||
searchUsers: function (query) { //TODO move to central component
|
||||
this.users_loading = true
|
||||
this.$http.get("{% url 'api:username-list' %}" + '?query=' + query + '&limit=10').then((response) => {
|
||||
this.users = response.data
|
||||
this.users_loading = false
|
||||
}).catch((err) => {
|
||||
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
|
||||
})
|
||||
},
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('online', this.updateOnlineStatus);
|
||||
window.removeEventListener('offline', this.updateOnlineStatus);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -10,7 +10,43 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1>{% trans 'System Information' %}</h1>
|
||||
<h1>{% trans 'System' %}</h1>
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h3>{% trans 'Invite Links' %}</h3>
|
||||
<a href="{% url 'list_invite_link' %}" class="btn btn-success">{% trans 'Show Links' %}</a>
|
||||
|
||||
</div>
|
||||
<!--
|
||||
<div class="col-md-6">
|
||||
<h3>{% trans 'Backup & Restore' %}</h3>
|
||||
<a href="{% url 'api_backup' %}" class="btn btn-success">{% trans 'Download Backup' %}</a>
|
||||
|
||||
<br/> <br/>
|
||||
<div class="alert alert-danger" role="alert">
|
||||
⚠️ Backups simply create a so called fixture. Fixtures are json files containing all your data (WITHOUT
|
||||
MEDIA FILES) <br>
|
||||
They can be imported into django by running <code style="color: white">manage.py loaddata [fixture-name]</code> <br>
|
||||
It is planned to provide a better way of backing up and restoring data but it is not yet implemented.<br><br>
|
||||
⚠️<b>Please make sure to setup a solid backup strategy on your server to save the Database and the <code style="color: white">mediafiles</code>
|
||||
directory</b>⚠️
|
||||
</div>
|
||||
|
||||
</div>
|
||||
-->
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
<h3>{% trans 'System Information' %}</h3>
|
||||
|
||||
{% blocktrans %}
|
||||
Django Recipes is an open source free software application. It can be found on
|
||||
@@ -45,7 +81,8 @@
|
||||
{% trans 'Warning' %}{% else %}{% trans 'Ok' %}{% endif %}</span></h4>
|
||||
{% if secret_key %}
|
||||
{% blocktrans %}
|
||||
You do not have a <code>SECRET_KEY</code> configured in your <code>.env</code> file. Django defaulted to the standard key
|
||||
You do not have a <code>SECRET_KEY</code> configured in your <code>.env</code> file. Django defaulted to the
|
||||
standard key
|
||||
provided with the installation which is publicly know and insecure! Please set
|
||||
<code>SECRET_KEY</code> int the <code>.env</code> configuration file.
|
||||
{% endblocktrans %}
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
<div class="col-md-1">
|
||||
<input class="form-control" v-model="i.amount">
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<div class="col-md-4">
|
||||
|
||||
<table class="table-layout:fixed">
|
||||
<col width="95%"/>
|
||||
@@ -119,7 +119,7 @@
|
||||
|
||||
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<div class="col-md-4">
|
||||
|
||||
<multiselect v-tabindex
|
||||
ref="ingredient"
|
||||
@@ -143,6 +143,10 @@
|
||||
|
||||
</multiselect>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="text" placeholder="{% trans 'Note' %}" class="form-control"
|
||||
v-model="i.note">
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<button class="btn btn-outline-danger btn-lg" type="button"
|
||||
@click="deleteIngredient(i)" tabindex="-1"><i
|
||||
@@ -191,13 +195,15 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_all_keywords">{% trans 'All Keywords' %}</label><br/>
|
||||
{% trans 'All Keywords' %}<br/>
|
||||
<input id="id_all_keywords" type="checkbox"
|
||||
v-model="all_keywords"> {% trans 'Import all Keywords not only the ones already existing.' %}
|
||||
v-model="all_keywords"> <label
|
||||
for="id_all_keywords">{% trans 'Import all keywords, not only the ones already existing.' %}</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="button" class="btn btn-success" @click="importRecipe()" :disabled="importing_recipe">{% trans 'Import' %}</button>
|
||||
<button type="button" class="btn btn-success" @click="importRecipe()"
|
||||
:disabled="importing_recipe">{% trans 'Import' %}</button>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
@@ -243,6 +249,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="{% url 'javascript-catalog' %}"></script>
|
||||
<script type="application/javascript">
|
||||
let csrftoken = Cookies.get('csrftoken');
|
||||
Vue.http.headers.common['X-CSRFToken'] = csrftoken;
|
||||
@@ -280,6 +287,7 @@
|
||||
this.searchKeywords('')
|
||||
this.searchUnits('')
|
||||
this.searchIngredients('')
|
||||
|
||||
},
|
||||
methods: {
|
||||
makeToast: function (title, message, variant = null) {
|
||||
@@ -295,19 +303,19 @@
|
||||
this.recipe_data = undefined
|
||||
this.error = undefined
|
||||
this.loading = true
|
||||
this.$http.get("{% url 'api_recipe_from_url' 12345 %}".replace(/12345/, this.remote_url)).then((response) => {
|
||||
this.$http.post("{% url 'api_recipe_from_url' %}", {'url': this.remote_url}, {emulateJSON: true}).then((response) => {
|
||||
this.recipe_data = response.data;
|
||||
this.loading = false
|
||||
}).catch((err) => {
|
||||
this.error = err.data
|
||||
this.loading = false
|
||||
console.log(err)
|
||||
this.makeToast('{% trans 'Error' %}', '{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
|
||||
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
|
||||
})
|
||||
},
|
||||
importRecipe: function () {
|
||||
if (this.importing_recipe) {
|
||||
this.makeToast('{% trans 'Error' %}', '{% trans 'Already importing the selected recipe, please wait!' %}', 'danger')
|
||||
this.makeToast(gettext('Error'), gettext('Already importing the selected recipe, please wait!'), 'danger')
|
||||
return;
|
||||
}
|
||||
this.importing_recipe = true
|
||||
@@ -316,7 +324,7 @@
|
||||
window.location.href = response.data
|
||||
}).catch((err) => {
|
||||
console.log(err);
|
||||
this.makeToast('{% trans 'Error' %}', '{% trans 'An error occurred while trying to import this recipe!' %}' + err.bodyText, 'danger')
|
||||
this.makeToast(gettext('Error'), gettext('An error occurred while trying to import this recipe!') + err.bodyText, 'danger')
|
||||
})
|
||||
},
|
||||
deleteIngredient: function (i) {
|
||||
@@ -345,7 +353,9 @@
|
||||
},
|
||||
openUnitSelect: function (id) {
|
||||
let index = id.replace('unit_', '')
|
||||
this.$set(app.$refs.unit[index].$data, 'search', this.recipe_data.recipeIngredient[index].unit.text)
|
||||
if (this.recipe_data.recipeIngredient[index].unit !== null) {
|
||||
this.$set(app.$refs.unit[index].$data, 'search', this.recipe_data.recipeIngredient[index].unit.text)
|
||||
}
|
||||
},
|
||||
openIngredientSelect: function (id) {
|
||||
let index = id.replace('ingredient_', '')
|
||||
@@ -358,7 +368,7 @@
|
||||
this.keywords_loading = false
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
this.makeToast('{% trans 'Error' %}', '{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
|
||||
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
|
||||
})
|
||||
},
|
||||
searchUnits: function (query) {
|
||||
@@ -367,7 +377,7 @@
|
||||
this.units = response.data.results;
|
||||
if (this.recipe_data !== undefined) {
|
||||
for (let x of Array.from(this.recipe_data.recipeIngredient)) {
|
||||
if (x.unit.text !== '') {
|
||||
if (x.unit !== null && x.unit.text !== '') {
|
||||
this.units = this.units.filter(item => item.text !== x.unit.text)
|
||||
this.units.push(x.unit)
|
||||
}
|
||||
@@ -376,7 +386,7 @@
|
||||
this.units_loading = false
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
this.makeToast('{% trans 'Error' %}', '{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
|
||||
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
|
||||
})
|
||||
},
|
||||
searchIngredients: function (query) {
|
||||
@@ -395,7 +405,7 @@
|
||||
this.ingredients_loading = false
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
this.makeToast('{% trans 'Error' %}', '{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
|
||||
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.urls import reverse, NoReverseMatch
|
||||
|
||||
from cookbook.helper.mdx_attributes import MarkdownFormatExtension
|
||||
from cookbook.helper.mdx_urlize import UrlizeExtension
|
||||
from cookbook.models import get_model_name
|
||||
from cookbook.models import get_model_name, Space
|
||||
from recipes import settings
|
||||
|
||||
register = template.Library()
|
||||
@@ -46,13 +46,15 @@ def recipe_rating(recipe, user):
|
||||
rating = recipe.cooklog_set.filter(created_by=user).aggregate(Avg('rating'))
|
||||
if rating['rating__avg']:
|
||||
|
||||
rating_stars = ''
|
||||
rating_stars = '<span style="display: inline-block;">'
|
||||
for i in range(int(rating['rating__avg'])):
|
||||
rating_stars = rating_stars + '<i class="fas fa-star fa-xs"></i>'
|
||||
|
||||
if rating['rating__avg'] % 1 >= 0.5:
|
||||
rating_stars = rating_stars + '<i class="fas fa-star-half-alt fa-xs"></i>'
|
||||
|
||||
rating_stars += '</span>'
|
||||
|
||||
return rating_stars
|
||||
else:
|
||||
return ''
|
||||
@@ -69,6 +71,11 @@ def recipe_last(recipe, user):
|
||||
return ''
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def message_of_the_day():
|
||||
return Space.objects.first().message
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def is_debug():
|
||||
return settings.DEBUG
|
||||
|
||||
26
cookbook/tests/api/test_api_recipe.py
Normal file
26
cookbook/tests/api/test_api_recipe.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import json
|
||||
|
||||
from django.contrib import auth
|
||||
from django.db.models import ProtectedError
|
||||
from django.urls import reverse
|
||||
|
||||
from cookbook.models import Storage, Sync, Keyword, ShoppingList, Recipe
|
||||
from cookbook.tests.views.test_views import TestViews
|
||||
|
||||
|
||||
class TestApiShopping(TestViews):
|
||||
|
||||
def setUp(self):
|
||||
super(TestApiShopping, self).setUp()
|
||||
self.internal_recipe = Recipe.objects.create(
|
||||
name='Test',
|
||||
internal=True,
|
||||
created_by=auth.get_user(self.user_client_1)
|
||||
)
|
||||
|
||||
def test_shopping_view_permissions(self):
|
||||
self.batch_requests([(self.anonymous_client, 403), (self.guest_client_1, 200), (self.user_client_1, 200),
|
||||
(self.user_client_2, 200), (self.admin_client_1, 200), (self.superuser_client, 200)],
|
||||
reverse('api:recipe-detail', args={self.internal_recipe.id}))
|
||||
|
||||
# TODO add tests for editing
|
||||
27
cookbook/tests/api/test_api_shopping.py
Normal file
27
cookbook/tests/api/test_api_shopping.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import json
|
||||
|
||||
from django.contrib import auth
|
||||
from django.db.models import ProtectedError
|
||||
from django.urls import reverse
|
||||
|
||||
from cookbook.models import Storage, Sync, Keyword, ShoppingList
|
||||
from cookbook.tests.views.test_views import TestViews
|
||||
|
||||
|
||||
class TestApiShopping(TestViews):
|
||||
|
||||
def setUp(self):
|
||||
super(TestApiShopping, self).setUp()
|
||||
self.list_1 = ShoppingList.objects.create(created_by=auth.get_user(self.user_client_1))
|
||||
self.list_2 = ShoppingList.objects.create(created_by=auth.get_user(self.user_client_2))
|
||||
|
||||
def test_shopping_view_permissions(self):
|
||||
self.batch_requests([(self.anonymous_client, 403), (self.guest_client_1, 404), (self.user_client_1, 200), (self.user_client_2, 404), (self.admin_client_1, 404), (self.superuser_client, 200)],
|
||||
reverse('api:shoppinglist-detail', args={self.list_1.id}))
|
||||
|
||||
self.list_1.shared.add(auth.get_user(self.user_client_2))
|
||||
|
||||
self.batch_requests([(self.anonymous_client, 403), (self.guest_client_1, 404), (self.user_client_1, 200), (self.user_client_2, 200), (self.admin_client_1, 404), (self.superuser_client, 200)],
|
||||
reverse('api:shoppinglist-detail', args={self.list_1.id}))
|
||||
|
||||
# TODO add tests for editing
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
|
||||
from cookbook.helper.ingredient_parser import parse_ingredient
|
||||
from cookbook.helper.recipe_url_import import get_from_html
|
||||
from cookbook.tests.test_setup import TestBase
|
||||
|
||||
@@ -8,21 +9,79 @@ class TestEditsRecipe(TestBase):
|
||||
|
||||
def test_ld_json(self):
|
||||
test_list = [
|
||||
{'file': 'cookbook/tests/resources/websites/ld_json_1.html', 'result_length': 3128},
|
||||
{'file': 'cookbook/tests/resources/websites/ld_json_2.html', 'result_length': 1450},
|
||||
{'file': 'cookbook/tests/resources/websites/ld_json_3.html', 'result_length': 1545},
|
||||
{'file': 'cookbook/tests/resources/websites/ld_json_4.html', 'result_length': 1657},
|
||||
{'file': 'cookbook/tests/resources/websites/ld_json_invalid.html', 'result_length': 115},
|
||||
{'file': 'cookbook/tests/resources/websites/ld_json_itemList.html', 'result_length': 3131},
|
||||
{'file': 'cookbook/tests/resources/websites/ld_json_multiple.html', 'result_length': 1546},
|
||||
{'file': 'cookbook/tests/resources/websites/micro_data_1.html', 'result_length': 1022},
|
||||
{'file': 'cookbook/tests/resources/websites/micro_data_2.html', 'result_length': 1384},
|
||||
{'file': 'cookbook/tests/resources/websites/micro_data_3.html', 'result_length': 1100},
|
||||
{'file': 'cookbook/tests/resources/websites/micro_data_4.html', 'result_length': 4231},
|
||||
{'file': 'cookbook/tests/resources/websites/ld_json_1.html', 'result_length': 3218},
|
||||
{'file': 'cookbook/tests/resources/websites/ld_json_2.html', 'result_length': 1510},
|
||||
{'file': 'cookbook/tests/resources/websites/ld_json_3.html', 'result_length': 1629},
|
||||
{'file': 'cookbook/tests/resources/websites/ld_json_4.html', 'result_length': 1729},
|
||||
{'file': 'cookbook/tests/resources/websites/ld_json_itemList.html', 'result_length': 3200},
|
||||
{'file': 'cookbook/tests/resources/websites/ld_json_multiple.html', 'result_length': 1606},
|
||||
{'file': 'cookbook/tests/resources/websites/micro_data_1.html', 'result_length': 1079},
|
||||
{'file': 'cookbook/tests/resources/websites/micro_data_2.html', 'result_length': 1429},
|
||||
{'file': 'cookbook/tests/resources/websites/micro_data_3.html', 'result_length': 1148},
|
||||
{'file': 'cookbook/tests/resources/websites/micro_data_4.html', 'result_length': 4396},
|
||||
]
|
||||
|
||||
for test in test_list:
|
||||
with open(test['file'], 'rb') as file:
|
||||
print(f'Testing {test["file"]} expecting length {test["result_length"]}')
|
||||
parsed_content = json.loads(get_from_html(file.read(), 'test_url').content)
|
||||
self.assertEqual(len(str(parsed_content)), test['result_length'])
|
||||
file.close()
|
||||
|
||||
def test_ingredient_parser(self):
|
||||
expectations = {
|
||||
"2¼ l Wasser": (2.25, "l", "Wasser", ""),
|
||||
"2¼l Wasser": (2.25, "l", "Wasser", ""),
|
||||
"3l Wasser": (3, "l", "Wasser", ""),
|
||||
"4 l Wasser": (4, "l", "Wasser", ""),
|
||||
"½l Wasser": (0.5, "l", "Wasser", ""),
|
||||
"⅛ Liter Sauerrahm": (0.125, "Liter", "Sauerrahm", ""),
|
||||
"5 Zwiebeln": (5, "", "Zwiebeln", ""),
|
||||
"3 Zwiebeln, gehackt": (3, "", "Zwiebeln", "gehackt"),
|
||||
"5 Zwiebeln (gehackt)": (5, "", "Zwiebeln", "gehackt"),
|
||||
"1 Zwiebel(n)": (1, "", "Zwiebel(n)", ""),
|
||||
"4 1/2 Zwiebeln": (4.5, "", "Zwiebeln", ""),
|
||||
"4 ½ Zwiebeln": (4.5, "", "Zwiebeln", ""),
|
||||
"etwas Mehl": (0, "", "etwas Mehl", ""),
|
||||
"Öl zum Anbraten": (0, "", "Öl zum Anbraten", ""),
|
||||
"n. B. Knoblauch, zerdrückt": (0, "", "n. B. Knoblauch", "zerdrückt"),
|
||||
"Kräuter, mediterrane (Oregano, Rosmarin, Basilikum)": (
|
||||
0, "", "Kräuter, mediterrane", "Oregano, Rosmarin, Basilikum"),
|
||||
"600 g Kürbisfleisch (Hokkaido), geschält, entkernt und geraspelt": (
|
||||
600, "g", "Kürbisfleisch (Hokkaido)", "geschält, entkernt und geraspelt"),
|
||||
"Muskat": (0, "", "Muskat", ""),
|
||||
"200 g Mehl, glattes": (200, "g", "Mehl", "glattes"),
|
||||
"1 Ei(er)": (1, "", "Ei(er)", ""),
|
||||
"1 Prise(n) Salz": (1, "Prise(n)", "Salz", ""),
|
||||
"etwas Wasser, lauwarmes": (0, "", "etwas Wasser", "lauwarmes"),
|
||||
"Strudelblätter, fertige, für zwei Strudel": (0, "", "Strudelblätter", "fertige, für zwei Strudel"),
|
||||
"barrel-aged Bourbon": (0, "", "barrel-aged Bourbon", ""),
|
||||
"golden syrup": (0, "", "golden syrup", ""),
|
||||
"unsalted butter, for greasing": (0, "", "unsalted butter", "for greasing"),
|
||||
"unsalted butter , for greasing": (0, "", "unsalted butter", "for greasing"), # trim
|
||||
"1 small sprig of fresh rosemary": (1, "small", "sprig of fresh rosemary", ""),
|
||||
# does not always work perfectly!
|
||||
"75 g fresh breadcrumbs": (75, "g", "fresh breadcrumbs", ""),
|
||||
"4 acorn squash , or onion squash (600-800g)": (4, "acorn", "squash , or onion squash", "600-800g"),
|
||||
"1 x 250 g packet of cooked mixed grains , such as spelt and wild rice": (
|
||||
1, "x", "250 g packet of cooked mixed grains", "such as spelt and wild rice"),
|
||||
"1 big bunch of fresh mint , (60g)": (1, "big", "bunch of fresh mint ,", "60g"),
|
||||
"1 large red onion": (1, "large", "red onion", ""),
|
||||
# "2-3 TL Curry": (), # idk what it should use here either
|
||||
"1 Zwiebel gehackt": (1, "Zwiebel", "gehackt", ""),
|
||||
"1 EL Kokosöl": (1, "EL", "Kokosöl", ""),
|
||||
"0.5 paket jäst (à 50 g)": (0.5, "paket", "jäst", "à 50 g"),
|
||||
"ägg": (0, "", "ägg", ""),
|
||||
"50 g smör eller margarin": (50, "g", "smör eller margarin", ""),
|
||||
"3,5 l Wasser": (3.5, "l", "Wasser", ""),
|
||||
"3.5 l Wasser": (3.5, "l", "Wasser", "")
|
||||
}
|
||||
# for German you could say that if an ingredient does not have an amount and it starts with a lowercase letter, then that is a unit ("etwas", "evtl.")
|
||||
# does not apply to English tho
|
||||
|
||||
errors = 0
|
||||
count = 0
|
||||
for key, val in expectations.items():
|
||||
count += 1
|
||||
parsed = parse_ingredient(key)
|
||||
self.assertNotEqual(val, parsed)
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<script type="application/ld+json"> {
|
||||
"@context": "https://schema.org/",
|
||||
"@type": "Recipe",
|
||||
"name": "Selbstgemachter Schokopudding",
|
||||
"author": "AP",
|
||||
"image": "https://www.lidl-kochen.de/images/recipe/64590/selbstgemachter-schokopudding-144479.jpg",
|
||||
"description": "Rezept für Selbstgemachter Schokopudding » Über 245x nachgekocht » 20min Zubereitung » 7 Zutaten » 473 kcal/Portion
|
||||
",
|
||||
"keywords": "Schokolade, Milch, Schlagsahne, Kuvertüre, schnell, einfach, günstig, lecker, leicht, Snack, glutenfrei, vegetarisch, unter 500 kcal, Sommer, Winter, Herbst, Frühling, Deutschland, Kinder, Familie, für Singles, für Studenten, kochen, ohne Backofen, für Paare, fürs Büro, für die Arbeit, beliebte Rezepte, Dessert", "recipeCuisine": "Deutschland", "cookTime": "PT20M",
|
||||
"nutrition": {
|
||||
"@type": "NutritionInformation",
|
||||
"calories": "473 Kalorie"
|
||||
},
|
||||
"recipeInstructions": [{"@type":"HowToStep","text":"Schokolade fein hacken. In einem Topf 400 ml Milch, 150 g Sahne, Zucker, Salz und Schokolade langsam bei kleiner bis mittlerer Stufe unter Rühren erhitzen, bis die Schokolade geschmolzen ist. Anschließend aufkochen, dabei weiter rühren. \r\n\r\n"},{"@type":"HowToStep","text":"Übrige kalte Milch und Stärke in einer Schüssel mit einem Schneebesen gründlich verrühren. Kochende Milch vom Herd nehmen, angerührte Stärke einrühren und Topf zurück auf den Herd setzen. Unter Rühren ca. 1 Min. aufkochen, bis der Pudding dickflüssig ist. Vom Herd nehmen, in eine mit kaltem Wasser ausgespülte Schüssel füllen und erkalten lassen. \r\n\r\n"},{"@type":"HowToStep","text":"In einem hohen Gefäß übrige Sahne mit einem Handrührgerät mit Schneebesen steif schlagen. Pudding mit Sahne und Schokostreuseln garnieren und servieren.\r\n\r\nGuten Appetit!"}],
|
||||
"recipeIngredient": ["Kuvertüre, zartbitter 150 g","Milch 450 ml","Schlagsahne 200 g","Zucker 75 g","Salz Prise","Speisestärke 30 g","Schokoladenstreusel, Zartbitter 2 EL"],
|
||||
"recipeCategory": "Snack, Dessert, Andere" }</script>
|
||||
@@ -18,24 +18,27 @@ router.register(r'keyword', api.KeywordViewSet)
|
||||
router.register(r'unit', api.UnitViewSet)
|
||||
router.register(r'food', api.FoodViewSet)
|
||||
|
||||
|
||||
router.register(r'step', api.StepViewSet)
|
||||
router.register(r'recipe', api.RecipeViewSet)
|
||||
router.register(r'ingredient', api.IngredientViewSet)
|
||||
router.register(r'meal-plan', api.MealPlanViewSet)
|
||||
router.register(r'meal-type', api.MealTypeViewSet)
|
||||
router.register(r'shopping-list', api.ShoppingListViewSet)
|
||||
router.register(r'shopping-list-entry', api.ShoppingListEntryViewSet)
|
||||
router.register(r'shopping-list-recipe', api.ShoppingListRecipeViewSet)
|
||||
router.register(r'view-log', api.ViewLogViewSet)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path('', views.index, name='index'),
|
||||
path('setup/', views.setup, name='view_setup'),
|
||||
path('signup/<slug:token>', views.signup, name='view_signup'),
|
||||
path('system/', views.system, name='view_system'),
|
||||
path('search/', views.search, name='view_search'),
|
||||
path('books/', views.books, name='view_books'),
|
||||
path('plan/', views.meal_plan, name='view_plan'),
|
||||
path('plan/entry/<int:pk>', views.meal_plan_entry, name='view_plan_entry'),
|
||||
path('shopping/', views.shopping_list, name='view_shopping'),
|
||||
path('shopping/<int:pk>', views.shopping_list, name='view_shopping'),
|
||||
path('settings/', views.user_settings, name='view_settings'),
|
||||
path('history/', views.history, name='view_history'),
|
||||
path('test/', views.test, name='view_test'),
|
||||
@@ -70,8 +73,9 @@ urlpatterns = [
|
||||
path('api/get_recipe_file/<int:recipe_id>/', api.get_recipe_file, name='api_get_recipe_file'),
|
||||
path('api/sync_all/', api.sync_all, name='api_sync'),
|
||||
path('api/log_cooking/<int:recipe_id>/', api.log_cooking, name='api_log_cooking'),
|
||||
path('api/plan-ical/<slug:html_week>/', api.get_plan_ical, name='api_get_plan_ical'),
|
||||
path('api/recipe-from-url/<path:url>/', api.recipe_from_url, name='api_recipe_from_url'),
|
||||
path('api/plan-ical/<slug:from_date>/<slug:to_date>/', api.get_plan_ical, name='api_get_plan_ical'),
|
||||
path('api/recipe-from-url/', api.recipe_from_url, name='api_recipe_from_url'),
|
||||
path('api/backup/', api.get_backup, name='api_backup'),
|
||||
|
||||
path('dal/keyword/', dal.KeywordAutocomplete.as_view(), name='dal_keyword'),
|
||||
path('dal/food/', dal.IngredientsAutocomplete.as_view(), name='dal_food'),
|
||||
@@ -90,7 +94,7 @@ urlpatterns = [
|
||||
|
||||
]
|
||||
|
||||
generic_models = (Recipe, RecipeImport, Storage, RecipeBook, MealPlan, SyncLog, Sync, Comment, RecipeBookEntry, Keyword, Food)
|
||||
generic_models = (Recipe, RecipeImport, Storage, RecipeBook, MealPlan, SyncLog, Sync, Comment, RecipeBookEntry, Keyword, Food, ShoppingList, InviteLink)
|
||||
|
||||
for m in generic_models:
|
||||
py_name = get_model_name(m)
|
||||
|
||||
@@ -9,10 +9,13 @@ from annoying.decorators import ajax_request
|
||||
from annoying.functions import get_object_or_None
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.models import User
|
||||
from django.core import management
|
||||
from django.core.files import File
|
||||
from django.db.models import Q
|
||||
from django.http import HttpResponse, FileResponse, JsonResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.utils import timezone, dateformat
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic.base import View
|
||||
from icalendar import Calendar, Event
|
||||
@@ -23,13 +26,14 @@ from rest_framework.parsers import JSONParser, FileUploadParser, MultiPartParser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ViewSetMixin
|
||||
|
||||
from cookbook.helper.permission_helper import group_required, CustomIsOwner, CustomIsAdmin, CustomIsUser, CustomIsGuest, CustomIsShare
|
||||
from cookbook.helper.permission_helper import group_required, CustomIsOwner, CustomIsAdmin, CustomIsUser, CustomIsGuest, CustomIsShare, CustomIsShared
|
||||
from cookbook.helper.recipe_url_import import get_from_html
|
||||
from cookbook.models import Recipe, Sync, Storage, CookLog, MealPlan, MealType, ViewLog, UserPreference, RecipeBook, Ingredient, Food, Step, Keyword, Unit, SyncLog
|
||||
from cookbook.models import Recipe, Sync, Storage, CookLog, MealPlan, MealType, ViewLog, UserPreference, RecipeBook, Ingredient, Food, Step, Keyword, Unit, SyncLog, ShoppingListRecipe, ShoppingList, ShoppingListEntry
|
||||
from cookbook.provider.dropbox import Dropbox
|
||||
from cookbook.provider.nextcloud import Nextcloud
|
||||
from cookbook.serializer import MealPlanSerializer, MealTypeSerializer, RecipeSerializer, ViewLogSerializer, UserNameSerializer, UserPreferenceSerializer, RecipeBookSerializer, IngredientSerializer, FoodSerializer, StepSerializer, \
|
||||
KeywordSerializer, RecipeImageSerializer, StorageSerializer, SyncSerializer, SyncLogSerializer, UnitSerializer
|
||||
KeywordSerializer, RecipeImageSerializer, StorageSerializer, SyncSerializer, SyncLogSerializer, UnitSerializer, ShoppingListSerializer, ShoppingListRecipeSerializer, ShoppingListEntrySerializer, ShoppingListEntryCheckedSerializer, \
|
||||
ShoppingListAutoSyncSerializer
|
||||
|
||||
|
||||
class UserNameViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
@@ -100,8 +104,12 @@ class StandardFilterMixin(ViewSetMixin):
|
||||
queryset = queryset.filter(name__icontains=query)
|
||||
|
||||
limit = self.request.query_params.get('limit', None)
|
||||
random = self.request.query_params.get('random', False)
|
||||
if limit is not None:
|
||||
queryset = queryset[:int(limit)]
|
||||
if random:
|
||||
queryset = queryset.random(int(limit))
|
||||
else:
|
||||
queryset = queryset[:int(limit)]
|
||||
return queryset
|
||||
|
||||
|
||||
@@ -146,19 +154,24 @@ class MealPlanViewSet(viewsets.ModelViewSet):
|
||||
list:
|
||||
optional parameters
|
||||
|
||||
- **html_week**: filter for a calendar week (format 2020-W24 as html input type week)
|
||||
- **from_date**: filter from (inclusive) a certain date onward
|
||||
- **to_date**: filter upward to (inclusive) certain date
|
||||
|
||||
"""
|
||||
queryset = MealPlan.objects.all()
|
||||
serializer_class = MealPlanSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [permissions.IsAuthenticated] # TODO fix permissions
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = MealPlan.objects.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).distinct().all()
|
||||
week = self.request.query_params.get('html_week', None)
|
||||
if week is not None:
|
||||
y, w = week.replace('-W', ' ').split()
|
||||
queryset = queryset.filter(date__week=w, date__year=y)
|
||||
|
||||
from_date = self.request.query_params.get('from_date', None)
|
||||
if from_date is not None:
|
||||
queryset = queryset.filter(date__gte=from_date)
|
||||
|
||||
to_date = self.request.query_params.get('to_date', None)
|
||||
if to_date is not None:
|
||||
queryset = queryset.filter(date__lte=to_date)
|
||||
return queryset
|
||||
|
||||
|
||||
@@ -199,7 +212,16 @@ class RecipeViewSet(viewsets.ModelViewSet, StandardFilterMixin):
|
||||
queryset = Recipe.objects.all()
|
||||
serializer_class = RecipeSerializer
|
||||
permission_classes = [CustomIsShare | CustomIsGuest] # TODO split read and write permission for meal plan guest
|
||||
# TODO write extensive tests for permissions
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
internal = self.request.query_params.get('internal', None)
|
||||
if internal:
|
||||
self.queryset = self.queryset.filter(internal=True)
|
||||
|
||||
return super().get_queryset()
|
||||
|
||||
# TODO write extensive tests for permissions
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
@@ -229,6 +251,39 @@ class RecipeViewSet(viewsets.ModelViewSet, StandardFilterMixin):
|
||||
return Response(serializer.errors, 400)
|
||||
|
||||
|
||||
class ShoppingListRecipeViewSet(viewsets.ModelViewSet):
|
||||
queryset = ShoppingListRecipe.objects.all()
|
||||
serializer_class = ShoppingListRecipeSerializer
|
||||
permission_classes = [CustomIsUser, ] # TODO add custom validation
|
||||
|
||||
# TODO custom get qs
|
||||
|
||||
|
||||
class ShoppingListEntryViewSet(viewsets.ModelViewSet):
|
||||
queryset = ShoppingListEntry.objects.all()
|
||||
serializer_class = ShoppingListEntrySerializer
|
||||
permission_classes = [CustomIsOwner, ] # TODO add custom validation
|
||||
|
||||
# TODO custom get qs
|
||||
|
||||
|
||||
class ShoppingListViewSet(viewsets.ModelViewSet):
|
||||
queryset = ShoppingList.objects.all()
|
||||
serializer_class = ShoppingListSerializer
|
||||
permission_classes = [CustomIsOwner | CustomIsShared]
|
||||
|
||||
def get_queryset(self):
|
||||
if self.request.user.is_superuser:
|
||||
return self.queryset
|
||||
return self.queryset.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).all()
|
||||
|
||||
def get_serializer_class(self):
|
||||
autosync = self.request.query_params.get('autosync', None)
|
||||
if autosync:
|
||||
return ShoppingListAutoSyncSerializer
|
||||
return self.serializer_class
|
||||
|
||||
|
||||
class ViewLogViewSet(viewsets.ModelViewSet):
|
||||
queryset = ViewLog.objects.all()
|
||||
serializer_class = ViewLogSerializer
|
||||
@@ -318,11 +373,14 @@ def log_cooking(request, recipe_id):
|
||||
|
||||
|
||||
@group_required('user')
|
||||
def get_plan_ical(request, html_week):
|
||||
def get_plan_ical(request, from_date, to_date):
|
||||
queryset = MealPlan.objects.filter(Q(created_by=request.user) | Q(shared=request.user)).distinct().all()
|
||||
|
||||
y, w = html_week.replace('-W', ' ').split()
|
||||
queryset = queryset.filter(date__week=w, date__year=y)
|
||||
if from_date is not None:
|
||||
queryset = queryset.filter(date__gte=from_date)
|
||||
|
||||
if to_date is not None:
|
||||
queryset = queryset.filter(date__lte=to_date)
|
||||
|
||||
cal = Calendar()
|
||||
|
||||
@@ -336,13 +394,15 @@ def get_plan_ical(request, html_week):
|
||||
cal.add_component(event)
|
||||
|
||||
response = FileResponse(io.BytesIO(cal.to_ical()))
|
||||
response["Content-Disposition"] = f'attachment; filename=meal_plan_{html_week}.ics'
|
||||
response["Content-Disposition"] = f'attachment; filename=meal_plan_{from_date}-{to_date}.ics'
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@group_required('user')
|
||||
def recipe_from_url(request, url):
|
||||
def recipe_from_url(request):
|
||||
url = request.POST['url']
|
||||
|
||||
headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.106 Safari/537.36'}
|
||||
try:
|
||||
response = requests.get(url, headers=headers)
|
||||
@@ -352,3 +412,16 @@ def recipe_from_url(request, url):
|
||||
if response.status_code == 403:
|
||||
return JsonResponse({'error': True, 'msg': _('The requested page refused to provide any information (Status Code 403).')}, status=400)
|
||||
return get_from_html(response.text, url)
|
||||
|
||||
|
||||
def get_backup(request):
|
||||
if not request.user.is_superuser:
|
||||
return HttpResponse('', status=403)
|
||||
|
||||
buf = io.StringIO()
|
||||
management.call_command('dumpdata', exclude=['contenttypes', 'auth'], stdout=buf)
|
||||
|
||||
response = FileResponse(buf.getvalue())
|
||||
response["Content-Disposition"] = f'attachment; filename=backup{date_format(timezone.now(), format="SHORT_DATETIME_FORMAT", use_l10n=True)}.json'
|
||||
|
||||
return response
|
||||
|
||||
@@ -3,9 +3,10 @@ from datetime import datetime
|
||||
from io import BytesIO
|
||||
|
||||
import requests
|
||||
from PIL import Image
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from django.contrib import messages
|
||||
from django.core.files import File
|
||||
from django.db.transaction import atomic
|
||||
from django.utils.translation import gettext as _
|
||||
from django.http import HttpResponseRedirect, HttpResponse
|
||||
from django.shortcuts import redirect, render
|
||||
@@ -94,6 +95,7 @@ def batch_edit(request):
|
||||
|
||||
|
||||
@group_required('user')
|
||||
@atomic
|
||||
def import_url(request):
|
||||
if request.method == 'POST':
|
||||
data = json.loads(request.body)
|
||||
@@ -120,35 +122,44 @@ def import_url(request):
|
||||
recipe.keywords.add(k)
|
||||
|
||||
for ing in data['recipeIngredient']:
|
||||
f, f_created = Food.objects.get_or_create(name=ing['ingredient']['text'])
|
||||
if ing['unit']:
|
||||
u, u_created = Unit.objects.get_or_create(name=ing['unit']['text'])
|
||||
else:
|
||||
u = Unit.objects.get(name=request.user.userpreference.default_unit)
|
||||
ingredient = Ingredient()
|
||||
|
||||
ingredient.food, f_created = Food.objects.get_or_create(name=ing['ingredient']['text'])
|
||||
if ing['unit'] and ing['unit']['text'] != '':
|
||||
ingredient.unit, u_created = Unit.objects.get_or_create(name=ing['unit']['text'])
|
||||
|
||||
# TODO properly handle no_amount recipes
|
||||
if isinstance(ing['amount'], str):
|
||||
try:
|
||||
ing['amount'] = float(ing['amount'].replace(',', '.'))
|
||||
ingredient.amount = float(ing['amount'].replace(',', '.'))
|
||||
except ValueError:
|
||||
# TODO return proper error
|
||||
ingredient.no_amount = True
|
||||
pass
|
||||
elif isinstance(ing['amount'], float) or isinstance(ing['amount'], int):
|
||||
ingredient.amount = ing['amount']
|
||||
ingredient.note = ing['note'] if 'note' in ing else ''
|
||||
|
||||
step.ingredients.add(Ingredient.objects.create(food=f, unit=u, amount=ing['amount']))
|
||||
ingredient.save()
|
||||
step.ingredients.add(ingredient)
|
||||
print(ingredient)
|
||||
|
||||
if data['image'] != '':
|
||||
response = requests.get(data['image'])
|
||||
img = Image.open(BytesIO(response.content))
|
||||
if 'image' in data and data['image'] != '':
|
||||
try:
|
||||
response = requests.get(data['image'])
|
||||
img = Image.open(BytesIO(response.content))
|
||||
|
||||
# todo move image processing to dedicated function
|
||||
basewidth = 720
|
||||
wpercent = (basewidth / float(img.size[0]))
|
||||
hsize = int((float(img.size[1]) * float(wpercent)))
|
||||
img = img.resize((basewidth, hsize), Image.ANTIALIAS)
|
||||
# todo move image processing to dedicated function
|
||||
basewidth = 720
|
||||
wpercent = (basewidth / float(img.size[0]))
|
||||
hsize = int((float(img.size[1]) * float(wpercent)))
|
||||
img = img.resize((basewidth, hsize), Image.ANTIALIAS)
|
||||
|
||||
im_io = BytesIO()
|
||||
img.save(im_io, 'PNG', quality=70)
|
||||
recipe.image = File(im_io, name=f'{uuid.uuid4()}_{recipe.pk}.png')
|
||||
recipe.save()
|
||||
im_io = BytesIO()
|
||||
img.save(im_io, 'PNG', quality=70)
|
||||
recipe.image = File(im_io, name=f'{uuid.uuid4()}_{recipe.pk}.png')
|
||||
recipe.save()
|
||||
except UnidentifiedImageError:
|
||||
pass
|
||||
|
||||
return HttpResponse(reverse('view_recipe', args=[recipe.pk]))
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from django.views.generic import DeleteView
|
||||
|
||||
from cookbook.helper.permission_helper import group_required, GroupRequiredMixin, OwnerRequiredMixin
|
||||
from cookbook.models import Recipe, Sync, Keyword, RecipeImport, Storage, Comment, RecipeBook, \
|
||||
RecipeBookEntry, MealPlan, Food
|
||||
RecipeBookEntry, MealPlan, Food, InviteLink
|
||||
from cookbook.provider.dropbox import Dropbox
|
||||
from cookbook.provider.nextcloud import Nextcloud
|
||||
|
||||
@@ -129,7 +129,7 @@ class RecipeBookEntryDelete(GroupRequiredMixin, DeleteView):
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
obj = self.get_object()
|
||||
if not (obj.book.created_by == request.user or request.user.is_superuser):
|
||||
messages.add_message(request, messages.ERROR, _('You cannot interact with this object as its not owned by you!'))
|
||||
messages.add_message(request, messages.ERROR, _('You cannot interact with this object as it is not owned by you!'))
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
return super(RecipeBookEntryDelete, self).dispatch(request, *args, **kwargs)
|
||||
|
||||
@@ -148,3 +148,14 @@ class MealPlanDelete(OwnerRequiredMixin, DeleteView):
|
||||
context = super(MealPlanDelete, self).get_context_data(**kwargs)
|
||||
context['title'] = _("Meal-Plan")
|
||||
return context
|
||||
|
||||
|
||||
class InviteLinkDelete(OwnerRequiredMixin, DeleteView):
|
||||
template_name = "generic/delete_template.html"
|
||||
model = InviteLink
|
||||
success_url = reverse_lazy('list_invite_link')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super(InviteLinkDelete, self).get_context_data(**kwargs)
|
||||
context['title'] = _("Invite Link")
|
||||
return context
|
||||
|
||||
@@ -239,27 +239,33 @@ def edit_ingredients(request):
|
||||
if units_form.is_valid():
|
||||
new_unit = units_form.cleaned_data['new_unit']
|
||||
old_unit = units_form.cleaned_data['old_unit']
|
||||
recipe_ingredients = Ingredient.objects.filter(unit=old_unit).all()
|
||||
for i in recipe_ingredients:
|
||||
i.unit = new_unit
|
||||
i.save()
|
||||
if new_unit != old_unit:
|
||||
recipe_ingredients = Ingredient.objects.filter(unit=old_unit).all()
|
||||
for i in recipe_ingredients:
|
||||
i.unit = new_unit
|
||||
i.save()
|
||||
|
||||
old_unit.delete()
|
||||
success = True
|
||||
messages.add_message(request, messages.SUCCESS, _('Units merged!'))
|
||||
old_unit.delete()
|
||||
success = True
|
||||
messages.add_message(request, messages.SUCCESS, _('Units merged!'))
|
||||
else:
|
||||
messages.add_message(request, messages.ERROR, _('Cannot merge with the same object!'))
|
||||
|
||||
food_form = FoodMergeForm(request.POST, prefix=FoodMergeForm.prefix)
|
||||
if food_form.is_valid():
|
||||
new_food = food_form.cleaned_data['new_food']
|
||||
old_food = food_form.cleaned_data['old_food']
|
||||
ingredients = Ingredient.objects.filter(food=old_food).all()
|
||||
for i in ingredients:
|
||||
i.food = new_food
|
||||
i.save()
|
||||
if new_food != old_food:
|
||||
ingredients = Ingredient.objects.filter(food=old_food).all()
|
||||
for i in ingredients:
|
||||
i.food = new_food
|
||||
i.save()
|
||||
|
||||
old_food.delete()
|
||||
success = True
|
||||
messages.add_message(request, messages.SUCCESS, _('Foods merged!'))
|
||||
old_food.delete()
|
||||
success = True
|
||||
messages.add_message(request, messages.SUCCESS, _('Foods merged!'))
|
||||
else:
|
||||
messages.add_message(request, messages.ERROR, _('Cannot merge with the same object!'))
|
||||
|
||||
if success:
|
||||
units_form = UnitMergeForm()
|
||||
|
||||
@@ -23,7 +23,7 @@ def import_recipe(request):
|
||||
form = ImportForm(request.POST)
|
||||
if form.is_valid():
|
||||
try:
|
||||
data = json.loads(form.cleaned_data['recipe'])
|
||||
data = json.loads(re.sub(r'"id":([0-9])+,', '', form.cleaned_data['recipe']))
|
||||
|
||||
sr = RecipeSerializer(data=data)
|
||||
if sr.is_valid():
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
from datetime import datetime
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Q
|
||||
from django.db.models.functions import Lower
|
||||
from django.shortcuts import render
|
||||
from django.utils.translation import gettext as _
|
||||
from django_tables2 import RequestConfig
|
||||
|
||||
from cookbook.filters import IngredientFilter
|
||||
from cookbook.filters import IngredientFilter, ShoppingListFilter
|
||||
from cookbook.helper.permission_helper import group_required
|
||||
from cookbook.models import Keyword, SyncLog, RecipeImport, Storage, Food
|
||||
from cookbook.tables import KeywordTable, ImportLogTable, RecipeImportTable, StorageTable, IngredientTable
|
||||
from cookbook.models import Keyword, SyncLog, RecipeImport, Storage, Food, ShoppingList, InviteLink
|
||||
from cookbook.tables import KeywordTable, ImportLogTable, RecipeImportTable, StorageTable, IngredientTable, ShoppingListTable, InviteLinkTable
|
||||
|
||||
|
||||
@group_required('user')
|
||||
@@ -45,9 +48,27 @@ def food(request):
|
||||
return render(request, 'generic/list_template.html', {'title': _("Ingredients"), 'table': table, 'filter': f})
|
||||
|
||||
|
||||
@group_required('user')
|
||||
def shopping_list(request):
|
||||
f = ShoppingListFilter(request.GET, queryset=ShoppingList.objects.filter(Q(created_by=request.user) | Q(shared=request.user)).all().order_by('finished', 'created_at'))
|
||||
|
||||
table = ShoppingListTable(f.qs)
|
||||
RequestConfig(request, paginate={'per_page': 25}).configure(table)
|
||||
|
||||
return render(request, 'generic/list_template.html', {'title': _("Shopping Lists"), 'table': table, 'filter': f, 'create_url': 'view_shopping'})
|
||||
|
||||
|
||||
@group_required('admin')
|
||||
def storage(request):
|
||||
table = StorageTable(Storage.objects.all())
|
||||
RequestConfig(request, paginate={'per_page': 25}).configure(table)
|
||||
|
||||
return render(request, 'generic/list_template.html', {'title': _("Storage Backend"), 'table': table, 'create_url': 'new_storage'})
|
||||
|
||||
|
||||
@group_required('admin')
|
||||
def invite_link(request):
|
||||
table = InviteLinkTable(InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None).all())
|
||||
RequestConfig(request, paginate={'per_page': 25}).configure(table)
|
||||
|
||||
return render(request, 'generic/list_template.html', {'title': _("Invite Links"), 'table': table, 'create_url': 'new_invite_link'})
|
||||
|
||||
@@ -9,9 +9,9 @@ from django.utils.translation import gettext as _
|
||||
from django.views.generic import CreateView
|
||||
|
||||
from cookbook.forms import ImportRecipeForm, RecipeImport, KeywordForm, Storage, StorageForm, InternalRecipeForm, \
|
||||
RecipeBookForm, MealPlanForm
|
||||
RecipeBookForm, MealPlanForm, InviteLinkForm
|
||||
from cookbook.helper.permission_helper import GroupRequiredMixin, group_required
|
||||
from cookbook.models import Keyword, Recipe, RecipeBook, MealPlan, ShareLink, MealType, Step
|
||||
from cookbook.models import Keyword, Recipe, RecipeBook, MealPlan, ShareLink, MealType, Step, InviteLink
|
||||
|
||||
|
||||
class RecipeCreate(GroupRequiredMixin, CreateView):
|
||||
@@ -162,3 +162,21 @@ class MealPlanCreate(GroupRequiredMixin, CreateView):
|
||||
context['default_recipe'] = Recipe.objects.get(pk=int(recipe))
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class InviteLinkCreate(GroupRequiredMixin, CreateView):
|
||||
groups_required = ['admin']
|
||||
template_name = "generic/new_template.html"
|
||||
model = InviteLink
|
||||
form_class = InviteLinkForm
|
||||
|
||||
def form_valid(self, form):
|
||||
obj = form.save(commit=False)
|
||||
obj.created_by = self.request.user
|
||||
obj.save()
|
||||
return HttpResponseRedirect(reverse('list_invite_link'))
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super(InviteLinkCreate, self).get_context_data(**kwargs)
|
||||
context['title'] = _("Invite Link")
|
||||
return context
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import copy
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from uuid import UUID
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import update_session_auth_hash, authenticate
|
||||
from django.contrib.auth.forms import PasswordChangeForm
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Q
|
||||
from django.db.models import Q, Avg
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.utils import timezone
|
||||
@@ -83,7 +83,8 @@ def recipe_view(request, pk, share=None):
|
||||
|
||||
if request.method == "POST":
|
||||
if not request.user.is_authenticated:
|
||||
messages.add_message(request, messages.ERROR, _('You do not have the required permissions to perform this action!'))
|
||||
messages.add_message(request, messages.ERROR,
|
||||
_('You do not have the required permissions to perform this action!'))
|
||||
return HttpResponseRedirect(reverse('view_recipe', kwargs={'pk': recipe.pk, 'share': share}))
|
||||
|
||||
comment_form = CommentForm(request.POST, prefix='comment')
|
||||
@@ -110,13 +111,19 @@ def recipe_view(request, pk, share=None):
|
||||
comment_form = CommentForm()
|
||||
bookmark_form = RecipeBookEntryForm()
|
||||
|
||||
user_servings = None
|
||||
if request.user.is_authenticated:
|
||||
if not ViewLog.objects.filter(recipe=recipe).filter(created_by=request.user).filter(created_at__gt=(timezone.now() - timezone.timedelta(minutes=5))).exists():
|
||||
user_servings = CookLog.objects.filter(recipe=recipe, created_by=request.user,
|
||||
servings__gt=0).all().aggregate(Avg('servings'))['servings__avg']
|
||||
|
||||
if request.user.is_authenticated:
|
||||
if not ViewLog.objects.filter(recipe=recipe).filter(created_by=request.user).filter(
|
||||
created_at__gt=(timezone.now() - timezone.timedelta(minutes=5))).exists():
|
||||
ViewLog.objects.create(recipe=recipe, created_by=request.user)
|
||||
|
||||
return render(request, 'recipe_view.html',
|
||||
{'recipe': recipe, 'comments': comments, 'comment_form': comment_form,
|
||||
'bookmark_form': bookmark_form, 'share': share})
|
||||
'bookmark_form': bookmark_form, 'share': share, 'user_servings': user_servings})
|
||||
|
||||
|
||||
@group_required('user')
|
||||
@@ -158,50 +165,25 @@ def meal_plan_entry(request, pk):
|
||||
messages.add_message(request, messages.ERROR, _('You do not have the required permissions to view this page!'))
|
||||
return HttpResponseRedirect(reverse_lazy('index'))
|
||||
|
||||
same_day_plan = MealPlan.objects.filter(date=plan.date).exclude(pk=plan.pk).filter(Q(created_by=request.user) | Q(shared=request.user)).order_by('meal_type').all()
|
||||
same_day_plan = MealPlan.objects.filter(date=plan.date).exclude(pk=plan.pk).filter(
|
||||
Q(created_by=request.user) | Q(shared=request.user)).order_by('meal_type').all()
|
||||
|
||||
return render(request, 'meal_plan_entry.html', {'plan': plan, 'same_day_plan': same_day_plan})
|
||||
|
||||
|
||||
@group_required('user')
|
||||
def shopping_list(request):
|
||||
markdown_format = True
|
||||
def shopping_list(request, pk=None):
|
||||
raw_list = request.GET.getlist('r')
|
||||
|
||||
if request.method == "POST":
|
||||
form = ShoppingForm(request.POST)
|
||||
if form.is_valid():
|
||||
recipes = form.cleaned_data['recipe']
|
||||
markdown_format = form.cleaned_data['markdown_format']
|
||||
else:
|
||||
recipes = []
|
||||
else:
|
||||
raw_list = request.GET.getlist('r')
|
||||
recipes = []
|
||||
for r in raw_list:
|
||||
r = r.replace('[', '').replace(']', '')
|
||||
if re.match(r'^([0-9])+,([0-9])+[.]*([0-9])*$', r):
|
||||
rid, multiplier = r.split(',')
|
||||
if recipe := Recipe.objects.filter(pk=int(rid)).first():
|
||||
recipes.append({'recipe': recipe.id, 'multiplier': multiplier})
|
||||
|
||||
recipes = []
|
||||
for r in raw_list:
|
||||
if re.match(r'^([1-9])+$', r):
|
||||
if Recipe.objects.filter(pk=int(r)).exists():
|
||||
recipes.append(int(r))
|
||||
|
||||
markdown_format = False
|
||||
form = ShoppingForm(initial={'recipe': recipes, 'markdown_format': False})
|
||||
|
||||
ingredients = []
|
||||
|
||||
for r in recipes:
|
||||
for s in r.steps.all():
|
||||
for ri in s.ingredients.exclude(unit__name__contains='Special:').all():
|
||||
index = None
|
||||
for x, ig in enumerate(ingredients):
|
||||
if ri.food == ig.food and ri.unit == ig.unit:
|
||||
index = x
|
||||
|
||||
if index:
|
||||
ingredients[index].amount = ingredients[index].amount + ri.amount
|
||||
else:
|
||||
ingredients.append(ri)
|
||||
|
||||
return render(request, 'shopping_list.html', {'ingredients': ingredients, 'recipes': recipes, 'form': form, 'markdown_format': markdown_format})
|
||||
return render(request, 'shopping_list.html', {'shopping_list_id': pk, 'recipes': recipes})
|
||||
|
||||
|
||||
@group_required('guest')
|
||||
@@ -228,6 +210,12 @@ def user_settings(request):
|
||||
up.plan_share.set(form.cleaned_data['plan_share'])
|
||||
up.ingredient_decimals = form.cleaned_data['ingredient_decimals']
|
||||
up.comments = form.cleaned_data['comments']
|
||||
up.use_fractions = form.cleaned_data['use_fractions']
|
||||
|
||||
up.shopping_auto_sync = form.cleaned_data['shopping_auto_sync']
|
||||
if up.shopping_auto_sync < settings.SHOPPING_MIN_AUTOSYNC_INTERVAL:
|
||||
up.shopping_auto_sync = settings.SHOPPING_MIN_AUTOSYNC_INTERVAL
|
||||
|
||||
up.save()
|
||||
|
||||
if 'user_name_form' in request.POST:
|
||||
@@ -251,7 +239,9 @@ def user_settings(request):
|
||||
if (api_token := Token.objects.filter(user=request.user).first()) is None:
|
||||
api_token = Token.objects.create(user=request.user)
|
||||
|
||||
return render(request, 'settings.html', {'preference_form': preference_form, 'user_name_form': user_name_form, 'password_form': password_form, 'api_token': api_token})
|
||||
return render(request, 'settings.html',
|
||||
{'preference_form': preference_form, 'user_name_form': user_name_form, 'password_form': password_form,
|
||||
'api_token': api_token})
|
||||
|
||||
|
||||
@group_required('guest')
|
||||
@@ -263,20 +253,24 @@ def history(request):
|
||||
|
||||
@group_required('admin')
|
||||
def system(request):
|
||||
postgres = False if settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql_psycopg2' else True
|
||||
postgres = False if (settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql_psycopg2' or
|
||||
settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql') else True
|
||||
|
||||
secret_key = False if os.getenv('SECRET_KEY') else True
|
||||
|
||||
return render(request, 'system.html', {'gunicorn_media': settings.GUNICORN_MEDIA, 'debug': settings.DEBUG, 'postgres': postgres, 'version': VERSION_NUMBER, 'ref': BUILD_REF, 'secret_key': secret_key})
|
||||
return render(request, 'system.html',
|
||||
{'gunicorn_media': settings.GUNICORN_MEDIA, 'debug': settings.DEBUG, 'postgres': postgres,
|
||||
'version': VERSION_NUMBER, 'ref': BUILD_REF, 'secret_key': secret_key})
|
||||
|
||||
|
||||
def setup(request):
|
||||
if User.objects.count() > 0 or 'django.contrib.auth.backends.RemoteUserBackend' in settings.AUTHENTICATION_BACKENDS:
|
||||
messages.add_message(request, messages.ERROR, _('The setup page can only be used to create the first user! If you have forgotten your superuser credentials please consult the django documentation on how to reset passwords.'))
|
||||
messages.add_message(request, messages.ERROR, _(
|
||||
'The setup page can only be used to create the first user! If you have forgotten your superuser credentials please consult the django documentation on how to reset passwords.'))
|
||||
return HttpResponseRedirect(reverse('login'))
|
||||
|
||||
if request.method == 'POST':
|
||||
form = SuperUserForm(request.POST)
|
||||
form = UserCreateForm(request.POST)
|
||||
if form.is_valid():
|
||||
if form.cleaned_data['password'] != form.cleaned_data['password_confirm']:
|
||||
form.add_error('password', _('Passwords dont match!'))
|
||||
@@ -296,11 +290,59 @@ def setup(request):
|
||||
for m in e:
|
||||
form.add_error('password', m)
|
||||
else:
|
||||
form = SuperUserForm()
|
||||
form = UserCreateForm()
|
||||
|
||||
return render(request, 'setup.html', {'form': form})
|
||||
|
||||
|
||||
def signup(request, token):
|
||||
try:
|
||||
token = UUID(token, version=4)
|
||||
except ValueError:
|
||||
messages.add_message(request, messages.ERROR, _('Malformed Invite Link supplied!'))
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
|
||||
if link := InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None, uuid=token).first():
|
||||
if request.method == 'POST':
|
||||
|
||||
form = UserCreateForm(request.POST)
|
||||
if link.username != '':
|
||||
data = dict(form.data)
|
||||
data['name'] = link.username
|
||||
form.data = data
|
||||
|
||||
if form.is_valid():
|
||||
if form.cleaned_data['password'] != form.cleaned_data['password_confirm']:
|
||||
form.add_error('password', _('Passwords dont match!'))
|
||||
else:
|
||||
user = User(
|
||||
username=form.cleaned_data['name'],
|
||||
)
|
||||
try:
|
||||
validate_password(form.cleaned_data['password'], user=user)
|
||||
user.set_password(form.cleaned_data['password'])
|
||||
user.save()
|
||||
messages.add_message(request, messages.SUCCESS, _('User has been created, please login!'))
|
||||
|
||||
link.used_by = user
|
||||
link.save()
|
||||
user.groups.add(link.group)
|
||||
return HttpResponseRedirect(reverse('login'))
|
||||
except ValidationError as e:
|
||||
for m in e:
|
||||
form.add_error('password', m)
|
||||
else:
|
||||
form = UserCreateForm()
|
||||
|
||||
if link.username != '':
|
||||
form.fields['name'].initial = link.username
|
||||
form.fields['name'].disabled = True
|
||||
return render(request, 'registration/signup.html', {'form': form, 'link': link})
|
||||
|
||||
messages.add_message(request, messages.ERROR, _('Invite Link not valid or already used!'))
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
|
||||
|
||||
def markdown_info(request):
|
||||
return render(request, 'markdown_info.html', {})
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
This is a further example combining the power of nginx with the reverse proxy authentication service, [Authelia](https://github.com/authelia/authelia).
|
||||
|
||||
Please refer to the appropriate documentation on how to setup the reverse proxy, authentication, and networks.
|
||||
|
||||
Ensure users have been configured for Authelia, and that the endpoint that recipes is pointed to is protected, but available.
|
||||
|
||||
There is a good guide to the other additional files that need to be added to your Nginx set up at the [Authelia Docs](https://docs.authelia.com/deployment/supported-proxies/nginx.html).
|
||||
|
||||
Remember to add the appropriate environment variables to `.env` file:
|
||||
```
|
||||
VIRTUAL_HOST=
|
||||
LETSENCRYPT_HOST=
|
||||
LETSENCRYPT_EMAIL=
|
||||
PROXY_HEADER=
|
||||
```
|
||||
@@ -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:
|
||||
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
|
||||
@@ -1,37 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
client_max_body_size 16M;
|
||||
|
||||
# serve static files
|
||||
location /static/ {
|
||||
alias /static/;
|
||||
}
|
||||
# serve media files
|
||||
location /media/ {
|
||||
alias /media/;
|
||||
}
|
||||
|
||||
# Authelia endpoint for authentication requests
|
||||
include /config/nginx/auth.conf;
|
||||
|
||||
# pass requests for dynamic content to gunicorn
|
||||
location / {
|
||||
proxy_set_header Host $host;
|
||||
proxy_pass http://web_recipes:8080;
|
||||
|
||||
# Ensure Authelia is specifically required for this endpoint
|
||||
# This line is important as it will return a 401 error if the user doesn't have access
|
||||
include /config/nginx/authelia.conf;
|
||||
|
||||
auth_request_set $user $upstream_http_remote_user;
|
||||
proxy_set_header REMOTE-USER $user;
|
||||
}
|
||||
|
||||
# Required to allow user to logout of authentication from within Recipes
|
||||
# Ensure the <auth_endpoint> below is changed to actual the authentication url
|
||||
location /accounts/logout/ {
|
||||
return 301 http://<auth_endpoint>/logout
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
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=
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user