diff --git a/.github/contributing.md b/.github/contributing.md index c3081aa0b..fb3bc3c0e 100644 --- a/.github/contributing.md +++ b/.github/contributing.md @@ -15,7 +15,8 @@ in a pull request. ## Questions Are you just using the software and have a question or improvement? -* Ask it on the [gitter channel](https://gitter.im/wger-project/wger) +* Ask it on the [gitter channel](https://gitter.im/wger-project/wger), +* the [discord server](https://discord.gg/rPWFv6W) * or just [open an issue](https://github.com/wger-project/wger/issues) ## Issues diff --git a/.github/linters/.eslintrc.yml b/.github/linters/.eslintrc.yml index 3af1b093c..72d6b56f5 100644 --- a/.github/linters/.eslintrc.yml +++ b/.github/linters/.eslintrc.yml @@ -1,7 +1,8 @@ { "env": { "browser": true, - "jquery": true + "jquery": true, + "es6": true }, "globals": { "d3": true, diff --git a/.github/linters/.python-lint b/.github/linters/.python-lint index b6327a3a0..5149f8f31 100644 --- a/.github/linters/.python-lint +++ b/.github/linters/.python-lint @@ -55,7 +55,7 @@ confidence= # can either give multiple identifiers separated by comma (,) or put this # option multiple times (only on the command line, not in the configuration # file where it should appear only once). You can also use "--disable=all" to -# disable everything first and then reenable specific checks. For example, if +# disable everything first and then re-enable specific checks. For example, if # you want to run only the similarities checker, you can use "--disable=all # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use "--disable=all --enable=classes @@ -143,8 +143,8 @@ disable=import-error, comprehension-escape # Enable the message, report, category or checker with the given id(s). You can -# either give multiple identifier separated by comma (,) or put this option -# multiple time (only on the command line, not in the configuration file where +# either give multiple identifiers separated by comma (,) or put this option +# multiple times (only on the command line, not in the configuration file where # it should appear only once). See also the "--disable" option for examples. enable=c-extension-no-member @@ -180,7 +180,7 @@ score=yes max-nested-blocks=5 # Complete name of functions that never returns. When checking for -# inconsistent-return-statements if a never returning function is called then +# inconsistent-return-statements, if a never returning function is called then # it will be considered as an explicit return statement and no message will be # printed. never-returning-functions=sys.exit @@ -504,7 +504,7 @@ max-attributes=7 # Maximum number of boolean expressions in an if statement (see R0916). max-bool-expr=5 -# Maximum number of branch for function / method body. +# Maximum number of branches for function / method body. max-branches=12 # Maximum number of locals for function / method body. diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 53e966220..3e8f5143a 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -6,38 +6,44 @@ on: - master jobs: - deploy: + path-context: runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v2 + - name: Checkout + uses: actions/checkout@v2 - - name: Build base image - uses: docker/build-push-action@v1 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - repository: wger/base - dockerfile: extras/docker/base/Dockerfile - tags: latest,2.0-dev - tag_with_ref: true + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 - - name: Build dev image - uses: docker/build-push-action@v1 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - repository: wger/devel - dockerfile: extras/docker/development/Dockerfile - tags: latest,2.0-dev - tag_with_ref: true + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 - - name: Build apache image - uses: docker/build-push-action@v1 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - repository: wger/apache - dockerfile: extras/docker/apache/Dockerfile - tags: latest,2.0-dev - tag_with_ref: true + - name: Login to DockerHub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build base image + uses: docker/build-push-action@v2 + with: + context: . + file: extras/docker/base/Dockerfile + push: true + tags: wger/base:latest,wger/base:2.0-dev + + - name: Build apache image + uses: docker/build-push-action@v2 + with: + context: . + file: extras/docker/apache/Dockerfile + push: true + tags: wger/apache:latest,wger/apache:2.0-dev + + - name: Build dev image + uses: docker/build-push-action@v2 + with: + context: . + file: extras/docker/development/Dockerfile + push: true + tags: wger/devel:latest,wger/devel:2.0-dev diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 37d7e405d..c1fa515ac 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -46,8 +46,9 @@ jobs: # Run Linter against code base # ################################ - name: Lint Code Base - uses: docker://github/super-linter:v3.5.1 + uses: docker://github/super-linter:v3.9.3 env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} VALIDATE_PYTHON_FLAKE8: true VALIDATE_MD: true VALIDATE_JAVASCRIPT_ES: true diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index d021fc2ff..3860538c8 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -1,4 +1,4 @@ -# This workflows will upload a Python Package using Twine when a release is created +# This workflow will upload a Python Package using Twine when a release is created # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries name: Upload Package to PyPI diff --git a/.travis.yml b/.travis.yml index 243260206..e0b8d4e67 100644 --- a/.travis.yml +++ b/.travis.yml @@ -27,7 +27,7 @@ env: # Install the application install: # Install requirements - - pip install -r requirements_devel.txt + - pip install -r requirements_dev.txt - python setup.py develop - cd wger - if [[ "$DB" = "postgresql" ]]; then pip install psycopg2; fi diff --git a/AUTHORS.rst b/AUTHORS.rst index b9a3585af..1146adc9e 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -25,6 +25,11 @@ Developers * Malcolm Jones: https://github.com/DevloperMal * Boniface Mwenda: https://github.com/andela-bmwenda * Scott Peshak: https://github.com/speshak +* Musanje Louis Michael: https://github.com/louiCoder +* Kevin Antonio Rateni Iatauro: https://github.com/WalkingPizza +* Sven - https://github.com/Svn-Sp +* Christopher OConnell - https://github.com/oconnelc +* Biplov - https://github.com/beingbiplov Translators ----------- diff --git a/MANIFEST.in b/MANIFEST.in index 69dc7c1fa..b29980052 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,7 +4,7 @@ include AUTHORS.txt include AGPL.txt include CC-BY-SA.txt include requirements.txt -include requirements_devel.txt +include requirements_dev.txt # Application folder as well as extras recursive-include extras *.* diff --git a/README.md b/README.md new file mode 100644 index 000000000..2695440bf --- /dev/null +++ b/README.md @@ -0,0 +1,146 @@ + + +# wger + +wger (ˈvɛɡɐ) Workout Manager is a free, open source web application that help +you manage your personal workouts, weight and diet plans and can also be used +as a simple gym management utility. It offers a REST API as well, for easy +integration with other projects and tools. + +For a live system, refer to the project's site: + +![Workout plan](https://raw.githubusercontent.com/wger-project/wger/master/wger/software/static/images/workout.png) + +## Installation + +These are the basic steps to install and run the application locally on a Linux +system. There are more detailed instructions, other deployment options as well +as an administration guide available at or locally +in your code repository in the docs folder. + +Please consult the commands' help for further information and available +parameters. + + +### Production + +If you want to host your own instance, take a look at the provided docker +compose file. This config will persist your database and uploaded images: + + + +### Demo + +If you just want to try it out: + +```shell script + docker run -ti --name wger.apache --publish 8000:80 wger/apache +``` + +Then just open and log in as **admin**, password **admin** + +Please note that this image will overwrite your data when you pull a new version, +it is only intended as an easy to setup demo + +### Development version + +#### Docker + +To develop, check the README in wger/extras/docker on how to use +the wger/devel docker image or the docker-compose file for development + +#### Local installation (git) + +**Note:** You can safely install from master, it is almost always in a usable +and stable state. + + +Install the necessary packages + +```shell script +sudo apt-get install python3-dev nodejs npm git +sudo npm install -g yarn sass +``` + +Make a virtualenv where we will install the python packages + +```shell script +python3 -m venv venv-wger +source venv-wger/bin/activate +``` + +Start the application. This will download the required JS and CSS libraries +and create a SQlite database and populate it with data on the first run. If +you want to use another database, edit the settings.py file before calling +bootstrap. You will need to create the database and user yourself. + +```shell script +git clone https://github.com/wger-project/wger.git +cd wger +pip install -r requirements.txt +python setup.py develop +wger create-settings +wger bootstrap +python manage.py runserver +``` + +Log in as: **admin**, password **admin** + +After the first run you just start django's development server:: + +```shell script +python manage.py runserver +``` + + +### Command line options + +You can get a list of all available commands by calling ``wger`` without any +arguments: + +* `bootstrap` Performs all steps necessary to bootstrap the application +* `config-location` Returns the default location for the settings file + and the data folder +* `create-or-reset-admin` Creates an admin user or resets the password + for an existing one +* `create-settings` Creates a local settings file +* `load-fixtures` Loads all fixtures +* `migrate-db` Run all database migrations +* `start` Start the application using django's built in webserver + +To get help on a specific command: ``wger --help``. + + +## Contact + +Feel free to contact us if you found this useful or if there was something that +didn't behave as you expected. We can't fix what we don't know about, so please +report liberally. If you're not sure if something is a bug or not, feel free to +file a bug anyway. + +* **discord:** +* **gitter:** +* **issue tracker:** +* **twitter:** + + +## Sources + +All the code and the content is available on github: + + + + +## License + +The application is licensed under the Affero GNU General Public License 3 or +later (AGPL 3+). + +The initial exercise and ingredient data is licensed additionally under one of +the Creative Commons licenses, see the individual exercises for more details. + +The documentation is released under a CC-BY-SA: either version 4 of the License, +or (at your option) any later version. + +Some images were taken from Wikipedia, see the SOURCES file in their respective +folders for more details. diff --git a/README.rst b/README.rst deleted file mode 100644 index d29c700ba..000000000 --- a/README.rst +++ /dev/null @@ -1,166 +0,0 @@ -wger -==== - -wger (ˈvɛɡɐ) Workout Manager is a free, open source web application that help -you manage your personal workouts, weight and diet plans and can also be used -as a simple gym management utility. It offers a REST API as well, for easy -integration with other projects and tools. - -For a live system, refer to the project's site: https://wger.de/ - - -Installation -============ - -These are the basic steps to install and run the application locally on a Linux -system. There are more detailed instructions, other deployment options as well -as an administration guide available at https://wger.readthedocs.io or locally -in your code repository in the docs folder. - -Please consult the commands' help for further information and available -parameters. - - -Docker ------- - -Useful to just try it out. Check the documentation on how to use the wger/devel -docker image or the docker-compose file for development:: - - docker run -ti --name wger.apache --publish 8000:80 wger/apache - -Then just open http://localhost:8000 and log in as: **admin**, password **admin** - - -Development version (from git) ------------------------------- - -**Note:** You can safely install from master, it is almost always in a usable -and stable state. - - -1) Install the necessary packages - -:: - - $ sudo apt-get install python3-dev nodejs npm git - $ sudo npm install -g yarn sass - - -Then install the python packages from pypi in the virtualenv:: - - $ python3 -m venv venv-wger - $ source venv-wger/bin/activate - - -2) Start the application. This will download the required JS and CSS libraries - and create a SQlite database and populate it with data on the first run. - -:: - - $ git clone https://github.com/wger-project/wger.git - $ cd wger - $ pip install -r requirements.txt - $ python setup.py develop - $ wger create-settings --settings-path $(pwd)/settings.py --database-path $(pwd)/database.sqlite - $ wger bootstrap --settings-path $(pwd)/settings.py --no-start-server - $ python manage.py runserver - -3) Log in as: **admin**, password **admin** - -After the first run you just start django's development server:: - - $ python manage.py runserver - - -Stable version (from PyPI) --------------------------- - -1) Install the necessary packages and their dependencies in a virtualenv - -:: - - $ sudo apt-get install python3-dev nodejs npm git - $ sudo npm install -g yarn - $ python3 -m venv venv-wger - $ source venv-wger/bin/activate - $ pip install wger - - -2) Start the application. This will download the required JS and CSS libraries - and create a SQlite database and populate it with data on the first run. - Then, log in as: **admin**, password **admin** - -:: - - $ wger bootstrap - - -3) To start the installation again, just call wger start - -:: - - $ wger start - - -Command line options --------------------- -You can get a list of all available commands by calling ``wger`` without any -arguments:: - - Available tasks: - - bootstrap Performs all steps necessary to bootstrap the application - config-location Returns the default location for the settings file and the data folder - create-or-reset-admin Creates an admin user or resets the password for an existing one - create-settings Creates a local settings file - load-fixtures Loads all fixtures - migrate-db Run all database migrations - start Start the application using django's built in webserver - -You can also get help on a specific command with ``wger --help ``. - -Contact -======= - -Feel free to contact us if you found this useful or if there was something that -didn't behave as you expected. We can't fix what we don't know about, so please -report liberally. If you're not sure if something is a bug or not, feel free to -file a bug anyway. - -* **gitter:** https://gitter.im/wger-project/wger -* **issue tracker:** https://github.com/wger-project/wger/issues -* **twitter:** https://twitter.com/wger_project - - -Sources -======= - -All the code and the content is freely available: - -* **Main repository:** https://github.com/wger-project/wger - - -Donations -========= -wger is free software and will always remain that way. However, if you want to -help and support the project you are more than welcome to donate an amount of -your choice. - -.. image:: https://www.paypalobjects.com/en_US/i/btn/btn_donate_LG.gif - :target: https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=UPMWQJY85JC5N - -License -======= - -The application is licensed under the Affero GNU General Public License 3 or -later (AGPL 3+). - -The initial exercise and ingredient data is licensed additionally under one of -the Creative Commons licenses, see the individual exercises for more details. - -The documentation is released under a CC-BY-SA: either version 4 of the License, -or (at your option) any later version. - -Some images were taken from Wikipedia, see the SOURCES file in their respective -folders for more details. diff --git a/docs/changelog.rst b/docs/changelog.rst index d9f378cf0..fc70625f2 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,7 +9,7 @@ Upgrade steps from 1.9: * Update python libraries ``pip install -r requirements.txt`` * Install ``yarn`` and ``sass`` (e.g. ``sudo npm install -g yarn sass``) * Update CSS and JS libraries ``yarn install`` -* Compile the CSS ``sass wger/core/static/scss/main.scss:wger/core/static/yarn/bootstrap-compiled.css`` +* Compile the CSS ``yarn build:css:sass`` * Run migrations ``python manage.py migrate`` * Update static files (only production): ``python manage.py collectstatic`` * Subcommands for ``wger`` now use dashes in their names (i.e. create-settings instead of create_settings) @@ -17,13 +17,16 @@ Upgrade steps from 1.9: 🚀 Features: -* Add nutrition diary to log the daily calories actually taken `#284`_ +* Add nutrition diary to log the daily calories actually taken `#284`_ `#501`_ (thanks `@WalkingPizza`_) +`#506`_ (thanks `@oconnelc`_) * Improved user experience, on desktop and mobile `#337`_ +* Show BMI on weight graph `#462`_ (thanks `@Svn-Sp`_) +* Allow user to edit and delete body weight entries `#478`_ (thanks `@beingbiplov`_) 🐛 Bug Fixes: -* `#499`_, `#505`_, `#504`_ +* `#499`_, `#505`_, `#504`_, `#511`_, `#516`_, `#522`_ 🧰 Maintenance: @@ -32,14 +35,29 @@ Upgrade steps from 1.9: * Updated many libraries to last version (bootstrap, font awesome, etc.) * Use yarn to download CSS/JS libraries * Improvements to documentation (e.g. `#494`_) +* Improved cache handling `#246`_ (thanks `@louiCoder`_) +.. _@Svn-Sp: https://github.com/Svn-Sp +.. _@louiCoder: https://github.com/louiCoder +.. _@WalkingPizza: https://github.com/WalkingPizza +.. _@oconnelc: https://github.com/oconnelc +.. _@beingbiplov: https://github.com/beingbiplov + +.. _#246: https://github.com/wger-project/wger/issues/246 .. _#284: https://github.com/wger-project/wger/issues/284 .. _#337: https://github.com/wger-project/wger/issues/337 .. _#340: https://github.com/wger-project/wger/issues/340 +.. _#462: https://github.com/wger-project/wger/issues/462 +.. _#478: https://github.com/wger-project/wger/issues/478 .. _#494: https://github.com/wger-project/wger/issues/494 .. _#499: https://github.com/wger-project/wger/issues/499 +.. _#501: https://github.com/wger-project/wger/issues/501 .. _#504: https://github.com/wger-project/wger/issues/504 .. _#505: https://github.com/wger-project/wger/issues/505 +.. _#506: https://github.com/wger-project/wger/issues/506 +.. _#511: https://github.com/wger-project/wger/issues/511 +.. _#516: https://github.com/wger-project/wger/issues/516 +.. _#522: https://github.com/wger-project/wger/issues/522 diff --git a/docs/commands.rst b/docs/commands.rst index 3d4178e0c..87860b722 100644 --- a/docs/commands.rst +++ b/docs/commands.rst @@ -8,7 +8,7 @@ running application (to e.g. delete guest users, send emails, etc.). Administration Commands ----------------------- -The application provides several administration and bootstraping commands that +The application provides several administration and bootstrapping commands that can be passed to the ``wger`` command:: wger @@ -25,7 +25,7 @@ arguments:: create-settings Creates a local settings file load-fixtures Loads all fixtures migrate-db Run all database migrations - start Start the application using django's built in webserver + start Start the application using django's built-in webserver You can also get help on a specific command with ``wger --help ``. diff --git a/docs/conf.py b/docs/conf.py index f6374037f..d1445aa9d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -47,8 +47,8 @@ source_suffix = '.rst' master_doc = 'index' # General information about the project. -project = u'wger Workout Manager' -copyright = u'2020, Roland Geider' +project = 'wger Workout Manager' +copyright = '2020, Roland Geider' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -105,18 +105,18 @@ pygments_style = 'sphinx' html_theme = 'default' # Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the +# further. For a list of options available for each theme, see the # documentation. #html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. #html_theme_path = [] -# The name for this set of Sphinx documents. If None, it defaults to +# The name for this set of Sphinx documents. If None, it defaults to # " v documentation". #html_title = None -# A shorter title for the navigation bar. Default is the same as html_title. +# A shorter title for the navigation bar. Default is the same as html_title. #html_short_title = None # The name of an image file (relative to this directory) to place at the top @@ -124,7 +124,7 @@ html_theme = 'default' #html_logo = None # The name of an image file (within the static path) to use as favicon of the -# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. #html_favicon = None @@ -200,8 +200,8 @@ latex_elements = { # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - ('index', 'wgerWorkoutManager.tex', u'wger Workout Manager Documentation', - u'Roland Geider', 'manual'), + ('index', 'wgerWorkoutManager.tex', 'wger Workout Manager Documentation', + 'Roland Geider', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -230,8 +230,8 @@ latex_documents = [ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - ('index', 'wgerworkoutmanager', u'wger Workout Manager Documentation', - [u'Roland Geider'], 1) + ('index', 'wgerworkoutmanager', 'wger Workout Manager Documentation', + ['Roland Geider'], 1) ] # If true, show URL addresses after external links. @@ -244,8 +244,8 @@ man_pages = [ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'wgerWorkoutManager', u'wger Workout Manager Documentation', - u'Roland Geider', 'wgerWorkoutManager', 'One line description of project.', + ('index', 'wgerWorkoutManager', 'wger Workout Manager Documentation', + 'Roland Geider', 'wgerWorkoutManager', 'One line description of project.', 'Miscellaneous'), ] diff --git a/docs/development.rst b/docs/development.rst index a8e95daa7..6da84fdfb 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -21,13 +21,12 @@ Get the code $ git clone https://github.com/wger-project/wger.git src $ cd src - $ WGER_PATH=$(pwd) Install Requirements ~~~~~~~~~~~~~~~~~~~~ :: - $ pip install -r requirements_devel.txt + $ pip install -r requirements_dev.txt $ npm install -g yarn sass $ python setup.py develop @@ -38,12 +37,8 @@ This will download the required JS and CSS libraries and create a SQlite database and populate it with data on the first run:: - $ wger create-settings \ - --settings-path $WGER_PATH/settings.py \ - --database-path $WGER_PATH/database.sqlite - $ wger bootstrap \ - --settings-path $WGER_PATH/settings.py \ - --no-start-server + $ wger create-settings + $ wger bootstrap You can of course also use other databases such as postgres or mariaDB. Create a database and user and edit the DATABASES settings before calling bootstrap. diff --git a/docs/gym.rst b/docs/gym.rst index 407f2d794..0b83d30d4 100644 --- a/docs/gym.rst +++ b/docs/gym.rst @@ -18,7 +18,7 @@ There are 3 groups used for the different administrative roles: * **general manager:** Can manage (add, edit, delete) the different gyms for the installation as well as add gym managers, trainers and member, but is not - allowed to see the members workout data. + allowed to see the members' workout data. * **gym manager:** Can manage users for a single gym (editing, deactivating, adding contracts, etc.). * **trainer:** Can manage the workouts and other data for the members of a @@ -34,7 +34,7 @@ be changed later. The user's gym appears in the top right menu. Member management ----------------- -You can new members to a gym by clicking the *Add member* button at the top of +You can add members to a gym by clicking the *Add member* button at the top of the member overview. After filling in the form, a password will be generated for the user. You should save this and give it to the user, as it is not possible to retrieve it later. Alternatively you can just instruct the new members to diff --git a/docs/index.rst b/docs/index.rst index 4336a1664..2952ad5ae 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -58,11 +58,10 @@ Feel free to contact us if you found this useful or if there was something that didn't behave as you expected (in this case you can also open a ticket on the issue tracker). +* **discord:** https://discord.gg/rPWFv6W * **gitter:** https://gitter.im/wger-project/wger * **issue tracker:** https://github.com/wger-project/wger/issues * **twitter:** https://twitter.com/wger_project -* **mailing list:** https://groups.google.com/group/wger / wger@googlegroups.com, - no registration needed Sources diff --git a/docs/production.rst b/docs/production.rst index 7ccba2dc9..38580f658 100644 --- a/docs/production.rst +++ b/docs/production.rst @@ -88,7 +88,8 @@ If using sqlite, create a folder for it (must be writable by the apache user):: mkdir db touch db/database.sqlite - chmod -R o+w db + chown :www-data -R /home/wger/db + chmod g+w /home/wger/db /home/wger/db/database.sqlite Application ----------- @@ -116,9 +117,7 @@ Get the application:: npm install -g yarn sass python setup.py develop pip install psycopg2 # Only if using postgres - wger create-settings \ - --settings-path /home/wger/src/settings.py \ - --database-path /home/wger/db/database.sqlite + wger create-settings --database-path /home/wger/db/database.sqlite If you are using postgres, you need to edit the settings file and set the correct values for the database (use ``django.db.backends.postgresql_psycopg2`` @@ -128,7 +127,7 @@ for the engine). Also set ``MEDIA_ROOT`` to ``/home/wger/media`` and Run the installation script, this will download some CSS and JS libraries and load all initial data:: - wger bootstrap --settings-path /home/wger/src/settings.py --no-start-server + wger bootstrap Collect all static resources:: diff --git a/docs/tips_and_tricks.rst b/docs/tips_and_tricks.rst index 57de0bf83..93268a5bf 100644 --- a/docs/tips_and_tricks.rst +++ b/docs/tips_and_tricks.rst @@ -32,7 +32,7 @@ Updating SASS files ``````````````````` After updating the SASS files, you need to compile them to regular CSS:: - sass wger/core/static/scss/main.scss:wger/core/static/yarn/bootstrap-compiled.css + yarn build:css:sass Clearing the cache @@ -80,7 +80,8 @@ Or for options for, e.g. user generation:: To get you started, you might want to invoke the script in the following way. This will create 10 gyms and 300 users, randomly assigning them to a different gym. Each -user will have 20 workouts and each exercise in each workout 30 log entries:: +user will have 20 workouts and each exercise in each workout 30 log entries as well +as 10 nutrition diary entries per day:: python generator.py gyms 10 python generator.py users 300 @@ -89,6 +90,7 @@ user will have 20 workouts and each exercise in each workout 30 log entries:: python generator.py sessions random python generator.py weight 100 python generator.py nutrition 20 + python generator.py nutrition-diary 10 .. note:: All generated users have their username as password. diff --git a/extras/bench/README.txt b/extras/bench/README.txt index e37542828..0919aa55e 100644 --- a/extras/bench/README.txt +++ b/extras/bench/README.txt @@ -9,7 +9,7 @@ package manager) if you want to create plots. It requires a running web test server (you can edit which one in the .conf file) WARNING: You should *not* run this script against a server that is not under -your responsablity as it can result a DOS in bench mode. +your responsibility as it can result a DOS in bench mode. For more information on configuration, options, etc. refer to the funkload documentation page: http://funkload.nuxeo.org diff --git a/extras/docker/apache/Dockerfile b/extras/docker/apache/Dockerfile index 3e1a6feea..ff746b1a7 100644 --- a/extras/docker/apache/Dockerfile +++ b/extras/docker/apache/Dockerfile @@ -1,7 +1,7 @@ # # A wger installation under apache with WSGI # -# Note: you MUST build this image from the projec's root! +# Note: you MUST build this image from the project's root! # docker build -f extras/docker/apache/Dockerfile --tag wger/apache . # # Please consult the documentation for usage @@ -31,7 +31,7 @@ RUN apt-get update \ # Set up the application COPY requirements* ./ -RUN pip3 wheel --no-cache-dir --wheel-dir /usr/src/app/wheels -r requirements_devel.txt +RUN pip3 wheel --no-cache-dir --wheel-dir /usr/src/app/wheels -r requirements_dev.txt ######## @@ -85,12 +85,8 @@ RUN . /home/wger/venv/bin/activate \ && pip install wheel \ && pip install --no-cache /wheels/* \ && python setup.py develop \ - && wger create-settings \ - --settings-path /home/wger/src/settings.py \ - --database-path /home/wger/db/database.sqlite \ - && wger bootstrap \ - --settings-path /home/wger/src/settings.py \ - --no-start-server + && wger create-settings --database-path /home/wger/db/database.sqlite \ + && wger bootstrap # Change permissions of some files and folders so the apache process @@ -104,6 +100,7 @@ RUN mkdir -p ~/static/CACHE ~/media \ USER root RUN apt-get remove build-essential -y \ && apt autoremove -y \ - && chown www-data:www-data -R /home/wger/db + && chown :www-data -R /home/wger/db \ + && chmod g+w /home/wger/db /home/wger/db/database.sqlite ENTRYPOINT ["/home/wger/entrypoint.sh"] diff --git a/extras/docker/apache/README.md b/extras/docker/apache/README.md index eeee20d65..d086842b8 100644 --- a/extras/docker/apache/README.md +++ b/extras/docker/apache/README.md @@ -1,12 +1,17 @@ Demo image for wger =================== -Thank you for downloading wger Workout Manager. wger (ˈvɛɡɐ) is a free, open -source web application that manages your exercises and personal workouts, weight -and diet plans. It can also be used as a simple gym management utility, providing -different administrative roles (trainer, manager, etc.). It offers a REST API -as well, for easy integration with other projects and tools. +wger (ˈvɛɡɐ) Workout Manager is a free, open source web application that help +you manage your personal workouts, weight and diet plans and can also be used +as a simple gym management utility. It offers a REST API as well, for easy +integration with other projects and tools. -It is written with python/django and uses jQuery and some D3js for charts. + +Please note that this image will overwrite your data when you pull a new version, +it is only intended as an easy to setup demo. If you want to host your own +instance, take a look at the provided docker compose file. That config will +persist your database and uploaded images: + + Installation ------------ @@ -46,10 +51,10 @@ didn't behave as you expected. We can't fix what we don't know about, so please report liberally. If you're not sure if something is a bug or not, feel free to file a bug anyway. +* discord: * gitter: * issue tracker: * twitter: -* mailing list: / wger@googlegroups.com, no registration needed Sources ------- diff --git a/extras/docker/base/Dockerfile b/extras/docker/base/Dockerfile index 6029c2b62..63d92e3c8 100644 --- a/extras/docker/base/Dockerfile +++ b/extras/docker/base/Dockerfile @@ -25,7 +25,7 @@ RUN apt-get update \ sqlite3 \ netcat-openbsd \ && rm -rf /var/lib/apt/lists/* \ - && npm install -g yarn sass \ + && npm install -g yarn@1.22.5 sass\ && locale-gen en_US.UTF-8 # Environmental variables diff --git a/extras/docker/base/README.md b/extras/docker/base/README.md index 37263b2b1..4debed86d 100644 --- a/extras/docker/base/README.md +++ b/extras/docker/base/README.md @@ -14,10 +14,10 @@ didn't behave as you expected. We can't fix what we don't know about, so please report liberally. If you're not sure if something is a bug or not, feel free to file a bug anyway. +* discord: * gitter: * issue tracker: * twitter: -* mailing list: / wger@googlegroups.com, no registration needed Sources ------- diff --git a/extras/docker/compose/README.md b/extras/docker/compose/README.md index d98eea952..4847066e9 100644 --- a/extras/docker/compose/README.md +++ b/extras/docker/compose/README.md @@ -66,10 +66,10 @@ didn't behave as you expected. We can't fix what we don't know about, so please report liberally. If you're not sure if something is a bug or not, feel free to file a bug anyway. +* discord: * gitter: * issue tracker: * twitter: -* mailing list: / wger@googlegroups.com, no registration needed ## Sources diff --git a/extras/docker/compose/dev.env b/extras/docker/compose/dev.env index c0532b43a..62418e8b3 100644 --- a/extras/docker/compose/dev.env +++ b/extras/docker/compose/dev.env @@ -14,3 +14,7 @@ DJANGO_CACHE_TIMEOUT=100 DJANGO_CACHE_CLIENT_CLASS=django_redis.client.DefaultClient DJANGO_MEDIA_ROOT=/home/wger/media + +# Others +DJANGO_DEBUG=True +WGER_USE_GUNICORN=False diff --git a/extras/docker/compose/docker-compose.yml b/extras/docker/compose/docker-compose.yml index e90db7026..56ff1901a 100644 --- a/extras/docker/compose/docker-compose.yml +++ b/extras/docker/compose/docker-compose.yml @@ -7,6 +7,7 @@ services: - type: bind source: ../../../ target: /home/wger/src/ + - media:/home/wger/media ports: - 8000:8000 env_file: @@ -34,3 +35,4 @@ services: volumes: wger-postgres-data: + media: diff --git a/extras/docker/development-venv/Dockerfile b/extras/docker/development-venv/Dockerfile index 41148195a..8f702768a 100644 --- a/extras/docker/development-venv/Dockerfile +++ b/extras/docker/development-venv/Dockerfile @@ -2,12 +2,12 @@ # Docker image for wger development: # # This image uses a virtual environment, which is not necessary in a docker -# image and is more or less intented to check that the installation instructions -# for a local develoment are up-to-date +# image and is more or less intended to check that the installation instructions +# for a local development are up-to-date # # Please consult the documentation for usage # -# Note: you MUST build this image from the projec's root! +# Note: you MUST build this image from the project's root! # docker build -f extras/docker/development/Dockerfile --tag wger/devel . # # Run the container: @@ -43,7 +43,7 @@ RUN apt-get update \ # Set up the application COPY . . RUN pip3 install wheel \ - && pip3 wheel --no-cache-dir --wheel-dir /usr/src/app/wheels -r requirements_devel.txt + && pip3 wheel --no-cache-dir --wheel-dir /usr/src/app/wheels -r requirements_dev.txt ######## @@ -66,12 +66,8 @@ RUN . /home/wger/venv/bin/activate \ && pip install --upgrade pip \ && pip install --no-cache /wheels/* \ && python setup.py develop \ - && wger create-settings \ - --settings-path /home/wger/src/settings.py \ - --database-path /home/wger/db/database.sqlite \ - && wger bootstrap \ - --settings-path /home/wger/src/settings.py \ - --no-start-server + && wger create-settings --database-path /home/wger/db/database.sqlite \ + && wger bootstrap # Download the exercise images RUN mkdir ~/media \ diff --git a/extras/docker/development/Dockerfile b/extras/docker/development/Dockerfile index cc59f6f65..d0769946f 100644 --- a/extras/docker/development/Dockerfile +++ b/extras/docker/development/Dockerfile @@ -3,7 +3,7 @@ # # Please consult the README for usage # -# Note: you MUST build this image from the projec's root! +# Note: you MUST build this image from the project's root! # docker build -f extras/docker/development/Dockerfile --tag wger/devel . # # Run the container: @@ -25,7 +25,7 @@ RUN apt-get update \ # Build the necessary python wheels COPY requirements* ./ -RUN pip3 wheel --no-cache-dir --wheel-dir /wheels -r requirements_devel.txt +RUN pip3 wheel --no-cache-dir --wheel-dir /wheels -r requirements_prod.txt @@ -47,14 +47,13 @@ COPY ${DOCKER_DIR}/settings.py /tmp/ COPY ${DOCKER_DIR}/entrypoint.sh /home/wger/entrypoint.sh RUN chmod +x /home/wger/entrypoint.sh RUN pip3 install --no-cache /wheels/* \ - && pip3 install psycopg2-binary \ - && pip3 install django-redis \ && python3 setup.py develop RUN chown -R wger:wger . USER wger RUN mkdir ~/media \ + && mkdir ~/static \ && mkdir ~/db/ CMD ["/home/wger/entrypoint.sh"] diff --git a/extras/docker/development/README.md b/extras/docker/development/README.md index 80115ffba..1f0a31d71 100644 --- a/extras/docker/development/README.md +++ b/extras/docker/development/README.md @@ -1,10 +1,15 @@ # Development image for wger +wger (ˈvɛɡɐ) Workout Manager is a free, open source web application that help +you manage your personal workouts, weight and diet plans and can also be used +as a simple gym management utility. It offers a REST API as well, for easy +integration with other projects and tools. + + +Please note that this image is intended for development, if you want to +host your own instance, take a look at the provided docker compose file: + + -Thank you for downloading wger Workout Manager. wger (ˈvɛɡɐ) is a free, open -source web application that manages your exercises and personal workouts, weight -and diet plans. It can also be used as a simple gym management utility, providing -different administrative roles (trainer, manager, etc.). It offers a REST API -as well, for easy integration with other projects and tools. ## Usage @@ -65,10 +70,10 @@ didn't behave as you expected. We can't fix what we don't know about, so please report liberally. If you're not sure if something is a bug or not, feel free to file a bug anyway. +* discord: * gitter: * issue tracker: * twitter: -* mailing list: / wger@googlegroups.com, no registration needed ## Sources diff --git a/extras/docker/development/entrypoint.sh b/extras/docker/development/entrypoint.sh index 6265ab7c3..5cfd3942a 100644 --- a/extras/docker/development/entrypoint.sh +++ b/extras/docker/development/entrypoint.sh @@ -16,16 +16,31 @@ if [[ "$DJANGO_DB_PORT" == "5432" ]]; then echo "PostgreSQL started :)" fi -# Bootstrap the application -wger bootstrap \ - --settings-path /home/wger/src/settings.py \ - --no-start-server - -if [[ "$WGER_DOWNLOAD_IMGS" == "TRUE" ]]; +# The python wger package needs to be installed in development mode. +# If the created folder does not exist (e.g. because this image was mounted +# after a first checkout), repeat the process. +if [ ! -d "/home/wger/src/wger.egg-info" ]; then - wger download-exercise-images - chmod -R g+w ~wger/media + python3 setup.py develop --user fi -# Run the development server -python3 manage.py runserver 0.0.0.0:8000 +# Bootstrap the application +# * Load the fixtures with exercises, ingredients, etc +# * Create an admin user +# * Download JS and CSS files +# * Compile custom bootstrap theme +wger bootstrap + +# Collect static files +if [[ "$DJANGO_DEBUG" == "False" ]]; +then + python3 manage.py collectstatic --no-input +fi + +# Run the server +if [[ "$WGER_USE_GUNICORN" == "True" ]]; +then + gunicorn wger.wsgi:application --reload --bind 0.0.0.0:8000 +else + python3 manage.py runserver 0.0.0.0:8000 +fi diff --git a/extras/docker/development/settings.py b/extras/docker/development/settings.py index bea1370fd..1f9a73752 100644 --- a/extras/docker/development/settings.py +++ b/extras/docker/development/settings.py @@ -6,8 +6,7 @@ from wger.settings_global import * # Use 'DEBUG = True' to get more details for server errors -DEBUG = True -TEMPLATES[0]['OPTIONS']['debug'] = True +DEBUG = os.environ.get("DJANGO_DEBUG", True) ADMINS = ( ('Your name', 'your_email@example.com'), @@ -51,9 +50,11 @@ SITE_URL = 'http://localhost:8000' # Path to uploaded files # Absolute filesystem path to the directory that will hold user-uploaded files. -MEDIA_ROOT = '/home/wger/media' +MEDIA_ROOT = os.environ.get("DJANGO_MEDIA_ROOT", '/home/wger/media') MEDIA_URL = '/media/' +STATIC_ROOT = os.environ.get("DJANGO_STATIC_ROOT", '/home/wger/static') + # Allow all hosts to access the application. Change if used in production. ALLOWED_HOSTS = '*' diff --git a/extras/dummy_generator/generator.py b/extras/dummy_generator/generator.py index c2018d575..6319ba2b2 100644 --- a/extras/dummy_generator/generator.py +++ b/extras/dummy_generator/generator.py @@ -15,53 +15,59 @@ # # You should have received a copy of the GNU Affero General Public License -import os -import sys -import csv -import uuid -import random -import django -import datetime +# Standard Library import argparse +import csv +import datetime +import os +import random +import sys +import uuid +# Django +import django from django.db import IntegrityError +from django.utils import timezone from django.utils.text import slugify + sys.path.insert(0, os.path.join('..', '..')) os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings') django.setup() +# Django # Must happen after calling django.setup() from django.contrib.auth.models import User -from wger.core.models import DaysOfWeek + +# wger +from wger.core.models import ( + DaysOfWeek, + Language +) from wger.exercises.models import Exercise from wger.gym.models import ( - GymUserConfig, - Gym + Gym, + GymUserConfig ) from wger.manager.models import ( - Workout, Day, - Set, - Setting, Schedule, ScheduleStep, + Set, + Setting, + Workout, WorkoutLog, WorkoutSession ) -from wger.weight.models import WeightEntry - -from wger.core.models import Language - -# Nutrition import //_c from wger.nutrition.models import ( Ingredient, - IngredientWeightUnit, - WeightUnit, - NutritionPlan, + LogItem, Meal, - MealItem + MealItem, + NutritionPlan ) +from wger.weight.models import WeightEntry + parser = argparse.ArgumentParser(description='Data generator. Please consult the documentation') subparsers = parser.add_subparsers(help='The kind of entries you want to generate') @@ -132,12 +138,27 @@ weight_parser.add_argument('--base-weight', # Nutrition options nutrition_parser = subparsers.add_parser('nutrition', help='Creates a meal plan') nutrition_parser.add_argument('number_nutrition_plans', - action='store', - help='Number of meal plans to create', - type=int) + action='store', + help='Number of meal plans to create', + type=int) nutrition_parser.add_argument('--add-to-user', - action='store', - help='Add to the specified user-ID, not all existing users') + action='store', + help='Add to the specified user-ID, not all existing users') + +# Nutrition diary options +nutrition_parser = subparsers.add_parser('nutrition-diary', help='Creates a meal plan') +nutrition_parser.add_argument('number_nutrition_logs', + action='store', + help='Number of nutrition diary logs to create', + type=int) +nutrition_parser.add_argument('--number-diary-dates', + action='store', + help='Number of dates in which to create logs (default: 30)', + default=30, + type=int) +nutrition_parser.add_argument('--add-to-user', + action='store', + help='Add to the specified user-ID, not all existing users') args = parser.parse_args() # print(args) @@ -248,7 +269,6 @@ if hasattr(args, 'number_gyms'): # Bulk-create all the gyms Gym.objects.bulk_create(gym_list) - # # Workout generator # @@ -364,7 +384,6 @@ if hasattr(args, 'number_logs'): # Bulk-create the logs WorkoutLog.objects.bulk_create(weight_log) - # # Session generator # @@ -382,8 +401,8 @@ if hasattr(args, 'impression_sessions'): workout = WorkoutLog.objects.filter(user=user, date=date).first().workout start = datetime.time(hour=random.randint(8, 20), minute=random.randint(0, 59)) - end = datetime.datetime.combine(datetime.date.today(), start) \ - + datetime.timedelta(minutes=random.randint(40, 120)) + end = datetime.datetime.combine(datetime.date.today(), start) \ + + datetime.timedelta(minutes=random.randint(40, 120)) end = datetime.time(hour=end.hour, minute=end.minute) session = WorkoutSession() @@ -449,7 +468,7 @@ if hasattr(args, 'number_nutrition_plans'): userlist = [i for i in User.objects.all()] # Load all ingredients to a list - ingredientList = [i for i in Ingredient.objects.order_by('?').all()[:100]] + ingredient_list = [i for i in Ingredient.objects.order_by('?').all()[:100]] # Total meals per plan total_meals = 4 @@ -461,8 +480,9 @@ if hasattr(args, 'number_nutrition_plans'): for i in range(0, args.number_nutrition_plans): uid = str(uuid.uuid4()).split('-') start_date = datetime.date.today() - datetime.timedelta(days=random.randint(0, 100)) - nutrition_plan = NutritionPlan(language=Language.objects.all()[1], description='Dummy nutrition plan - {0}'.format(uid[1]), - creation_date=start_date) + nutrition_plan = NutritionPlan(language=Language.objects.all()[1], + description='Dummy nutrition plan - {0}'.format(uid[1]), + creation_date=start_date) nutrition_plan.user = user nutrition_plan.save() @@ -472,8 +492,40 @@ if hasattr(args, 'number_nutrition_plans'): for j in range(0, total_meals): meal = Meal(plan=nutrition_plan, order=order) meal.save() - for k in range(0, random.randint(1,5)): - ingredient = random.choice(ingredientList) - meal_item = MealItem(meal=meal, ingredient=ingredient, weight_unit=None, order=order, amount=random.randint(10, 250)) + for k in range(0, random.randint(1, 5)): + ingredient = random.choice(ingredient_list) + meal_item = MealItem(meal=meal, ingredient=ingredient, weight_unit=None, + order=order, amount=random.randint(10, 250)) meal_item.save() order = order + 1 + +# Nutrition logs Generator +if hasattr(args, 'number_nutrition_logs'): + print("** Generating {0} nutrition plan(s) per user".format(args.number_nutrition_logs)) + + if args.add_to_user: + userlist = [User.objects.get(pk=args.add_to_user)] + else: + userlist = [i for i in User.objects.all()] + + # Load all ingredients to a list + ingredient_list = [i for i in Ingredient.objects.order_by('?').all()[:100]] + + for user in userlist: + plan_list = NutritionPlan.objects.order_by('?').filter(user=user) + print(' - generating for {0}'.format(user.username)) + + # Add diary entries + for plan in NutritionPlan.objects.filter(user=user): + for i in range(0, args.number_diary_dates): + date = timezone.now() - datetime.timedelta(days=random.randint(0, 100), + hours=random.randint(0, 12), + minutes=random.randint(0, 59)) + for j in range(0, args.number_nutrition_logs): + ingredient = random.choice(ingredient_list) + log = LogItem(plan=plan, + datetime=date, + ingredient=ingredient, + weight_unit=None, + amount=random.randint(10, 300)) + log.save() diff --git a/manage.py b/manage.py index f79cb6c7e..c35ed859a 100644 --- a/manage.py +++ b/manage.py @@ -8,7 +8,7 @@ from django.core.management import execute_from_command_line # wger from wger.tasks import ( - get_user_config_path, + get_path, setup_django_environment ) @@ -17,7 +17,7 @@ if __name__ == "__main__": # If user passed the settings flag ignore the default wger settings if not any('--settings' in s for s in sys.argv): - setup_django_environment(get_user_config_path('wger', 'settings.py')) + setup_django_environment(get_path('settings.py')) # Alternative to above # os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") diff --git a/package.json b/package.json index ab38ce1df..1fd669955 100644 --- a/package.json +++ b/package.json @@ -19,14 +19,17 @@ "yarn": "^1.22.5", "Sortable": "RubaXa/Sortable#1.10.2", "bootstrap": "twbs/bootstrap#4.x", - "components-font-awesome": "components/font-awesome#5.14.0", + "components-font-awesome": "components/font-awesome#5.15.1", "d3": "mbostock-bower/d3-bower#>=5", "datatables": "DataTables/DataTables#1.10.x", "devbridge-autocomplete": "^1.4.11", "jquery": "^3.5.0", "metrics-graphics": "mozilla/metrics-graphics#2.15.x", "shariff": "^3.2.1", - "tinymce": "^5.4.2" + "tinymce": "^5.5.1" + }, + "scripts": { + "build:css:sass": "sass wger/core/static/scss/main.scss:wger/core/static/yarn/bootstrap-compiled.css" }, "engines": { "yarn": ">= 1.0.0" diff --git a/requirements.txt b/requirements.txt index 550d234be..3bd1a6135 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,10 @@ # -# Requirements for wger for production +# Common requirements for wger # -# Building/installing -wheel - # Application -bleach~=3.1 -django-activity-stream +bleach~=3.2 +django-activity-stream~=0.9 django-bootstrap-breadcrumbs~=0.9 django-formtools~=2.2 django-recaptcha==2.0.6 @@ -16,13 +13,13 @@ django-crispy-forms~=1.9 django_compressor~=2.4 django_extensions~=3.0 django-sortedm2m~=3.0 -django-storages~=1.9 +django-storages~=1.10 easy-thumbnails~=2.7 -icalendar==4.0.6 +icalendar==4.0.7 invoke~=1.4 -pillow~=7.2 +pillow~=8.0 python-mimeparse -reportlab==3.5.48 +reportlab==3.5.53 matplotlib>=3.1 requests setuptools>=18.5 @@ -31,14 +28,11 @@ sphinx # AWS #boto3 -# Production -#psycopg2 -#python-memcached # REST API django-cors-headers>=3.0 -django-filter==2.3.0 -djangorestframework~=3.11 +django-filter==2.4.0 +djangorestframework~=3.12 # Explicitly set versions as a workaround for CI/Devel. diff --git a/requirements_devel.txt b/requirements_dev.txt similarity index 52% rename from requirements_devel.txt rename to requirements_dev.txt index 02895e332..34d9e1ab7 100644 --- a/requirements_devel.txt +++ b/requirements_dev.txt @@ -1,10 +1,13 @@ # -# Requirements for wger during development +# Requirements for wger during development only # -# Production packages +# Regular packages -r requirements.txt +# Building/installing +wheel + # Development packages coverage django-debug-toolbar diff --git a/requirements_prod.txt b/requirements_prod.txt new file mode 100644 index 000000000..2aeec311f --- /dev/null +++ b/requirements_prod.txt @@ -0,0 +1,11 @@ +# +# Requirements for wger for production +# + +# Regular packages +-r requirements.txt + +gunicorn~=20.0 +psycopg2-binary +django-redis + diff --git a/setup.cfg b/setup.cfg index d3646b25b..4426d5335 100644 --- a/setup.cfg +++ b/setup.cfg @@ -26,7 +26,7 @@ import_heading_django=Django # A comment to consistently place directly above imports from the standard library. import_heading_stdlib=Standard Library -# A comment to consistently place directly above thirdparty imports. +# A comment to consistently place directly above third-party imports. import_heading_thirdparty=Third Party # A comment to consistently place directly above wger imports. @@ -39,18 +39,18 @@ import_heading_localfolder=Local # Tab to indent by a single tab. indent=' ' -# A list of imports that will be forced to display withing the first party +# A list of imports that will be forced to display within the first party # category. known_first_party=wger known_django=django -# An integer that represents how you want imports to be displayed if their long +# An integer that represents how you want imports to be displayed if they're long # enough to span multiple lines. A full definition of all possible modes can be # found in isort's README. multi_line_output=3 force_grid_wrap=True -# If set to true - isort will create separate sections withing "from" imports +# If set to true - isort will create separate sections within "from" imports # for CONSTANTS, Classes, and modules/functions. order_by_type=True diff --git a/setup.py b/setup.py index c18522647..e4eaff647 100644 --- a/setup.py +++ b/setup.py @@ -17,7 +17,7 @@ from setuptools import ( from wger import get_version -with open('README.rst') as readme: +with open('README.md') as readme: long_description = readme.read() with open('requirements.txt') as requirements_production: @@ -27,7 +27,7 @@ setup( name='wger', description='FLOSS workout, fitness and weight manager/tracker written with Django', long_description=long_description, - long_description_content_type='text/x-rst', + long_description_content_type='text/markdown', version=get_version(), url='https://github.com/wger-project', author='Roland Geider', @@ -49,7 +49,7 @@ setup( 'Programming Language :: Python :: 3.8', ], - nstall_requires=install_requires, + install_requires=install_requires, entry_points={ 'console_scripts': [ 'wger = wger.__main__:main', diff --git a/wger/__main__.py b/wger/__main__.py index 96cb41190..b42898a4f 100644 --- a/wger/__main__.py +++ b/wger/__main__.py @@ -32,7 +32,7 @@ invoke_cmd = 'invoke ' def main(): - # Change the working directory so that invoke can find the tasks fiel + # Change the working directory so that invoke can find the tasks file os.chdir(os.path.dirname(os.path.abspath(__file__))) args = sys.argv[1:] diff --git a/wger/config/models.py b/wger/config/models.py index 309852379..cf6a30947 100644 --- a/wger/config/models.py +++ b/wger/config/models.py @@ -78,7 +78,7 @@ class LanguageConfig(models.Model): """ Return a more human-readable representation """ - return u"Config for language {0}".format(self.language) + return "Config for language {0}".format(self.language) def save(self, *args, **kwargs): """ @@ -134,7 +134,7 @@ class GymConfig(models.Model): """ Return a more human-readable representation """ - return u"Default gym {0}".format(self.default_gym) + return "Default gym {0}".format(self.default_gym) def save(self, *args, **kwargs): """ diff --git a/wger/core/api/serializers.py b/wger/core/api/serializers.py index abb3bcbe9..55a52365d 100644 --- a/wger/core/api/serializers.py +++ b/wger/core/api/serializers.py @@ -15,6 +15,9 @@ # You should have received a copy of the GNU Affero General Public License # along with Workout Manager. If not, see . +# Django +from django.contrib.auth.models import User + # Third Party from rest_framework import serializers @@ -45,6 +48,16 @@ class UsernameSerializer(serializers.Serializer): username = serializers.CharField() +class UserApiSerializer(serializers.ModelSerializer): + """ Serializer to map to User model in relation to api user""" + username = serializers.CharField(required=True) + password = serializers.CharField(required=True, min_length=8) + + class Meta: + model = User + fields = ['username', 'password'] + + class LanguageSerializer(serializers.ModelSerializer): """ Language serializer diff --git a/wger/core/api/views.py b/wger/core/api/views.py index 067431d3a..0d943681b 100644 --- a/wger/core/api/views.py +++ b/wger/core/api/views.py @@ -15,12 +15,19 @@ # You should have received a copy of the GNU Affero General Public License # along with Workout Manager. If not, see . +# Standard Library +import logging + # Django from django.contrib.auth.models import User # Third Party -from rest_framework import viewsets +from rest_framework import ( + status, + viewsets +) from rest_framework.decorators import action +from rest_framework.permissions import AllowAny from rest_framework.response import Response # wger @@ -29,6 +36,7 @@ from wger.core.api.serializers import ( LanguageSerializer, LicenseSerializer, RepetitionUnitSerializer, + UserApiSerializer, UsernameSerializer, UserprofileSerializer, WeightUnitSerializer @@ -41,12 +49,16 @@ from wger.core.models import ( UserProfile, WeightUnit ) +from wger.utils.api_token import create_token from wger.utils.permissions import ( UpdateOnlyPermission, WgerPermission ) +logger = logging.getLogger(__name__) + + class UserProfileViewSet(viewsets.ModelViewSet): """ API endpoint for workout objects @@ -78,6 +90,43 @@ class UserProfileViewSet(viewsets.ModelViewSet): return Response(UsernameSerializer(user).data) +class UserAPILoginView(viewsets.ViewSet): + """ + API endpoint for api user objects + """ + permission_classes = (AllowAny,) + queryset = User.objects.all() + serializer_class = UserApiSerializer + throttle_scope = 'login' + + def get(self, request): + return Response({'message': "You must send a 'username' and 'password' via POST"}) + + def post(self, request): + data = request.data + serializer = self.serializer_class(data=data) + serializer.is_valid(raise_exception=True) + + username = serializer.data["username"] + password = serializer.data["password"] + + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + logger.info(f"Tried logging via API with unknown user: '{username}'") + return Response({'detail': 'Username or password unknown'}, + status=status.HTTP_401_UNAUTHORIZED) + + if user.check_password(password): + token = create_token(user) + return Response({'token': token.key}, + status=status.HTTP_200_OK) + else: + logger.info(f"User '{username}' tried logging via API with a wrong password") + return Response({'detail': 'Username or password unknown'}, + status=status.HTTP_401_UNAUTHORIZED) + + class LanguageViewSet(viewsets.ReadOnlyModelViewSet): """ API endpoint for workout objects diff --git a/wger/core/forms.py b/wger/core/forms.py index 2aec72990..3ec666a91 100644 --- a/wger/core/forms.py +++ b/wger/core/forms.py @@ -112,7 +112,7 @@ class UserPreferencesForm(forms.ModelForm): 'workout_reminder', 'workout_duration', ), - AccordionGroup("{} ({})".format(_("Gym mode"), _("mobile version only")), + AccordionGroup(f"{_('Gym mode')} ({_('mobile version only')})", "timer_active", "timer_pause" ), diff --git a/wger/core/management/commands/clear-cache.py b/wger/core/management/commands/clear-cache.py index 6b1ce0699..ef9a89a3a 100644 --- a/wger/core/management/commands/clear-cache.py +++ b/wger/core/management/commands/clear-cache.py @@ -80,24 +80,24 @@ class Command(BaseCommand): for user in User.objects.all(): if int(options['verbosity']) >= 2: - self.stdout.write("* Processing user {0}".format(user.username)) + self.stdout.write(f"* Processing user {user.username}") for entry in WorkoutLog.objects.filter(user=user).dates('date', 'year'): if int(options['verbosity']) >= 3: - self.stdout.write(" Year {0}".format(entry.year)) + self.stdout.write(f" Year {entry.year}") for month in WorkoutLog.objects.filter(user=user, date__year=entry.year).dates('date', 'month'): if int(options['verbosity']) >= 3: - self.stdout.write(" Month {0}".format(entry.month)) + self.stdout.write(f" Month {entry.month}") reset_workout_log(user.id, entry.year, entry.month) for day in WorkoutLog.objects.filter(user=user, date__year=entry.year, date__month=month.month).dates('date', 'day'): if int(options['verbosity']) >= 3: - self.stdout.write(" Day {0}".format(day.day)) + self.stdout.write(f" Day {day.day}") reset_workout_log(user.id, entry.year, entry.month, day) for language in Language.objects.all(): diff --git a/wger/core/management/commands/delete-temp-users.py b/wger/core/management/commands/delete-temp-users.py index e8bd740f8..d3a89a745 100644 --- a/wger/core/management/commands/delete-temp-users.py +++ b/wger/core/management/commands/delete-temp-users.py @@ -43,4 +43,4 @@ class Command(BaseCommand): counter += 1 profile.user.delete() - self.stdout.write("Deleted {0} temporary users".format(counter)) + self.stdout.write(f"Deleted {counter} temporary users") diff --git a/wger/core/management/commands/extract-i18n.py b/wger/core/management/commands/extract-i18n.py index 684d38588..d3383db5c 100644 --- a/wger/core/management/commands/extract-i18n.py +++ b/wger/core/management/commands/extract-i18n.py @@ -44,5 +44,5 @@ class Command(BaseCommand): # Print the result for i in out: - self.stdout.write('msgid "{0}"\n' - 'msgstr ""\n\n'.format(i)) + self.stdout.write(f'msgid "{i}"\n' + 'msgstr ""\n\n') diff --git a/wger/core/models.py b/wger/core/models.py index 37a2b21f3..72547dace 100644 --- a/wger/core/models.py +++ b/wger/core/models.py @@ -63,7 +63,7 @@ class Language(models.Model): """ Return a more human-readable representation """ - return u"{0} ({1})".format(self.full_name, self.short_name) + return f"{self.full_name} ({self.short_name})" def get_absolute_url(self): """ @@ -382,7 +382,7 @@ by the US Department of Agriculture. It is extremely complete, with around """ Return a more human-readable representation """ - return u"Profile for user {0}".format(self.user) + return f"Profile for user {self.user}" @property def use_metric(self): @@ -521,7 +521,7 @@ class UserCache(models.Model): """ Return a more human-readable representation """ - return u"Cache for user {0}".format(self.user) + return f"Cache for user {self.user}" class DaysOfWeek(models.Model): @@ -582,7 +582,7 @@ class License(models.Model): """ Return a more human-readable representation """ - return u"{0} ({1})".format(self.full_name, self.short_name) + return f"{self.full_name} ({self.short_name})" # # Own methods diff --git a/wger/core/templates/403.html b/wger/core/templates/403.html index e0b2ef552..49f6a69db 100644 --- a/wger/core/templates/403.html +++ b/wger/core/templates/403.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "template_no_context.html" %} {% load i18n static %} {% block title %}{% trans "Forbidden!" %}{% endblock %} diff --git a/wger/core/templates/404.html b/wger/core/templates/404.html index aaa39f1b9..064275198 100644 --- a/wger/core/templates/404.html +++ b/wger/core/templates/404.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "template_no_context.html" %} {% load i18n static %} {% block title %}{% trans "Page not found" %}{% endblock %} diff --git a/wger/core/templates/500.html b/wger/core/templates/500.html index e2e839e85..85e7f53a6 100644 --- a/wger/core/templates/500.html +++ b/wger/core/templates/500.html @@ -1,7 +1,7 @@ -{% extends "base.html" %} +{% extends "template_no_context.html" %} {% load i18n static %} -{% block title %}{% trans "An error occured" %}{% endblock %} +{% block title %}{% trans "An error occurred" %}{% endblock %} {% block content %}

