diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 9f4c97f3..5a5aefc7 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,7 +1,7 @@ # These are supported funding model platforms -github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] -patreon: danielroygreenfeld +github: pydanny +patreon: roygreenfeld open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 00000000..a29e237d --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,11 @@ +# Add 'docs' to any changes within 'docs' folder or any subfolders +docs: + - 'README.rst' + - 'docs/**/*' + - '{{cookiecutter.project_slug}}/docs/**/*' + +# Flag PR related to docker +docker: + - '{{cookiecutter.project_slug}}/compose/**/*' + - '{{cookiecutter.project_slug}}/local.yml' + - '{{cookiecutter.project_slug}}/production.yml' diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 00000000..734a541a --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,29 @@ +categories: + - title: 'Breaking Changes' + labels: + - 'breaking' + - title: 'Major Changes' + labels: + - 'major' + - title: 'Minor Changes' + labels: + - 'enhancement' + - title: 'Bugfixes' + labels: + - 'bug' + - title: 'Removals' + labels: + - 'removed' + - title: 'Documentation updates' + labels: + - 'docs' + +exclude-labels: + - 'skip-changelog' + - 'update' + - 'project infrastructure' + +template: | + ## Changes + + $CHANGES diff --git a/.github/workflows/draft-release.yml b/.github/workflows/draft-release.yml new file mode 100644 index 00000000..6c2d6620 --- /dev/null +++ b/.github/workflows/draft-release.yml @@ -0,0 +1,14 @@ +name: Release Drafter + +on: + push: + branches: + - master + +jobs: + release_notes: + runs-on: ubuntu-latest + steps: + - uses: release-drafter/release-drafter@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/label.yml b/.github/workflows/label.yml new file mode 100644 index 00000000..3df0ec0c --- /dev/null +++ b/.github/workflows/label.yml @@ -0,0 +1,20 @@ +# This workflow will triage pull requests and apply a label based on the +# paths that are modified in the pull request. +# +# To use this workflow, you will need to set up a .github/labeler.yml +# file with configuration. For more information, see: +# https://github.com/actions/labeler/blob/master/README.md + + +name: Labeler +on: [pull_request] + +jobs: + label: + + runs-on: ubuntu-latest + + steps: + - uses: actions/labeler@v2 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.pyup.yml b/.pyup.yml index 4978524e..c503dabf 100644 --- a/.pyup.yml +++ b/.pyup.yml @@ -8,6 +8,11 @@ update: all # allowed: True, False pin: True +# add a label to pull requests, default is not set +# requires private repo permissions, even on public repos +# default: empty +label_prs: update + # Specify requirement files by hand, pyup seems to struggle to # find the ones in the project_slug folder requirements: diff --git a/.travis.yml b/.travis.yml index 925d82e7..b250148e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,7 @@ services: language: python -python: 3.7 +python: 3.8 before_install: - docker-compose -v @@ -14,11 +14,7 @@ before_install: matrix: include: - name: Test results - script: tox -e py37 - - name: Run flake8 on result - script: tox -e flake8 - - name: Run black on result - script: tox -e black + script: tox -e py38 - name: Black template script: tox -e black-template - name: Basic Docker diff --git a/CHANGELOG.md b/CHANGELOG.md index ae8f7efe..27a8578a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,36 @@ # Change Log All enhancements and patches to Cookiecutter Django will be documented in this file. +## [2020-04-13] +### Changed +- Updated to Python 3.8 (@codnee) +- Moved converage config in setup.cfg (@danihodovic) + +## [2020-04-08] +### Fixed +- Internal IPs for debug toolbar (@dudanogueira) + +## [2020-04-04] +### Fixed +- Added compress command command with Django compressor (@gwiskur) + +## [2020-03-23] +### Changed +- Updated project to Django 3.0 + +## [2020-03-17] +### Changed +- Handle paths using Pathlib (@jules-ch) + +### Fixed +- Pre-commit hook regex (@demestav) + +## [2020-03-16] +### Added +- Support for all Anymail providers (@Andrew-Chen-Wang) +### Fixed +- Django compressor setup (@jameswilliams1) + ## [2020-01-23] ### Changed - Fix UserFactory to set the password if provided (@BoPeng) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index cb59ae5f..0a64e628 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -39,9 +39,9 @@ To run all tests using various versions of python in virtualenvs defined in tox. It is possible to test with a specific version of python. To do this, the command is:: - $ tox -e py37 + $ tox -e py38 -This will run py.test with the python3.7 interpreter, for example. +This will run py.test with the python3.8 interpreter, for example. To run a particular test with tox for against your current Python version:: diff --git a/CONTRIBUTORS.rst b/CONTRIBUTORS.rst index 2554d2cf..280a9f4f 100644 --- a/CONTRIBUTORS.rst +++ b/CONTRIBUTORS.rst @@ -49,6 +49,7 @@ Listed in alphabetical order. Adam Dobrawy `@ad-m`_ Adam Steele `@adammsteele`_ Agam Dua + Agustín Scaramuzza `@scaramagus`_ @scaramagus Alberto Sanchez `@alb3rto`_ Alex Tsai `@caffodian`_ Alvaro [Andor] `@andor-pierdelacabeza`_ @@ -56,6 +57,7 @@ Listed in alphabetical order. Andreas Meistad `@ameistad`_ Andres Gonzalez `@andresgz`_ Andrew Mikhnevich `@zcho`_ + Andrew Chen Wang `@Andrew-Chen-Wang`_ Andy Rose Anna Callahan `@jazztpt`_ Anna Sidwell `@takkaria`_ @@ -87,6 +89,7 @@ Listed in alphabetical order. Chris Pappalardo `@ChrisPappalardo`_ Christopher Clarke `@chrisdev`_ Cole Mackenzie `@cmackenzie1`_ + Cole Maclean `@cole`_ @cole Collederas `@Collederas`_ Craig Margieson `@cmargieson`_ Cristian Vargas `@cdvv7788`_ @@ -96,6 +99,7 @@ Listed in alphabetical order. Dani Hodovic `@danihodovic`_ Daniel Hepper `@dhepper`_ @danielhepper Daniel Hillier `@danifus`_ + Daniel Sears `@highpost`_ @highpost Daniele Tricoli `@eriol`_ David Díaz `@ddiazpinto`_ @DavidDiazPinto Davit Tovmasyan `@davitovmasyan`_ @@ -108,15 +112,20 @@ Listed in alphabetical order. Diane Chen `@purplediane`_ @purplediane88 Dónal Adams `@epileptic-fish`_ Dong Huynh `@trungdong`_ + Duda Nogueira `@dudanogueira`_ @dudanogueira Emanuel Calso `@bloodpet`_ @bloodpet Eraldo Energy `@eraldo`_ Eric Groom `@ericgroom`_ + Ernesto Cedeno `@codnee`_ Eyad Al Sibai `@eyadsibai`_ Felipe Arruda `@arruda`_ Florian Idelberger `@step21`_ @windrush + Gabriel Mejia `@elgartoinf`_ @elgartoinf Garry Cairns `@garry-cairns`_ Garry Polley `@garrypolley`_ Gilbishkosma `@Gilbishkosma`_ + Glenn Wiskur `@gwiskur`_ + Guilherme Guy `@guilherme1guy`_ Hamish Durkin `@durkode`_ Hana Quadara `@hanaquadara`_ Harry Moreno `@morenoh149`_ @morenoh149 @@ -128,6 +137,7 @@ Listed in alphabetical order. Irfan Ahmad `@erfaan`_ @erfaan Isaac12x `@Isaac12x`_ Ivan Khomutov `@ikhomutov`_ + James Williams `@jameswilliams1`_ Jan Van Bruggen `@jvanbrug`_ Jelmer Draaijer `@foarsitter`_ Jerome Caisip `@jeromecaisip`_ @@ -135,6 +145,7 @@ Listed in alphabetical order. Jerome Leclanche `@jleclanche`_ @Adys Jimmy Gitonga `@afrowave`_ @afrowave John Cass `@jcass77`_ @cass_john + Jules Cheron `@jules-ch`_ Julien Almarcha `@sladinji`_ Julio Castillo `@juliocc`_ Kaido Kert `@kaidokert`_ @@ -175,6 +186,7 @@ Listed in alphabetical order. Oleg Russkin `@rolep`_ Pablo `@oubiga`_ Parbhat Puri `@parbhat`_ + Pawan Chaurasia `@rjsnh1522`_ Peter Bittner `@bittner`_ Peter Coles `@mrcoles`_ Philipp Matthies `@canonnervio`_ @@ -190,6 +202,7 @@ Listed in alphabetical order. Sascha `@saschalalala`_ @saschalalala Shupeyko Nikita `@webyneter`_ Sławek Ehlert `@slafs`_ + Sorasful `@sorasful`_ Srinivas Nyayapati `@shireenrao`_ stepmr `@stepmr`_ Steve Steiner `@ssteinerX`_ @@ -205,6 +218,7 @@ Listed in alphabetical order. Tubo Shi `@Tubo`_ Umair Ashraf `@umrashrf`_ @fabumair Vadim Iskuchekov `@Egregors`_ @egregors + Vicente G. Reyes `@reyesvicente`_ @highcenburg Vitaly Babiy Vivian Guillen `@viviangb`_ Vlad Doster `@vladdoster`_ @@ -228,6 +242,7 @@ Listed in alphabetical order. .. _@andor-pierdelacabeza: https://github.com/andor-pierdelacabeza .. _@andresgz: https://github.com/andresgz .. _@antoniablair: https://github.com/antoniablair +.. _@Andrew-Chen-Wang: https://github.com/Andrew-Chen-Wang .. _@apirobot: https://github.com/apirobot .. _@archinal: https://github.com/archinal .. _@areski: https://github.com/areski @@ -258,6 +273,8 @@ Listed in alphabetical order. .. _@chuckus: https://github.com/chuckus .. _@cmackenzie1: https://github.com/cmackenzie1 .. _@cmargieson: https://github.com/cmargieson +.. _@codnee: https://github.com/codnee +.. _@cole: https://github.com/cole .. _@Collederas: https://github.com/Collederas .. _@curtisstpierre: https://github.com/curtisstpierre .. _@dadokkio: https://github.com/dadokkio @@ -270,9 +287,12 @@ Listed in alphabetical order. .. _@dezoito: https://github.com/dezoito .. _@dhepper: https://github.com/dhepper .. _@dot2dotseurat: https://github.com/dot2dotseurat +.. _@dudanogueira: https://github.com/dudanogueira .. _@dsclementsen: https://github.com/dsclementsen +.. _@guilherme1guy: https://github.com/guilherme1guy .. _@durkode: https://github.com/durkode .. _@Egregors: https://github.com/Egregors +.. _@elgartoinf: https://gihub.com/elgartoinf .. _@epileptic-fish: https://gihub.com/epileptic-fish .. _@eraldo: https://github.com/eraldo .. _@erfaan: https://github.com/erfaan @@ -284,16 +304,19 @@ Listed in alphabetical order. .. _@garry-cairns: https://github.com/garry-cairns .. _@garrypolley: https://github.com/garrypolley .. _@Gilbishkosma: https://github.com/Gilbishkosma +.. _@gwiskur: https://github.com/gwiskur .. _@glasslion: https://github.com/glasslion .. _@goldhand: https://github.com/goldhand .. _@hackebrot: https://github.com/hackebrot .. _@hairychris: https://github.com/hairychris .. _@hanaquadara: https://github.com/hanaquadara .. _@hendrikschneider: https://github.com/hendrikschneider +.. _@highpost: https://github.com/highpost .. _@hjwp: https://github.com/hjwp .. _@howiezhao: https://github.com/howiezhao .. _@IanLee1521: https://github.com/IanLee1521 .. _@ikhomutov: https://github.com/ikhomutov +.. _@jameswilliams1: https://github.com/jameswilliams1 .. _@ikkebr: https://github.com/ikkebr .. _@Isaac12x: https://github.com/Isaac12x .. _@iynaix: https://github.com/iynaix @@ -302,6 +325,7 @@ Listed in alphabetical order. .. _@jcass77: https://github.com/jcass77 .. _@jeromecaisip: https://github.com/jeromecaisip .. _@jleclanche: https://github.com/jleclanche +.. _@jules-ch: https://github.com/jules-ch .. _@juliocc: https://github.com/juliocc .. _@jvanbrug: https://github.com/jvanbrug .. _@ka7eh: https://github.com/ka7eh @@ -335,21 +359,25 @@ Listed in alphabetical order. .. _@originell: https://github.com/originell .. _@oubiga: https://github.com/oubiga .. _@parbhat: https://github.com/parbhat +.. _@rjsnh1522: https://github.com/rjsnh1522 .. _@pchiquet: https://github.com/pchiquet .. _@phiberjenz: https://github.com/phiberjenz .. _@purplediane: https://github.com/purplediane .. _@raonyguimaraes: https://github.com/raonyguimaraes .. _@reggieriser: https://github.com/reggieriser +.. _@reyesvicente: https://github.com/reyesvicente .. _@rm--: https://github.com/rm-- .. _@rolep: https://github.com/rolep .. _@romanosipenko: https://github.com/romanosipenko .. _@saschalalala: https://github.com/saschalalala +.. _@scaramagus: https://github.com/scaramagus .. _@shireenrao: https://github.com/shireenrao .. _@show0k: https://github.com/show0k .. _@shultz: https://github.com/shultz .. _@siauPatrick: https://github.com/siauPatrick .. _@sladinji: https://github.com/sladinji .. _@slafs: https://github.com/slafs +.. _@sorasful:: https://github.com/sorasful .. _@ssteinerX: https://github.com/ssteinerx .. _@step21: https://github.com/step21 .. _@stepmr: https://github.com/stepmr diff --git a/LICENSE b/LICENSE index 28466d40..a67e4da2 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2013-2018, Daniel Roy Greenfeld +Copyright (c) 2013-2020, Daniel Roy Greenfeld All rights reserved. Redistribution and use in source and binary forms, with or without modification, diff --git a/README.rst b/README.rst index 6e534b0f..02f220cf 100644 --- a/README.rst +++ b/README.rst @@ -36,8 +36,8 @@ production-ready Django projects quickly. Features --------- -* For Django 2.2 -* Works with Python 3.7 +* For Django 3.0 +* Works with Python 3.8 * Renders Django projects with 100% starting test coverage * Twitter Bootstrap_ v4 (`maintained Foundation fork`_ also available) * 12-Factor_ based settings via django-environ_ @@ -45,8 +45,9 @@ Features * Optimized development and production settings * Registration via django-allauth_ * Comes with custom user model ready to go +* Optional basic ASGI setup for Websockets * Optional custom static build using Gulp and livereload -* Send emails via Anymail_ (using Mailgun_ by default, but switchable) +* Send emails via Anymail_ (using Mailgun_ by default or Amazon SES if AWS is selected cloud provider, but switchable) * Media storage using Amazon S3 or Google Cloud Storage * Docker support using docker-compose_ for development and production (using Traefik_ with LetsEncrypt_ support) * Procfile_ for deploying to Heroku @@ -85,7 +86,7 @@ Optional Integrations .. _PythonAnywhere: https://www.pythonanywhere.com/ .. _Traefik: https://traefik.io/ .. _LetsEncrypt: https://letsencrypt.org/ -.. _pre-commit: https://github.com/pre-commit/pre-commit +.. _pre-commit: https://github.com/pre-commit/pre-commit Constraints ----------- @@ -105,16 +106,16 @@ This project is run by volunteers. Please support them in their efforts to maint Projects that provide financial support to the maintainers: -Two Scoops of Django 1.11 +Django Crash Course ~~~~~~~~~~~~~~~~~~~~~~~~~ -.. image:: https://cdn.shopify.com/s/files/1/0304/6901/products/2017-06-29-tsd11-sticker-02.png - :name: Two Scoops of Django 1.11 Cover +.. image:: https://cdn.shopify.com/s/files/1/0304/6901/files/Django-Crash-Course-300x436.jpg + :name: Django Crash Course: Covers Django 3.0 and Python 3.8 :align: center - :alt: Two Scoops of Django - :target: http://twoscoopspress.com/products/two-scoops-of-django-1-11 + :alt: Django Crash Course + :target: https://www.roygreenfeld.com/products/django-crash-course -Two Scoops of Django is the best dessert-themed Django reference in the universe +Django Crash Course for Django 3.0 and Python 3.8 is the best cheese-themed Django reference in the universe! pyup ~~~~~~~~~~~~~~~~~~ @@ -135,7 +136,7 @@ and then editing the results to include your name, email, and various configurat First, get Cookiecutter. Trust me, it's awesome:: - $ pip install "cookiecutter>=1.4.0" + $ pip install "cookiecutter>=1.7.0" Now run it against this repo:: @@ -272,7 +273,7 @@ If you do rename your fork, I encourage you to submit it to the following places * cookiecutter_ so it gets listed in the README as a template. * The cookiecutter grid_ on Django Packages. -.. _cookiecutter: https://github.com/audreyr/cookiecutter +.. _cookiecutter: https://github.com/cookiecutter/cookiecutter .. _grid: https://www.djangopackages.com/grids/g/cookiecutters/ Submit a Pull Request diff --git a/cookiecutter.json b/cookiecutter.json index 4e77d110..62155ad0 100644 --- a/cookiecutter.json +++ b/cookiecutter.json @@ -33,6 +33,18 @@ "GCP", "None" ], + "mail_service": [ + "Mailgun", + "Amazon SES", + "Mailjet", + "Mandrill", + "Postmark", + "Sendgrid", + "SendinBlue", + "SparkPost", + "Other SMTP" + ], + "use_async": "n", "use_drf": "n", "custom_bootstrap_compilation": "n", "use_compressor": "n", diff --git a/docs/deployment-on-pythonanywhere.rst b/docs/deployment-on-pythonanywhere.rst index 4738d5a5..16dba5a2 100644 --- a/docs/deployment-on-pythonanywhere.rst +++ b/docs/deployment-on-pythonanywhere.rst @@ -15,7 +15,7 @@ Full instructions follow, but here's a high-level view. 2. Set your config variables in the *postactivate* script -3. Run the *manage.py* ``migrate`` and ``collectstatic`` commands +3. Run the *manage.py* ``migrate`` and ``collectstatic`` {%- if cookiecutter.use_compressor == "y" %}and ``compress`` {%- endif %}commands 4. Add an entry to the PythonAnywhere *Web tab* @@ -35,7 +35,7 @@ Make sure your project is fully committed and pushed up to Bitbucket or Github o git clone # you can also use hg cd my-project-name - mkvirtualenv --python=/usr/bin/python3.7 my-project-name + mkvirtualenv --python=/usr/bin/python3.8 my-project-name pip install -r requirements/production.txt # may take a few minutes @@ -109,6 +109,7 @@ Now run the migration, and collectstatic: source $VIRTUAL_ENV/bin/postactivate python manage.py migrate python manage.py collectstatic + {%- if cookiecutter.use_compressor == "y" %}python manage.py compress {%- endif %} # and, optionally python manage.py createsuperuser @@ -175,6 +176,7 @@ For subsequent deployments, the procedure is much simpler. In a Bash console: git pull python manage.py migrate python manage.py collectstatic + {%- if cookiecutter.use_compressor == "y" %}python manage.py compress {%- endif %} And then go to the Web tab and hit **Reload** diff --git a/docs/deployment-with-docker.rst b/docs/deployment-with-docker.rst index 0df50ff4..8a206885 100644 --- a/docs/deployment-with-docker.rst +++ b/docs/deployment-with-docker.rst @@ -25,7 +25,9 @@ Provided you have opted for Celery (via setting ``use_celery`` to ``y``) there a * ``celeryworker`` running a Celery worker process; * ``celerybeat`` running a Celery beat process; -* ``flower`` running Flower_ (for more info, check out :ref:`CeleryFlower` instructions for local environment). +* ``flower`` running Flower_. + +The ``flower`` service is served by Traefik over HTTPS, through the port ``5555``. For more information about Flower and its login credentials, check out :ref:`CeleryFlower` instructions for local environment. .. _`Flower`: https://github.com/mher/flower diff --git a/docs/developing-locally.rst b/docs/developing-locally.rst index 7a58d099..4f70414c 100644 --- a/docs/developing-locally.rst +++ b/docs/developing-locally.rst @@ -9,7 +9,7 @@ Setting Up Development Environment Make sure to have the following on your host: -* Python 3.7 +* Python 3.8 * PostgreSQL_. * Redis_, if using Celery @@ -17,7 +17,7 @@ First things first. #. Create a virtualenv: :: - $ python3.7 -m venv + $ python3.8 -m venv #. Activate the virtualenv you have just created: :: @@ -68,10 +68,14 @@ First things first. $ python manage.py migrate -#. See the application being served through Django development server: :: +#. If you're running synchronously, see the application being served through Django development server: :: $ python manage.py runserver 0.0.0.0:8000 +or if you're running asynchronously: :: + + $ gunicorn config.asgi --bind 0.0.0.0:8000 -k uvicorn.workers.UvicornWorker --reload + .. _PostgreSQL: https://www.postgresql.org/download/ .. _Redis: https://redis.io/download .. _createdb: https://www.postgresql.org/docs/current/static/app-createdb.html diff --git a/docs/faq.rst b/docs/faq.rst index 1481a8ba..59e82465 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -24,4 +24,4 @@ Why doesn't this follow the layout from Two Scoops of Django? You may notice that some elements of this project do not exactly match what we describe in chapter 3 of `Two Scoops of Django 1.11`_. The reason for that is this project, amongst other things, serves as a test bed for trying out new ideas and concepts. Sometimes they work, sometimes they don't, but the end result is that it won't necessarily match precisely what is described in the book I co-authored. -.. _Two Scoops of Django 1.11: https://www.twoscoopspress.com/collections/django/products/two-scoops-of-django-1-11 +.. _Two Scoops of Django 1.11: https://www.feldroy.com/collections/django/products/two-scoops-of-django-1-11 diff --git a/docs/index.rst b/docs/index.rst index 8e0d04aa..b29e728e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -5,7 +5,7 @@ Welcome to Cookiecutter Django's documentation! A Cookiecutter_ template for Django. -.. _cookiecutter: https://github.com/audreyr/cookiecutter +.. _cookiecutter: https://github.com/cookiecutter/cookiecutter Contents: diff --git a/docs/project-generation-options.rst b/docs/project-generation-options.rst index ae47b097..1c2fc314 100644 --- a/docs/project-generation-options.rst +++ b/docs/project-generation-options.rst @@ -70,6 +70,25 @@ cloud_provider: Note that if you choose no cloud provider, media files won't work. +mail_service: + Select an email service that Django-Anymail provides + + 1. Mailgun_ + 2. `Amazon SES`_ + 3. Mailjet_ + 4. Mandrill_ + 5. Postmark_ + 6. SendGrid_ + 7. SendinBlue_ + 8. SparkPost_ + 9. `Other SMTP`_ + +use_async: + Indicates whether the project should use web sockets with Uvicorn + Gunicorn. + +use_drf: + Indicates whether the project should be configured to use `Django Rest Framework`_. + custom_bootstrap_compilation: Indicates whether the project should support Bootstrap recompilation via the selected JavaScript task runner's task. This can be useful @@ -98,8 +117,8 @@ ci_tool: Select a CI tool for running tests. The choices are: 1. None - 2. Travis_ - 3. Gitlab_ + 2. `Travis CI`_ + 3. `Gitlab CI`_ keep_local_envs_in_vcs: Indicates whether the project's ``.envs/.local/`` should be kept in VCS @@ -129,6 +148,18 @@ debug: .. _AWS: https://aws.amazon.com/s3/ .. _GCP: https://cloud.google.com/storage/ +.. _Amazon SES: https://aws.amazon.com/ses/ +.. _Mailgun: https://www.mailgun.com +.. _Mailjet: https://www.mailjet.com +.. _Mandrill: http://mandrill.com +.. _Postmark: https://postmarkapp.com +.. _SendGrid: https://sendgrid.com +.. _SendinBlue: https://www.sendinblue.com +.. _SparkPost: https://www.sparkpost.com +.. _Other SMTP: https://anymail.readthedocs.io/en/stable/ + +.. _Django Rest Framework: https://github.com/encode/django-rest-framework/ + .. _Django Compressor: https://github.com/django-compressor/django-compressor .. _Celery: https://github.com/celery/celery diff --git a/docs/settings.rst b/docs/settings.rst index e586c963..2ae8814e 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -52,6 +52,21 @@ DJANGO_SENTRY_LOG_LEVEL SENTRY_LOG_LEVEL n/a MAILGUN_API_KEY MAILGUN_API_KEY n/a raises error MAILGUN_DOMAIN MAILGUN_SENDER_DOMAIN n/a raises error MAILGUN_API_URL n/a n/a "https://api.mailgun.net/v3" +MAILJET_API_KEY MAILJET_API_KEY n/a raises error +MAILJET_SECRET_KEY MAILJET_SECRET_KEY n/a raises error +MAILJET_API_URL n/a n/a "https://api.mailjet.com/v3" +MANDRILL_API_KEY MANDRILL_API_KEY n/a raises error +MANDRILL_API_URL n/a n/a "https://mandrillapp.com/api/1.0" +POSTMARK_SERVER_TOKEN POSTMARK_SERVER_TOKEN n/a raises error +POSTMARK_API_URL n/a n/a "https://api.postmarkapp.com/" +SENDGRID_API_KEY SENDGRID_API_KEY n/a raises error +SENDGRID_GENERATE_MESSAGE_ID True n/a raises error +SENDGRID_MERGE_FIELD_FORMAT None n/a raises error +SENDGRID_API_URL n/a n/a "https://api.sendgrid.com/v3/" +SENDINBLUE_API_KEY SENDINBLUE_API_KEY n/a raises error +SENDINBLUE_API_URL n/a n/a "https://api.sendinblue.com/v3/" +SPARKPOST_API_KEY SPARKPOST_API_KEY n/a raises error +SPARKPOST_API_URL n/a n/a "https://api.sparkpost.com/api/v1" ======================================= =========================== ============================================== ====================================================================== -------------------------- diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py index 5cc8c32f..ad1db3ee 100644 --- a/hooks/post_gen_project.py +++ b/hooks/post_gen_project.py @@ -101,6 +101,15 @@ def remove_celery_files(): os.remove(file_name) +def remove_async_files(): + file_names = [ + os.path.join("config", "asgi.py"), + os.path.join("config", "websocket.py"), + ] + for file_name in file_names: + os.remove(file_name) + + def remove_dottravisyml_file(): os.remove(".travis.yml") @@ -292,6 +301,10 @@ def remove_drf_starter_files(): shutil.rmtree(os.path.join("{{cookiecutter.project_slug}}", "users", "api")) +def remove_storages_module(): + os.remove(os.path.join("{{cookiecutter.project_slug}}", "utils", "storages.py")) + + def main(): debug = "{{ cookiecutter.debug }}".lower() == "y" @@ -352,6 +365,7 @@ def main(): WARNING + "You chose not to use a cloud provider, " "media files won't be served in production." + TERMINATOR ) + remove_storages_module() if "{{ cookiecutter.use_celery }}".lower() == "n": remove_celery_files() @@ -367,6 +381,9 @@ def main(): if "{{ cookiecutter.use_drf }}".lower() == "n": remove_drf_starter_files() + if "{{ cookiecutter.use_async }}".lower() == "n": + remove_async_files() + print(SUCCESS + "Project initialized, keep up the good work!" + TERMINATOR) diff --git a/hooks/pre_gen_project.py b/hooks/pre_gen_project.py index 668a6e27..8eaf4983 100644 --- a/hooks/pre_gen_project.py +++ b/hooks/pre_gen_project.py @@ -35,7 +35,7 @@ if "{{ cookiecutter.use_docker }}".lower() == "n": if python_major_version == 2: print( WARNING + "You're running cookiecutter under Python 2, but the generated " - "project requires Python 3.7+. Do you want to proceed (y/n)? " + TERMINATOR + "project requires Python 3.8+. Do you want to proceed (y/n)? " + TERMINATOR ) yes_options, no_options = frozenset(["y"]), frozenset(["n"]) while True: @@ -68,3 +68,15 @@ if ( "You should either use Whitenoise or select a Cloud Provider to serve static files" ) sys.exit(1) + +if ( + "{{ cookiecutter.cloud_provider }}" == "GCP" + and "{{ cookiecutter.mail_service }}" == "Amazon SES" +) or ( + "{{ cookiecutter.cloud_provider }}" == "None" + and "{{ cookiecutter.mail_service }}" == "Amazon SES" +): + print( + "You should either use AWS or select a different Mail Service for sending emails." + ) + sys.exit(1) diff --git a/pytest.ini b/pytest.ini index 89aeb302..03ca1389 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,7 +1,4 @@ [pytest] -addopts = -x --tb=short +addopts = -v --tb=short python_paths = . norecursedirs = .tox .git */migrations/* */static/* docs venv */{{cookiecutter.project_slug}}/* -markers = - flake8: Run flake8 on all possible template combinations - black: Run black on all possible template combinations diff --git a/requirements.txt b/requirements.txt index afc125ad..c5601892 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -cookiecutter==1.7.0 +cookiecutter==1.7.2 sh==1.12.14 binaryornot==0.4.4 @@ -6,12 +6,12 @@ binaryornot==0.4.4 # ------------------------------------------------------------------------------ black==19.10b0 flake8==3.7.9 +flake8-isort==3.0.0 # Testing # ------------------------------------------------------------------------------ -tox==3.14.3 -pytest==5.3.5 -pytest_cases==1.12.1 -pytest-cookies==0.4.0 -pytest-xdist==1.31.0 -pyyaml==5.3 +tox==3.14.6 +pytest==5.4.1 +pytest-cookies==0.5.1 +pytest-instafail==0.4.1.post0 +pyyaml==5.3.1 diff --git a/setup.py b/setup.py index 33032009..6dfd9e50 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ except ImportError: # Our version ALWAYS matches the version of Django we support # If Django has a new release, we branch, tag, then update this setting after the tag. -version = "2.2.1" +version = "3.0.5-01" if sys.argv[-1] == "tag": os.system(f'git tag -a {version} -m "version {version}"') @@ -34,13 +34,13 @@ setup( classifiers=[ "Development Status :: 4 - Beta", "Environment :: Console", - "Framework :: Django :: 2.2", + "Framework :: Django :: 3.0", "Intended Audience :: Developers", "Natural Language :: English", "License :: OSI Approved :: BSD License", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", "Programming Language :: Python :: Implementation :: CPython", "Topic :: Software Development", ], diff --git a/tests/test_cookiecutter_generation.py b/tests/test_cookiecutter_generation.py index 40e6cf1b..51500705 100755 --- a/tests/test_cookiecutter_generation.py +++ b/tests/test_cookiecutter_generation.py @@ -3,7 +3,6 @@ import re import pytest from cookiecutter.exceptions import FailedHookException -from pytest_cases import pytest_fixture_plus import sh import yaml from binaryornot.check import is_binary @@ -26,49 +25,93 @@ def context(): } -@pytest_fixture_plus -@pytest.mark.parametrize("windows", ["y", "n"], ids=lambda yn: f"win:{yn}") -@pytest.mark.parametrize("use_docker", ["y", "n"], ids=lambda yn: f"docker:{yn}") -@pytest.mark.parametrize("use_celery", ["y", "n"], ids=lambda yn: f"celery:{yn}") -@pytest.mark.parametrize("use_mailhog", ["y", "n"], ids=lambda yn: f"mailhog:{yn}") -@pytest.mark.parametrize("use_sentry", ["y", "n"], ids=lambda yn: f"sentry:{yn}") -@pytest.mark.parametrize("use_compressor", ["y", "n"], ids=lambda yn: f"cmpr:{yn}") -@pytest.mark.parametrize("use_drf", ["y", "n"], ids=lambda yn: f"drf:{yn}") -@pytest.mark.parametrize( - "use_whitenoise,cloud_provider", - [ - ("y", "AWS"), - ("y", "GCP"), - ("y", "None"), - ("n", "AWS"), - ("n", "GCP"), - # no whitenoise + no cloud provider is not supported - ], - ids=lambda id: f"wnoise:{id[0]}-cloud:{id[1]}", -) -def context_combination( - windows, - use_docker, - use_celery, - use_mailhog, - use_sentry, - use_compressor, - use_whitenoise, - use_drf, - cloud_provider, -): - """Fixture that parametrize the function where it's used.""" - return { - "windows": windows, - "use_docker": use_docker, - "use_compressor": use_compressor, - "use_celery": use_celery, - "use_mailhog": use_mailhog, - "use_sentry": use_sentry, - "use_whitenoise": use_whitenoise, - "use_drf": use_drf, - "cloud_provider": cloud_provider, - } +SUPPORTED_COMBINATIONS = [ + {"open_source_license": "MIT"}, + {"open_source_license": "BSD"}, + {"open_source_license": "GPLv3"}, + {"open_source_license": "Apache Software License 2.0"}, + {"open_source_license": "Not open source"}, + {"windows": "y"}, + {"windows": "n"}, + {"use_pycharm": "y"}, + {"use_pycharm": "n"}, + {"use_docker": "y"}, + {"use_docker": "n"}, + {"postgresql_version": "11.3"}, + {"postgresql_version": "10.8"}, + {"postgresql_version": "9.6"}, + {"postgresql_version": "9.5"}, + {"postgresql_version": "9.4"}, + {"cloud_provider": "AWS", "use_whitenoise": "y"}, + {"cloud_provider": "AWS", "use_whitenoise": "n"}, + {"cloud_provider": "GCP", "use_whitenoise": "y"}, + {"cloud_provider": "GCP", "use_whitenoise": "n"}, + {"cloud_provider": "None", "use_whitenoise": "y", "mail_service": "Mailgun"}, + {"cloud_provider": "None", "use_whitenoise": "y", "mail_service": "Mailjet"}, + {"cloud_provider": "None", "use_whitenoise": "y", "mail_service": "Mandrill"}, + {"cloud_provider": "None", "use_whitenoise": "y", "mail_service": "Postmark"}, + {"cloud_provider": "None", "use_whitenoise": "y", "mail_service": "Sendgrid"}, + {"cloud_provider": "None", "use_whitenoise": "y", "mail_service": "SendinBlue"}, + {"cloud_provider": "None", "use_whitenoise": "y", "mail_service": "SparkPost"}, + {"cloud_provider": "None", "use_whitenoise": "y", "mail_service": "Other SMTP"}, + # Note: cloud_provider=None AND use_whitenoise=n is not supported + {"cloud_provider": "AWS", "mail_service": "Mailgun"}, + {"cloud_provider": "AWS", "mail_service": "Amazon SES"}, + {"cloud_provider": "AWS", "mail_service": "Mailjet"}, + {"cloud_provider": "AWS", "mail_service": "Mandrill"}, + {"cloud_provider": "AWS", "mail_service": "Postmark"}, + {"cloud_provider": "AWS", "mail_service": "Sendgrid"}, + {"cloud_provider": "AWS", "mail_service": "SendinBlue"}, + {"cloud_provider": "AWS", "mail_service": "SparkPost"}, + {"cloud_provider": "AWS", "mail_service": "Other SMTP"}, + {"cloud_provider": "GCP", "mail_service": "Mailgun"}, + {"cloud_provider": "GCP", "mail_service": "Mailjet"}, + {"cloud_provider": "GCP", "mail_service": "Mandrill"}, + {"cloud_provider": "GCP", "mail_service": "Postmark"}, + {"cloud_provider": "GCP", "mail_service": "Sendgrid"}, + {"cloud_provider": "GCP", "mail_service": "SendinBlue"}, + {"cloud_provider": "GCP", "mail_service": "SparkPost"}, + {"cloud_provider": "GCP", "mail_service": "Other SMTP"}, + # Note: cloud_providers GCP and None with mail_service Amazon SES is not supported + {"use_async": "y"}, + {"use_async": "n"}, + {"use_drf": "y"}, + {"use_drf": "n"}, + {"js_task_runner": "None"}, + {"js_task_runner": "Gulp"}, + {"custom_bootstrap_compilation": "y"}, + {"custom_bootstrap_compilation": "n"}, + {"use_compressor": "y"}, + {"use_compressor": "n"}, + {"use_celery": "y"}, + {"use_celery": "n"}, + {"use_mailhog": "y"}, + {"use_mailhog": "n"}, + {"use_sentry": "y"}, + {"use_sentry": "n"}, + {"use_whitenoise": "y"}, + {"use_whitenoise": "n"}, + {"use_heroku": "y"}, + {"use_heroku": "n"}, + {"ci_tool": "None"}, + {"ci_tool": "Travis"}, + {"ci_tool": "Gitlab"}, + {"keep_local_envs_in_vcs": "y"}, + {"keep_local_envs_in_vcs": "n"}, + {"debug": "y"}, + {"debug": "n"}, +] + +UNSUPPORTED_COMBINATIONS = [ + {"cloud_provider": "None", "use_whitenoise": "n"}, + {"cloud_provider": "GCP", "mail_service": "Amazon SES"}, + {"cloud_provider": "None", "mail_service": "Amazon SES"}, +] + + +def _fixture_id(ctx): + """Helper to get a user friendly test name from the parametrized context.""" + return "-".join(f"{key}:{value}" for key, value in ctx.items()) def build_files_list(root_dir): @@ -81,9 +124,7 @@ def build_files_list(root_dir): def check_paths(paths): - """Method to check all paths have correct substitutions, - used by other tests cases - """ + """Method to check all paths have correct substitutions.""" # Assert that no match is found in any of the files for path in paths: if is_binary(path): @@ -95,13 +136,10 @@ def check_paths(paths): assert match is None, msg.format(path) -def test_project_generation(cookies, context, context_combination): - """ - Test that project is generated and fully rendered. - - This is parametrized for each combination from ``context_combination`` fixture - """ - result = cookies.bake(extra_context={**context, **context_combination}) +@pytest.mark.parametrize("context_override", SUPPORTED_COMBINATIONS, ids=_fixture_id) +def test_project_generation(cookies, context, context_override): + """Test that project is generated and fully rendered.""" + result = cookies.bake(extra_context={**context, **context_override}) assert result.exit_code == 0 assert result.exception is None assert result.project.basename == context["project_slug"] @@ -112,38 +150,34 @@ def test_project_generation(cookies, context, context_combination): check_paths(paths) -@pytest.mark.flake8 -def test_flake8_passes(cookies, context_combination): - """ - Generated project should pass flake8. - - This is parametrized for each combination from ``context_combination`` fixture - """ - result = cookies.bake(extra_context=context_combination) +@pytest.mark.parametrize("context_override", SUPPORTED_COMBINATIONS, ids=_fixture_id) +def test_flake8_passes(cookies, context_override): + """Generated project should pass flake8.""" + result = cookies.bake(extra_context=context_override) try: sh.flake8(str(result.project)) except sh.ErrorReturnCode as e: - pytest.fail(e) + pytest.fail(e.stdout.decode()) -@pytest.mark.black -def test_black_passes(cookies, context_combination): - """ - Generated project should pass black. - - This is parametrized for each combination from ``context_combination`` fixture - """ - result = cookies.bake(extra_context=context_combination) +@pytest.mark.parametrize("context_override", SUPPORTED_COMBINATIONS, ids=_fixture_id) +def test_black_passes(cookies, context_override): + """Generated project should pass black.""" + result = cookies.bake(extra_context=context_override) try: sh.black("--check", "--diff", "--exclude", "migrations", f"{result.project}/") except sh.ErrorReturnCode as e: - pytest.fail(e) + pytest.fail(e.stdout.decode()) -def test_travis_invokes_pytest(cookies, context): - context.update({"ci_tool": "Travis"}) +@pytest.mark.parametrize( + ["use_docker", "expected_test_script"], + [("n", "pytest"), ("y", "docker-compose -f local.yml run django pytest"),], +) +def test_travis_invokes_pytest(cookies, context, use_docker, expected_test_script): + context.update({"ci_tool": "Travis", "use_docker": use_docker}) result = cookies.bake(extra_context=context) assert result.exit_code == 0 @@ -153,13 +187,21 @@ def test_travis_invokes_pytest(cookies, context): with open(f"{result.project}/.travis.yml", "r") as travis_yml: try: - assert yaml.load(travis_yml)["script"] == ["pytest"] + yml = yaml.load(travis_yml, Loader=yaml.FullLoader)["jobs"]["include"] + assert yml[0]["script"] == ["flake8"] + assert yml[1]["script"] == [expected_test_script] except yaml.YAMLError as e: - pytest.fail(e) + pytest.fail(str(e)) -def test_gitlab_invokes_flake8_and_pytest(cookies, context): - context.update({"ci_tool": "Gitlab"}) +@pytest.mark.parametrize( + ["use_docker", "expected_test_script"], + [("n", "pytest"), ("y", "docker-compose -f local.yml run django pytest"),], +) +def test_gitlab_invokes_flake8_and_pytest( + cookies, context, use_docker, expected_test_script +): + context.update({"ci_tool": "Gitlab", "use_docker": use_docker}) result = cookies.bake(extra_context=context) assert result.exit_code == 0 @@ -169,9 +211,9 @@ def test_gitlab_invokes_flake8_and_pytest(cookies, context): with open(f"{result.project}/.gitlab-ci.yml", "r") as gitlab_yml: try: - gitlab_config = yaml.load(gitlab_yml) + gitlab_config = yaml.load(gitlab_yml, Loader=yaml.FullLoader) assert gitlab_config["flake8"]["script"] == ["flake8"] - assert gitlab_config["pytest"]["script"] == ["pytest"] + assert gitlab_config["pytest"]["script"] == [expected_test_script] except yaml.YAMLError as e: pytest.fail(e) @@ -187,9 +229,10 @@ def test_invalid_slug(cookies, context, slug): assert isinstance(result.exception, FailedHookException) -def test_no_whitenoise_and_no_cloud_provider(cookies, context): - """It should not generate project if neither whitenoise or cloud provider are set""" - context.update({"use_whitenoise": "n", "cloud_provider": "None"}) +@pytest.mark.parametrize("invalid_context", UNSUPPORTED_COMBINATIONS) +def test_error_if_incompatible(cookies, context, invalid_context): + """It should not generate project an incompatible combination is selected.""" + context.update(invalid_context) result = cookies.bake(extra_context=context) assert result.exit_code != 0 diff --git a/tox.ini b/tox.ini index 1c83465c..242183c3 100644 --- a/tox.ini +++ b/tox.ini @@ -1,18 +1,10 @@ [tox] skipsdist = true -envlist = py37,flake8,black,black-template +envlist = py38,black-template [testenv] deps = -rrequirements.txt -commands = pytest -m "not flake8" -m "not black" {posargs:./tests} - -[testenv:flake8] -deps = -rrequirements.txt -commands = pytest -m flake8 {posargs:./tests} - -[testenv:black] -deps = -rrequirements.txt -commands = pytest -m black {posargs:./tests} +commands = pytest {posargs:./tests} [testenv:black-template] deps = black diff --git a/{{cookiecutter.project_slug}}/.coveragerc b/{{cookiecutter.project_slug}}/.coveragerc deleted file mode 100644 index 283a4b89..00000000 --- a/{{cookiecutter.project_slug}}/.coveragerc +++ /dev/null @@ -1,5 +0,0 @@ -[run] -include = {{cookiecutter.project_slug}}/* -omit = *migrations*, *tests* -plugins = - django_coverage_plugin diff --git a/{{cookiecutter.project_slug}}/.editorconfig b/{{cookiecutter.project_slug}}/.editorconfig index 792dd3b0..39c15f07 100644 --- a/{{cookiecutter.project_slug}}/.editorconfig +++ b/{{cookiecutter.project_slug}}/.editorconfig @@ -13,8 +13,8 @@ indent_style = space indent_size = 4 [*.py] -line_length = 120 -known_first_party = {{ cookiecutter.project_slug }} +line_length = 88 +known_first_party = {{cookiecutter.project_slug}},config multi_line_output = 3 default_section = THIRDPARTY recursive = true diff --git a/{{cookiecutter.project_slug}}/.envs/.production/.django b/{{cookiecutter.project_slug}}/.envs/.production/.django index 2c2e94f2..e7e8461c 100644 --- a/{{cookiecutter.project_slug}}/.envs/.production/.django +++ b/{{cookiecutter.project_slug}}/.envs/.production/.django @@ -13,9 +13,26 @@ DJANGO_SECURE_SSL_REDIRECT=False # Email # ------------------------------------------------------------------------------ -MAILGUN_API_KEY= DJANGO_SERVER_EMAIL= +{% if cookiecutter.mail_service == 'Mailgun' %} +MAILGUN_API_KEY= MAILGUN_DOMAIN= +{% elif cookiecutter.mail_service == 'Mailjet' %} +MAILJET_API_KEY= +MAILJET_SECRET_KEY= +{% elif cookiecutter.mail_service == 'Mandrill' %} +MANDRILL_API_KEY= +{% elif cookiecutter.mail_service == 'Postmark' %} +POSTMARK_SERVER_TOKEN= +{% elif cookiecutter.mail_service == 'Sendgrid' %} +SENDGRID_API_KEY= +SENDGRID_GENERATE_MESSAGE_ID=True +SENDGRID_MERGE_FIELD_FORMAT=None +{% elif cookiecutter.mail_service == 'SendinBlue' %} +SENDINBLUE_API_KEY= +{% elif cookiecutter.mail_service == 'SparkPost' %} +SPARKPOST_API_KEY= +{% endif %} {% if cookiecutter.cloud_provider == 'AWS' %} # AWS # ------------------------------------------------------------------------------ @@ -31,11 +48,7 @@ DJANGO_GCP_STORAGE_BUCKET_NAME= # django-allauth # ------------------------------------------------------------------------------ DJANGO_ACCOUNT_ALLOW_REGISTRATION=True -{% if cookiecutter.use_compressor == 'y' %} -# django-compressor -# ------------------------------------------------------------------------------ -COMPRESS_ENABLED= -{% endif %} + # Gunicorn # ------------------------------------------------------------------------------ WEB_CONCURRENCY=4 diff --git a/{{cookiecutter.project_slug}}/.gitlab-ci.yml b/{{cookiecutter.project_slug}}/.gitlab-ci.yml index 15ff73b1..a74d5de8 100644 --- a/{{cookiecutter.project_slug}}/.gitlab-ci.yml +++ b/{{cookiecutter.project_slug}}/.gitlab-ci.yml @@ -6,6 +6,10 @@ variables: POSTGRES_USER: '{{ cookiecutter.project_slug }}' POSTGRES_PASSWORD: '' POSTGRES_DB: 'test_{{ cookiecutter.project_slug }}' + POSTGRES_HOST_AUTH_METHOD: trust + {% if cookiecutter.use_celery == 'y' -%} + CELERY_BROKER_URL: 'redis://redis:6379/0' + {%- endif %} flake8: stage: lint @@ -18,8 +22,21 @@ flake8: pytest: stage: test image: python:3.7 + {% if cookiecutter.use_docker == 'y' -%} tags: - docker + services: + - docker + before_script: + - docker-compose -f local.yml build + # Ensure celerybeat does not crash due to non-existent tables + - docker-compose -f local.yml run --rm django python manage.py migrate + - docker-compose -f local.yml up -d + script: + - docker-compose -f local.yml run django pytest + {%- else %} + tags: + - python services: - postgres:11 variables: @@ -30,4 +47,5 @@ pytest: script: - pytest + {%- endif %} diff --git a/{{cookiecutter.project_slug}}/.idea/workspace.xml b/{{cookiecutter.project_slug}}/.idea/workspace.xml index a5d73c18..deb28ba1 100644 --- a/{{cookiecutter.project_slug}}/.idea/workspace.xml +++ b/{{cookiecutter.project_slug}}/.idea/workspace.xml @@ -1,6 +1,11 @@ - {%- if cookiecutter.use_celery == 'y' %} + {%- if cookiecutter.use_docker == 'n' %} + + + {%- elif cookiecutter.use_celery == 'y' %} diff --git a/{{cookiecutter.project_slug}}/.pre-commit-config.yaml b/{{cookiecutter.project_slug}}/.pre-commit-config.yaml index b9d69a9a..c50fefe9 100644 --- a/{{cookiecutter.project_slug}}/.pre-commit-config.yaml +++ b/{{cookiecutter.project_slug}}/.pre-commit-config.yaml @@ -7,7 +7,7 @@ repos: rev: master hooks: - id: trailing-whitespace - files: (^|/)a/.+\.(py|html|sh|css|js)$ + files: (^|/).+\.(py|html|sh|css|js)$ - repo: local hooks: @@ -16,4 +16,5 @@ repos: entry: flake8 language: python types: [python] + args: ['--config=setup.cfg'] diff --git a/{{cookiecutter.project_slug}}/.travis.yml b/{{cookiecutter.project_slug}}/.travis.yml index 31695e41..24f9f24a 100644 --- a/{{cookiecutter.project_slug}}/.travis.yml +++ b/{{cookiecutter.project_slug}}/.travis.yml @@ -1,17 +1,45 @@ dist: xenial -services: - - postgresql -before_install: - - sudo apt-get update -qq - - sudo apt-get install -qq build-essential gettext python-dev zlib1g-dev libpq-dev xvfb - - sudo apt-get install -qq libjpeg8-dev libfreetype6-dev libwebp-dev - - sudo apt-get install -qq graphviz-dev python-setuptools python3-dev python-virtualenv python-pip - - sudo apt-get install -qq firefox automake libtool libreadline6 libreadline6-dev libreadline-dev - - sudo apt-get install -qq libsqlite3-dev libxml2 libxml2-dev libssl-dev libbz2-dev wget curl llvm + language: python python: - - "3.7" -install: - - pip install -r requirements/local.txt -script: - - "pytest" + - "3.8" + +services: + - {% if cookiecutter.use_docker == 'y' %}docker{% else %}postgresql{% endif %} +jobs: + include: + - name: "Linter" + before_script: + - pip install -q flake8 + script: + - "flake8" + + - name: "Django Test" + {%- if cookiecutter.use_docker == 'y' %} + before_script: + - docker-compose -v + - docker -v + - docker-compose -f local.yml build + # Ensure celerybeat does not crash due to non-existent tables + - docker-compose -f local.yml run --rm django python manage.py migrate + - docker-compose -f local.yml up -d + script: + - "docker-compose -f local.yml run django pytest" + after_failure: + - docker-compose -f local.yml logs + {%- else %} + before_install: + - sudo apt-get update -qq + - sudo apt-get install -qq build-essential gettext python-dev zlib1g-dev libpq-dev xvfb + - sudo apt-get install -qq libjpeg8-dev libfreetype6-dev libwebp-dev + - sudo apt-get install -qq graphviz-dev python-setuptools python3-dev python-virtualenv python-pip + - sudo apt-get install -qq firefox automake libtool libreadline6 libreadline6-dev libreadline-dev + - sudo apt-get install -qq libsqlite3-dev libxml2 libxml2-dev libssl-dev libbz2-dev wget curl llvm + language: python + python: + - "3.8" + install: + - pip install -r requirements/local.txt + script: + - "pytest" + {%- endif %} diff --git a/{{cookiecutter.project_slug}}/LICENSE b/{{cookiecutter.project_slug}}/LICENSE index 3a6c7e13..c831e030 100644 --- a/{{cookiecutter.project_slug}}/LICENSE +++ b/{{cookiecutter.project_slug}}/LICENSE @@ -50,4 +50,196 @@ GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . +{% elif cookiecutter.open_source_license == 'Apache Software License 2.0' %} + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright {% now 'utc', '%Y' %} {{ cookiecutter.author_name }} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. {% endif %} diff --git a/{{cookiecutter.project_slug}}/Procfile b/{{cookiecutter.project_slug}}/Procfile index 5b8e9eaf..0becb2cb 100644 --- a/{{cookiecutter.project_slug}}/Procfile +++ b/{{cookiecutter.project_slug}}/Procfile @@ -1,5 +1,10 @@ release: python manage.py migrate +{% if cookiecutter.use_async == "y" -%} +web: gunicorn config.asgi:application -k uvicorn.workers.UvicornWorker +{%- else %} web: gunicorn config.wsgi:application +{%- endif %} {% if cookiecutter.use_celery == "y" -%} worker: celery worker --app=config.celery_app --loglevel=info +beat: celery beat --app=config.celery_app --loglevel=info {%- endif %} diff --git a/{{cookiecutter.project_slug}}/compose/local/django/Dockerfile b/{{cookiecutter.project_slug}}/compose/local/django/Dockerfile index 94c79952..5473f114 100644 --- a/{{cookiecutter.project_slug}}/compose/local/django/Dockerfile +++ b/{{cookiecutter.project_slug}}/compose/local/django/Dockerfile @@ -1,6 +1,7 @@ -FROM python:3.7-slim-buster +FROM python:3.8-slim-buster ENV PYTHONUNBUFFERED 1 +ENV PYTHONDONTWRITEBYTECODE 1 RUN apt-get update \ # dependencies for building Python packages diff --git a/{{cookiecutter.project_slug}}/compose/local/django/start b/{{cookiecutter.project_slug}}/compose/local/django/start index f076ee51..9c0b43d1 100644 --- a/{{cookiecutter.project_slug}}/compose/local/django/start +++ b/{{cookiecutter.project_slug}}/compose/local/django/start @@ -6,4 +6,8 @@ set -o nounset python manage.py migrate +{%- if cookiecutter.use_async == 'y' %} +/usr/local/bin/gunicorn config.asgi --bind 0.0.0.0:8000 --chdir=/app -k uvicorn.workers.UvicornWorker --reload +{%- else %} python manage.py runserver_plus 0.0.0.0:8000 +{% endif %} diff --git a/{{cookiecutter.project_slug}}/compose/production/django/Dockerfile b/{{cookiecutter.project_slug}}/compose/production/django/Dockerfile index 42ecbeaf..72f71d6e 100644 --- a/{{cookiecutter.project_slug}}/compose/production/django/Dockerfile +++ b/{{cookiecutter.project_slug}}/compose/production/django/Dockerfile @@ -9,7 +9,7 @@ RUN npm run build # Python build stage {%- endif %} -FROM python:3.7-slim-buster +FROM python:3.8-slim-buster ENV PYTHONUNBUFFERED 1 diff --git a/{{cookiecutter.project_slug}}/compose/production/django/start b/{{cookiecutter.project_slug}}/compose/production/django/start index 709c1dd0..1a41ed48 100644 --- a/{{cookiecutter.project_slug}}/compose/production/django/start +++ b/{{cookiecutter.project_slug}}/compose/production/django/start @@ -6,4 +6,29 @@ set -o nounset python /app/manage.py collectstatic --noinput +{% if cookiecutter.use_whitenoise == 'y' and cookiecutter.use_compressor == 'y' %} +compress_enabled() { +python << END +import sys + +from environ import Env + +env = Env(COMPRESS_ENABLED=(bool, True)) +if env('COMPRESS_ENABLED'): + sys.exit(0) +else: + sys.exit(1) + +END +} + +if compress_enabled; then + # NOTE this command will fail if django-compressor is disabled + python /app/manage.py compress +fi +{%- endif %} +{% if cookiecutter.use_async == 'y' %} +/usr/local/bin/gunicorn config.asgi --bind 0.0.0.0:5000 --chdir=/app -k uvicorn.workers.UvicornWorker +{% else %} /usr/local/bin/gunicorn config.wsgi --bind 0.0.0.0:5000 --chdir=/app +{%- endif %} diff --git a/{{cookiecutter.project_slug}}/compose/production/traefik/traefik.yml b/{{cookiecutter.project_slug}}/compose/production/traefik/traefik.yml index 324c62af..960d1ac2 100644 --- a/{{cookiecutter.project_slug}}/compose/production/traefik/traefik.yml +++ b/{{cookiecutter.project_slug}}/compose/production/traefik/traefik.yml @@ -9,6 +9,11 @@ entryPoints: web-secure: # https address: ":443" + {%- if cookiecutter.use_celery == 'y' %} + + flower: + address: ":5555" + {%- endif %} certificatesResolvers: letsencrypt: @@ -23,7 +28,7 @@ certificatesResolvers: http: routers: web-router: - rule: "Host(`{{ cookiecutter.domain_name }}`)" + rule: "Host(`{{ cookiecutter.domain_name }}`) || Host(`www.{{ cookiecutter.domain_name }}`)" entryPoints: - web middlewares: @@ -32,7 +37,7 @@ http: service: django web-secure-router: - rule: "Host(`{{ cookiecutter.domain_name }}`)" + rule: "Host(`{{ cookiecutter.domain_name }}`) || Host(`www.{{ cookiecutter.domain_name }}`)" entryPoints: - web-secure middlewares: @@ -41,6 +46,17 @@ http: tls: # https://docs.traefik.io/master/routing/routers/#certresolver certResolver: letsencrypt + {%- if cookiecutter.use_celery == 'y' %} + + flower-secure-router: + rule: "Host(`{{ cookiecutter.domain_name }}`)" + entryPoints: + - flower + service: flower + tls: + # https://docs.traefik.io/master/routing/routers/#certresolver + certResolver: letsencrypt + {%- endif %} middlewares: redirect: @@ -52,13 +68,20 @@ http: # https://docs.traefik.io/master/middlewares/headers/#hostsproxyheaders # https://docs.djangoproject.com/en/dev/ref/csrf/#ajax headers: - hostsProxyHeaders: ['X-CSRFToken'] + hostsProxyHeaders: ["X-CSRFToken"] services: django: loadBalancer: servers: - url: http://django:5000 + {%- if cookiecutter.use_celery == 'y' %} + + flower: + loadBalancer: + servers: + - url: http://flower:5555 + {%- endif %} providers: # https://docs.traefik.io/master/providers/file/ diff --git a/{{cookiecutter.project_slug}}/config/api_router.py b/{{cookiecutter.project_slug}}/config/api_router.py index 46a797a7..743069b2 100644 --- a/{{cookiecutter.project_slug}}/config/api_router.py +++ b/{{cookiecutter.project_slug}}/config/api_router.py @@ -1,5 +1,6 @@ -from rest_framework.routers import DefaultRouter, SimpleRouter from django.conf import settings +from rest_framework.routers import DefaultRouter, SimpleRouter + from {{ cookiecutter.project_slug }}.users.api.views import UserViewSet if settings.DEBUG: diff --git a/{{cookiecutter.project_slug}}/config/asgi.py b/{{cookiecutter.project_slug}}/config/asgi.py new file mode 100644 index 00000000..562ecf40 --- /dev/null +++ b/{{cookiecutter.project_slug}}/config/asgi.py @@ -0,0 +1,40 @@ +""" +ASGI config for {{ cookiecutter.project_name }} project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/dev/howto/deployment/asgi/ + +""" +import os +import sys +from pathlib import Path + +from django.core.asgi import get_asgi_application + +# This allows easy placement of apps within the interior +# {{ cookiecutter.project_slug }} directory. +app_path = Path(__file__).parents[1].resolve() +sys.path.append(str(app_path / "{{ cookiecutter.project_slug }}")) + +# If DJANGO_SETTINGS_MODULE is unset, default to the local settings +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local") + +# This application object is used by any ASGI server configured to use this file. +django_application = get_asgi_application() +# Apply ASGI middleware here. +# from helloworld.asgi import HelloWorldApplication +# application = HelloWorldApplication(application) + +# Import websocket application here, so apps from django_application are loaded first +from config.websocket import websocket_application # noqa isort:skip + + +async def application(scope, receive, send): + if scope["type"] == "http": + await django_application(scope, receive, send) + elif scope["type"] == "websocket": + await websocket_application(scope, receive, send) + else: + raise NotImplementedError(f"Unknown scope type {scope['type']}") diff --git a/{{cookiecutter.project_slug}}/config/celery_app.py b/{{cookiecutter.project_slug}}/config/celery_app.py index e275f054..0728a649 100644 --- a/{{cookiecutter.project_slug}}/config/celery_app.py +++ b/{{cookiecutter.project_slug}}/config/celery_app.py @@ -1,4 +1,5 @@ import os + from celery import Celery # set the default Django settings module for the 'celery' program. diff --git a/{{cookiecutter.project_slug}}/config/settings/base.py b/{{cookiecutter.project_slug}}/config/settings/base.py index 6ad8e820..390f2252 100644 --- a/{{cookiecutter.project_slug}}/config/settings/base.py +++ b/{{cookiecutter.project_slug}}/config/settings/base.py @@ -1,20 +1,19 @@ """ Base settings to build other settings files upon. """ +from pathlib import Path import environ -ROOT_DIR = ( - environ.Path(__file__) - 3 -) # ({{ cookiecutter.project_slug }}/config/settings/base.py - 3 = {{ cookiecutter.project_slug }}/) -APPS_DIR = ROOT_DIR.path("{{ cookiecutter.project_slug }}") - +ROOT_DIR = Path(__file__).parents[2] +# {{ cookiecutter.project_slug }}/) +APPS_DIR = ROOT_DIR / "{{ cookiecutter.project_slug }}" env = environ.Env() READ_DOT_ENV_FILE = env.bool("DJANGO_READ_DOT_ENV_FILE", default=False) if READ_DOT_ENV_FILE: # OS environment variables take precedence over variables from .env - env.read_env(str(ROOT_DIR.path(".env"))) + env.read_env(str(ROOT_DIR / ".env")) # GENERAL # ------------------------------------------------------------------------------ @@ -36,7 +35,7 @@ USE_L10N = True # https://docs.djangoproject.com/en/dev/ref/settings/#use-tz USE_TZ = True # https://docs.djangoproject.com/en/dev/ref/settings/#locale-paths -LOCALE_PATHS = [ROOT_DIR.path("locale")] +LOCALE_PATHS = [str(ROOT_DIR / "locale")] # DATABASES # ------------------------------------------------------------------------------ @@ -75,10 +74,13 @@ THIRD_PARTY_APPS = [ "allauth", "allauth.account", "allauth.socialaccount", - "rest_framework", {%- if cookiecutter.use_celery == 'y' %} "django_celery_beat", {%- endif %} +{%- if cookiecutter.use_drf == "y" %} + "rest_framework", + "rest_framework.authtoken", +{%- endif %} ] LOCAL_APPS = [ @@ -148,11 +150,11 @@ MIDDLEWARE = [ # STATIC # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#static-root -STATIC_ROOT = str(ROOT_DIR("staticfiles")) +STATIC_ROOT = str(ROOT_DIR / "staticfiles") # https://docs.djangoproject.com/en/dev/ref/settings/#static-url STATIC_URL = "/static/" # https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#std:setting-STATICFILES_DIRS -STATICFILES_DIRS = [str(APPS_DIR.path("static"))] +STATICFILES_DIRS = [str(APPS_DIR / "static")] # https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#staticfiles-finders STATICFILES_FINDERS = [ "django.contrib.staticfiles.finders.FileSystemFinder", @@ -162,7 +164,7 @@ STATICFILES_FINDERS = [ # MEDIA # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#media-root -MEDIA_ROOT = str(APPS_DIR("media")) +MEDIA_ROOT = str(APPS_DIR / "media") # https://docs.djangoproject.com/en/dev/ref/settings/#media-url MEDIA_URL = "/media/" @@ -174,7 +176,7 @@ TEMPLATES = [ # https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-TEMPLATES-BACKEND "BACKEND": "django.template.backends.django.DjangoTemplates", # https://docs.djangoproject.com/en/dev/ref/settings/#template-dirs - "DIRS": [str(APPS_DIR.path("templates"))], + "DIRS": [str(APPS_DIR / "templates")], "OPTIONS": { # https://docs.djangoproject.com/en/dev/ref/settings/#template-loaders # https://docs.djangoproject.com/en/dev/ref/templates/api/#loader-types @@ -207,7 +209,7 @@ CRISPY_TEMPLATE_PACK = "bootstrap4" # FIXTURES # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#fixture-dirs -FIXTURE_DIRS = (str(APPS_DIR.path("fixtures")),) +FIXTURE_DIRS = (str(APPS_DIR / "fixtures"),) # SECURITY # ------------------------------------------------------------------------------ @@ -226,7 +228,7 @@ X_FRAME_OPTIONS = "DENY" EMAIL_BACKEND = env( "DJANGO_EMAIL_BACKEND", default="django.core.mail.backends.smtp.EmailBackend" ) -# https://docs.djangoproject.com/en/2.2/ref/settings/#email-timeout +# https://docs.djangoproject.com/en/dev/ref/settings/#email-timeout EMAIL_TIMEOUT = 5 # ADMIN @@ -309,7 +311,7 @@ INSTALLED_APPS += ["compressor"] STATICFILES_FINDERS += ["compressor.finders.CompressorFinder"] {%- endif %} {% if cookiecutter.use_drf == "y" -%} -# django-reset-framework +# django-rest-framework # ------------------------------------------------------------------------------- # django-rest-framework - https://www.django-rest-framework.org/api-guide/settings/ REST_FRAMEWORK = { diff --git a/{{cookiecutter.project_slug}}/config/settings/local.py b/{{cookiecutter.project_slug}}/config/settings/local.py index 015d8aff..21e6a8df 100644 --- a/{{cookiecutter.project_slug}}/config/settings/local.py +++ b/{{cookiecutter.project_slug}}/config/settings/local.py @@ -68,7 +68,7 @@ if env("USE_DOCKER") == "yes": import socket hostname, _, ips = socket.gethostbyname_ex(socket.gethostname()) - INTERNAL_IPS += [ip[:-1] + "1" for ip in ips] + INTERNAL_IPS += [".".join(ip.split(".")[:-1] + ["1"]) for ip in ips] {%- endif %} # django-extensions diff --git a/{{cookiecutter.project_slug}}/config/settings/production.py b/{{cookiecutter.project_slug}}/config/settings/production.py index 36667b33..cea29b8e 100644 --- a/{{cookiecutter.project_slug}}/config/settings/production.py +++ b/{{cookiecutter.project_slug}}/config/settings/production.py @@ -2,7 +2,6 @@ import logging import sentry_sdk - from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.logging import LoggingIntegration {%- if cookiecutter.use_celery == 'y' %} @@ -103,11 +102,11 @@ GS_DEFAULT_ACL = "publicRead" {% if cookiecutter.use_whitenoise == 'y' -%} STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" {% elif cookiecutter.cloud_provider == 'AWS' -%} -STATICFILES_STORAGE = "config.settings.production.StaticRootS3Boto3Storage" +STATICFILES_STORAGE = "{{cookiecutter.project_slug}}.utils.storages.StaticRootS3Boto3Storage" COLLECTFAST_STRATEGY = "collectfast.strategies.boto3.Boto3Strategy" STATIC_URL = f"https://{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com/static/" {% elif cookiecutter.cloud_provider == 'GCP' -%} -STATICFILES_STORAGE = "config.settings.production.StaticRootGoogleCloudStorage" +STATICFILES_STORAGE = "{{cookiecutter.project_slug}}.utils.storages.StaticRootGoogleCloudStorage" COLLECTFAST_STRATEGY = "collectfast.strategies.gcloud.GoogleCloudStrategy" STATIC_URL = f"https://storage.googleapis.com/{GS_BUCKET_NAME}/static/" {% endif -%} @@ -115,39 +114,10 @@ STATIC_URL = f"https://storage.googleapis.com/{GS_BUCKET_NAME}/static/" # MEDIA # ------------------------------------------------------------------------------ {%- if cookiecutter.cloud_provider == 'AWS' %} -# region http://stackoverflow.com/questions/10390244/ -# Full-fledge class: https://stackoverflow.com/a/18046120/104731 -from storages.backends.s3boto3 import S3Boto3Storage # noqa E402 - - -class StaticRootS3Boto3Storage(S3Boto3Storage): - location = "static" - default_acl = "public-read" - - -class MediaRootS3Boto3Storage(S3Boto3Storage): - location = "media" - file_overwrite = False - - -# endregion -DEFAULT_FILE_STORAGE = "config.settings.production.MediaRootS3Boto3Storage" +DEFAULT_FILE_STORAGE = "{{cookiecutter.project_slug}}.utils.storages.MediaRootS3Boto3Storage" MEDIA_URL = f"https://{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com/media/" {%- elif cookiecutter.cloud_provider == 'GCP' %} -from storages.backends.gcloud import GoogleCloudStorage # noqa E402 - - -class StaticRootGoogleCloudStorage(GoogleCloudStorage): - location = "static" - default_acl = "publicRead" - - -class MediaRootGoogleCloudStorage(GoogleCloudStorage): - location = "media" - file_overwrite = False - - -DEFAULT_FILE_STORAGE = "config.settings.production.MediaRootGoogleCloudStorage" +DEFAULT_FILE_STORAGE = "{{cookiecutter.project_slug}}.utils.storages.MediaRootGoogleCloudStorage" MEDIA_URL = f"https://storage.googleapis.com/{GS_BUCKET_NAME}/media/" {%- endif %} @@ -182,17 +152,80 @@ EMAIL_SUBJECT_PREFIX = env( # Django Admin URL regex. ADMIN_URL = env("DJANGO_ADMIN_URL") -# Anymail (Mailgun) +# Anymail # ------------------------------------------------------------------------------ # https://anymail.readthedocs.io/en/stable/installation/#installing-anymail INSTALLED_APPS += ["anymail"] # noqa F405 -EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend" +# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend # https://anymail.readthedocs.io/en/stable/installation/#anymail-settings-reference +{%- if cookiecutter.mail_service == 'Mailgun' %} +# https://anymail.readthedocs.io/en/stable/esps/mailgun/ +EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend" ANYMAIL = { "MAILGUN_API_KEY": env("MAILGUN_API_KEY"), "MAILGUN_SENDER_DOMAIN": env("MAILGUN_DOMAIN"), "MAILGUN_API_URL": env("MAILGUN_API_URL", default="https://api.mailgun.net/v3"), } +{%- elif cookiecutter.mail_service == 'Amazon SES' %} +# https://anymail.readthedocs.io/en/stable/esps/amazon_ses/ +EMAIL_BACKEND = "anymail.backends.amazon_ses.EmailBackend" +ANYMAIL = {} +{%- elif cookiecutter.mail_service == 'Mailjet' %} +# https://anymail.readthedocs.io/en/stable/esps/mailjet/ +EMAIL_BACKEND = "anymail.backends.mailjet.EmailBackend" +ANYMAIL = { + "MAILJET_API_KEY": env("MAILJET_API_KEY"), + "MAILJET_SECRET_KEY": env("MAILJET_SECRET_KEY"), + "MAILJET_API_URL": env("MAILJET_API_URL", default="https://api.mailjet.com/v3"), +} +{%- elif cookiecutter.mail_service == 'Mandrill' %} +# https://anymail.readthedocs.io/en/stable/esps/mandrill/ +EMAIL_BACKEND = "anymail.backends.mandrill.EmailBackend" +ANYMAIL = { + "MANDRILL_API_KEY": env("MANDRILL_API_KEY"), + "MANDRILL_API_URL": env( + "MANDRILL_API_URL", default="https://mandrillapp.com/api/1.0" + ), +} +{%- elif cookiecutter.mail_service == 'Postmark' %} +# https://anymail.readthedocs.io/en/stable/esps/postmark/ +EMAIL_BACKEND = "anymail.backends.postmark.EmailBackend" +ANYMAIL = { + "POSTMARK_SERVER_TOKEN": env("POSTMARK_SERVER_TOKEN"), + "POSTMARK_API_URL": env("POSTMARK_API_URL", default="https://api.postmarkapp.com/"), +} +{%- elif cookiecutter.mail_service == 'Sendgrid' %} +# https://anymail.readthedocs.io/en/stable/esps/sendgrid/ +EMAIL_BACKEND = "anymail.backends.sendgrid.EmailBackend" +ANYMAIL = { + "SENDGRID_API_KEY": env("SENDGRID_API_KEY"), + "SENDGRID_GENERATE_MESSAGE_ID": env("SENDGRID_GENERATE_MESSAGE_ID"), + "SENDGRID_MERGE_FIELD_FORMAT": env("SENDGRID_MERGE_FIELD_FORMAT"), + "SENDGRID_API_URL": env("SENDGRID_API_URL", default="https://api.sendgrid.com/v3/"), +} +{%- elif cookiecutter.mail_service == 'SendinBlue' %} +# https://anymail.readthedocs.io/en/stable/esps/sendinblue/ +EMAIL_BACKEND = "anymail.backends.sendinblue.EmailBackend" +ANYMAIL = { + "SENDINBLUE_API_KEY": env("SENDINBLUE_API_KEY"), + "SENDINBLUE_API_URL": env( + "SENDINBLUE_API_URL", default="https://api.sendinblue.com/v3/" + ), +} +{%- elif cookiecutter.mail_service == 'SparkPost' %} +# https://anymail.readthedocs.io/en/stable/esps/sparkpost/ +EMAIL_BACKEND = "anymail.backends.sparkpost.EmailBackend" +ANYMAIL = { + "SPARKPOST_API_KEY": env("SPARKPOST_API_KEY"), + "SPARKPOST_API_URL": env( + "SPARKPOST_API_URL", default="https://api.sparkpost.com/api/v1" + ), +} +{%- elif cookiecutter.mail_service == 'Other SMTP' %} +# https://anymail.readthedocs.io/en/stable/esps +EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" +ANYMAIL = {} +{%- endif %} {% if cookiecutter.use_compressor == 'y' -%} # django-compressor @@ -200,9 +233,27 @@ ANYMAIL = { # https://django-compressor.readthedocs.io/en/latest/settings/#django.conf.settings.COMPRESS_ENABLED COMPRESS_ENABLED = env.bool("COMPRESS_ENABLED", default=True) # https://django-compressor.readthedocs.io/en/latest/settings/#django.conf.settings.COMPRESS_STORAGE +{%- if cookiecutter.cloud_provider == 'AWS' %} COMPRESS_STORAGE = "storages.backends.s3boto3.S3Boto3Storage" +{%- elif cookiecutter.cloud_provider == 'GCP' %} +COMPRESS_STORAGE = "storages.backends.gcloud.GoogleCloudStorage" +{%- elif cookiecutter.cloud_provider == 'None' %} +COMPRESS_STORAGE = "compressor.storage.GzipCompressorFileStorage" +{%- endif %} # https://django-compressor.readthedocs.io/en/latest/settings/#django.conf.settings.COMPRESS_URL COMPRESS_URL = STATIC_URL{% if cookiecutter.use_whitenoise == 'y' or cookiecutter.cloud_provider == 'None' %} # noqa F405{% endif %} +{%- if cookiecutter.use_whitenoise == 'y' %} +# https://django-compressor.readthedocs.io/en/latest/settings/#django.conf.settings.COMPRESS_OFFLINE +COMPRESS_OFFLINE = True # Offline compression is required when using Whitenoise +{%- endif %} +# https://django-compressor.readthedocs.io/en/latest/settings/#django.conf.settings.COMPRESS_FILTERS +COMPRESS_FILTERS = { + "css": [ + "compressor.filters.css_default.CssAbsoluteFilter", + "compressor.filters.cssmin.rCSSMinFilter", + ], + "js": ["compressor.filters.jsmin.JSMinFilter"], +} {% endif %} {%- if cookiecutter.use_whitenoise == 'n' -%} # Collectfast diff --git a/{{cookiecutter.project_slug}}/config/urls.py b/{{cookiecutter.project_slug}}/config/urls.py index 382bf895..168d77a8 100644 --- a/{{cookiecutter.project_slug}}/config/urls.py +++ b/{{cookiecutter.project_slug}}/config/urls.py @@ -1,10 +1,13 @@ from django.conf import settings -from django.urls import include, path from django.conf.urls.static import static from django.contrib import admin -from django.views.generic import TemplateView +{%- if cookiecutter.use_async == 'y' %} +from django.contrib.staticfiles.urls import staticfiles_urlpatterns +{%- endif %} +from django.urls import include, path from django.views import defaults as default_views -{% if cookiecutter.use_drf == 'y' -%} +from django.views.generic import TemplateView +{%- if cookiecutter.use_drf == 'y' %} from rest_framework.authtoken.views import obtain_auth_token {%- endif %} @@ -20,7 +23,12 @@ urlpatterns = [ path("accounts/", include("allauth.urls")), # Your stuff: custom urls includes go here ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) -{% if cookiecutter.use_drf == 'y' -%} +{%- if cookiecutter.use_async == 'y' %} +if settings.DEBUG: + # Static file serving when using Gunicorn + Uvicorn for local web socket development + urlpatterns += staticfiles_urlpatterns() +{%- endif %} +{% if cookiecutter.use_drf == 'y' %} # API URLS urlpatterns += [ # API base url diff --git a/{{cookiecutter.project_slug}}/config/websocket.py b/{{cookiecutter.project_slug}}/config/websocket.py new file mode 100644 index 00000000..81adfbc6 --- /dev/null +++ b/{{cookiecutter.project_slug}}/config/websocket.py @@ -0,0 +1,13 @@ +async def websocket_application(scope, receive, send): + while True: + event = await receive() + + if event["type"] == "websocket.connect": + await send({"type": "websocket.accept"}) + + if event["type"] == "websocket.disconnect": + break + + if event["type"] == "websocket.receive": + if event["text"] == "ping": + await send({"type": "websocket.send", "text": "pong!"}) diff --git a/{{cookiecutter.project_slug}}/config/wsgi.py b/{{cookiecutter.project_slug}}/config/wsgi.py index 1899a30c..fccfea35 100644 --- a/{{cookiecutter.project_slug}}/config/wsgi.py +++ b/{{cookiecutter.project_slug}}/config/wsgi.py @@ -15,15 +15,14 @@ framework. """ import os import sys +from pathlib import Path from django.core.wsgi import get_wsgi_application # This allows easy placement of apps within the interior # {{ cookiecutter.project_slug }} directory. -app_path = os.path.abspath( - os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir) -) -sys.path.append(os.path.join(app_path, "{{ cookiecutter.project_slug }}")) +app_path = Path(__file__).parents[1].resolve() +sys.path.append(str(app_path / "{{ cookiecutter.project_slug }}")) # We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks # if running multiple sites in the same mod_wsgi process. To fix this, use # mod_wsgi daemon mode with each site in its own daemon process, or use diff --git a/{{cookiecutter.project_slug}}/docs/pycharm/configuration.rst b/{{cookiecutter.project_slug}}/docs/pycharm/configuration.rst index 1c20d023..d8e76916 100644 --- a/{{cookiecutter.project_slug}}/docs/pycharm/configuration.rst +++ b/{{cookiecutter.project_slug}}/docs/pycharm/configuration.rst @@ -14,7 +14,7 @@ This repository comes with already prepared "Run/Debug Configurations" for docke .. image:: images/2.png -But as you can see, at the beginning there is something wrong with them. They have red X on django icon, and they cannot be used, without configuring remote python interpteter. To do that, you have to go to *Settings > Build, Execution, Deployment* first. +But as you can see, at the beginning there is something wrong with them. They have red X on django icon, and they cannot be used, without configuring remote python interpreter. To do that, you have to go to *Settings > Build, Execution, Deployment* first. Next, you have to add new remote python interpreter, based on already tested deployment settings. Go to *Settings > Project > Project Interpreter*. Click on the cog icon, and click *Add Remote*. diff --git a/{{cookiecutter.project_slug}}/gulpfile.js b/{{cookiecutter.project_slug}}/gulpfile.js index 3c28a735..1f9884ec 100644 --- a/{{cookiecutter.project_slug}}/gulpfile.js +++ b/{{cookiecutter.project_slug}}/gulpfile.js @@ -110,6 +110,18 @@ function imgCompression() { .pipe(dest(paths.images)) } +{% if cookiecutter.use_async == 'y' -%} +// Run django server +function asyncRunServer() { + var cmd = spawn('gunicorn', [ + 'config.asgi', '-k', 'uvicorn.workers.UvicornWorker', '--reload' + ], {stdio: 'inherit'} + ) + cmd.on('close', function(code) { + console.log('gunicorn exited with code ' + code) + }) +} +{%- else %} // Run django server function runServer(cb) { var cmd = spawn('python', ['manage.py', 'runserver'], {stdio: 'inherit'}) @@ -118,6 +130,7 @@ function runServer(cb) { cb(code) }) } +{%- endif %} // Browser sync server for live reload function initBrowserSync() { @@ -166,8 +179,12 @@ const generateAssets = parallel( // Set up dev environment const dev = parallel( {%- if cookiecutter.use_docker == 'n' %} + {%- if cookiecutter.use_async == 'y' %} + asyncRunServer, + {%- else %} runServer, {%- endif %} + {%- endif %} initBrowserSync, watchPaths ) diff --git a/{{cookiecutter.project_slug}}/manage.py b/{{cookiecutter.project_slug}}/manage.py index 7e9c99e0..c44cc826 100755 --- a/{{cookiecutter.project_slug}}/manage.py +++ b/{{cookiecutter.project_slug}}/manage.py @@ -1,6 +1,7 @@ #!/usr/bin/env python import os import sys +from pathlib import Path if __name__ == "__main__": os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local") @@ -24,7 +25,7 @@ if __name__ == "__main__": # This allows easy placement of apps within the interior # {{ cookiecutter.project_slug }} directory. - current_path = os.path.dirname(os.path.abspath(__file__)) - sys.path.append(os.path.join(current_path, "{{ cookiecutter.project_slug }}")) + current_path = Path(__file__).parent.resolve() + sys.path.append(str(current_path / "{{ cookiecutter.project_slug }}")) execute_from_command_line(sys.argv) diff --git a/{{cookiecutter.project_slug}}/merge_production_dotenvs_in_dotenv.py b/{{cookiecutter.project_slug}}/merge_production_dotenvs_in_dotenv.py index 4e70e2ad..d1170eff 100644 --- a/{{cookiecutter.project_slug}}/merge_production_dotenvs_in_dotenv.py +++ b/{{cookiecutter.project_slug}}/merge_production_dotenvs_in_dotenv.py @@ -1,15 +1,16 @@ import os +from pathlib import Path from typing import Sequence import pytest -ROOT_DIR_PATH = os.path.dirname(os.path.realpath(__file__)) -PRODUCTION_DOTENVS_DIR_PATH = os.path.join(ROOT_DIR_PATH, ".envs", ".production") +ROOT_DIR_PATH = Path(__file__).parent.resolve() +PRODUCTION_DOTENVS_DIR_PATH = ROOT_DIR_PATH / ".envs" / ".production" PRODUCTION_DOTENV_FILE_PATHS = [ - os.path.join(PRODUCTION_DOTENVS_DIR_PATH, ".django"), - os.path.join(PRODUCTION_DOTENVS_DIR_PATH, ".postgres"), + PRODUCTION_DOTENVS_DIR_PATH / ".django", + PRODUCTION_DOTENVS_DIR_PATH / ".postgres", ] -DOTENV_FILE_PATH = os.path.join(ROOT_DIR_PATH, ".env") +DOTENV_FILE_PATH = ROOT_DIR_PATH / ".env" def merge( @@ -31,9 +32,9 @@ def main(): @pytest.mark.parametrize("merged_file_count", range(3)) @pytest.mark.parametrize("append_linesep", [True, False]) def test_merge(tmpdir_factory, merged_file_count: int, append_linesep: bool): - tmp_dir_path = str(tmpdir_factory.getbasetemp()) + tmp_dir_path = Path(str(tmpdir_factory.getbasetemp())) - output_file_path = os.path.join(tmp_dir_path, ".env") + output_file_path = tmp_dir_path / ".env" expected_output_file_content = "" merged_file_paths = [] @@ -41,7 +42,7 @@ def test_merge(tmpdir_factory, merged_file_count: int, append_linesep: bool): merged_file_ord = i + 1 merged_filename = ".service{}".format(merged_file_ord) - merged_file_path = os.path.join(tmp_dir_path, merged_filename) + merged_file_path = tmp_dir_path / merged_filename merged_file_content = merged_filename * merged_file_ord diff --git a/{{cookiecutter.project_slug}}/production.yml b/{{cookiecutter.project_slug}}/production.yml index 62ec9d82..2cd2af13 100644 --- a/{{cookiecutter.project_slug}}/production.yml +++ b/{{cookiecutter.project_slug}}/production.yml @@ -42,6 +42,9 @@ services: ports: - "0.0.0.0:80:80" - "0.0.0.0:443:443" + {%- if cookiecutter.use_celery == 'y' %} + - "0.0.0.0:5555:5555" + {%- endif %} redis: image: redis:5.0 @@ -60,8 +63,6 @@ services: flower: <<: *django image: {{ cookiecutter.project_slug }}_production_flower - ports: - - "5555:5555" command: /start-flower {%- endif %} diff --git a/{{cookiecutter.project_slug}}/requirements/base.txt b/{{cookiecutter.project_slug}}/requirements/base.txt index dabc10c9..896110c2 100644 --- a/{{cookiecutter.project_slug}}/requirements/base.txt +++ b/{{cookiecutter.project_slug}}/requirements/base.txt @@ -1,6 +1,6 @@ pytz==2019.3 # https://github.com/stub42/pytz python-slugify==4.0.0 # https://github.com/un33k/python-slugify -Pillow==7.0.0 # https://github.com/python-pillow/Pillow +Pillow==7.1.1 # https://github.com/python-pillow/Pillow {%- if cookiecutter.use_compressor == "y" %} rcssmin==1.0.6{% if cookiecutter.windows == 'y' and cookiecutter.use_docker == 'n' %} --install-option="--without-c-extensions"{% endif %} # https://github.com/ndparker/rcssmin {%- endif %} @@ -8,27 +8,31 @@ argon2-cffi==19.2.0 # https://github.com/hynek/argon2_cffi {%- if cookiecutter.use_whitenoise == 'y' %} whitenoise==5.0.1 # https://github.com/evansd/whitenoise {%- endif %} -redis==3.3.11 # pyup: != 3.4.0 # https://github.com/andymccurdy/redis-py +redis==3.4.1 # https://github.com/andymccurdy/redis-py {%- if cookiecutter.use_celery == "y" %} -celery==4.4.0 # pyup: < 5.0 # https://github.com/celery/celery -django-celery-beat==1.6.0 # https://github.com/celery/django-celery-beat +celery==4.4.2 # pyup: < 5.0 # https://github.com/celery/celery +django-celery-beat==2.0.0 # https://github.com/celery/django-celery-beat {%- if cookiecutter.use_docker == 'y' %} -flower==0.9.3 # https://github.com/mher/flower +flower==0.9.4 # https://github.com/mher/flower {%- endif %} {%- endif %} +{%- if cookiecutter.use_async == 'y' %} +uvicorn==0.11.3 # https://github.com/encode/uvicorn +gunicorn==20.0.4 # https://github.com/benoitc/gunicorn +{%- endif %} # Django # ------------------------------------------------------------------------------ -django==2.2.9 # pyup: < 3.0 # https://www.djangoproject.com/ +django==3.0.5 # pyup: < 3.1 # https://www.djangoproject.com/ django-environ==0.4.5 # https://github.com/joke2k/django-environ django-model-utils==4.0.0 # https://github.com/jazzband/django-model-utils django-allauth==0.41.0 # https://github.com/pennersr/django-allauth -django-crispy-forms==1.8.1 # https://github.com/django-crispy-forms/django-crispy-forms +django-crispy-forms==1.9.0 # https://github.com/django-crispy-forms/django-crispy-forms {%- if cookiecutter.use_compressor == "y" %} django-compressor==2.4 # https://github.com/django-compressor/django-compressor {%- endif %} django-redis==4.11.0 # https://github.com/niwinz/django-redis - +{%- if cookiecutter.use_drf == "y" %} # Django REST Framework djangorestframework==3.11.0 # https://github.com/encode/django-rest-framework -coreapi==2.3.3 # https://github.com/core-api/python-client +{%- endif %} diff --git a/{{cookiecutter.project_slug}}/requirements/local.txt b/{{cookiecutter.project_slug}}/requirements/local.txt index f7782f0d..f6be44b5 100644 --- a/{{cookiecutter.project_slug}}/requirements/local.txt +++ b/{{cookiecutter.project_slug}}/requirements/local.txt @@ -1,37 +1,38 @@ -r ./base.txt -Werkzeug==0.16.1 # https://github.com/pallets/werkzeug -ipdb==0.12.3 # https://github.com/gotcha/ipdb -Sphinx==2.3.1 # https://github.com/sphinx-doc/sphinx +Werkzeug==1.0.1 # https://github.com/pallets/werkzeug +ipdb==0.13.2 # https://github.com/gotcha/ipdb +Sphinx==3.0.2 # https://github.com/sphinx-doc/sphinx {%- if cookiecutter.use_docker == 'y' %} -psycopg2==2.8.4 --no-binary psycopg2 # https://github.com/psycopg/psycopg2 +psycopg2==2.8.5 --no-binary psycopg2 # https://github.com/psycopg/psycopg2 {%- else %} -psycopg2-binary==2.8.4 # https://github.com/psycopg/psycopg2 +psycopg2-binary==2.8.5 # https://github.com/psycopg/psycopg2 {%- endif %} # Testing # ------------------------------------------------------------------------------ -mypy==0.761 # https://github.com/python/mypy -django-stubs==1.4.0 # https://github.com/typeddjango/django-stubs +mypy==0.770 # https://github.com/python/mypy +django-stubs==1.5.0 # https://github.com/typeddjango/django-stubs pytest==5.3.5 # https://github.com/pytest-dev/pytest pytest-sugar==0.9.2 # https://github.com/Frozenball/pytest-sugar # Code quality # ------------------------------------------------------------------------------ flake8==3.7.9 # https://github.com/PyCQA/flake8 -coverage==5.0.3 # https://github.com/nedbat/coveragepy +flake8-isort==3.0.0 # https://github.com/gforcada/flake8-isort +coverage==5.1 # https://github.com/nedbat/coveragepy black==19.10b0 # https://github.com/ambv/black -pylint-django==2.0.13 # https://github.com/PyCQA/pylint-django +pylint-django==2.0.15 # https://github.com/PyCQA/pylint-django {%- if cookiecutter.use_celery == 'y' %} pylint-celery==0.3 # https://github.com/PyCQA/pylint-celery {%- endif %} -pre-commit==2.0.1 # https://github.com/pre-commit/pre-commit +pre-commit==2.3.0 # https://github.com/pre-commit/pre-commit # Django # ------------------------------------------------------------------------------ factory-boy==2.12.0 # https://github.com/FactoryBoy/factory_boy django-debug-toolbar==2.2 # https://github.com/jazzband/django-debug-toolbar -django-extensions==2.2.6 # https://github.com/django-extensions/django-extensions +django-extensions==2.2.9 # https://github.com/django-extensions/django-extensions django-coverage-plugin==1.8.0 # https://github.com/nedbat/django_coverage_plugin -pytest-django==3.8.0 # https://github.com/pytest-dev/pytest-django +pytest-django==3.9.0 # https://github.com/pytest-dev/pytest-django diff --git a/{{cookiecutter.project_slug}}/requirements/production.txt b/{{cookiecutter.project_slug}}/requirements/production.txt index 38420a38..b45afb66 100644 --- a/{{cookiecutter.project_slug}}/requirements/production.txt +++ b/{{cookiecutter.project_slug}}/requirements/production.txt @@ -2,20 +2,40 @@ -r ./base.txt +{%- if cookiecutter.use_async == 'n' %} gunicorn==20.0.4 # https://github.com/benoitc/gunicorn -psycopg2==2.8.4 --no-binary psycopg2 # https://github.com/psycopg/psycopg2 +{%- endif %} +psycopg2==2.8.5 --no-binary psycopg2 # https://github.com/psycopg/psycopg2 {%- if cookiecutter.use_whitenoise == 'n' %} -Collectfast==1.3.1 # https://github.com/antonagestam/collectfast +Collectfast==2.1.0 # https://github.com/antonagestam/collectfast {%- endif %} {%- if cookiecutter.use_sentry == "y" %} -sentry-sdk==0.14.1 # https://github.com/getsentry/sentry-python +sentry-sdk==0.14.3 # https://github.com/getsentry/sentry-python {%- endif %} # Django # ------------------------------------------------------------------------------ {%- if cookiecutter.cloud_provider == 'AWS' %} -django-storages[boto3]==1.8 # https://github.com/jschneier/django-storages +django-storages[boto3]==1.9.1 # https://github.com/jschneier/django-storages {%- elif cookiecutter.cloud_provider == 'GCP' %} -django-storages[google]==1.8 # https://github.com/jschneier/django-storages +django-storages[google]==1.9.1 # https://github.com/jschneier/django-storages +{%- endif %} +{%- if cookiecutter.mail_service == 'Mailgun' %} +django-anymail[mailgun]==7.1.0 # https://github.com/anymail/django-anymail +{%- elif cookiecutter.mail_service == 'Amazon SES' %} +django-anymail[amazon_ses]==7.1.0 # https://github.com/anymail/django-anymail +{%- elif cookiecutter.mail_service == 'Mailjet' %} +django-anymail[mailjet]==7.1.0 # https://github.com/anymail/django-anymail +{%- elif cookiecutter.mail_service == 'Mandrill' %} +django-anymail[mandrill]==7.1.0 # https://github.com/anymail/django-anymail +{%- elif cookiecutter.mail_service == 'Postmark' %} +django-anymail[postmark]==7.1.0 # https://github.com/anymail/django-anymail +{%- elif cookiecutter.mail_service == 'Sendgrid' %} +django-anymail[sendgrid]==7.1.0 # https://github.com/anymail/django-anymail +{%- elif cookiecutter.mail_service == 'SendinBlue' %} +django-anymail[sendinblue]==7.1.0 # https://github.com/anymail/django-anymail +{%- elif cookiecutter.mail_service == 'SparkPost' %} +django-anymail[sparkpost]==7.1.0 # https://github.com/anymail/django-anymail +{%- elif cookiecutter.mail_service == 'Other SMTP' %} +django-anymail==7.1.0 # https://github.com/anymail/django-anymail {%- endif %} -django-anymail[mailgun]==7.0.0 # https://github.com/anymail/django-anymail diff --git a/{{cookiecutter.project_slug}}/runtime.txt b/{{cookiecutter.project_slug}}/runtime.txt index 6919bf9e..724c203e 100644 --- a/{{cookiecutter.project_slug}}/runtime.txt +++ b/{{cookiecutter.project_slug}}/runtime.txt @@ -1 +1 @@ -python-3.7.6 +python-3.8.2 diff --git a/{{cookiecutter.project_slug}}/setup.cfg b/{{cookiecutter.project_slug}}/setup.cfg index 5f7d9b65..6520aff7 100644 --- a/{{cookiecutter.project_slug}}/setup.cfg +++ b/{{cookiecutter.project_slug}}/setup.cfg @@ -7,7 +7,7 @@ max-line-length = 120 exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules [mypy] -python_version = 3.7 +python_version = 3.8 check_untyped_defs = True ignore_missing_imports = True warn_unused_ignores = True @@ -21,3 +21,9 @@ django_settings_module = config.settings.test [mypy-*.migrations.*] # Django migrations should not produce any errors: ignore_errors = True + +[coverage:run] +include = {{cookiecutter.project_slug}}/* +omit = *migrations*, *tests* +plugins = + django_coverage_plugin diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/conftest.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/conftest.py index 8cd51d7f..335648e0 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/conftest.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/conftest.py @@ -1,5 +1,4 @@ import pytest -from django.test import RequestFactory from {{ cookiecutter.project_slug }}.users.models import User from {{ cookiecutter.project_slug }}.users.tests.factories import UserFactory @@ -13,8 +12,3 @@ def media_storage(settings, tmpdir): @pytest.fixture def user() -> User: return UserFactory() - - -@pytest.fixture -def request_factory() -> RequestFactory: - return RequestFactory() diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/api/serializers.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/api/serializers.py index bb52738b..04bf4c85 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/api/serializers.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/api/serializers.py @@ -1,4 +1,5 @@ from rest_framework import serializers + from {{ cookiecutter.project_slug }}.users.models import User diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/api/views.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/api/views.py index 7b5af999..288ea7ab 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/api/views.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/api/views.py @@ -1,7 +1,7 @@ from django.contrib.auth import get_user_model from rest_framework import status from rest_framework.decorators import action -from rest_framework.mixins import RetrieveModelMixin, ListModelMixin, UpdateModelMixin +from rest_framework.mixins import ListModelMixin, RetrieveModelMixin, UpdateModelMixin from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/forms.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/forms.py index 250cc904..25065419 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/forms.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/forms.py @@ -1,4 +1,4 @@ -from django.contrib.auth import get_user_model, forms +from django.contrib.auth import forms, get_user_model from django.core.exceptions import ValidationError from django.utils.translation import ugettext_lazy as _ diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/test_tasks.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/test_tasks.py index addb091d..41d5af29 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/test_tasks.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/test_tasks.py @@ -1,12 +1,12 @@ import pytest from celery.result import EagerResult - from {{ cookiecutter.project_slug }}.users.tasks import get_users_count from {{ cookiecutter.project_slug }}.users.tests.factories import UserFactory +pytestmark = pytest.mark.django_db + -@pytest.mark.django_db def test_user_count(settings): """A basic test to execute the get_users_count Celery task.""" UserFactory.create_batch(3) diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/test_urls.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/test_urls.py index 933396ba..aab6d0a8 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/test_urls.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/test_urls.py @@ -1,5 +1,5 @@ import pytest -from django.urls import reverse, resolve +from django.urls import resolve, reverse from {{ cookiecutter.project_slug }}.users.models import User diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/test_views.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/test_views.py index 9e868899..ca006059 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/test_views.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/test_views.py @@ -16,18 +16,18 @@ class TestUserUpdateView: https://github.com/pytest-dev/pytest-django/pull/258 """ - def test_get_success_url(self, user: User, request_factory: RequestFactory): + def test_get_success_url(self, user: User, rf: RequestFactory): view = UserUpdateView() - request = request_factory.get("/fake-url/") + request = rf.get("/fake-url/") request.user = user view.request = request assert view.get_success_url() == f"/users/{user.username}/" - def test_get_object(self, user: User, request_factory: RequestFactory): + def test_get_object(self, user: User, rf: RequestFactory): view = UserUpdateView() - request = request_factory.get("/fake-url/") + request = rf.get("/fake-url/") request.user = user view.request = request @@ -36,9 +36,9 @@ class TestUserUpdateView: class TestUserRedirectView: - def test_get_redirect_url(self, user: User, request_factory: RequestFactory): + def test_get_redirect_url(self, user: User, rf: RequestFactory): view = UserRedirectView() - request = request_factory.get("/fake-url") + request = rf.get("/fake-url") request.user = user view.request = request diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/urls.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/urls.py index eff24dd0..8c8c7e2e 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/urls.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/urls.py @@ -1,9 +1,9 @@ from django.urls import path from {{ cookiecutter.project_slug }}.users.views import ( + user_detail_view, user_redirect_view, user_update_view, - user_detail_view, ) app_name = "users" diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/views.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/views.py index 5c0d5b5c..f0e81dff 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/views.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/views.py @@ -1,9 +1,9 @@ +from django.contrib import messages from django.contrib.auth import get_user_model from django.contrib.auth.mixins import LoginRequiredMixin from django.urls import reverse -from django.views.generic import DetailView, RedirectView, UpdateView -from django.contrib import messages from django.utils.translation import ugettext_lazy as _ +from django.views.generic import DetailView, RedirectView, UpdateView User = get_user_model() diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/utils/storages.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/utils/storages.py new file mode 100644 index 00000000..b712d323 --- /dev/null +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/utils/storages.py @@ -0,0 +1,25 @@ +{% if cookiecutter.cloud_provider == 'AWS' -%} +from storages.backends.s3boto3 import S3Boto3Storage + + +class StaticRootS3Boto3Storage(S3Boto3Storage): + location = "static" + default_acl = "public-read" + + +class MediaRootS3Boto3Storage(S3Boto3Storage): + location = "media" + file_overwrite = False +{%- elif cookiecutter.cloud_provider == 'GCP' -%} +from storages.backends.gcloud import GoogleCloudStorage + + +class StaticRootGoogleCloudStorage(GoogleCloudStorage): + location = "static" + default_acl = "publicRead" + + +class MediaRootGoogleCloudStorage(GoogleCloudStorage): + location = "media" + file_overwrite = False +{%- endif %}