{% trans "Something happened that caused an error." %}

diff --git a/wger/core/templates/index.html b/wger/core/templates/index.html index f59c8569a..cc25f5d27 100644 --- a/wger/core/templates/index.html +++ b/wger/core/templates/index.html @@ -101,9 +101,9 @@ day in your current workout:{% endblocktrans %} {{current_workout}}

-

- – {{ current_workout.creation_date }} -

+

+ – {{ current_workout.creation_date }} +

{% endif %} diff --git a/wger/core/templates/rest_framework/api.html b/wger/core/templates/rest_framework/api.html index 6236e177e..7ac3a18f2 100644 --- a/wger/core/templates/rest_framework/api.html +++ b/wger/core/templates/rest_framework/api.html @@ -98,9 +98,9 @@
HTTP {{ response.status_code }} {{ response.status_text }}{% autoescape off %} {% for key, val in response_headers.items %} {{ key }}: - {{ val|break_long_headers|urlize_quoted_links }} + {{ val|break_long_headers|urlize }} {% endfor %} -
{{ content|urlize_quoted_links }} + {{ content|urlize }} {% endautoescape %} diff --git a/wger/core/templates/tags/render_day.html b/wger/core/templates/tags/render_day.html index d8637b60a..f8ee03a30 100644 --- a/wger/core/templates/tags/render_day.html +++ b/wger/core/templates/tags/render_day.html @@ -34,6 +34,7 @@ $(document).ready(function() { {% trans 'Edit' %} + {% trans 'Delete' %} @@ -50,23 +51,31 @@ $(document).ready(function() { {% for set in day.set_list %} + @@ -38,46 +39,110 @@ + + + + + + - + + +
-

#{{ forloop.counter }}

+ #{{ forloop.counter }} {% if editable %} + + + {% if false and day.set_list|length > 1 %} - {% if day.set_list|length > 1 %}
- {% endif %} - - -
- -
+ {% endif %} {% endif %}
diff --git a/wger/core/templates/template_no_context.html b/wger/core/templates/template_no_context.html new file mode 100644 index 000000000..69807f876 --- /dev/null +++ b/wger/core/templates/template_no_context.html @@ -0,0 +1,98 @@ + + + +{% load i18n static wger_extras compress django_bootstrap_breadcrumbs %} + + + + + + + + + + + + + + + {% block header %}{% endblock %} + + {% trans "An error occurred" %} + + + +{# #} +{# Navigation #} +{# #} + + + + + +{# #} +{# Main content #} +{# #} +
+
+
+
+ +
+
+
+
+
+
+
+ {% block content %}{% endblock %} +
+
+
+
+ + + + + + + + + + + diff --git a/wger/core/templates/user/delete_account.html b/wger/core/templates/user/delete_account.html index 9285fac6c..b3bcff532 100644 --- a/wger/core/templates/user/delete_account.html +++ b/wger/core/templates/user/delete_account.html @@ -24,6 +24,7 @@ It will also irrevocably remove all the data associated with it (workouts, logs and can't be undone. {% endblocktrans %}{% endwith %}

+
{% crispy form %} {% endblock %} diff --git a/wger/core/templatetags/wger_extras.py b/wger/core/templatetags/wger_extras.py index 8ec4d518a..11b580b24 100644 --- a/wger/core/templatetags/wger_extras.py +++ b/wger/core/templatetags/wger_extras.py @@ -131,9 +131,9 @@ def render_muscles(muscles=None, muscles_sec=None): except IndexError: front_back = "front" if out_sec[0].is_front else "back" - backgrounds = ["images/muscles/main/muscle-{}.svg".format(i.id) for i in out_main] \ - + ["images/muscles/secondary/muscle-{}.svg".format(i.id) for i in out_sec] \ - + ["images/muscles/muscular_system_{}.svg".format(front_back)] + backgrounds = [f"images/muscles/main/muscle-{i.id}.svg" for i in out_main] \ + + [f"images/muscles/secondary/muscle-{i.id}.svg" for i in out_sec] \ + + [f"images/muscles/muscular_system_{front_back}.svg"] return {"backgrounds": backgrounds, "empty": False} @@ -146,7 +146,7 @@ def language_select(context, language): """ return {'language_name': language[1], - 'path': 'images/icons/flag-{0}.svg'.format(language[0]), + 'path': f'images/icons/flag-{language[0]}.svg', 'i18n_path': context['i18n_path'][language[0]]} @@ -158,6 +158,22 @@ def get_item(dictionary, key): return dictionary.get(key) +@register.filter +def minus(a, b): + """ + Simple function that subtracts two values in a template + """ + return a - b + + +@register.filter +def is_positive(a): + """ + Simple function that checks whether one value is bigger than the other + """ + return a > 0 + + @register.simple_tag def fa_class(class_name='', icon_type='fas', fixed_width=True): """ @@ -171,7 +187,7 @@ def fa_class(class_name='', icon_type='fas', fixed_width=True): if not class_name: return css - css += '{} fa-{}'.format(icon_type, class_name) + css += f'{icon_type} fa-{class_name}' if fixed_width: css += ' fa-fw' diff --git a/wger/core/tests/api_base_test.py b/wger/core/tests/api_base_test.py index db0cc71c4..2f1fbfa9a 100644 --- a/wger/core/tests/api_base_test.py +++ b/wger/core/tests/api_base_test.py @@ -79,14 +79,14 @@ class ApiBaseTestCase(APITestCase): """ Return the URL to use for testing """ - return '/api/{0}/{1}/'.format(self.api_version, self.get_resource_name()) + return f'/api/{self.api_version}/{self.get_resource_name()}/' @property def url_detail(self): """ Return the detail URL to use for testing """ - return '{0}{1}/'.format(self.url, self.pk) + return f'{self.url}{self.pk}/' def get_credentials(self, username=None): """ @@ -399,7 +399,7 @@ class ApiPutTestCase(object): # # Currently resources that have a 'user' field 'succeed' if response.status_code == status.HTTP_201_CREATED: - # print('201: {0}'.format(self.url_detail)) + # print(f'201: {self.url_detail}') obj = self.resource.objects.get(pk=response.data['id']) obj2 = self.resource.objects.get(pk=self.pk) self.assertNotEqual(obj.get_owner_object().user.username, @@ -408,7 +408,7 @@ class ApiPutTestCase(object): self.assertEqual(count_before + 1, count_after) elif response.status_code == status.HTTP_403_FORBIDDEN: - # print('403: {0}'.format(self.url_detail)) + # print(f'403: {self.url_detail}') self.assertEqual(count_before, count_after) else: # Anonymous user diff --git a/wger/core/tests/base_testcase.py b/wger/core/tests/base_testcase.py index ddf6fd694..819c87e0e 100644 --- a/wger/core/tests/base_testcase.py +++ b/wger/core/tests/base_testcase.py @@ -75,13 +75,13 @@ def delete_testcase_add_methods(cls): def test_unauthorized(self): self.user_login(user) self.delete_object(fail=False) - setattr(cls, 'test_unauthorized_{0}'.format(user), test_unauthorized) + setattr(cls, f'test_unauthorized_{user}', test_unauthorized) for user in get_user_list(cls.user_success): def test_authorized(self): self.user_login(user) self.delete_object(fail=False) - setattr(cls, 'test_authorized_{0}'.format(user), test_authorized) + setattr(cls, f'test_authorized_{user}', test_authorized) class BaseTestCase(object): @@ -173,7 +173,7 @@ class WgerTestCase(BaseTestCase, TestCase): """ Login the user, by default as 'admin' """ - password = '{0}{0}'.format(user) + password = f'{user}{user}' self.client.login(username=user, password=password) self.current_user = user self.current_password = password diff --git a/wger/core/tests/test_daysofweek.py b/wger/core/tests/test_daysofweek.py index 6dfab787a..9bb7becd6 100644 --- a/wger/core/tests/test_daysofweek.py +++ b/wger/core/tests/test_daysofweek.py @@ -28,7 +28,7 @@ class DaysOfWeekRepresentationTestCase(WgerTestCase): """ Test that the representation of an object is correct """ - self.assertEqual("{0}".format(DaysOfWeek.objects.get(pk=1)), 'Monday') + self.assertEqual(f"{DaysOfWeek.objects.get(pk=1)}", 'Monday') class DaysOfWeekApiTestCase(api_base_test.ApiBaseResourceTestCase): diff --git a/wger/core/tests/test_delete_user.py b/wger/core/tests/test_delete_user.py index c99dce436..a28c9094a 100644 --- a/wger/core/tests/test_delete_user.py +++ b/wger/core/tests/test_delete_user.py @@ -84,10 +84,10 @@ class DeleteUserByAdminTestCase(WgerTestCase): self.assertEqual(User.objects.filter(username='test').count(), 1) if fail: self.assertIn(response.status_code, (302, 403), - 'Unexpected status code for user {0}'.format(self.current_user)) + f'Unexpected status code for user {self.current_user}') else: self.assertEqual(response.status_code, 200, - 'Unexpected status code for user {0}'.format(self.current_user)) + f'Unexpected status code for user {self.current_user}') # Wrong admin password if not fail: diff --git a/wger/core/tests/test_language.py b/wger/core/tests/test_language.py index 08df6fcf9..c90820a79 100644 --- a/wger/core/tests/test_language.py +++ b/wger/core/tests/test_language.py @@ -39,7 +39,7 @@ class LanguageRepresentationTestCase(WgerTestCase): """ Test that the representation of an object is correct """ - self.assertEqual("{0}".format(Language.objects.get(pk=1)), 'Deutsch (de)') + self.assertEqual(f"{Language.objects.get(pk=1)}", 'Deutsch (de)') class LanguageOverviewTest(WgerAccessTestCase): diff --git a/wger/core/tests/test_license.py b/wger/core/tests/test_license.py index 60aa7ba8e..945eebf73 100644 --- a/wger/core/tests/test_license.py +++ b/wger/core/tests/test_license.py @@ -34,7 +34,7 @@ class LicenseRepresentationTestCase(WgerTestCase): """ Test that the representation of an object is correct """ - self.assertEqual("{0}".format(License.objects.get(pk=1)), + self.assertEqual(f"{License.objects.get(pk=1)}", 'A cool and free license - Germany (ACAFL - DE)') diff --git a/wger/core/tests/test_repetition_unit.py b/wger/core/tests/test_repetition_unit.py index e0c1590c5..a17e5dd3c 100644 --- a/wger/core/tests/test_repetition_unit.py +++ b/wger/core/tests/test_repetition_unit.py @@ -34,7 +34,7 @@ class RepresentationTestCase(WgerTestCase): """ Test that the representation of an object is correct """ - self.assertEqual("{0}".format(RepetitionUnit.objects.get(pk=1)), 'Repetitions') + self.assertEqual(f"{RepetitionUnit.objects.get(pk=1)}", 'Repetitions') class OverviewTest(WgerAccessTestCase): diff --git a/wger/core/tests/test_robots_txt.py b/wger/core/tests/test_robots_txt.py index 877218a98..57aad466b 100644 --- a/wger/core/tests/test_robots_txt.py +++ b/wger/core/tests/test_robots_txt.py @@ -29,5 +29,5 @@ class RobotsTxtTestCase(WgerTestCase): response = self.client.get(reverse('robots')) for lang in Language.objects.all(): - self.assertTrue('wger.de/{0}/sitemap.xml'.format(lang.short_name) + self.assertTrue(f'wger.de/{lang.short_name}/sitemap.xml' in str(response.content)) diff --git a/wger/core/tests/test_weight_unit.py b/wger/core/tests/test_weight_unit.py index f2fabb92b..bf8335718 100644 --- a/wger/core/tests/test_weight_unit.py +++ b/wger/core/tests/test_weight_unit.py @@ -34,7 +34,7 @@ class RepresentationTestCase(WgerTestCase): """ Test that the representation of an object is correct """ - self.assertEqual("{0}".format(WeightUnit.objects.get(pk=1)), 'kg') + self.assertEqual(f"{WeightUnit.objects.get(pk=1)}", 'kg') class OverviewTest(WgerAccessTestCase): diff --git a/wger/core/views/languages.py b/wger/core/views/languages.py index fdc1a7970..787ce346c 100644 --- a/wger/core/views/languages.py +++ b/wger/core/views/languages.py @@ -90,7 +90,7 @@ class LanguageDeleteView(WgerDeleteMixin, LoginRequiredMixin, PermissionRequired Send some additional data to the template """ context = super(LanguageDeleteView, self).get_context_data(**kwargs) - context['title'] = _(u'Delete {0}?').format(self.object.full_name) + context['title'] = _('Delete {0}?').format(self.object.full_name) return context @@ -108,5 +108,5 @@ class LanguageEditView(WgerFormMixin, LoginRequiredMixin, PermissionRequiredMixi Send some additional data to the template """ context = super(LanguageEditView, self).get_context_data(**kwargs) - context['title'] = _(u'Edit {0}').format(self.object.full_name) + context['title'] = _('Edit {0}').format(self.object.full_name) return context diff --git a/wger/core/views/license.py b/wger/core/views/license.py index 62607b357..613578fb7 100644 --- a/wger/core/views/license.py +++ b/wger/core/views/license.py @@ -81,7 +81,7 @@ class LicenseUpdateView(WgerFormMixin, LoginRequiredMixin, PermissionRequiredMix Send some additional data to the template """ context = super(LicenseUpdateView, self).get_context_data(**kwargs) - context['title'] = _(u'Edit {0}').format(self.object) + context['title'] = _('Edit {0}').format(self.object) return context @@ -100,5 +100,5 @@ class LicenseDeleteView(WgerDeleteMixin, LoginRequiredMixin, PermissionRequiredM Send some additional data to the template """ context = super(LicenseDeleteView, self).get_context_data(**kwargs) - context['title'] = _(u'Delete {0}?').format(self.object) + context['title'] = _('Delete {0}?').format(self.object) return context diff --git a/wger/core/views/misc.py b/wger/core/views/misc.py index 8ba59c071..21ea31c86 100644 --- a/wger/core/views/misc.py +++ b/wger/core/views/misc.py @@ -32,7 +32,6 @@ from django.urls import ( reverse_lazy ) from django.utils.translation import ugettext as _ -from django.views.decorators.vary import vary_on_headers from django.views.generic import TemplateView from django.views.generic.edit import FormView @@ -94,7 +93,6 @@ def demo_entries(request): @login_required -@vary_on_headers('User-Agent') def dashboard(request): """ Show the index page, in our case, the last workout and nutritional plan diff --git a/wger/core/views/repetition_units.py b/wger/core/views/repetition_units.py index 5940e6127..dc6735a69 100644 --- a/wger/core/views/repetition_units.py +++ b/wger/core/views/repetition_units.py @@ -82,7 +82,7 @@ class UpdateView(WgerFormMixin, LoginRequiredMixin, PermissionRequiredMixin, Upd Send some additional data to the template """ context = super(UpdateView, self).get_context_data(**kwargs) - context['title'] = _(u'Edit {0}').format(self.object) + context['title'] = _('Edit {0}').format(self.object) return context @@ -111,5 +111,5 @@ class DeleteView(WgerDeleteMixin, LoginRequiredMixin, PermissionRequiredMixin, D Send some additional data to the template """ context = super(DeleteView, self).get_context_data(**kwargs) - context['title'] = _(u'Delete {0}?').format(self.object) + context['title'] = _('Delete {0}?').format(self.object) return context diff --git a/wger/core/views/user.py b/wger/core/views/user.py index 4bd37dac3..16df45cea 100644 --- a/wger/core/views/user.py +++ b/wger/core/views/user.py @@ -95,6 +95,7 @@ from wger.manager.models import ( WorkoutSession ) from wger.nutrition.models import NutritionPlan +from wger.utils.api_token import create_token from wger.utils.generic_views import ( WgerFormMixin, WgerMultiplePermissionRequiredMixin @@ -159,6 +160,7 @@ def delete(request, user_pk=None): else: gym_pk = request.user.userprofile.gym_id return HttpResponseRedirect(reverse('gym:gym:user-list', kwargs={'pk': gym_pk})) + form.helper.form_action = request.path context = {'form': form, 'user_delete': user} @@ -196,13 +198,11 @@ def trainer_login(request, user_pk): or user.has_perm('gym.manage_gyms')): own = True - # Note: it seems we have to manually set the authentication backend here - # - https://docs.djangoproject.com/en/1.6/topics/auth/default/#auth-web-requests - # - http://stackoverflow.com/questions/3807777/django-login-without-authenticating + # Note: when logging without authenticating, it is necessary to set the + # authentication backend if own: del(request.session['trainer.identity']) - user.backend = 'django.contrib.auth.backends.ModelBackend' - django_login(request, user) + django_login(request, user, 'django.contrib.auth.backends.ModelBackend') if not own: request.session['trainer.identity'] = orig_user_pk @@ -456,12 +456,10 @@ def api_key(request): try: token = Token.objects.get(user=request.user) except Token.DoesNotExist: - token = False - if request.GET.get('new_key'): - if token: - token.delete() + token = None - token = Token.objects.create(user=request.user) + if request.GET.get('new_key'): + token = create_token(request.user, request.GET.get('new_key')) # Redirect to get rid of the GET parameter return HttpResponseRedirect(reverse('core:user:api-key')) diff --git a/wger/core/views/weight_units.py b/wger/core/views/weight_units.py index 191ca578a..14821923e 100644 --- a/wger/core/views/weight_units.py +++ b/wger/core/views/weight_units.py @@ -82,7 +82,7 @@ class UpdateView(WgerFormMixin, LoginRequiredMixin, PermissionRequiredMixin, Upd Send some additional data to the template """ context = super(UpdateView, self).get_context_data(**kwargs) - context['title'] = _(u'Edit {0}').format(self.object) + context['title'] = _('Edit {0}').format(self.object) return context @@ -111,5 +111,5 @@ class DeleteView(WgerDeleteMixin, LoginRequiredMixin, PermissionRequiredMixin, D Send some additional data to the template """ context = super(DeleteView, self).get_context_data(**kwargs) - context['title'] = _(u'Delete {0}?').format(self.object) + context['title'] = _('Delete {0}?').format(self.object) return context diff --git a/wger/exercises/api/views.py b/wger/exercises/api/views.py index 00d2112f1..ceacdbbd7 100644 --- a/wger/exercises/api/views.py +++ b/wger/exercises/api/views.py @@ -59,7 +59,7 @@ class ExerciseViewSet(viewsets.ModelViewSet): """ API endpoint for exercise objects """ - queryset = Exercise.objects.all() + queryset = Exercise.objects.accepted() serializer_class = ExerciseSerializer permission_classes = (IsAuthenticatedOrReadOnly, CreateOnlyPermission) ordering_fields = '__all__' diff --git a/wger/exercises/management/commands/download-exercise-images.py b/wger/exercises/management/commands/download-exercise-images.py index 9fa985539..83aa81e33 100644 --- a/wger/exercises/management/commands/download-exercise-images.py +++ b/wger/exercises/management/commands/download-exercise-images.py @@ -43,6 +43,11 @@ from wger.exercises.models import ( ) +EXERCISE_API = "{0}/api/v2/exercise/?limit=999&status=2" +IMAGE_API = "{0}/api/v2/exerciseimage/?exercise={1}" +THUMBNAIL_API = "{0}/api/v2/exerciseimage/{1}/thumbnails/" + + class Command(BaseCommand): """ Download exercise images from wger.de and updates the local database @@ -79,40 +84,35 @@ class Command(BaseCommand): except ValidationError: raise CommandError('Please enter a valid URL') - exercise_api = "{0}/api/v2/exercise/?limit=999&status=2" - image_api = "{0}/api/v2/exerciseimage/?exercise={1}" - thumbnail_api = "{0}/api/v2/exerciseimage/{1}/thumbnails/" - headers = {'User-agent': default_user_agent('wger/{} + requests'.format(get_version()))} # Get all exercises - result = requests.get(exercise_api.format(remote_url), headers=headers).json() + result = requests.get(EXERCISE_API.format(remote_url), headers=headers).json() for exercise_json in result['results']: exercise_name = exercise_json['name'] exercise_uuid = exercise_json['uuid'] exercise_id = exercise_json['id'] self.stdout.write('') - self.stdout.write(u"*** Processing {0} (ID: {1}, UUID: {2})".format(exercise_name, - exercise_id, - exercise_uuid)) + self.stdout.write("*** Processing {0} (ID: {1}, UUID: {2})".format(exercise_name, + exercise_id, + exercise_uuid)) try: exercise = Exercise.objects.get(uuid=exercise_uuid) except Exercise.DoesNotExist: self.stdout.write(' Remote exercise not found in local DB, skipping...') - continue # Get all images - images = requests.get(image_api.format(remote_url, exercise_id), headers=headers).json() + images = requests.get(IMAGE_API.format(remote_url, exercise_id), headers=headers).json() if images['count']: for image_json in images['results']: image_id = image_json['id'] - result = requests.get(thumbnail_api.format(remote_url, image_id), + result = requests.get(THUMBNAIL_API.format(remote_url, image_id), headers=headers).json() image_name = os.path.basename(result['original']) @@ -129,7 +129,7 @@ class Command(BaseCommand): # Save the downloaded image, see link for details # http://stackoverflow.com/questions/1308386/programmatically-saving-image-to- - retrieved_image = requests.get(result['original'], headers=headers) + retrieved_image = requests.get(remote_url + result['original'], headers=headers) img_temp = NamedTemporaryFile(delete=True) img_temp.write(retrieved_image.content) img_temp.flush() diff --git a/wger/exercises/migrations/0003_auto_20160921_2000.py b/wger/exercises/migrations/0003_auto_20160921_2000.py index a43a8e359..7200060b2 100644 --- a/wger/exercises/migrations/0003_auto_20160921_2000.py +++ b/wger/exercises/migrations/0003_auto_20160921_2000.py @@ -26,7 +26,7 @@ def capitalize_name(apps, schema_editor): def capitalize(input): out = [] for word in input.split(' '): - if len(word) > 2 and word[0] != u'ß': + if len(word) > 2 and word[0] != 'ß': out.append(word[:1].upper() + word[1:]) else: out.append(word) diff --git a/wger/exercises/models.py b/wger/exercises/models.py index 730262463..b0fe5933e 100644 --- a/wger/exercises/models.py +++ b/wger/exercises/models.py @@ -328,7 +328,7 @@ class Exercise(AbstractSubmissionModel, AbstractLicenseModel, models.Model): self.license_author = request.user.username subject = _('New user submitted exercise') - message = _(u'The user {0} submitted a new exercise "{1}".').format( + message = _('The user {0} submitted a new exercise "{1}".').format( request.user.username, self.name) mail.mail_admins(str(subject), str(message), @@ -448,7 +448,7 @@ class ExerciseImage(AbstractSubmissionModel, AbstractLicenseModel, models.Model) self.license_author = request.user.username subject = _('New user submitted image') - message = _(u'The user {0} submitted a new image "{1}" for exercise {2}.').format( + message = _('The user {0} submitted a new image "{1}" for exercise {2}.').format( request.user.username, self.name, self.exercise) diff --git a/wger/exercises/templates/exercise/view.html b/wger/exercises/templates/exercise/view.html index a35b553b2..1e3c6754b 100644 --- a/wger/exercises/templates/exercise/view.html +++ b/wger/exercises/templates/exercise/view.html @@ -171,15 +171,21 @@ $(document).ready(function() { {% for comment in comments %}
  • {{ comment }} + {% if perms.exercises.change_exercisecomment %} - - - - - + + + {% endif %}
  • diff --git a/wger/exercises/views/categories.py b/wger/exercises/views/categories.py index 64b759c77..d7062b916 100644 --- a/wger/exercises/views/categories.py +++ b/wger/exercises/views/categories.py @@ -90,7 +90,7 @@ class ExerciseCategoryUpdateView(WgerFormMixin, # Send some additional data to the template def get_context_data(self, **kwargs): context = super(ExerciseCategoryUpdateView, self).get_context_data(**kwargs) - context['title'] = _(u'Edit {0}').format(self.object.name) + context['title'] = _('Edit {0}').format(self.object.name) return context @@ -118,5 +118,5 @@ class ExerciseCategoryDeleteView(WgerDeleteMixin, # Send some additional data to the template def get_context_data(self, **kwargs): context = super(ExerciseCategoryDeleteView, self).get_context_data(**kwargs) - context['title'] = _(u'Delete {0}?').format(self.object.name) + context['title'] = _('Delete {0}?').format(self.object.name) return context diff --git a/wger/exercises/views/exercises.py b/wger/exercises/views/exercises.py index 0a8c97a35..64c7aacae 100644 --- a/wger/exercises/views/exercises.py +++ b/wger/exercises/views/exercises.py @@ -237,7 +237,7 @@ class ExerciseUpdateView(ExercisesEditAddView, def get_context_data(self, **kwargs): context = super(ExerciseUpdateView, self).get_context_data(**kwargs) - context['title'] = _(u'Edit {0}').format(self.object.name) + context['title'] = _('Edit {0}').format(self.object.name) return context @@ -283,7 +283,7 @@ class ExerciseCorrectView(ExercisesEditAddView, LoginRequiredMixin, UpdateView): def get_context_data(self, **kwargs): context = super(ExerciseCorrectView, self).get_context_data(**kwargs) - context['title'] = _(u'Correct {0}').format(self.object.name) + context['title'] = _('Correct {0}').format(self.object.name) return context def form_valid(self, form): @@ -334,7 +334,7 @@ class ExerciseDeleteView(WgerDeleteMixin, Send some additional data to the template """ context = super(ExerciseDeleteView, self).get_context_data(**kwargs) - context['title'] = _(u'Delete {0}?').format(self.object.name) + context['title'] = _('Delete {0}?').format(self.object.name) return context diff --git a/wger/exercises/views/muscles.py b/wger/exercises/views/muscles.py index bf246a514..d16401419 100644 --- a/wger/exercises/views/muscles.py +++ b/wger/exercises/views/muscles.py @@ -102,7 +102,7 @@ class MuscleUpdateView(WgerFormMixin, LoginRequiredMixin, PermissionRequiredMixi Send some additional data to the template """ context = super(MuscleUpdateView, self).get_context_data(**kwargs) - context['title'] = _(u'Edit {0}').format(self.object.name) + context['title'] = _('Edit {0}').format(self.object.name) return context @@ -122,5 +122,5 @@ class MuscleDeleteView(WgerDeleteMixin, LoginRequiredMixin, PermissionRequiredMi Send some additional data to the template """ context = super(MuscleDeleteView, self).get_context_data(**kwargs) - context['title'] = _(u'Delete {0}?').format(self.object.name) + context['title'] = _('Delete {0}?').format(self.object.name) return context diff --git a/wger/gym/forms.py b/wger/gym/forms.py index ae20fef6f..37abfc9e6 100644 --- a/wger/gym/forms.py +++ b/wger/gym/forms.py @@ -19,6 +19,10 @@ from django import forms from django.contrib.auth.models import User from django.utils.translation import ugettext as _ +# Third Party +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Submit + # wger from wger.core.forms import UserPersonalInformationForm from wger.utils.widgets import BootstrapSelectMultiple @@ -62,6 +66,9 @@ class GymUserPermissionForm(forms.ModelForm): self.fields['role'] = forms.MultipleChoiceField(choices=field_choices, initial=User, widget=BootstrapSelectMultiple()) + self.helper = FormHelper() + self.helper.form_class = 'wger-form' + self.helper.add_input(Submit('submit', _("Save"), css_class='btn-success btn-block')) class GymUserAddForm(GymUserPermissionForm, UserPersonalInformationForm): diff --git a/wger/gym/models.py b/wger/gym/models.py index 35fba7e32..028b40cea 100644 --- a/wger/gym/models.py +++ b/wger/gym/models.py @@ -70,19 +70,19 @@ class Gym(m.Model): null=True) """Gym owner""" - zip_code = m.CharField(_(u'ZIP code'), + zip_code = m.CharField(_('ZIP code'), max_length=10, blank=True, null=True) """ZIP code""" - city = m.CharField(_(u'City'), + city = m.CharField(_('City'), max_length=30, blank=True, null=True) """City""" - street = m.CharField(_(u'Street'), + street = m.CharField(_('Street'), max_length=30, blank=True, null=True) @@ -139,7 +139,7 @@ class GymConfig(m.Model): """ Return a more human-readable representation """ - return ugettext(u'Configuration for {}'.format(self.gym.name)) + return ugettext('Configuration for {}'.format(self.gym.name)) def get_owner_object(self): """ @@ -401,7 +401,7 @@ class ContractType(m.Model): """ Return a more human-readable representation """ - return u"{}".format(self.name) + return "{}".format(self.name) def get_owner_object(self): """ @@ -450,7 +450,7 @@ class ContractOption(m.Model): """ Return a more human-readable representation """ - return u"{}".format(self.name) + return "{}".format(self.name) def get_owner_object(self): """ @@ -571,19 +571,19 @@ class Contract(m.Model): null=True) """The member's email""" - zip_code = m.CharField(_(u'ZIP code'), + zip_code = m.CharField(_('ZIP code'), max_length=10, blank=True, null=True) """ZIP code""" - city = m.CharField(_(u'City'), + city = m.CharField(_('City'), max_length=30, blank=True, null=True) """City""" - street = m.CharField(_(u'Street'), + street = m.CharField(_('Street'), max_length=30, blank=True, null=True) diff --git a/wger/gym/views/admin_notes.py b/wger/gym/views/admin_notes.py index 694f387f8..9a651e973 100644 --- a/wger/gym/views/admin_notes.py +++ b/wger/gym/views/admin_notes.py @@ -158,7 +158,7 @@ class UpdateView(WgerFormMixin, LoginRequiredMixin, PermissionRequiredMixin, Upd Send some additional data to the template """ context = super(UpdateView, self).get_context_data(**kwargs) - context['title'] = _(u'Edit {0}').format(self.object) + context['title'] = _('Edit {0}').format(self.object) return context @@ -194,5 +194,5 @@ class DeleteView(WgerDeleteMixin, LoginRequiredMixin, PermissionRequiredMixin, D Send some additional data to the template """ context = super(DeleteView, self).get_context_data(**kwargs) - context['title'] = _(u'Delete {0}?').format(self.object) + context['title'] = _('Delete {0}?').format(self.object) return context diff --git a/wger/gym/views/config.py b/wger/gym/views/config.py index 4d63f163f..ab2d2fa31 100644 --- a/wger/gym/views/config.py +++ b/wger/gym/views/config.py @@ -65,5 +65,5 @@ class GymConfigUpdateView(WgerFormMixin, LoginRequiredMixin, PermissionRequiredM Send some additional data to the template """ context = super(GymConfigUpdateView, self).get_context_data(**kwargs) - context['title'] = _(u'Edit {0}').format(self.object) + context['title'] = _('Edit {0}').format(self.object) return context diff --git a/wger/gym/views/contract.py b/wger/gym/views/contract.py index fe623fdde..b21fc3cdb 100644 --- a/wger/gym/views/contract.py +++ b/wger/gym/views/contract.py @@ -151,7 +151,7 @@ class UpdateView(WgerFormMixin, LoginRequiredMixin, PermissionRequiredMixin, Upd Send some additional data to the template """ context = super(UpdateView, self).get_context_data(**kwargs) - context['title'] = _(u'Edit {0}').format(self.object) + context['title'] = _('Edit {0}').format(self.object) return context diff --git a/wger/gym/views/contract_option.py b/wger/gym/views/contract_option.py index 997e3a0d7..1c48f5813 100644 --- a/wger/gym/views/contract_option.py +++ b/wger/gym/views/contract_option.py @@ -120,7 +120,7 @@ class UpdateView(WgerFormMixin, LoginRequiredMixin, PermissionRequiredMixin, Upd Send some additional data to the template """ context = super(UpdateView, self).get_context_data(**kwargs) - context['title'] = _(u'Edit {0}').format(self.object) + context['title'] = _('Edit {0}').format(self.object) return context @@ -157,7 +157,7 @@ class DeleteView(WgerDeleteMixin, LoginRequiredMixin, PermissionRequiredMixin, D Send some additional data to the template """ context = super(DeleteView, self).get_context_data(**kwargs) - context['title'] = _(u'Delete {0}').format(self.object) + context['title'] = _('Delete {0}').format(self.object) return context diff --git a/wger/gym/views/contract_type.py b/wger/gym/views/contract_type.py index d135085f8..8b84b1ffe 100644 --- a/wger/gym/views/contract_type.py +++ b/wger/gym/views/contract_type.py @@ -120,7 +120,7 @@ class UpdateView(WgerFormMixin, LoginRequiredMixin, PermissionRequiredMixin, Upd Send some additional data to the template """ context = super(UpdateView, self).get_context_data(**kwargs) - context['title'] = _(u'Edit {0}').format(self.object) + context['title'] = _('Edit {0}').format(self.object) return context @@ -157,7 +157,7 @@ class DeleteView(WgerDeleteMixin, LoginRequiredMixin, PermissionRequiredMixin, D Send some additional data to the template """ context = super(DeleteView, self).get_context_data(**kwargs) - context['title'] = _(u'Delete {0}').format(self.object) + context['title'] = _('Delete {0}').format(self.object) return context diff --git a/wger/gym/views/document.py b/wger/gym/views/document.py index f53f1e23e..ac70f48c5 100644 --- a/wger/gym/views/document.py +++ b/wger/gym/views/document.py @@ -168,7 +168,7 @@ class UpdateView(WgerFormMixin, LoginRequiredMixin, PermissionRequiredMixin, Upd Send some additional data to the template """ context = super(UpdateView, self).get_context_data(**kwargs) - context['title'] = _(u'Edit {0}').format(self.object) + context['title'] = _('Edit {0}').format(self.object) return context @@ -204,5 +204,5 @@ class DeleteView(WgerDeleteMixin, LoginRequiredMixin, PermissionRequiredMixin, D Send some additional data to the template """ context = super(DeleteView, self).get_context_data(**kwargs) - context['title'] = _(u'Delete {0}?').format(self.object) + context['title'] = _('Delete {0}?').format(self.object) return context diff --git a/wger/gym/views/gym.py b/wger/gym/views/gym.py index 598a6deed..e3f1a7fd6 100644 --- a/wger/gym/views/gym.py +++ b/wger/gym/views/gym.py @@ -311,11 +311,13 @@ def gym_permissions_user_edit(request, user_pk): form = GymUserPermissionForm(initial={'role': initial_data}, available_roles=form_group_permission) - context = {} - context['title'] = member.get_full_name() - context['form'] = form - context['extend_template'] = 'base_empty.html' if request.is_ajax() else 'base.html' - context['submit_text'] = 'Save' + # Set form action to absolute path + form.helper.form_action = request.path + + context = {'title': member.get_full_name(), + 'form': form, + 'extend_template': 'base_empty.html' if request.is_ajax() else 'base.html', + 'submit_text': 'Save'} return render(request, 'form.html', context) @@ -446,7 +448,7 @@ class GymUpdateView(WgerFormMixin, LoginRequiredMixin, PermissionRequiredMixin, Send some additional data to the template """ context = super(GymUpdateView, self).get_context_data(**kwargs) - context['title'] = _(u'Edit {0}').format(self.object) + context['title'] = _('Edit {0}').format(self.object) return context @@ -471,5 +473,5 @@ class GymDeleteView(WgerDeleteMixin, LoginRequiredMixin, PermissionRequiredMixin Send some additional data to the template """ context = super(GymDeleteView, self).get_context_data(**kwargs) - context['title'] = _(u'Delete {0}?').format(self.object) + context['title'] = _('Delete {0}?').format(self.object) return context diff --git a/wger/manager/api/serializers.py b/wger/manager/api/serializers.py index a084aa79a..9d82a9713 100644 --- a/wger/manager/api/serializers.py +++ b/wger/manager/api/serializers.py @@ -24,7 +24,10 @@ from wger.core.api.serializers import ( RepetitionUnitSerializer, WeightUnitSerializer ) -from wger.exercises.api.serializers import ExerciseSerializer +from wger.exercises.api.serializers import ( + ExerciseSerializer, + MuscleSerializer +) from wger.manager.models import ( Day, Schedule, @@ -115,6 +118,16 @@ class SettingSerializer(serializers.ModelSerializer): # # Custom helper serializers for the canonical form of a workout # +class MusclesCanonicalFormSerializer(serializers.Serializer): + """ + Serializer for the muscles in the canonical form of a day/workout + """ + front = serializers.ListField(child=MuscleSerializer()) + back = serializers.ListField(child=MuscleSerializer()) + frontsecondary = serializers.ListField(child=MuscleSerializer()) + backsecondary = serializers.ListField(child=MuscleSerializer()) + + class WorkoutCanonicalFormExerciseListSerializer(serializers.Serializer): """ Serializer for settings in the canonical form of a workout @@ -139,7 +152,7 @@ class WorkoutCanonicalFormExerciseSerializer(serializers.Serializer): exercise_list = WorkoutCanonicalFormExerciseListSerializer(many=True) has_settings = serializers.BooleanField() is_superset = serializers.BooleanField() - muscles = serializers.ReadOnlyField() + muscles = MusclesCanonicalFormSerializer() class DaysOfWeekCanonicalFormSerializer(serializers.Serializer): @@ -159,7 +172,7 @@ class DayCanonicalFormSerializer(serializers.Serializer): obj = DaySerializer() set_list = WorkoutCanonicalFormExerciseSerializer(many=True) days_of_week = DaysOfWeekCanonicalFormSerializer() - muscles = serializers.ReadOnlyField() + muscles = MusclesCanonicalFormSerializer() class WorkoutCanonicalFormSerializer(serializers.Serializer): @@ -167,5 +180,5 @@ class WorkoutCanonicalFormSerializer(serializers.Serializer): Serializer for the canonical form of a workout """ obj = WorkoutSerializer() - muscles = serializers.ReadOnlyField() day_list = DayCanonicalFormSerializer(many=True) + muscles = MusclesCanonicalFormSerializer() diff --git a/wger/manager/forms.py b/wger/manager/forms.py index 9d92ed255..55f39af97 100644 --- a/wger/manager/forms.py +++ b/wger/manager/forms.py @@ -209,7 +209,7 @@ class WorkoutScheduleDownloadForm(Form): Form for the workout schedule download """ pdf_type = ChoiceField( - label=ugettext_lazy(u"Type"), + label=ugettext_lazy("Type"), choices=(("log", ugettext_lazy("Log")), ("table", ugettext_lazy("Table"))) ) diff --git a/wger/manager/helpers.py b/wger/manager/helpers.py index 7ddf1d393..2bba72491 100644 --- a/wger/manager/helpers.py +++ b/wger/manager/helpers.py @@ -37,7 +37,11 @@ from reportlab.platypus import ( # wger from wger.utils.helpers import normalize_decimal -from wger.utils.pdf import styleSheet +from wger.utils.pdf import ( + header_colour, + row_color, + styleSheet +) def render_workout_day(day, nr_of_weeks=7, images=False, comments=False, only_table=False): @@ -66,20 +70,13 @@ def render_workout_day(day, nr_of_weeks=7, images=False, comments=False, only_ta day_markers = [] group_exercise_marker = {} - # Background colour for days - # Reportlab doesn't use the HTML hexadecimal format, but has a range of - # 0 till 1, so we have to convert here. - header_colour = colors.Color(int('73', 16) / 255.0, - int('8a', 16) / 255.0, - int('5f', 16) / 255.0) - set_count = 1 day_markers.append(len(data)) - p = Paragraph(u'%(days)s: %(description)s' % + p = Paragraph('%(days)s: %(description)s' % {'days': day['days_of_week']['text'], 'description': day['obj'].description}, - styleSheet["Bold"]) + styleSheet["SubHeader"]) data.append([p]) @@ -100,7 +97,7 @@ def render_workout_day(day, nr_of_weeks=7, images=False, comments=False, only_ta # Process the settings if exercise['has_weight']: setting_out = [] - for i in exercise['setting_text'].split(u'–'): + for i in exercise['setting_text'].split('–'): setting_out.append(Paragraph(i, styleSheet["Small"], bulletText='')) else: setting_out = Paragraph(exercise['setting_text'], styleSheet["Small"]) @@ -138,7 +135,7 @@ def render_workout_day(day, nr_of_weeks=7, images=False, comments=False, only_ta bulletFontSize=3, start='square')] - data.append([set_count, + data.append([f"#{set_count}", exercise_content, setting_out] + [''] * nr_of_weeks) @@ -176,7 +173,7 @@ def render_workout_day(day, nr_of_weeks=7, images=False, comments=False, only_ta # list for i in range(exercise_start, len(data) + 1): if not i % 2: - table_style.append(('BACKGROUND', (1, i - 1), (-1, i - 1), colors.lavender)) + table_style.append(('BACKGROUND', (0, i - 1), (-1, i - 1), row_color)) # Put everything together and manually set some of the widths t = Table(data, style=table_style) @@ -217,7 +214,7 @@ def reps_smart_text(settings, set_obj): if setting.repetition_unit_id != 2: reps = "{0} {1}".format(setting.reps, rep_unit).strip() else: - reps = u'∞' + reps = '∞' return reps def get_weight_unit_reprentation(setting): @@ -262,8 +259,8 @@ def reps_smart_text(settings, set_obj): weight_unit = settings[0].weight_unit weight = normalize_weight(settings[0]) - setting_text = u'{0} × {1}'.format(set_obj.sets, reps).strip() - setting_list_text = u'{0} {1}'.format(reps, rep_unit).strip() + setting_text = '{0} × {1}'.format(set_obj.sets, reps).strip() + setting_list_text = '{0} {1}'.format(reps, rep_unit).strip() if weight: setting_text += ' ({0} {1})'.format(weight, weight_unit) setting_list_text += ' ({0} {1})'.format(weight, weight_unit) @@ -296,7 +293,7 @@ def reps_smart_text(settings, set_obj): tmp_repetition_unit.append(setting.repetition_unit) tmp_weight_unit.append(setting.weight_unit) - setting_text = u' – '.join(tmp_reps_text) + setting_text = ' – '.join(tmp_reps_text) setting_list = tmp_reps_text repetition_units = tmp_repetition_unit weight_units = tmp_weight_unit diff --git a/wger/manager/models.py b/wger/manager/models.py index 0556816ef..e327c0296 100644 --- a/wger/manager/models.py +++ b/wger/manager/models.py @@ -97,9 +97,9 @@ class Workout(models.Model): Return a more human-readable representation """ if self.comment: - return u"{0}".format(self.comment) + return "{0}".format(self.comment) else: - return u"{0} ({1})".format(_('Workout'), self.creation_date) + return "{0} ({1})".format(_('Workout'), self.creation_date) def save(self, *args, **kwargs): """ @@ -464,16 +464,16 @@ class Day(models.Model): # Muscles for this set for muscle in exercise.muscles.all(): - if muscle.is_front and muscle.id not in muscles_front: - muscles_front.append(muscle.id) - elif not muscle.is_front and muscle.id not in muscles_back: - muscles_back.append(muscle.id) + if muscle.is_front and muscle not in muscles_front: + muscles_front.append(muscle) + elif not muscle.is_front and muscle not in muscles_back: + muscles_back.append(muscle) for muscle in exercise.muscles_secondary.all(): - if muscle.is_front and muscle.id not in muscles_front: - muscles_front_secondary.append(muscle.id) + if muscle.is_front and muscle not in muscles_front: + muscles_front_secondary.append(muscle) elif not muscle.is_front and muscle.id not in muscles_back: - muscles_back_secondary.append(muscle.id) + muscles_back_secondary.append(muscle) for setting in Setting.objects.filter(set=set_obj, exercise=exercise).order_by('order', 'id'): @@ -546,7 +546,7 @@ class Day(models.Model): return {'obj': self, 'days_of_week': { - 'text': u', '.join([str(_(i.day_of_week)) + 'text': ', '.join([str(_(i.day_of_week)) for i in tmp_days_of_week]), 'day_list': tmp_days_of_week}, 'muscles': { @@ -585,7 +585,7 @@ class Set(models.Model): """ Return a more human-readable representation """ - return u"Set-ID {0}".format(self.id) + return "Set-ID {0}".format(self.id) def get_owner_object(self): """ @@ -666,7 +666,7 @@ class Setting(models.Model): """ Return a more human-readable representation """ - return u"settings for exercise {0} in set {1}".format(self.exercise.id, self.set.id) + return "settings for exercise {0} in set {1}".format(self.exercise.id, self.set.id) def save(self, *args, **kwargs): """ @@ -751,9 +751,9 @@ class WorkoutLog(models.Model): """ Return a more human-readable representation """ - return u"Log entry: {0} - {1} kg on {2}".format(self.reps, - self.weight, - self.date) + return "Log entry: {0} - {1} kg on {2}".format(self.reps, + self.weight, + self.date) def get_owner_object(self): """ @@ -869,7 +869,7 @@ class WorkoutSession(models.Model): """ Return a more human-readable representation """ - return u"{0} - {1}".format(self.workout, self.date) + return "{0} - {1}".format(self.workout, self.date) class Meta: """ diff --git a/wger/manager/templates/calendar/day.html b/wger/manager/templates/calendar/day.html index c51a3a77f..c56bb7273 100644 --- a/wger/manager/templates/calendar/day.html +++ b/wger/manager/templates/calendar/day.html @@ -56,18 +56,18 @@ {{log.weight}} {% trans log.weight_unit.name %} {% if is_owner %} - + diff --git a/wger/manager/templates/calendar/month.html b/wger/manager/templates/calendar/month.html index cc4a8cff0..d653c419e 100644 --- a/wger/manager/templates/calendar/month.html +++ b/wger/manager/templates/calendar/month.html @@ -65,7 +65,7 @@ $(document).ready(function() { {{ exercise }} -
    +
    {% trans "Macronutrients" %} {% trans "Planned" %} {% trans "Logged" %}{% trans "Difference" %}
    {% trans "Energy" %} {{ nutritional_data.total.energy|floatformat:0 }} {% trans "kcal" %} {{ log_summary.energy|floatformat:0 }} {% trans "kcal" %} + {% with diff=nutritional_data.total.energy|minus:log_summary.energy %} + {% if diff|is_positive %} + + + {% endif %} + {{ diff|floatformat:0 }} + {% endwith %} +
    {% trans "Protein" %} {{ nutritional_data.total.protein|floatformat:1 }} {% trans_weight_unit 'g' owner_user %} {{ log_summary.protein|floatformat:1 }} {% trans_weight_unit 'g' owner_user %} + {% with diff=nutritional_data.total.protein|minus:log_summary.protein %} + {% if diff|is_positive %} + + + {% endif %} + {{ diff|floatformat:0 }} + {% endwith %} +
    {% trans "Carbohydrates" %} {{ nutritional_data.total.carbohydrates|floatformat:1 }} {% trans_weight_unit 'g' owner_user %} {{ log_summary.carbohydrates|floatformat:1 }} {% trans_weight_unit 'g' owner_user %} + {% with diff=nutritional_data.total.carbohydrates|minus:log_summary.carbohydrates %} + {% if diff|is_positive %} + + + {% endif %} + {{ diff|floatformat:0 }} + {% endwith %} +
    {% trans "Sugar content in carbohydrates" %} {{ nutritional_data.total.carbohydrates_sugar|floatformat:1 }} {% trans_weight_unit 'g' owner_user %} {{ log_summary.carbohydrates_sugar|floatformat:1 }} {% trans_weight_unit 'g' owner_user %} + {% with diff=nutritional_data.total.carbohydrates_sugar|minus:log_summary.carbohydrates_sugar %} + {% if diff|is_positive %} + + + {% endif %} + {{ diff|floatformat:0 }} + {% endwith %} +
    {% trans "Fat" %} {{ nutritional_data.total.fat|floatformat:1 }} {% trans_weight_unit 'g' owner_user %} {{ log_summary.fat|floatformat:1 }} {% trans_weight_unit 'g' owner_user %} + {% with diff=nutritional_data.total.fat|minus:log_summary.fat %} + {% if diff|is_positive %} + + + {% endif %} + {{ diff|floatformat:0 }} + {% endwith %} +
    {% trans "Saturated fat content in fats" %} {{ nutritional_data.total.fat_saturated|floatformat:1 }} {% trans_weight_unit 'g' owner_user %} {{ log_summary.fat_saturated|floatformat:1 }} {% trans_weight_unit 'g' owner_user %} + {% with diff=nutritional_data.total.fat_saturated|minus:log_summary.fat_saturated %} + {% if diff|is_positive %} + + + {% endif %} + {{ diff|floatformat:0 }} + {% endwith %} +
    {% trans "Others" %}{% trans "Others" %}
    {% trans "Fibres" %} {{ nutritional_data.total.fibres|floatformat }} {% trans_weight_unit 'g' owner_user %} {{ log_summary.fibres|floatformat }} {% trans_weight_unit 'g' owner_user %} + {% with diff=nutritional_data.total.fibres|minus:log_summary.fibres %} + {% if diff|is_positive %} + + + {% endif %} + {{ diff|floatformat:0 }} + {% endwith %} +
    {% trans "Sodium" %} {{ nutritional_data.total.sodium|floatformat }} {% trans_weight_unit 'g' owner_user %} {{ log_summary.sodium|floatformat }} {% trans_weight_unit 'g' owner_user %} + {% with diff=nutritional_data.total.sodium|minus:log_summary.sodium %} + {% if diff|is_positive %} + + + {% endif %} + {{ diff|floatformat:0 }} + {% endwith %} +
    @@ -105,6 +170,17 @@ {{ entry.amount|floatformat:"0" }}{% trans "g" %} {% endif %} {{ entry.ingredient }} +

    + {% trans "Protein" %}: {{entry.get_nutritional_values.protein|floatformat:"1"}}
    + {% trans "Carbohydrates" %}: {{entry.get_nutritional_values.carbohydrates|floatformat:"1"}} ({{entry.get_nutritional_values.carbohydrates_sugar|floatformat:"1"}})
    + {% trans "Fat" %}: {{entry.get_nutritional_values.fat|floatformat:"1"}} ({{entry.get_nutritional_values.fat|floatformat:"1"}})
    + {% if entry.get_nutritional_values.fibres %} + {% trans "Fibres" %}: {{entry.get_nutritional_values.fibres|floatformat:"1"}}
    + {% endif %} + {% if entry.get_nutritional_values.sodium %} + {% trans "Sodium" %}: {{entry.get_nutritional_values.sodium|floatformat:"1"}}
    + {% endif %} +

    diff --git a/wger/nutrition/templates/plan/view.html b/wger/nutrition/templates/plan/view.html index 8b1ea1f47..677b63e37 100644 --- a/wger/nutrition/templates/plan/view.html +++ b/wger/nutrition/templates/plan/view.html @@ -158,6 +158,11 @@ function wgerCustomModalInit() {% endif %} {% endfor %} {% if is_owner %} + + + {% trans "Log this Plan" %} + + +{% if is_owner %}

    {% trans "Add custom diary entry" %}

    +{% endif %} {% endblock %} diff --git a/wger/nutrition/tests/test_ingredient.py b/wger/nutrition/tests/test_ingredient.py index fee2142ce..03a8f541a 100644 --- a/wger/nutrition/tests/test_ingredient.py +++ b/wger/nutrition/tests/test_ingredient.py @@ -236,14 +236,14 @@ class IngredientValuesTestCase(WgerTestCase): self.assertEqual(response.status_code, 200) result = json.loads(response.content.decode('utf8')) self.assertEqual(len(result), 8) - self.assertEqual(result, {u'sodium': u'0.01', - u'energy': u'1.76', - u'fat': u'0.08', - u'carbohydrates_sugar': u'0.00', - u'fat_saturated': u'0.03', - u'fibres': u'0.00', - u'protein': u'0.26', - u'carbohydrates': u'0.00'}) + self.assertEqual(result, {'sodium': '0.01', + 'energy': '1.76', + 'fat': '0.08', + 'carbohydrates_sugar': '0.00', + 'fat_saturated': '0.03', + 'fibres': '0.00', + 'protein': '0.26', + 'carbohydrates': '0.00'}) # Get the nutritional values in 1 unit of product response = self.client.get(reverse('api-ingredient-get-values', kwargs={'pk': 1}), @@ -254,14 +254,14 @@ class IngredientValuesTestCase(WgerTestCase): self.assertEqual(response.status_code, 200) result = json.loads(response.content.decode('utf8')) self.assertEqual(len(result), 8) - self.assertEqual(result, {u'sodium': u'0.61', - u'energy': u'196.24', - u'fat': u'9.13', - u'carbohydrates_sugar': u'0.00', - u'fat_saturated': u'3.62', - u'fibres': u'0.00', - u'protein': u'28.58', - u'carbohydrates': u'0.14'}) + self.assertEqual(result, {'sodium': '0.61', + 'energy': '196.24', + 'fat': '9.13', + 'carbohydrates_sugar': '0.00', + 'fat_saturated': '3.62', + 'fibres': '0.00', + 'protein': '28.58', + 'carbohydrates': '0.14'}) def test_calculate_value_anonymous(self): """ diff --git a/wger/nutrition/tests/test_nutrition_diary.py b/wger/nutrition/tests/test_nutrition_diary.py index f1403d904..2b29d7a2c 100644 --- a/wger/nutrition/tests/test_nutrition_diary.py +++ b/wger/nutrition/tests/test_nutrition_diary.py @@ -181,6 +181,45 @@ class NutritionDiaryTestCase(WgerTestCase): self.assertEqual(response.status_code, 403) self.assertEqual(LogItem.objects.filter(plan=plan).count(), 0) + def test_log_plan(self): + """ + Tests that logging a plan creates a log entry for all meals within the plan + """ + plan = NutritionPlan.objects.get(pk=1) + LogItem.objects.all().delete() + self.assertFalse(LogItem.objects.filter(plan=plan)) + self.user_login('test') + response = self.client.get( + reverse('nutrition:log:log_plan', kwargs={"plan_pk": 1})) + self.assertEqual(response.status_code, 302) + self.assertEqual(LogItem.objects.filter(plan=plan).count(), 3) + + def test_log_plan_logged_out(self): + """ + Tests that logging a plan doesn't work for a logged out user + """ + plan = NutritionPlan.objects.get(pk=1) + LogItem.objects.all().delete() + self.assertFalse(LogItem.objects.filter(plan=plan)) + response = self.client.get( + reverse('nutrition:log:log_plan', kwargs={"plan_pk": 1})) + self.assertEqual(response.status_code, 403) + self.assertEqual(LogItem.objects.filter(plan=plan).count(), 0) + + def test_log_plan_other_user(self): + """ + Tests that logging a plan doesn't work for a logged out user + """ + plan = NutritionPlan.objects.get(pk=1) + LogItem.objects.all().delete() + self.assertFalse(LogItem.objects.filter(plan=plan)) + self.user_login('admin') + response = self.client.get( + reverse('nutrition:log:log_plan', kwargs={"plan_pk": 1})) + + self.assertEqual(response.status_code, 403) + self.assertEqual(LogItem.objects.filter(plan=plan).count(), 0) + class AddMealItemUnitTestCase(WgerAddTestCase): """ diff --git a/wger/nutrition/tests/test_nutritional_cache.py b/wger/nutrition/tests/test_nutritional_cache.py new file mode 100644 index 000000000..cbd8ad9e7 --- /dev/null +++ b/wger/nutrition/tests/test_nutritional_cache.py @@ -0,0 +1,94 @@ +# Django +from django.contrib.auth.models import User +from django.core.cache import cache + +# wger +from wger.core.models import Language +from wger.core.tests.base_testcase import WgerTestCase +from wger.nutrition.models import ( + Meal, + MealItem, + NutritionPlan +) +from wger.utils.cache import cache_mapper + + +class NutritionaCacheTestCase(WgerTestCase): + + def create_nutrition_plan(self): + ''' + Create a nutrition plan and set dummy attributes that are required + Create meal and set dummy attributes that are required + Create meal item and set dummy attributes that are required + ''' + nutrition_plan = NutritionPlan() + nutrition_plan.user = User.objects.create_user(username='example_user_1') + nutrition_plan.language = Language.objects.get(short_name="en") + nutrition_plan.save() + meal = Meal() + meal.plan = nutrition_plan + meal.order = 1 + meal.save() + meal_item = MealItem() + meal_item.id = 1 + meal_item.meal = meal + meal_item.amount = 1 + meal_item.ingredient_id = 1 + meal_item.order = 1 + test_objects = [nutrition_plan, meal, meal_item] + return test_objects + + def test_cache_setting(self): + ''' + Test that a cache is set once the nutritional instance is created + ''' + nutrition_object = self.create_nutrition_plan()[0] + self.assertFalse(cache.get(cache_mapper.get_nutrition_cache_by_key(nutrition_object))) + nutrition_object.get_nutritional_values() + self.assertTrue(cache.get(cache_mapper.get_nutrition_cache_by_key(nutrition_object))) + + def test_nutrition_save_and_delete(self): + ''' + Test that cache is deleted when a nutrition is created or deleted. + ''' + nutrition_object = self.create_nutrition_plan()[0] + nutrition_object.get_nutritional_values() + self.assertTrue(cache.get(cache_mapper.get_nutrition_cache_by_key(nutrition_object))) + nutrition_object.save() + self.assertFalse(cache.get(cache_mapper.get_nutrition_cache_by_key(nutrition_object))) + nutrition_object.get_nutritional_values() + self.assertTrue(cache.get(cache_mapper.get_nutrition_cache_by_key(nutrition_object))) + nutrition_object.delete() + self.assertFalse(cache.get(cache_mapper.get_nutrition_cache_by_key(nutrition_object))) + + def test_meal_save_delete(self): + ''' + Test that the cache is deleted once a meal undergoes a save or delete operation + ''' + test_object_list = self.create_nutrition_plan() + nutritional_object = test_object_list[0] + meal = test_object_list[1] + nutritional_object.get_nutritional_values() + self.assertTrue(cache.get(cache_mapper.get_nutrition_cache_by_key(nutritional_object))) + meal.save() + self.assertFalse(cache.get(cache_mapper.get_nutrition_cache_by_key(nutritional_object.pk))) + nutritional_object.get_nutritional_values() + self.assertTrue(cache.get(cache_mapper.get_nutrition_cache_by_key(nutritional_object))) + meal.delete() + self.assertFalse(cache.get(cache_mapper.get_nutrition_cache_by_key(nutritional_object))) + + def test_meal_item_save_delete(self): + ''' + Test that the cache is deleted once a meal undergoes a save or delete operation + ''' + test_object_list = self.create_nutrition_plan() + nutritional_object = test_object_list[0] + meal_item = test_object_list[2] + nutritional_object.get_nutritional_values() + self.assertTrue(cache.get(cache_mapper.get_nutrition_cache_by_key(nutritional_object))) + meal_item.save() + self.assertFalse(cache.get(cache_mapper.get_nutrition_cache_by_key(nutritional_object.pk))) + nutritional_object.get_nutritional_values() + self.assertTrue(cache.get(cache_mapper.get_nutrition_cache_by_key(nutritional_object))) + meal_item.delete() + self.assertFalse(cache.get(cache_mapper.get_nutrition_cache_by_key(meal_item))) diff --git a/wger/nutrition/tests/test_pdf.py b/wger/nutrition/tests/test_pdf.py index aca200638..26098a6f5 100644 --- a/wger/nutrition/tests/test_pdf.py +++ b/wger/nutrition/tests/test_pdf.py @@ -47,8 +47,8 @@ class NutritionalPlanPdfExportTestCase(WgerTestCase): 'attachment; filename=nutritional-plan.pdf') # Approximate size - self.assertGreater(int(response['Content-Length']), 29000) - self.assertLess(int(response['Content-Length']), 34000) + self.assertGreater(int(response['Content-Length']), 38000) + self.assertLess(int(response['Content-Length']), 42000) def export_pdf(self, fail=False): """ @@ -68,8 +68,8 @@ class NutritionalPlanPdfExportTestCase(WgerTestCase): 'attachment; filename=nutritional-plan.pdf') # Approximate size - self.assertGreater(int(response['Content-Length']), 29000) - self.assertLess(int(response['Content-Length']), 34000) + self.assertGreater(int(response['Content-Length']), 38000) + self.assertLess(int(response['Content-Length']), 42000) # Create an empty plan user = User.objects.get(pk=2) @@ -90,8 +90,8 @@ class NutritionalPlanPdfExportTestCase(WgerTestCase): 'attachment; filename=nutritional-plan.pdf') # Approximate size - self.assertGreater(int(response['Content-Length']), 29000) - self.assertLess(int(response['Content-Length']), 33420) + self.assertGreater(int(response['Content-Length']), 38000) + self.assertLess(int(response['Content-Length']), 42000) def test_export_pdf_anonymous(self): """ diff --git a/wger/nutrition/urls.py b/wger/nutrition/urls.py index aafac4454..f198722c2 100644 --- a/wger/nutrition/urls.py +++ b/wger/nutrition/urls.py @@ -200,6 +200,9 @@ patterns_diary = [ url(r'^log-meal/(?P\d+)$', log.log_meal, name='log_meal'), + url(r'^log-plan/(?P\d+)$', + log.log_plan, + name='log_plan') ] diff --git a/wger/nutrition/views/ingredient.py b/wger/nutrition/views/ingredient.py index 3465b4f46..c1f266447 100644 --- a/wger/nutrition/views/ingredient.py +++ b/wger/nutrition/views/ingredient.py @@ -83,8 +83,8 @@ class IngredientListView(ListView): native language, see load_ingredient_languages) """ languages = load_ingredient_languages(self.request) - return (Ingredient.objects.filter(language__in=languages) - .filter(status=Ingredient.STATUS_ACCEPTED) + return (Ingredient.objects.accepted() + .filter(language__in=languages) .only('id', 'name')) def get_context_data(self, **kwargs): @@ -139,7 +139,7 @@ class IngredientDeleteView(WgerDeleteMixin, def get_context_data(self, **kwargs): context = super(IngredientDeleteView, self).get_context_data(**kwargs) - context['title'] = _(u'Delete {0}?').format(self.object) + context['title'] = _('Delete {0}?').format(self.object) return context @@ -174,7 +174,7 @@ class IngredientEditView(IngredientMixin, LoginRequiredMixin, PermissionRequired Send some additional data to the template """ context = super(IngredientEditView, self).get_context_data(**kwargs) - context['title'] = _(u'Edit {0}').format(self.object) + context['title'] = _('Edit {0}').format(self.object) return context diff --git a/wger/nutrition/views/log.py b/wger/nutrition/views/log.py index 5ee240db2..434d1d70d 100644 --- a/wger/nutrition/views/log.py +++ b/wger/nutrition/views/log.py @@ -121,26 +121,47 @@ def log_meal(request, meal_pk): # Check read permission meal = get_object_or_404(Meal, pk=meal_pk) - user = meal.plan.user - is_owner = request.user == user - date = datetime.date.today() + mealUser = meal.plan.user + is_owner = request.user == mealUser - if not is_owner and not user.userprofile.ro_access: + if not is_owner and not mealUser.userprofile.ro_access: return HttpResponseForbidden() - for item in meal.mealitem_set.select_related(): - log_item = LogItem(plan=item.meal.plan, - ingredient=item.ingredient, - weight_unit=item.weight_unit, - amount=item.amount) - log_item.save() - + _logMealPlan([meal]) + date = datetime.date.today() return HttpResponseRedirect(reverse('nutrition:log:detail', - kwargs={'pk': meal.plan.pk, + kwargs={'pk': meal_pk, 'year': date.year, 'month': date.month, - 'day': date.day - })) + 'day': date.day})) + + +def log_plan(request, plan_pk): + """ + Copy the requested plan item and log all of the meals within it + """ + plan = get_object_or_404(NutritionPlan, pk=plan_pk) + planUser = plan.user + is_owner = request.user == planUser + if not is_owner and not planUser.userprofile.ro_access: + return HttpResponseForbidden() + + _logMealPlan(plan.meal_set.select_related()) + return HttpResponseRedirect(reverse('nutrition:log:overview', kwargs={'pk': plan_pk})) + + +def _logMealPlan(meals): + """ + Helper method to log a collection of meals + """ + + for meal in meals: + for item in meal.mealitem_set.select_related(): + log_item = LogItem(plan=item.meal.plan, + ingredient=item.ingredient, + weight_unit=item.weight_unit, + amount=item.amount) + log_item.save() class LogCreateView(WgerFormMixin, CreateView): diff --git a/wger/nutrition/views/plan.py b/wger/nutrition/views/plan.py index fb676b6d6..282a0e6b6 100644 --- a/wger/nutrition/views/plan.py +++ b/wger/nutrition/views/plan.py @@ -15,7 +15,6 @@ # You should have received a copy of the GNU Affero General Public License # Standard Library -import datetime import logging # Django @@ -55,7 +54,6 @@ from reportlab.platypus import ( ) # wger -from wger import get_version from wger.nutrition.models import ( MEALITEM_WEIGHT_GRAM, MEALITEM_WEIGHT_UNIT, @@ -70,7 +68,13 @@ from wger.utils.helpers import ( make_token ) from wger.utils.language import load_language -from wger.utils.pdf import styleSheet +from wger.utils.pdf import ( + get_logo, + header_colour, + render_footer, + row_color, + styleSheet +) logger = logging.getLogger(__name__) @@ -121,7 +125,7 @@ class PlanDeleteView(WgerDeleteMixin, DeleteView): Send some additional data to the template """ context = super(PlanDeleteView, self).get_context_data(**kwargs) - context['title'] = _(u'Delete {0}?').format(self.object) + context['title'] = _('Delete {0}?').format(self.object) return context @@ -138,7 +142,7 @@ class PlanEditView(WgerFormMixin, UpdateView): Send some additional data to the template """ context = super(PlanEditView, self).get_context_data(**kwargs) - context['title'] = _(u'Edit {0}').format(self.object) + context['title'] = _('Edit {0}').format(self.object) return context @@ -254,16 +258,10 @@ def export_pdf(request, id, uidb64=None, token=None): # Create the PDF object, using the response object as its "file." doc = SimpleDocTemplate(response, pagesize=A4, - title=_('Nutrition plan'), + title=_('Nutritional plan'), author='wger Workout Manager', - subject=_('Nutritional plan %s') % request.user.username) - - # Background colour for header - # Reportlab doesn't use the HTML hexadecimal format, but has a range of - # 0 till 1, so we have to convert here. - header_colour = colors.Color(int('73', 16) / 255.0, - int('8a', 16) / 255.0, - int('5f', 16) / 255.0) + subject=_('Nutritional plan for %s') % request.user.username, + topMargin=1 * cm,) # container for the 'Flowable' objects elements = [] @@ -281,29 +279,32 @@ def export_pdf(request, id, uidb64=None, token=None): meal_markers.append(len(data)) if not meal.time: - p = Paragraph(u'{nr} {meal_nr}' + p = Paragraph('{nr} {meal_nr}' .format(nr=_('Nr.'), meal_nr=i), - styleSheet["Normal"]) + styleSheet["SubHeader"]) else: - p = Paragraph(u'' - u'{nr} {meal_nr} - {meal_time}' - u'' + p = Paragraph('' + '{nr} {meal_nr} - {meal_time}' + '' .format(nr=_('Nr.'), meal_nr=i, meal_time=meal.time.strftime("%H:%M")), - styleSheet["Normal"]) + styleSheet["SubHeader"]) data.append([p]) # Ingredients for item in meal.mealitem_set.select_related(): ingredient_markers.append(len(data)) - p = Paragraph(u'{0}'.format(item.ingredient.name), styleSheet["Normal"]) + p = Paragraph('{0}'.format(item.ingredient.name), styleSheet["Normal"]) if item.get_unit_type() == MEALITEM_WEIGHT_GRAM: unit_name = 'g' else: - unit_name = ' ' + item.weight_unit.unit.name + unit_name = ' × ' + item.weight_unit.unit.name - data.append([Paragraph(u"{0}{1}".format(item.amount, unit_name), styleSheet["Normal"]), - p]) + data.append([Paragraph("{0:.0f}{1}".format(item.amount, unit_name), + styleSheet["Normal"]), p]) + + # Add filler + data.append([Spacer(1 * cm, 0.6 * cm)]) # Set general table styles table_style = [] @@ -322,22 +323,27 @@ def export_pdf(request, id, uidb64=None, token=None): t = Table(data, style=table_style) # Manually set the width of the columns - t._argW[0] = 2.5 * cm + t._argW[0] = 3.5 * cm # There is nothing to output else: t = Paragraph(_('This is an empty plan, what did you expect on the PDF?'), styleSheet["Normal"]) + # Add site logo + elements.append(get_logo()) + elements.append(Spacer(10 * cm, 0.5 * cm)) + # Set the title (if available) if plan.description: + p = Paragraph('%(description)s' % {'description': plan.description}, - styleSheet["Bold"]) + styleSheet["HeaderBold"]) elements.append(p) # Filler - elements.append(Spacer(10 * cm, 0.5 * cm)) + elements.append(Spacer(10 * cm, 1.5 * cm)) # append the table to the document elements.append(t) @@ -345,8 +351,8 @@ def export_pdf(request, id, uidb64=None, token=None): # Create table with nutritional calculations data = [] - data.append([Paragraph(u'{0}'.format(_('Nutritional data')), - styleSheet["Bold"])]) + data.append([Paragraph('{0}'.format(_('Nutritional data')), + styleSheet["SubHeaderBlack"])]) data.append([Paragraph(_('Macronutrients'), styleSheet["Normal"]), Paragraph(_('Total'), styleSheet["Normal"]), Paragraph(_('Percent of energy'), styleSheet["Normal"]), @@ -364,7 +370,7 @@ def export_pdf(request, id, uidb64=None, token=None): styleSheet["Normal"]), Paragraph(str(plan_data['per_kg']['carbohydrates']), styleSheet["Normal"])]) - data.append([Paragraph(_('Sugar content in carbohydrates'), styleSheet["Normal"]), + data.append([Paragraph(" " + _('Sugar content in carbohydrates'), styleSheet["Normal"]), Paragraph(str(plan_data['total']['carbohydrates_sugar']), styleSheet["Normal"])]) data.append([Paragraph(_('Fat'), styleSheet["Normal"]), @@ -384,30 +390,22 @@ def export_pdf(request, id, uidb64=None, token=None): table_style.append(('GRID', (0, 0), (-1, -1), 0.40, colors.black)) table_style.append(('SPAN', (0, 0), (-1, 0))) # Title table_style.append(('SPAN', (1, 2), (-1, 2))) # Energy + table_style.append(('BACKGROUND', (0, 3), (-1, 3), row_color)) # Protein + table_style.append(('BACKGROUND', (0, 4), (-1, 4), row_color)) # Carbohydrates table_style.append(('SPAN', (1, 5), (-1, 5))) # Sugar + table_style.append(('LEFTPADDING', (0, 5), (0, 5), 15)) + table_style.append(('BACKGROUND', (0, 6), (-1, 6), row_color)) # Fats table_style.append(('SPAN', (1, 7), (-1, 7))) # Saturated fats + table_style.append(('LEFTPADDING', (0, 7), (0, 7), 15)) table_style.append(('SPAN', (1, 8), (-1, 8))) # Fibres table_style.append(('SPAN', (1, 9), (-1, 9))) # Sodium t = Table(data, style=table_style) - t._argW[0] = 5 * cm + t._argW[0] = 6 * cm elements.append(t) # Footer, date and info elements.append(Spacer(10 * cm, 0.5 * cm)) - created = datetime.date.today().strftime("%d.%m.%Y") - url = reverse('nutrition:plan:view', kwargs={'id': plan.id}) - p = Paragraph(""" - %(date)s - - %(url)s - - %(created)s - %(version)s - """ % - {'date': _("Created on the %s") % created, - 'created': "wger Workout Manager", - 'version': get_version(), - 'url': request.build_absolute_uri(url), }, - styleSheet["Normal"]) - elements.append(p) + elements.append(render_footer(request.build_absolute_uri(plan.get_absolute_url()))) doc.build(elements) response['Content-Disposition'] = 'attachment; filename=nutritional-plan.pdf' diff --git a/wger/nutrition/views/unit.py b/wger/nutrition/views/unit.py index 8451736a0..e8c73ceba 100644 --- a/wger/nutrition/views/unit.py +++ b/wger/nutrition/views/unit.py @@ -111,7 +111,7 @@ class WeightUnitDeleteView(WgerDeleteMixin, Send some additional data to the template """ context = super(WeightUnitDeleteView, self).get_context_data(**kwargs) - context['title'] = _(u'Delete {0}?').format(self.object) + context['title'] = _('Delete {0}?').format(self.object) return context @@ -135,5 +135,5 @@ class WeightUnitUpdateView(WgerFormMixin, Send some additional data to the template """ context = super(WeightUnitUpdateView, self).get_context_data(**kwargs) - context['title'] = _(u'Edit {0}').format(self.object) + context['title'] = _('Edit {0}').format(self.object) return context diff --git a/wger/settings.tpl b/wger/settings.tpl index b1b875615..41c22363f 100644 --- a/wger/settings.tpl +++ b/wger/settings.tpl @@ -7,7 +7,6 @@ from wger.settings_global import * # Use 'DEBUG = True' to get more details for server errors DEBUG = True -TEMPLATES[0]['OPTIONS']['debug'] = True ADMINS = ( ('Your name', 'your_email@example.com'), @@ -18,7 +17,7 @@ MANAGERS = ADMINS DATABASES = {{ 'default': {{ 'ENGINE': 'django.db.backends.{dbengine}', - 'NAME': {dbname}, + 'NAME': '{dbname}', 'USER': '{dbuser}', 'PASSWORD': '{dbpassword}', 'HOST': '{dbhost}', @@ -44,7 +43,7 @@ SITE_URL = '{siteurl}' # Path to uploaded files # Absolute filesystem path to the directory that will hold user-uploaded files. -MEDIA_ROOT = {media_folder_path} +MEDIA_ROOT = '{media_folder_path}' MEDIA_URL = '/media/' # Allow all hosts to access the application. Change if used in production. diff --git a/wger/settings_global.py b/wger/settings_global.py index 729221483..7b92c6953 100644 --- a/wger/settings_global.py +++ b/wger/settings_global.py @@ -198,7 +198,7 @@ LANGUAGES = ( ('de', 'German'), ('bg', 'Bulgarian'), ('es', 'Spanish'), - ('ru', 'Russian'), + ('r', 'Russian'), ('nl', 'Dutch'), ('pt', 'Portuguese'), ('el', 'Greek'), @@ -345,7 +345,13 @@ REST_FRAMEWORK = { 'rest_framework.authentication.TokenAuthentication', ), 'DEFAULT_FILTER_BACKENDS': ('django_filters.rest_framework.DjangoFilterBackend', - 'rest_framework.filters.OrderingFilter',) + 'rest_framework.filters.OrderingFilter'), + 'DEFAULT_THROTTLE_CLASSES': [ + 'rest_framework.throttling.ScopedRateThrottle' + ], + 'DEFAULT_THROTTLE_RATES': { + 'login': '3/min' + } } diff --git a/wger/software/static/images/empty b/wger/software/static/images/empty deleted file mode 100644 index e69de29bb..000000000 diff --git a/wger/software/static/images/plan.png b/wger/software/static/images/plan.png index 4b6a37643..2e4a87819 100644 Binary files a/wger/software/static/images/plan.png and b/wger/software/static/images/plan.png differ diff --git a/wger/software/static/images/workout.png b/wger/software/static/images/workout.png index c82c32036..da9e1fd43 100644 Binary files a/wger/software/static/images/workout.png and b/wger/software/static/images/workout.png differ diff --git a/wger/software/static/pdf/nutrition-plan-pdf-1.4-en.pdf b/wger/software/static/pdf/nutrition-plan-pdf-1.4-en.pdf deleted file mode 100644 index 950b4ac28..000000000 Binary files a/wger/software/static/pdf/nutrition-plan-pdf-1.4-en.pdf and /dev/null differ diff --git a/wger/software/static/pdf/workout-pdf-1.4-en.pdf b/wger/software/static/pdf/workout-pdf-1.4-en.pdf deleted file mode 100644 index 0258e1543..000000000 Binary files a/wger/software/static/pdf/workout-pdf-1.4-en.pdf and /dev/null differ diff --git a/wger/software/templates/api.html b/wger/software/templates/api.html index 0ef9b1d22..92f9277c4 100644 --- a/wger/software/templates/api.html +++ b/wger/software/templates/api.html @@ -15,8 +15,14 @@ objects: https://wger.de/api/v2/

    ingredients can be accessed without authentication. For user owned objects such as workouts, you need to generate an API KEY and pass it in the header, see the link on the sidebar for details.

    + +

    You can also generate a token via the login endpoint. Send a +username and password and you will get the user's token or a new one will be +generated. At the moment it is not possible to register via the API.

    +

    You should always use HTTPS if possible when communicating with the server.

    +
    @@ -175,12 +181,6 @@ it in the header, see the link on the sidebar for details.

    Exercises

    -

    - When accessing exercises, consider that by default all exercises are - returned, including those submitted by users but not yed approved. You will - very probably want to add a &status=2 to your URL to only get the - ones already added to the database. -

    Also note that, at the moment, to actually retrieve all the details for an exercise you will need to fire up different queries for the images, comments, etc. diff --git a/wger/tasks.py b/wger/tasks.py index f6a1dea31..bc73c2a20 100644 --- a/wger/tasks.py +++ b/wger/tasks.py @@ -15,14 +15,10 @@ # You should have received a copy of the GNU Affero General Public License # Standard Library -import ctypes import logging import os -import socket +import pathlib import sys -import threading -import time -import webbrowser # Django import django @@ -41,8 +37,7 @@ logger = logging.getLogger(__name__) @task(help={'address': 'Address to bind to. Default: localhost', 'port': 'Port to use. Default: 8000', - 'browser': 'Whether to open the application in a browser window. Default: false', - 'settings-path': 'Path to settings file (absolute path recommended). Leave empty for ' + 'settings-path': 'Path to settings file (absolute path). Leave empty for ' 'default', 'extra-args': 'Additional arguments to pass to the builtin server. Pass as string: ' '"--arg1 --arg2=value". Default: none'}) @@ -51,8 +46,6 @@ def start(context, address='localhost', port=8000, browser=False, settings_path= """ Start the application using django's built in webserver """ - if browser: - start_browser("http://{0}:{1}".format(address, port)) # Find the path to the settings and setup the django environment setup_django_environment(settings_path) @@ -65,37 +58,22 @@ def start(context, address='localhost', port=8000, browser=False, settings_path= execute_from_command_line(argv) -@task(help={'settings-path': 'Path to settings file (absolute path recommended). Leave empty for ' +@task(help={'settings-path': 'Path to settings file (absolute path). Leave empty for ' 'default', - 'database-path': 'Path to sqlite database (absolute path recommended). Leave empty ' - 'for default', - 'address': 'Address to use. Default: localhost', - 'port': 'Port to use. Default: 8000', - 'browser': 'Whether to open the application in a browser window. Default: false', - 'start-server': 'Whether to start the development server. Default: true'}) + 'database-path': 'Path to sqlite database (absolute path). Leave empty ' + 'for default'}) def bootstrap(context, settings_path=None, - database_path=None, - address='localhost', - port=8000, - browser=False, - start_server=True): + database_path=None): """ Performs all steps necessary to bootstrap the application """ - # Find url to wger - address, port = detect_listen_opts(address, port) - if port == 80: - url = "http://{0}".format(address) - else: - url = "http://{0}:{1}".format(address, port) - # Create settings if necessary if settings_path is None: - settings_path = get_user_config_path('wger', 'settings.py') + settings_path = get_path('settings.py') if not os.path.exists(settings_path): - create_settings(context, settings_path=settings_path, database_path=database_path, url=url) + create_settings(context, settings_path=settings_path, database_path=database_path) # Find the path to the settings and setup the django environment setup_django_environment(settings_path) @@ -109,41 +87,38 @@ def bootstrap(context, # Download JS and CSS libraries context.run("yarn install") - context.run("sass core/static/scss/main.scss:core/static/yarn/bootstrap-compiled.css") - - # Start the webserver - if start_server: - print('*** Bootstraping complete, starting application') - start(context, address=address, port=port, browser=browser, settings_path=settings_path) + context.run("yarn build:css:sass") -@task(help={'settings-path': 'Path to settings file (absolute path recommended). Leave empty for ' +@task(help={'settings-path': 'Path to settings file (absolute path). Leave empty for ' 'default', - 'database-path': 'Path to sqlite database (absolute path recommended). Leave empty ' + 'database-path': 'Path to sqlite database (absolute path). Leave empty ' 'for default', 'database-type': 'Database type to use. Supported: sqlite3, postgresql. Default: ' 'sqlite3', - 'key-length': 'Lenght of the generated secret key. Default: 50'}) -def create_settings(context, settings_path=None, database_path=None, url=None, - database_type='sqlite3', key_length=50): + 'key-length': 'Length of the generated secret key. Default: 50'}) +def create_settings(context, + settings_path=None, + database_path=None, + database_type='sqlite3', + key_length=50): """ Creates a local settings file """ if settings_path is None: - settings_path = get_user_config_path('wger', 'settings.py') + settings_path = get_path('settings.py') settings_module = os.path.dirname(settings_path) print("*** Creating settings file at {0}".format(settings_module)) if database_path is None: - database_path = get_user_data_path('wger', 'database.sqlite') - dbpath_value = repr(database_path) + database_path = get_path('database.sqlite').as_posix() + dbpath_value = database_path - media_folder_path = repr(get_user_data_path('wger', 'media')) + media_folder_path = get_path('media').as_posix() # Use localhost with default django port if no URL given - if url is None: - url = 'http://localhost:8000' + url = 'http://localhost:8000' # Fill in the config file template settings_template = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'settings.tpl') @@ -153,7 +128,7 @@ def create_settings(context, settings_path=None, database_path=None, url=None, # The environment variable is set by travis during testing if database_type == 'postgresql': dbengine = 'postgresql_psycopg2' - dbname = "'test_wger'" + dbname = 'test_wger' dbuser = 'postgres' dbpassword = '' dbhost = '127.0.0.1' @@ -191,7 +166,7 @@ def create_settings(context, settings_path=None, database_path=None, url=None, settings_file.write(settings_content) -@task(help={'settings-path': 'Path to settings file (absolute path recommended). Leave empty for ' +@task(help={'settings-path': 'Path to settings file (absolute path). Leave empty for ' 'default'}) def create_or_reset_admin(context, settings_path=None): """ @@ -217,7 +192,7 @@ def create_or_reset_admin(context, settings_path=None): call_command("loaddata", path + "users.json") -@task(help={'settings-path': 'Path to settings file (absolute path recommended). Leave empty for ' +@task(help={'settings-path': 'Path to settings file (absolute path). Leave empty for ' 'default'}) def migrate_db(context, settings_path=None): """ @@ -230,7 +205,7 @@ def migrate_db(context, settings_path=None): call_command("migrate") -@task(help={'settings-path': 'Path to settings file (absolute path recommended). Leave empty for ' +@task(help={'settings-path': 'Path to settings file (absolute path). Leave empty for ' 'default'}) def load_fixtures(context, settings_path=None): """ @@ -290,9 +265,9 @@ def config_location(context): Returns the default location for the settings file and the data folder """ print('Default locations:') - print('* settings: {0}'.format(get_user_config_path('wger', 'settings.py'))) - print('* media folder: {0}'.format(get_user_data_path('wger', 'media'))) - print('* database path: {0}'.format(get_user_data_path('wger', 'database.sqlite'))) + print('* settings: {0}'.format(get_path('settings.py'))) + print('* media folder: {0}'.format(get_path('media'))) + print('* database path: {0}'.format(get_path('database.sqlite'))) # @@ -304,67 +279,15 @@ def config_location(context): # packaged has a different sys path than the local one) # +def get_path(file="settings.py") -> pathlib.Path: + """ + Return the path of the given file relatively to the wger source folder -def get_user_data_path(*args): - if sys.platform == "win32": - return win32_get_app_data_path(*args) - - data_home = os.environ.get( - 'XDG_DATA_HOME', os.path.join( - os.path.expanduser('~'), '.local', 'share')) - - return os.path.join(data_home, *args) - - -def get_user_config_path(*args): - if sys.platform == "win32": - return win32_get_app_data_path(*args) - - config_home = os.environ.get( - 'XDG_CONFIG_HOME', os.path.join(os.path.expanduser('~'), '.config')) - - return os.path.join(config_home, *args) - - -def win32_get_app_data_path(*args): - shell32 = ctypes.WinDLL("shell32.dll") - SHGetFolderPath = shell32.SHGetFolderPathW - SHGetFolderPath.argtypes = ( - ctypes.c_void_p, ctypes.c_int, ctypes.c_void_p, ctypes.c_uint32, - ctypes.c_wchar_p) - SHGetFolderPath.restype = ctypes.c_uint32 - - CSIDL_LOCAL_APPDATA = 0x001c - MAX_PATH = 260 - - buf = ctypes.create_unicode_buffer(MAX_PATH) - res = SHGetFolderPath(0, CSIDL_LOCAL_APPDATA, 0, 0, buf) - if res != 0: - raise Exception("Could not deterime APPDATA path") - - return os.path.join(buf.value, *args) - - -def detect_listen_opts(address, port): - if address is None: - try: - address = socket.gethostbyname(socket.gethostname()) - except socket.error: - address = "127.0.0.1" - - if port is None: - # test if we can use port 80 - s = socket.socket() - port = 80 - try: - s.bind((address, port)) - s.listen(-1) - except socket.error: - port = 8000 - finally: - s.close() - - return address, port + Note: one parent is the step from e.g. some-checkout/wger/settings.py + to some-checkout/wger, the second one to get to the source folder + itself. + """ + return (pathlib.Path(__file__).parent.parent / file).resolve() def setup_django_environment(settings_path): @@ -374,7 +297,7 @@ def setup_django_environment(settings_path): # Use default settings if the user didn't specify something else if settings_path is None: - settings_path = get_user_config_path('wger', 'settings.py') + settings_path = get_path('settings.py').as_posix() print('*** No settings given, using {0}'.format(settings_path)) # Find out file path and fine name of settings and setup django @@ -411,17 +334,3 @@ def database_exists(): sys.exit(0) else: return True - - -def start_browser(url): - """ - Start the web browser with the given URL - """ - browser = webbrowser.get() - - def function(): - time.sleep(1) - browser.open(url) - - thread = threading.Thread(target=function) - thread.start() diff --git a/wger/urls.py b/wger/urls.py index 9ab55d4c7..0f8276180 100644 --- a/wger/urls.py +++ b/wger/urls.py @@ -140,6 +140,10 @@ urlpatterns += [ nutrition_api_views.search, name='ingredient-search'), url(r'^api/v2/', include(router.urls)), + + # The api user login + url(r'^api/v2/login/$', core_api_views.UserAPILoginView.as_view({ + 'post': 'post'}), name='api_user'), ] # diff --git a/wger/utils/api_token.py b/wger/utils/api_token.py new file mode 100644 index 000000000..721994f7b --- /dev/null +++ b/wger/utils/api_token.py @@ -0,0 +1,44 @@ +# This file is part of wger Workout Manager. +# +# wger Workout Manager is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# wger Workout Manager is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License + +# Standard Library +import logging + +# Third Party +from rest_framework.authtoken.models import Token + + +logger = logging.getLogger(__name__) + + +def create_token(user, force_new=False): + """ + Creates a new token for a user or returns the existing one. + + :param user: User object + :param force_new: forces creating a new token + """ + token = False + + try: + token = Token.objects.get(user=user) + except Token.DoesNotExist: + force_new = True + + if force_new: + if token: + token.delete() + token = Token.objects.create(user=user) + + return token diff --git a/wger/utils/cache.py b/wger/utils/cache.py index adafa77f1..700fd7398 100644 --- a/wger/utils/cache.py +++ b/wger/utils/cache.py @@ -58,6 +58,7 @@ class CacheKeyMapper(object): INGREDIENT_CACHE_KEY = 'ingredient-{0}' WORKOUT_CANONICAL_REPRESENTATION = 'workout-canonical-representation-{0}' WORKOUT_LOG_LIST = 'workout-log-hash-{0}' + NUTRITION_CACHE_KEY = 'nutrition-cache-log-{0}' def get_pk(self, param): """ @@ -100,5 +101,11 @@ class CacheKeyMapper(object): """ return self.WORKOUT_LOG_LIST.format(hash_value) + def get_nutrition_cache_by_key(self, params): + """ + get nutritional info values canonical representation using primary key. + """ + return self.NUTRITION_CACHE_KEY.format(self.get_pk(params)) + cache_mapper = CacheKeyMapper() diff --git a/wger/utils/context_processor.py b/wger/utils/context_processor.py index aac9aee86..68ac83da6 100644 --- a/wger/utils/context_processor.py +++ b/wger/utils/context_processor.py @@ -36,7 +36,7 @@ def processor(request): groups = Membership.objects.filter(user=user) if user.is_authenticated else [] for lang in settings.LANGUAGES: - i18n_path[lang[0]] = u'/{0}{1}'.format(lang[0], full_path[3:]) + i18n_path[lang[0]] = '/{0}{1}'.format(lang[0], full_path[3:]) context = { # Application version diff --git a/wger/utils/helpers.py b/wger/utils/helpers.py index e7c0e36e1..627f35aa6 100644 --- a/wger/utils/helpers.py +++ b/wger/utils/helpers.py @@ -230,7 +230,7 @@ def smart_capitalize(input): """ out = [] for word in input.split(' '): - if len(word) > 2 and word[0] != u'ß': + if len(word) > 2 and word[0] != 'ß': out.append(word[:1].upper() + word[1:]) else: out.append(word) diff --git a/wger/utils/pdf.py b/wger/utils/pdf.py index 79a450234..f0f5388b6 100644 --- a/wger/utils/pdf.py +++ b/wger/utils/pdf.py @@ -22,13 +22,19 @@ from django.core.exceptions import ObjectDoesNotExist from django.utils import translation # Third Party +from reportlab.lib import colors +from reportlab.lib.colors import HexColor from reportlab.lib.styles import ( ParagraphStyle, StyleSheet1 ) +from reportlab.lib.units import cm from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont -from reportlab.platypus import Paragraph +from reportlab.platypus import ( + Image, + Paragraph +) # wger from wger import get_version @@ -93,18 +99,29 @@ def render_footer(url, date=None): """ if not date: date = datetime.date.today().strftime("%d.%m.%Y") - p = Paragraph(""" - {date} - - {url} - - wger Workout Manager - {version} - """.format(date=date, - url=url, - version=get_version()), - styleSheet["Normal"]) + + p = Paragraph(""" + {date} - + {url} - + wger Workout Manager + {version} + """.format(date=date, + url=url, + version=get_version()), + styleSheet["Normal"]) return p +def get_logo(width=1.5): + """ + Returns the wger logo + """ + image = Image(path_join(settings.SITE_ROOT, 'core/static/images/logos/logo.png')) + image.drawHeight = width * cm * image.drawHeight / image.drawWidth + image.drawWidth = width * cm + return image + + # register new truetype fonts for reportlab pdfmetrics.registerFont(TTFont( 'OpenSans', path_join(settings.SITE_ROOT, 'core/static/fonts/OpenSans-Light.ttf'))) @@ -136,17 +153,21 @@ styleSheet.add(ParagraphStyle( styleSheet.add(ParagraphStyle( parent=styleSheet['Normal'], name='HeaderBold', - fontSize=14, + fontSize=16, fontName='OpenSans-Bold', )) styleSheet.add(ParagraphStyle( parent=styleSheet['Normal'], name='SubHeader', - fontSize=12, - fontName='OpenSans', + fontName='OpenSans-Bold', + textColor=colors.white )) styleSheet.add(ParagraphStyle( parent=styleSheet['Normal'], - name='Bold', + name='SubHeaderBlack', fontName='OpenSans-Bold', + textColor=colors.black )) + +header_colour = HexColor(0x24416b) +row_color = HexColor(0xd1def0) diff --git a/wger/utils/tests/test_make_token.py b/wger/utils/tests/test_make_token.py new file mode 100644 index 000000000..f08528fc9 --- /dev/null +++ b/wger/utils/tests/test_make_token.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 *-* + +# This file is part of wger Workout Manager. +# +# wger Workout Manager is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# wger Workout Manager is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License + + +# Django +from django.contrib.auth.models import User + +# Third Party +from rest_framework.authtoken.models import Token + +# wger +from wger.core.tests.base_testcase import WgerTestCase +from wger.utils.api_token import create_token + + +class TokenHelperTestCase(WgerTestCase): + """ + Tests the create_token helper + """ + + def test_make_token(self): + """ + Test that create_token returns the user's existing token + """ + user = User.objects.get(pk=2) + self.assertEqual(Token.objects.filter(user=user).count(), 1) + + token_before = Token.objects.get(user=user).key + token = create_token(user).key + token_after = Token.objects.get(user=user).key + + self.assertEqual(token_before, token_after) + self.assertEqual(token_before, token) + + def test_make_token_force_new(self): + """ + Test that create_token returns the user's existing token + """ + user = User.objects.get(pk=2) + self.assertEqual(Token.objects.filter(user=user).count(), 1) + + token_before = Token.objects.get(user=user).key + token = create_token(user, force_new=True).key + token_after = Token.objects.get(user=user).key + + self.assertNotEqual(token_before, token_after) + self.assertEqual(token, token_after) + + def test_make_token_new(self): + """ + Test that create_token creates a token for users that don't have one + """ + user = User.objects.get(pk=2) + Token.objects.filter(user=user).delete() + self.assertEqual(Token.objects.filter(user=user).count(), 0) + + create_token(user) + self.assertEqual(Token.objects.filter(user=user).count(), 1) diff --git a/wger/utils/widgets.py b/wger/utils/widgets.py index 5b6ac5af2..1c2e1177e 100644 --- a/wger/utils/widgets.py +++ b/wger/utils/widgets.py @@ -107,9 +107,9 @@ class ExerciseAjaxSelect(SelectMultiple): if value is None: value = [] - output = [u'

    '] - output.append(u'') - output.append(u'
    ') + output = ['
    '] + output.append('') + output.append('
    ') output.append('
    ') options = self.render_options(choices, value) @@ -117,7 +117,7 @@ class ExerciseAjaxSelect(SelectMultiple): output.append(options) output.append('
    ') - return mark_safe(u'\n'.join(output)) + return mark_safe('\n'.join(output)) def render_options(self, choices, selected_choices): # Normalize to strings. @@ -125,13 +125,13 @@ class ExerciseAjaxSelect(SelectMultiple): output = [] for option_value, option_label in chain(self.choices, choices): output.append(self.render_option(selected_choices, option_value, option_label)) - return u'\n'.join(output) + return '\n'.join(output) def render_option(self, selected_choices, option_value, option_label): option_value = force_text(option_value) if option_value in selected_choices: - return u""" + return """ - +
    + +
    @@ -70,6 +72,25 @@ -/- {% endif %} + + + {% endfor %} diff --git a/wger/weight/tests/test_entry.py b/wger/weight/tests/test_entry.py index c622147d1..ec087f8a5 100644 --- a/wger/weight/tests/test_entry.py +++ b/wger/weight/tests/test_entry.py @@ -24,7 +24,8 @@ from wger.core.tests import api_base_test from wger.core.tests.base_testcase import ( WgerAddTestCase, WgerEditTestCase, - WgerTestCase + WgerTestCase, + WgerDeleteTestCase ) from wger.utils.constants import TWOPLACES from wger.weight.models import WeightEntry @@ -112,6 +113,18 @@ class EditWeightEntryTestCase(WgerEditTestCase): user_fail = 'admin' +class DeleteWeightEntryTestCase(WgerDeleteTestCase): + """ + Tests deleting a weight entry + """ + + object_class = WeightEntry + url = 'weight:delete' + pk = 1 + user_success = 'test' + user_fail = 'admin' + + class WeightEntryTestCase(api_base_test.ApiBaseResourceTestCase): """ Tests the weight entry overview resource diff --git a/wger/weight/urls.py b/wger/weight/urls.py index a6a288b92..2e9b99075 100644 --- a/wger/weight/urls.py +++ b/wger/weight/urls.py @@ -33,6 +33,10 @@ urlpatterns = [ login_required(views.WeightUpdateView.as_view()), name='edit'), + url(r'^(?P\d+)/delete/$', + views.WeightDeleteView.as_view(), + name='delete'), + url(r'^export-csv/$', views.export_csv, name='export-csv'), diff --git a/wger/weight/views.py b/wger/weight/views.py index dd2d30a96..94d095ef6 100644 --- a/wger/weight/views.py +++ b/wger/weight/views.py @@ -21,6 +21,7 @@ import logging # Django from django.contrib.auth.decorators import login_required +from django.contrib.auth.mixins import LoginRequiredMixin from django.db.models import ( Max, Min @@ -37,7 +38,8 @@ from django.utils.translation import ( ) from django.views.generic import ( CreateView, - UpdateView + UpdateView, + DeleteView ) # Third Party @@ -46,7 +48,10 @@ from rest_framework.decorators import api_view from rest_framework.response import Response # wger -from wger.utils.generic_views import WgerFormMixin +from wger.utils.generic_views import ( + WgerFormMixin, + WgerDeleteMixin +) from wger.utils.helpers import check_access from wger.weight import helpers from wger.weight.forms import WeightForm @@ -88,7 +93,7 @@ class WeightAddView(WgerFormMixin, CreateView): return reverse('weight:overview', kwargs={'username': self.object.user.username}) -class WeightUpdateView(WgerFormMixin, UpdateView): +class WeightUpdateView(WgerFormMixin, LoginRequiredMixin, UpdateView): """ Generic view to edit an existing weight entry """ @@ -108,6 +113,28 @@ class WeightUpdateView(WgerFormMixin, UpdateView): return reverse('weight:overview', kwargs={'username': self.object.user.username}) +class WeightDeleteView(WgerDeleteMixin, LoginRequiredMixin, DeleteView): + """ + Generic view to delete a weight entry + """ + + model = WeightEntry + fields = ('weight',) + + messages = ugettext_lazy('Successfully deleted.') + + def get_context_data(self, **kwargs): + context = super(WeightDeleteView, self).get_context_data(**kwargs) + context['title'] = _('Delete weight entry for the %s') % self.object.date + return context + + def get_success_url(self): + """ + Return to overview with username + """ + return reverse('weight:overview', kwargs={'username': self.object.user.username}) + + @login_required def export_csv(request): """ diff --git a/yarn.lock b/yarn.lock index 7c282fa92..5f670b46d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -25,9 +25,10 @@ commander@2: resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== -components-font-awesome@components/font-awesome#5.14.0: - version "5.14.0" - resolved "https://codeload.github.com/components/font-awesome/tar.gz/75afe9bc19a5944b4700e723e0646a8efb4591a1" +components-font-awesome@components/font-awesome#5.15.1: + version "5.15.1" + uid "6943708effe6d823d40255bcdbe9ee021312c880" + resolved "https://codeload.github.com/components/font-awesome/tar.gz/6943708effe6d823d40255bcdbe9ee021312c880" d3-array@1, d3-array@^1.2.0: version "1.2.4" @@ -423,10 +424,10 @@ shariff@^3.2.1: "@fortawesome/fontawesome-free" "^5.8.2" jquery "^3.4.1" -tinymce@^5.4.2: - version "5.4.2" - resolved "https://registry.yarnpkg.com/tinymce/-/tinymce-5.4.2.tgz#a1df417ea7e85697c2932269f7f93fbef30d4502" - integrity sha512-MRquvKxI28AiSn2ikypgJFD7LH4mQZLtgFn+yROF1bTFp92MdlLeGBrI38/O5mej3cvz27g3Pgr+3MShepbJ+w== +tinymce@^5.5.1: + version "5.5.1" + resolved "https://registry.yarnpkg.com/tinymce/-/tinymce-5.5.1.tgz#4f24ca6e7b698a3c7418a39ef0f3d613f4a703a4" + integrity sha512-z03C8/0TBby68Kp7YUTSCZ0QJINsFCv9U+Cv3TNHg+T1spZ4V6vOIgD0zeTd/xKqkru0P7IOHoeAnOjfpTLq7g== xmlhttprequest@1: version "1.8.0" @@ -434,6 +435,6 @@ xmlhttprequest@1: integrity sha1-Z/4HXFwk/vOfnWX197f+dRcZaPw= yarn@^1.22.5: - version "1.22.5" - resolved "https://registry.yarnpkg.com/yarn/-/yarn-1.22.5.tgz#1933b7635429ca00847222dd9d38f05646e2df23" - integrity sha512-5uzKXwdMc++mYktXqkfpNYT9tY8ViWegU58Hgbo+KXzrzzhEyP1Ip+BTtXloLrXNcNlxFJbLiFKGaS9vK9ym6Q== + version "1.22.10" + resolved "https://registry.yarnpkg.com/yarn/-/yarn-1.22.10.tgz#c99daa06257c80f8fa2c3f1490724e394c26b18c" + integrity sha512-IanQGI9RRPAN87VGTF7zs2uxkSyQSrSPsju0COgbsKQOOXr5LtcVPeyXWgwVa0ywG3d8dg6kSYKGBuYK021qeA==