diff --git a/CONTRIBUTORS.rst b/CONTRIBUTORS.rst index 0f6761ed..bbbfaf9a 100644 --- a/CONTRIBUTORS.rst +++ b/CONTRIBUTORS.rst @@ -87,7 +87,9 @@ Listed in alphabetical order. David Díaz `@ddiazpinto`_ @DavidDiazPinto Davur Clementsen `@dsclementsen`_ @davur Delio Castillo `@jangeador`_ @jangeador + Denis Orehovsky `@apirobot`_ Dónal Adams `@epileptic-fish`_ + Diane Chen `@purplediane`_ @purplediane88 Dong Huynh `@trungdong`_ Emanuel Calso `@bloodpet`_ @bloodpet Eraldo Energy `@eraldo`_ @@ -98,11 +100,13 @@ Listed in alphabetical order. Garry Polley `@garrypolley`_ Hamish Durkin `@durkode`_ Harry Percival `@hjwp`_ + Hendrik Schneider `@hendrikschneider`_ Henrique G. G. Pereira `@ikkebr`_ Ian Lee `@IanLee1521`_ Jan Van Bruggen `@jvanbrug`_ Jens Nilsson `@phiberjenz`_ Jimmy Gitonga `@afrowave`_ @afrowave + John Cass `@jcass77`_ @cass_john Julien Almarcha `@sladinji`_ Julio Castillo `@juliocc`_ Kaido Kert `@kaidokert`_ @@ -121,6 +125,7 @@ Listed in alphabetical order. Malik Sulaimanov `@flyudvik`_ @flyudvik Martin Blech Martin Saizar `@msaizar`_ + Mateusz Ostaszewski `@mostaszewski`_ Mathijs Hoogland `@MathijsHoogland`_ Matt Braymer-Hayes `@mattayes`_ @mattayes Matt Linares @@ -161,6 +166,7 @@ Listed in alphabetical order. Will Farley `@goldhand`_ @g01dhand William Archinal `@archinal`_ Yaroslav Halchenko + Denis Bobrov `@delneg`_ ========================== ============================ ============== .. _@a7p: https://github.com/a7p @@ -172,6 +178,7 @@ Listed in alphabetical order. .. _@amjith: https://github.com/amjith .. _@andor-pierdelacabeza: https://github.com/andor-pierdelacabeza .. _@antoniablair: https://github.com/antoniablair +.. _@apirobot: https://github.com/apirobot .. _@archinal: https://github.com/archinal .. _@areski: https://github.com/areski .. _@arruda: https://github.com/arruda @@ -206,6 +213,7 @@ Listed in alphabetical order. .. _@goldhand: https://github.com/goldhand .. _@hackebrot: https://github.com/hackebrot .. _@hairychris: https://github.com/hairychris +.. _@hendrikschneider https://github.com/hendrikschneider .. _@hjwp: https://github.com/hjwp .. _@IanLee1521: https://github.com/IanLee1521 .. _@ikkebr: https://github.com/ikkebr @@ -223,6 +231,7 @@ Listed in alphabetical order. .. _@MathijsHoogland: https://github.com/MathijsHoogland .. _@mattayes: https://github.com/mattayes .. _@menzenski: https://github.com/menzenski +.. _@mostaszewski: https://github.com/mostaszewski .. _@mfwarren: https://github.com/mfwarren .. _@mimischi: https://github.com/mimischi .. _@mjsisley: https://github.com/mjsisley @@ -263,7 +272,8 @@ Listed in alphabetical order. .. _@brentpayne: https://github.com/brentpayne .. _@afrowave: https://github.com/afrowave .. _@pchiquet: https://github.com/pchiquet - +.. _@delneg: https://github.com/delneg +.. _@purplediane: https://github.com/purplediane Special Thanks ~~~~~~~~~~~~~~ diff --git a/README.rst b/README.rst index 0eaea519..b5a3e7c0 100644 --- a/README.rst +++ b/README.rst @@ -41,7 +41,7 @@ Features * For Django 2.0 * Works with Python 3.6 * Renders Django projects with 100% starting test coverage -* Twitter Bootstrap_ v4.0.0 (`maintained Foundation fork`_ also available) +* Twitter Bootstrap_ v4.1.1 (`maintained Foundation fork`_ also available) * 12-Factor_ based settings via django-environ_ * Secure by default. We believe in SSL. * Optimized development and production settings @@ -65,7 +65,7 @@ Optional Integrations *These features can be enabled during initial project setup.* * Serve static files from Amazon S3 or Whitenoise_ -* Configuration for Celery_ +* Configuration for Celery_ and Flower_ (the latter in Docker setup only) * Integration with MailHog_ for local email testing * Integration with Sentry_ for error logging @@ -78,6 +78,7 @@ Optional Integrations .. _Mailgun: http://www.mailgun.com/ .. _Whitenoise: https://whitenoise.readthedocs.io/ .. _Celery: http://www.celeryproject.org/ +.. _Flower: https://github.com/mher/flower .. _Anymail: https://github.com/anymail/django-anymail .. _MailHog: https://github.com/mailhog/MailHog .. _Sentry: https://sentry.io/welcome/ diff --git a/docs/deployment-on-heroku.rst b/docs/deployment-on-heroku.rst index 7006a287..d7d95184 100644 --- a/docs/deployment-on-heroku.rst +++ b/docs/deployment-on-heroku.rst @@ -21,17 +21,27 @@ Run these commands to deploy the project to Heroku: heroku addons:create sentry:f1 heroku config:set PYTHONHASHSEED=random + heroku config:set WEB_CONCURRENCY=4 + heroku config:set DJANGO_DEBUG=False heroku config:set DJANGO_SETTINGS_MODULE=config.settings.production heroku config:set DJANGO_SECRET_KEY="$(openssl rand -base64 64)" + # Generating a 32 character-long random string without any of the visually similiar characters "IOl01": heroku config:set DJANGO_ADMIN_URL="$(openssl rand -base64 4096 | tr -dc 'A-HJ-NP-Za-km-z2-9' | head -c 32)/" - heroku config:set DJANGO_ALLOWED_HOSTS= # Set this to your Heroku app url, e.g. 'bionic-beaver-28392.herokuapp.com' - - heroku config:set DJANGO_AWS_ACCESS_KEY_ID= # Assign with AWS_ACCESS_KEY_ID - heroku config:set DJANGO_AWS_SECRET_ACCESS_KEY= # Assign with AWS_SECRET_ACCESS_KEY - heroku config:set DJANGO_AWS_STORAGE_BUCKET_NAME= # Assign with AWS_STORAGE_BUCKET_NAME + + # Set this to your Heroku app url, e.g. 'bionic-beaver-28392.herokuapp.com' + heroku config:set DJANGO_ALLOWED_HOSTS= + + # Assign with AWS_ACCESS_KEY_ID + heroku config:set DJANGO_AWS_ACCESS_KEY_ID= + + # Assign with AWS_SECRET_ACCESS_KEY + heroku config:set DJANGO_AWS_SECRET_ACCESS_KEY= + + # Assign with AWS_STORAGE_BUCKET_NAME + heroku config:set DJANGO_AWS_STORAGE_BUCKET_NAME= git push heroku master diff --git a/docs/deployment-with-docker.rst b/docs/deployment-with-docker.rst index 42798aae..b2166824 100644 --- a/docs/deployment-with-docker.rst +++ b/docs/deployment-with-docker.rst @@ -21,10 +21,13 @@ Before you begin, check out the ``production.yml`` file in the root of this proj * ``redis``: Redis instance for caching; * ``caddy``: Caddy web server with HTTPS on by default. -Provided you have opted for Celery (via setting ``use_celery`` to ``y``) there are two more services: +Provided you have opted for Celery (via setting ``use_celery`` to ``y``) there are three more services: * ``celeryworker`` running a Celery worker process; -* ``celerybeat`` running a Celery beat process. +* ``celerybeat`` running a Celery beat process; +* ``flower`` running Flower_ (for more info, check out :ref:`CeleryFlower` instructions for local environment). + +.. _`Flower`: https://github.com/mher/flower Configuring the Stack @@ -70,7 +73,7 @@ You can read more about this here at `Automatic HTTPS`_ in the Caddy docs. (Optional) Postgres Data Volume Modifications --------------------------------------------- -Postgres is saving its database files to the ``postgres_data`` volume by default. Change that if you want something else and make sure to make backups since this is not done automatically. +Postgres is saving its database files to the ``production_postgres_data`` volume by default. Change that if you want something else and make sure to make backups since this is not done automatically. Building & Running Production Stack diff --git a/docs/developing-locally-docker.rst b/docs/developing-locally-docker.rst index 207f0ea2..08b25f3b 100644 --- a/docs/developing-locally-docker.rst +++ b/docs/developing-locally-docker.rst @@ -91,8 +91,8 @@ This is the excerpt from your project's ``local.yml``: :: context: . dockerfile: ./compose/production/postgres/Dockerfile volumes: - - postgres_data_local:/var/lib/postgresql/data - - postgres_backup_local:/backups + - local_postgres_data:/var/lib/postgresql/data + - local_postgres_data_backups:/backups env_file: - ./.envs/.local/.postgres @@ -170,3 +170,20 @@ When developing locally you can go with MailHog_ for email testing provided ``us #. open up ``http://127.0.0.1:8025``. .. _Mailhog: https://github.com/mailhog/MailHog/ + + +.. _`CeleryFlower`: + +Celery Flower +~~~~~~~~~~~~~ + +`Flower`_ is a "real-time monitor and web admin for Celery distributed task queue". + +Prerequisites: + +* ``use_docker`` was set to ``y`` on project initialization; +* ``use_celery`` was set to ``y`` on project initialization. + +By default, it's enabled both in local and production environments (``local.yml`` and ``production.yml`` Docker Compose configs, respectively) through a ``flower`` service. For added security, ``flower`` requires its clients to provide authentication credentials specified as the corresponding environments' ``.envs/.local/.django`` and ``.envs/.production/.django`` ``CELERY_FLOWER_USER`` and ``CELERY_FLOWER_PASSWORD`` environment variables. Check out ``localhost:5555`` and see for yourself. + +.. _`Flower`: https://github.com/mher/flower diff --git a/docs/project-generation-options.rst b/docs/project-generation-options.rst index 60453b87..a5483797 100644 --- a/docs/project-generation-options.rst +++ b/docs/project-generation-options.rst @@ -93,7 +93,8 @@ use_travisci: keep_local_envs_in_vcs: Indicates whether the project's ``.envs/.local/`` should be kept in VCS (comes in handy when working in teams where local environment reproducibility - is strongly encouraged). + is strongly encouraged). + Note: .env(s) are only utilized when Docker Compose and/or Heroku support is enabled. debug: Indicates whether the project should be configured for debugging. diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py index 6b48471e..669edb0e 100644 --- a/hooks/post_gen_project.py +++ b/hooks/post_gen_project.py @@ -32,7 +32,10 @@ DEBUG_VALUE = "debug" def remove_open_source_files(): - file_names = ["CONTRIBUTORS.txt"] + file_names = [ + "CONTRIBUTORS.txt", + "LICENSE", + ] for file_name in file_names: os.remove(file_name) @@ -61,6 +64,10 @@ def remove_docker_files(): os.remove(file_name) +def remove_utility_files(): + shutil.rmtree("utility") + + def remove_heroku_files(): file_names = ["Procfile", "runtime.txt", "requirements.txt"] for file_name in file_names: @@ -162,8 +169,12 @@ def set_django_admin_url(file_path): return django_admin_url +def generate_random_user(): + return generate_random_string(length=32, using_ascii_letters=True) + + def generate_postgres_user(debug=False): - return DEBUG_VALUE if debug else generate_random_string(length=32, using_ascii_letters=True) + return DEBUG_VALUE if debug else generate_random_user() def set_postgres_user(file_path, value): @@ -187,25 +198,56 @@ def set_postgres_password(file_path, value=None): return postgres_password +def set_celery_flower_user(file_path, value): + celery_flower_user = set_flag( + file_path, + "!!!SET CELERY_FLOWER_USER!!!", + value=value, + ) + return celery_flower_user + + +def set_celery_flower_password(file_path, value=None): + celery_flower_password = set_flag( + file_path, + "!!!SET CELERY_FLOWER_PASSWORD!!!", + value=value, + length=64, + using_digits=True, + using_ascii_letters=True, + ) + return celery_flower_password + + def append_to_gitignore_file(s): with open(".gitignore", "a") as gitignore_file: gitignore_file.write(s) gitignore_file.write(os.linesep) -def set_flags_in_envs(postgres_user, debug=False): - local_postgres_envs_path = os.path.join(".envs", ".local", ".postgres") - set_postgres_user(local_postgres_envs_path, value=postgres_user) - set_postgres_password(local_postgres_envs_path, value=DEBUG_VALUE if debug else None) - +def set_flags_in_envs( + postgres_user, + celery_flower_user, + debug=False, +): + local_django_envs_path = os.path.join(".envs", ".local", ".django") production_django_envs_path = os.path.join(".envs", ".production", ".django") + local_postgres_envs_path = os.path.join(".envs", ".local", ".postgres") + production_postgres_envs_path = os.path.join(".envs", ".production", ".postgres") + set_django_secret_key(production_django_envs_path) set_django_admin_url(production_django_envs_path) - production_postgres_envs_path = os.path.join(".envs", ".production", ".postgres") + set_postgres_user(local_postgres_envs_path, value=postgres_user) + set_postgres_password(local_postgres_envs_path, value=DEBUG_VALUE if debug else None) set_postgres_user(production_postgres_envs_path, value=postgres_user) set_postgres_password(production_postgres_envs_path, value=DEBUG_VALUE if debug else None) + set_celery_flower_user(local_django_envs_path, value=celery_flower_user) + set_celery_flower_password(local_django_envs_path, value=DEBUG_VALUE if debug else None) + set_celery_flower_user(production_django_envs_path, value=celery_flower_user) + set_celery_flower_password(production_django_envs_path, value=DEBUG_VALUE if debug else None) + def set_flags_in_settings_files(): set_django_secret_key(os.path.join("config", "settings", "local.py")) @@ -223,8 +265,13 @@ def remove_celery_compose_dirs(): def main(): - postgres_user = generate_postgres_user(debug="{{ cookiecutter.debug }}".lower() == "y") - set_flags_in_envs(postgres_user, debug="{{ cookiecutter.debug }}".lower() == "y") + debug = "{{ cookiecutter.debug }}".lower() == "y" + + set_flags_in_envs( + DEBUG_VALUE if debug else generate_random_user(), + DEBUG_VALUE if debug else generate_random_user(), + debug=debug, + ) set_flags_in_settings_files() if "{{ cookiecutter.open_source_license }}" == "Not open source": @@ -235,7 +282,9 @@ def main(): if "{{ cookiecutter.use_pycharm }}".lower() == "n": remove_pycharm_files() - if "{{ cookiecutter.use_docker }}".lower() == "n": + if "{{ cookiecutter.use_docker }}".lower() == "y": + remove_utility_files() + else: remove_docker_files() if "{{ cookiecutter.use_heroku }}".lower() == "n": diff --git a/requirements.txt b/requirements.txt index 9f378fda..5d710316 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,6 @@ flake8==3.5.0 # Testing # ------------------------------------------------------------------------------ -tox==3.0.0 -pytest==3.6.1 +tox==3.2.1 +pytest==3.7.3 pytest-cookies==0.3.0 diff --git a/tests/test_docker.sh b/tests/test_docker.sh index bebac148..eddfe98c 100755 --- a/tests/test_docker.sh +++ b/tests/test_docker.sh @@ -14,8 +14,11 @@ cd .cache/docker cookiecutter ../../ --no-input --overwrite-if-exists use_docker=y cd my_awesome_project +# run the project's type checks +docker-compose -f local.yml run django mypy my_awesome_project + # run the project's tests -docker-compose -f local.yml run django python manage.py test +docker-compose -f local.yml run django pytest # return non-zero status code if there are migrations that have not been created docker-compose -f local.yml run django python manage.py makemigrations --dry-run --check || { echo "ERROR: there were changes in the models, but migration listed above have not been created and are not saved in version control"; exit 1; } diff --git a/{{cookiecutter.project_slug}}/.envs/.local/.django b/{{cookiecutter.project_slug}}/.envs/.local/.django index 8aa9a994..d94a17e5 100644 --- a/{{cookiecutter.project_slug}}/.envs/.local/.django +++ b/{{cookiecutter.project_slug}}/.envs/.local/.django @@ -5,3 +5,11 @@ USE_DOCKER=yes # Redis # ------------------------------------------------------------------------------ REDIS_URL=redis://redis:6379/0 +{% if cookiecutter.use_celery == 'y' %} +# Celery +# ------------------------------------------------------------------------------ + +# Flower +CELERY_FLOWER_USER=!!!SET CELERY_FLOWER_USER!!! +CELERY_FLOWER_PASSWORD=!!!SET CELERY_FLOWER_PASSWORD!!! +{% endif %} diff --git a/{{cookiecutter.project_slug}}/.envs/.production/.django b/{{cookiecutter.project_slug}}/.envs/.production/.django index 5cb90897..4175f894 100644 --- a/{{cookiecutter.project_slug}}/.envs/.production/.django +++ b/{{cookiecutter.project_slug}}/.envs/.production/.django @@ -43,3 +43,11 @@ SENTRY_DSN= # Redis # ------------------------------------------------------------------------------ REDIS_URL=redis://redis:6379/0 +{% if cookiecutter.use_celery == 'y' %} +# Celery +# ------------------------------------------------------------------------------ + +# Flower +CELERY_FLOWER_USER=!!!SET CELERY_FLOWER_USER!!! +CELERY_FLOWER_PASSWORD=!!!SET CELERY_FLOWER_PASSWORD!!! +{% endif %} diff --git a/{{cookiecutter.project_slug}}/.idea/runConfigurations/tests___all.xml b/{{cookiecutter.project_slug}}/.idea/runConfigurations/pytest___.xml similarity index 52% rename from {{cookiecutter.project_slug}}/.idea/runConfigurations/tests___all.xml rename to {{cookiecutter.project_slug}}/.idea/runConfigurations/pytest___.xml index be70ffcd..08f76c45 100644 --- a/{{cookiecutter.project_slug}}/.idea/runConfigurations/tests___all.xml +++ b/{{cookiecutter.project_slug}}/.idea/runConfigurations/pytest___.xml @@ -1,18 +1,14 @@ - + - + \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/.idea/runConfigurations/tests___module__users.xml b/{{cookiecutter.project_slug}}/.idea/runConfigurations/pytest__users.xml similarity index 50% rename from {{cookiecutter.project_slug}}/.idea/runConfigurations/tests___module__users.xml rename to {{cookiecutter.project_slug}}/.idea/runConfigurations/pytest__users.xml index d838b5da..574361fe 100644 --- a/{{cookiecutter.project_slug}}/.idea/runConfigurations/tests___module__users.xml +++ b/{{cookiecutter.project_slug}}/.idea/runConfigurations/pytest__users.xml @@ -1,18 +1,14 @@ - + - + \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/.idea/runConfigurations/tests___class__TestUser.xml b/{{cookiecutter.project_slug}}/.idea/runConfigurations/tests___class__TestUser.xml deleted file mode 100644 index 204de9dd..00000000 --- a/{{cookiecutter.project_slug}}/.idea/runConfigurations/tests___class__TestUser.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - diff --git a/{{cookiecutter.project_slug}}/.idea/runConfigurations/tests___file__test_models.xml b/{{cookiecutter.project_slug}}/.idea/runConfigurations/tests___file__test_models.xml deleted file mode 100644 index ddb6d3d6..00000000 --- a/{{cookiecutter.project_slug}}/.idea/runConfigurations/tests___file__test_models.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - diff --git a/{{cookiecutter.project_slug}}/.idea/runConfigurations/tests___specific__test_get_absolute_url.xml b/{{cookiecutter.project_slug}}/.idea/runConfigurations/tests___specific__test_get_absolute_url.xml deleted file mode 100644 index c391058f..00000000 --- a/{{cookiecutter.project_slug}}/.idea/runConfigurations/tests___specific__test_get_absolute_url.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - diff --git a/{{cookiecutter.project_slug}}/README.rst b/{{cookiecutter.project_slug}}/README.rst index 3386d985..80923cd8 100644 --- a/{{cookiecutter.project_slug}}/README.rst +++ b/{{cookiecutter.project_slug}}/README.rst @@ -32,12 +32,21 @@ Setting Up Your Users For convenience, you can keep your normal user logged in on Chrome and your superuser logged in on Firefox (or similar), so that you can see how the site behaves for both kinds of users. +Type checks +^^^^^^^^^^^ + +Running type checks with mypy: + +:: + + $ mypy {{cookiecutter.project_slug}} + Test coverage ^^^^^^^^^^^^^ To run the tests, check your test coverage, and generate an HTML coverage report:: - $ coverage run manage.py test + $ coverage run -m pytest $ coverage html $ open htmlcov/index.html @@ -46,7 +55,7 @@ Running tests with py.test :: - $ py.test + $ pytest Live reloading and Sass CSS compilation ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -138,7 +147,7 @@ Custom Bootstrap Compilation ^^^^^^ The generated CSS is set up with automatic Bootstrap recompilation with variables of your choice. -Bootstrap v4 is installed using npm and customised by tweaking your variables in ``static/sass/custom_bootstrap_vars``. +Bootstrap v4.1.1 is installed using npm and customised by tweaking your variables in ``static/sass/custom_bootstrap_vars``. You can find a list of available variables `in the bootstrap source`_, or get explanations on them in the `Bootstrap docs`_. @@ -147,6 +156,6 @@ Bootstrap's javascript as well as its dependencies is concatenated into a single {% endif %} .. _in the bootstrap source: https://github.com/twbs/bootstrap/blob/v4-dev/scss/_variables.scss -.. _Bootstrap docs: https://getbootstrap.com/docs/4.0/getting-started/theming/ +.. _Bootstrap docs: https://getbootstrap.com/docs/4.1/getting-started/theming/ {% endif %} diff --git a/{{cookiecutter.project_slug}}/compose/local/django/Dockerfile b/{{cookiecutter.project_slug}}/compose/local/django/Dockerfile index 27424954..e2e9e5f3 100644 --- a/{{cookiecutter.project_slug}}/compose/local/django/Dockerfile +++ b/{{cookiecutter.project_slug}}/compose/local/django/Dockerfile @@ -34,6 +34,10 @@ RUN chmod +x /start-celeryworker COPY ./compose/local/django/celery/beat/start /start-celerybeat RUN sed -i 's/\r//' /start-celerybeat RUN chmod +x /start-celerybeat + +COPY ./compose/local/django/celery/flower/start /start-flower +RUN sed -i 's/\r//' /start-flower +RUN chmod +x /start-flower {% endif %} WORKDIR /app diff --git a/{{cookiecutter.project_slug}}/compose/local/django/celery/flower/start b/{{cookiecutter.project_slug}}/compose/local/django/celery/flower/start new file mode 100644 index 00000000..f0abae7e --- /dev/null +++ b/{{cookiecutter.project_slug}}/compose/local/django/celery/flower/start @@ -0,0 +1,10 @@ +#!/bin/sh + +set -o errexit +set -o nounset + + +celery flower \ + --app={{cookiecutter.project_slug}}.taskapp \ + --broker="${CELERY_BROKER_URL}" \ + --basic_auth="${CELERY_FLOWER_USER}:${CELERY_FLOWER_PASSWORD}" diff --git a/{{cookiecutter.project_slug}}/compose/production/django/Dockerfile b/{{cookiecutter.project_slug}}/compose/production/django/Dockerfile index b204a481..68d72327 100644 --- a/{{cookiecutter.project_slug}}/compose/production/django/Dockerfile +++ b/{{cookiecutter.project_slug}}/compose/production/django/Dockerfile @@ -38,6 +38,10 @@ COPY ./compose/production/django/celery/beat/start /start-celerybeat RUN sed -i 's/\r//' /start-celerybeat RUN chmod +x /start-celerybeat RUN chown django /start-celerybeat + +COPY ./compose/production/django/celery/flower/start /start-flower +RUN sed -i 's/\r//' /start-flower +RUN chmod +x /start-flower {% endif %} COPY . /app diff --git a/{{cookiecutter.project_slug}}/compose/production/django/celery/flower/start b/{{cookiecutter.project_slug}}/compose/production/django/celery/flower/start new file mode 100644 index 00000000..f0abae7e --- /dev/null +++ b/{{cookiecutter.project_slug}}/compose/production/django/celery/flower/start @@ -0,0 +1,10 @@ +#!/bin/sh + +set -o errexit +set -o nounset + + +celery flower \ + --app={{cookiecutter.project_slug}}.taskapp \ + --broker="${CELERY_BROKER_URL}" \ + --basic_auth="${CELERY_FLOWER_USER}:${CELERY_FLOWER_PASSWORD}" diff --git a/{{cookiecutter.project_slug}}/config/settings/base.py b/{{cookiecutter.project_slug}}/config/settings/base.py index 26bbd479..e4ab2884 100644 --- a/{{cookiecutter.project_slug}}/config/settings/base.py +++ b/{{cookiecutter.project_slug}}/config/settings/base.py @@ -229,19 +229,25 @@ MANAGERS = ADMINS # Celery # ------------------------------------------------------------------------------ INSTALLED_APPS += ['{{cookiecutter.project_slug}}.taskapp.celery.CeleryAppConfig'] +if USE_TZ: + # http://docs.celeryproject.org/en/latest/userguide/configuration.html#std:setting-timezone + CELERY_TIMEZONE = TIME_ZONE # http://docs.celeryproject.org/en/latest/userguide/configuration.html#std:setting-broker_url -CELERY_BROKER_URL = env('CELERY_BROKER_URL', default='django://') +CELERY_BROKER_URL = env('CELERY_BROKER_URL') # http://docs.celeryproject.org/en/latest/userguide/configuration.html#std:setting-result_backend -if CELERY_BROKER_URL == 'django://': - CELERY_RESULT_BACKEND = 'redis://' -else: - CELERY_RESULT_BACKEND = CELERY_BROKER_URL +CELERY_RESULT_BACKEND = CELERY_BROKER_URL # http://docs.celeryproject.org/en/latest/userguide/configuration.html#std:setting-accept_content CELERY_ACCEPT_CONTENT = ['json'] # http://docs.celeryproject.org/en/latest/userguide/configuration.html#std:setting-task_serializer CELERY_TASK_SERIALIZER = 'json' # http://docs.celeryproject.org/en/latest/userguide/configuration.html#std:setting-result_serializer CELERY_RESULT_SERIALIZER = 'json' +# http://docs.celeryproject.org/en/latest/userguide/configuration.html#task-time-limit +# TODO: set to whatever value is adequate in your circumstances +CELERYD_TASK_TIME_LIMIT = 5 * 60 +# http://docs.celeryproject.org/en/latest/userguide/configuration.html#task-soft-time-limit +# TODO: set to whatever value is adequate in your circumstances +CELERYD_TASK_SOFT_TIME_LIMIT = 60 {%- endif %} # django-allauth diff --git a/{{cookiecutter.project_slug}}/config/settings/local.py b/{{cookiecutter.project_slug}}/config/settings/local.py index ac11eda5..741f324a 100644 --- a/{{cookiecutter.project_slug}}/config/settings/local.py +++ b/{{cookiecutter.project_slug}}/config/settings/local.py @@ -76,8 +76,10 @@ INSTALLED_APPS += ['django_extensions'] # noqa F405 # Celery # ------------------------------------------------------------------------------ -# http://docs.celeryproject.org/en/latest/userguide/configuration.html#std:setting-task_always_eager -CELERY_ALWAYS_EAGER = True +# http://docs.celeryproject.org/en/latest/userguide/configuration.html#task-always-eager +CELERY_TASK_ALWAYS_EAGER = True +# http://docs.celeryproject.org/en/latest/userguide/configuration.html#task-eager-propagates +CELERY_TASK_EAGER_PROPAGATES = True {%- endif %} # Your stuff... diff --git a/{{cookiecutter.project_slug}}/config/settings/production.py b/{{cookiecutter.project_slug}}/config/settings/production.py index ecd517dd..39df0cbf 100644 --- a/{{cookiecutter.project_slug}}/config/settings/production.py +++ b/{{cookiecutter.project_slug}}/config/settings/production.py @@ -73,8 +73,6 @@ AWS_SECRET_ACCESS_KEY = env('DJANGO_AWS_SECRET_ACCESS_KEY') # https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings AWS_STORAGE_BUCKET_NAME = env('DJANGO_AWS_STORAGE_BUCKET_NAME') # https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings -AWS_AUTO_CREATE_BUCKET = True -# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings AWS_QUERYSTRING_AUTH = False # DO NOT change these unless you know what you're doing. _AWS_EXPIRY = 60 * 60 * 24 * 7 diff --git a/{{cookiecutter.project_slug}}/local.yml b/{{cookiecutter.project_slug}}/local.yml index 5ad26dfd..36638932 100644 --- a/{{cookiecutter.project_slug}}/local.yml +++ b/{{cookiecutter.project_slug}}/local.yml @@ -1,8 +1,8 @@ -version: '2' +version: '3' volumes: - postgres_data_local: {} - postgres_backup_local: {} + local_postgres_data: {} + local_postgres_data_backups: {} services: django:{% if cookiecutter.use_celery == 'y' %} &django{% endif %} @@ -30,8 +30,8 @@ services: dockerfile: ./compose/production/postgres/Dockerfile image: {{ cookiecutter.project_slug }}_production_postgres volumes: - - postgres_data_local:/var/lib/postgresql/data - - postgres_backup_local:/backups + - local_postgres_data:/var/lib/postgresql/data + - local_postgres_data_backups:/backups env_file: - ./.envs/.local/.postgres {%- if cookiecutter.use_mailhog == 'y' %} @@ -71,4 +71,11 @@ services: ports: [] command: /start-celerybeat + flower: + <<: *django + image: {{ cookiecutter.project_slug }}_local_flower + ports: + - "5555:5555" + command: /start-flower + {%- endif %} diff --git a/{{cookiecutter.project_slug}}/package.json b/{{cookiecutter.project_slug}}/package.json index f32c41a2..03a4e6a2 100644 --- a/{{cookiecutter.project_slug}}/package.json +++ b/{{cookiecutter.project_slug}}/package.json @@ -5,7 +5,7 @@ "devDependencies": { {% if cookiecutter.js_task_runner == 'Gulp' %} {% if cookiecutter.custom_bootstrap_compilation == 'y' %} - "bootstrap": "^4.0.0", + "bootstrap": "4.1.1", {% endif %} "browser-sync": "^2.14.0", "del": "^2.2.2", @@ -23,8 +23,8 @@ "gulp-uglify": "^3.0.0", "gulp-util": "^3.0.7", {% if cookiecutter.custom_bootstrap_compilation == 'y' %} - "jquery": "^3.2.1-slim", - "popper.js": "^1.12.3", + "jquery": "3.3.1-slim", + "popper.js": "1.14.3", {% endif %} "run-sequence": "^2.1.1" {% endif %} diff --git a/{{cookiecutter.project_slug}}/production.yml b/{{cookiecutter.project_slug}}/production.yml index 4ee178d8..fd8388ac 100644 --- a/{{cookiecutter.project_slug}}/production.yml +++ b/{{cookiecutter.project_slug}}/production.yml @@ -1,9 +1,9 @@ -version: '2' +version: '3' volumes: - postgres_data: {} - postgres_backup: {} - caddy: {} + production_postgres_data: {} + production_postgres_data_backups: {} + production_caddy: {} services: django:{% if cookiecutter.use_celery == 'y' %} &django{% endif %} @@ -25,8 +25,8 @@ services: dockerfile: ./compose/production/postgres/Dockerfile image: {{ cookiecutter.project_slug }}_production_postgres volumes: - - postgres_data:/var/lib/postgresql/data - - postgres_backup:/backups + - production_postgres_data:/var/lib/postgresql/data + - production_postgres_data_backups:/backups env_file: - ./.envs/.production/.postgres @@ -38,7 +38,7 @@ services: depends_on: - django volumes: - - caddy:/root/.caddy + - production_caddy:/root/.caddy env_file: - ./.envs/.production/.caddy ports: @@ -59,4 +59,11 @@ services: image: {{ cookiecutter.project_slug }}_production_celerybeat command: /start-celerybeat + 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 a5399ea5..510ab376 100644 --- a/{{cookiecutter.project_slug}}/requirements/base.txt +++ b/{{cookiecutter.project_slug}}/requirements/base.txt @@ -1,24 +1,27 @@ -pytz==2018.4 # https://github.com/stub42/pytz +pytz==2018.5 # https://github.com/stub42/pytz python-slugify==1.2.5 # https://github.com/un33k/python-slugify -Pillow==5.1.0 # https://github.com/python-pillow/Pillow +Pillow==5.2.0 # https://github.com/python-pillow/Pillow {%- if cookiecutter.use_compressor == "y" %} rcssmin==1.0.6{% if cookiecutter.windows == 'y' %} --install-option="--without-c-extensions"{% endif %} # https://github.com/ndparker/rcssmin {%- endif %} -argon2-cffi==18.1.0 # https://github.com/hynek/argon2_cffi +argon2-cffi==18.3.0 # https://github.com/hynek/argon2_cffi {%- if cookiecutter.use_whitenoise == 'y' %} -whitenoise==3.3.1 # https://github.com/evansd/whitenoise +whitenoise==4.0 # https://github.com/evansd/whitenoise {%- endif %} redis>=2.10.5 # https://github.com/antirez/redis {%- if cookiecutter.use_celery == "y" %} -celery==3.1.26.post2 # pyup: <4.0 # https://github.com/celery/celery +celery==4.2.1 # pyup: <5.0 # https://github.com/celery/celery +{%- if cookiecutter.use_docker == 'y' %} +flower==0.9.2 # https://github.com/mher/flower +{%- endif %} {%- endif %} # Django # ------------------------------------------------------------------------------ -django==2.0.6 # pyup: < 2.1 # https://www.djangoproject.com/ -django-environ==0.4.4 # https://github.com/joke2k/django-environ +django==2.0.8 # pyup: < 2.1 # https://www.djangoproject.com/ +django-environ==0.4.5 # https://github.com/joke2k/django-environ django-model-utils==3.1.2 # https://github.com/jazzband/django-model-utils -django-allauth==0.36.0 # https://github.com/pennersr/django-allauth +django-allauth==0.37.1 # https://github.com/pennersr/django-allauth django-crispy-forms==1.7.2 # https://github.com/django-crispy-forms/django-crispy-forms {%- if cookiecutter.use_compressor == "y" %} django-compressor==2.2 # https://github.com/django-compressor/django-compressor diff --git a/{{cookiecutter.project_slug}}/requirements/local.txt b/{{cookiecutter.project_slug}}/requirements/local.txt index a72a5418..365d94b0 100644 --- a/{{cookiecutter.project_slug}}/requirements/local.txt +++ b/{{cookiecutter.project_slug}}/requirements/local.txt @@ -2,7 +2,7 @@ Werkzeug==0.14.1 # https://github.com/pallets/werkzeug ipdb==0.11 # https://github.com/gotcha/ipdb -Sphinx==1.7.5 # https://github.com/sphinx-doc/sphinx +Sphinx==1.7.8 # https://github.com/sphinx-doc/sphinx {%- if cookiecutter.use_docker == 'y' %} psycopg2==2.7.4 --no-binary psycopg2 # https://github.com/psycopg/psycopg2 {%- else %} @@ -11,7 +11,8 @@ psycopg2-binary==2.7.5 # https://github.com/psycopg/psycopg2 # Testing # ------------------------------------------------------------------------------ -pytest==3.6.1 # https://github.com/pytest-dev/pytest +mypy==0.620 # https://github.com/python/mypy +pytest==3.7.3 # https://github.com/pytest-dev/pytest pytest-sugar==0.9.1 # https://github.com/Frozenball/pytest-sugar # Code quality @@ -22,9 +23,8 @@ coverage==4.5.1 # https://github.com/nedbat/coveragepy # Django # ------------------------------------------------------------------------------ factory-boy==2.11.1 # https://github.com/FactoryBoy/factory_boy -django-test-plus==1.1.0 # https://github.com/revsys/django-test-plus django-debug-toolbar==1.9.1 # https://github.com/jazzband/django-debug-toolbar -django-extensions==2.0.7 # https://github.com/django-extensions/django-extensions +django-extensions==2.1.2 # https://github.com/django-extensions/django-extensions django-coverage-plugin==1.5.0 # https://github.com/nedbat/django_coverage_plugin -pytest-django==3.3.0 # https://github.com/pytest-dev/pytest-django +pytest-django==3.4.2 # https://github.com/pytest-dev/pytest-django diff --git a/{{cookiecutter.project_slug}}/requirements/production.txt b/{{cookiecutter.project_slug}}/requirements/production.txt index d3b18ed9..21fe5bc4 100644 --- a/{{cookiecutter.project_slug}}/requirements/production.txt +++ b/{{cookiecutter.project_slug}}/requirements/production.txt @@ -14,4 +14,4 @@ raven==6.9.0 # https://github.com/getsentry/raven-python # Django # ------------------------------------------------------------------------------ django-storages[boto3]==1.6.6 # https://github.com/jschneier/django-storages -django-anymail[mailgun]==3.0 # https://github.com/anymail/django-anymail +django-anymail[mailgun]==4.1 # https://github.com/anymail/django-anymail diff --git a/{{cookiecutter.project_slug}}/runtime.txt b/{{cookiecutter.project_slug}}/runtime.txt index 486fcce1..1935e977 100644 --- a/{{cookiecutter.project_slug}}/runtime.txt +++ b/{{cookiecutter.project_slug}}/runtime.txt @@ -1 +1 @@ -python-3.6.5 +python-3.6.6 diff --git a/{{cookiecutter.project_slug}}/setup.cfg b/{{cookiecutter.project_slug}}/setup.cfg index 1ec89c78..c2139f1d 100644 --- a/{{cookiecutter.project_slug}}/setup.cfg +++ b/{{cookiecutter.project_slug}}/setup.cfg @@ -5,3 +5,17 @@ exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules [pycodestyle] max-line-length = 120 exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules + +[mypy] +python_version = 3.6 +check_untyped_defs = True +ignore_errors = False +ignore_missing_imports = True +strict_optional = True +warn_unused_ignores = True +warn_redundant_casts = True +warn_unused_configs = True + +[mypy-*.migrations.*] +# Django migrations should not produce any errors: +ignore_errors = True diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/conftest.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/conftest.py new file mode 100644 index 00000000..aae11d26 --- /dev/null +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/conftest.py @@ -0,0 +1,20 @@ +import pytest +from django.conf import settings +from django.test import RequestFactory + +from {{ cookiecutter.project_slug }}.users.tests.factories import UserFactory + + +@pytest.fixture(autouse=True) +def media_storage(settings, tmpdir): + settings.MEDIA_ROOT = tmpdir.strpath + + +@pytest.fixture +def user() -> settings.AUTH_USER_MODEL: + return UserFactory() + + +@pytest.fixture +def request_factory() -> RequestFactory: + return RequestFactory() diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/static/js/project.js b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/static/js/project.js index 91ab9e2d..d26d23b9 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/static/js/project.js +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/static/js/project.js @@ -1,21 +1 @@ /* Project specific Javascript goes here. */ - -/* -Formatting hack to get around crispy-forms unfortunate hardcoding -in helpers.FormHelper: - - if template_pack == 'bootstrap4': - grid_colum_matcher = re.compile('\w*col-(xs|sm|md|lg|xl)-\d+\w*') - using_grid_layout = (grid_colum_matcher.match(self.label_class) or - grid_colum_matcher.match(self.field_class)) - if using_grid_layout: - items['using_grid_layout'] = True - -Issues with the above approach: - -1. Fragile: Assumes Bootstrap 4's API doesn't change (it does) -2. Unforgiving: Doesn't allow for any variation in template design -3. Really Unforgiving: No way to override this behavior -4. Undocumented: No mention in the documentation, or it's too hard for me to find -*/ -$('.form-group').removeClass('row'); diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/taskapp/celery.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/taskapp/celery.py index ed5c7176..b3f0a388 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/taskapp/celery.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/taskapp/celery.py @@ -20,7 +20,9 @@ class CeleryAppConfig(AppConfig): def ready(self): # Using a string here means the worker will not have to # pickle the object when using Windows. - app.config_from_object('django.conf:settings') + # - namespace='CELERY' means all celery-related configuration keys + # should have a `CELERY_` prefix. + app.config_from_object('django.conf:settings', namespace='CELERY') installed_apps = [app_config.name for app_config in apps.get_app_configs()] app.autodiscover_tasks(lambda: installed_apps, force=True) diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/templates/base.html b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/templates/base.html index 2cb70566..6865c929 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/templates/base.html +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/templates/base.html @@ -17,8 +17,8 @@ {% block css %} {% endraw %}{% if cookiecutter.custom_bootstrap_compilation == "n" %}{% raw %} - - + + {% endraw %}{% endif %}{% raw %} @@ -102,10 +102,10 @@ {% endraw %}{% if cookiecutter.use_compressor == "y" %}{% raw %}{% endcompress %}{% endraw %}{% endif %}{% raw %} {% endraw %}{% else %}{% raw %} - - - - + + + + {% endraw %}{% endif %}{% raw %} diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/adapters.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/adapters.py index 5b63593b..9361d6ec 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/adapters.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/adapters.py @@ -1,15 +1,18 @@ -from django.conf import settings +from typing import Any + from allauth.account.adapter import DefaultAccountAdapter from allauth.socialaccount.adapter import DefaultSocialAccountAdapter +from django.conf import settings +from django.http import HttpRequest class AccountAdapter(DefaultAccountAdapter): - def is_open_for_signup(self, request): + def is_open_for_signup(self, request: HttpRequest): return getattr(settings, "ACCOUNT_ALLOW_REGISTRATION", True) class SocialAccountAdapter(DefaultSocialAccountAdapter): - def is_open_for_signup(self, request, sociallogin): + def is_open_for_signup(self, request: HttpRequest, sociallogin: Any): return getattr(settings, "ACCOUNT_ALLOW_REGISTRATION", True) diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/admin.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/admin.py index 8da8f86a..cc6efed5 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/admin.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/admin.py @@ -1,39 +1,17 @@ -from django import forms from django.contrib import admin -from django.contrib.auth.admin import UserAdmin as AuthUserAdmin -from django.contrib.auth.forms import UserChangeForm, UserCreationForm -from .models import User +from django.contrib.auth import admin as auth_admin +from django.contrib.auth import get_user_model +from {{ cookiecutter.project_slug }}.users.forms import UserChangeForm, UserCreationForm -class MyUserChangeForm(UserChangeForm): - - class Meta(UserChangeForm.Meta): - model = User - - -class MyUserCreationForm(UserCreationForm): - - error_message = UserCreationForm.error_messages.update( - {"duplicate_username": "This username has already been taken."} - ) - - class Meta(UserCreationForm.Meta): - model = User - - def clean_username(self): - username = self.cleaned_data["username"] - try: - User.objects.get(username=username) - except User.DoesNotExist: - return username - - raise forms.ValidationError(self.error_messages["duplicate_username"]) +User = get_user_model() @admin.register(User) -class MyUserAdmin(AuthUserAdmin): - form = MyUserChangeForm - add_form = MyUserCreationForm - fieldsets = (("User Profile", {"fields": ("name",)}),) + AuthUserAdmin.fieldsets - list_display = ("username", "name", "is_superuser") +class UserAdmin(auth_admin.UserAdmin): + + form = UserChangeForm + add_form = UserCreationForm + fieldsets = (("User", {"fields": ("name",)}),) + auth_admin.UserAdmin.fieldsets + list_display = ["username", "name", "is_superuser"] search_fields = ["name"] diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/apps.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/apps.py index 32fab76d..854665fd 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/apps.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/apps.py @@ -2,14 +2,11 @@ from django.apps import AppConfig class UsersAppConfig(AppConfig): - name = "{{cookiecutter.project_slug}}.users" + + name = "{{ cookiecutter.project_slug }}.users" verbose_name = "Users" def ready(self): - """Override this to put in: - Users system checks - Users signal registration - """ try: import users.signals # noqa F401 except ImportError: diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/forms.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/forms.py new file mode 100644 index 00000000..7bba81ff --- /dev/null +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/forms.py @@ -0,0 +1,31 @@ +from django.contrib.auth import get_user_model, forms +from django.core.exceptions import ValidationError +from django.utils.translation import ugettext_lazy as _ + +User = get_user_model() + + +class UserChangeForm(forms.UserChangeForm): + + class Meta(forms.UserChangeForm.Meta): + model = User + + +class UserCreationForm(forms.UserCreationForm): + + error_message = forms.UserCreationForm.error_messages.update( + {"duplicate_username": _("This username has already been taken.")} + ) + + class Meta(forms.UserCreationForm.Meta): + model = User + + def clean_username(self): + username = self.cleaned_data["username"] + + try: + User.objects.get(username=username) + except User.DoesNotExist: + return username + + raise ValidationError(self.error_messages["duplicate_username"]) diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/models.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/models.py index 30475871..8f07b15a 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/models.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/models.py @@ -1,5 +1,5 @@ from django.contrib.auth.models import AbstractUser -from django.db import models +from django.db.models import CharField from django.urls import reverse from django.utils.translation import ugettext_lazy as _ @@ -8,10 +8,7 @@ class User(AbstractUser): # First Name and Last Name do not cover name patterns # around the globe. - name = models.CharField(_("Name of User"), blank=True, max_length=255) - - def __str__(self): - return self.username + name = CharField(_("Name of User"), blank=True, max_length=255) def get_absolute_url(self): return reverse("users:detail", kwargs={"username": self.username}) diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/factories.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/factories.py index 8a871b64..00990576 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/factories.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/factories.py @@ -1,11 +1,29 @@ -import factory +from typing import Any, Sequence + +from django.contrib.auth import get_user_model +from factory import DjangoModelFactory, Faker, post_generation -class UserFactory(factory.django.DjangoModelFactory): - username = factory.Sequence(lambda n: f"user-{n}") - email = factory.Sequence(lambda n: f"user-{n}@example.com") - password = factory.PostGenerationMethodCall("set_password", "password") +class UserFactory(DjangoModelFactory): + + username = Faker("user_name") + email = Faker("email") + name = Faker("name") + + @post_generation + def password(self, create: bool, extracted: Sequence[Any], **kwargs): + password = Faker( + "password", + length=42, + special_chars=True, + digits=True, + upper_case=True, + lower_case=True, + ).generate( + extra_kwargs={} + ) + self.set_password(password) class Meta: - model = "users.User" - django_get_or_create = ("username",) + model = get_user_model() + django_get_or_create = ["username"] diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/test_admin.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/test_admin.py deleted file mode 100644 index a3307103..00000000 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/test_admin.py +++ /dev/null @@ -1,44 +0,0 @@ -from test_plus.test import TestCase - -from ..admin import MyUserCreationForm - - -class TestMyUserCreationForm(TestCase): - - def setUp(self): - self.user = self.make_user("notalamode", "notalamodespassword") - - def test_clean_username_success(self): - # Instantiate the form with a new username - form = MyUserCreationForm( - { - "username": "alamode", - "password1": "7jefB#f@Cc7YJB]2v", - "password2": "7jefB#f@Cc7YJB]2v", - } - ) - # Run is_valid() to trigger the validation - valid = form.is_valid() - self.assertTrue(valid) - - # Run the actual clean_username method - username = form.clean_username() - self.assertEqual("alamode", username) - - def test_clean_username_false(self): - # Instantiate the form with the same username as self.user - form = MyUserCreationForm( - { - "username": self.user.username, - "password1": "notalamodespassword", - "password2": "notalamodespassword", - } - ) - # Run is_valid() to trigger the validation, which is going to fail - # because the username is already taken - valid = form.is_valid() - self.assertFalse(valid) - - # The form.errors dict should contain a single error called 'username' - self.assertTrue(len(form.errors) == 1) - self.assertTrue("username" in form.errors) diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/test_forms.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/test_forms.py new file mode 100644 index 00000000..e8066164 --- /dev/null +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/test_forms.py @@ -0,0 +1,41 @@ +import pytest + +from {{ cookiecutter.project_slug }}.users.forms import UserCreationForm +from {{ cookiecutter.project_slug }}.users.tests.factories import UserFactory + +pytestmark = pytest.mark.django_db + + +class TestUserCreationForm: + + def test_clean_username(self): + # A user with proto_user params does not exist yet. + proto_user = UserFactory.build() + + form = UserCreationForm( + { + "username": proto_user.username, + "password1": proto_user._password, + "password2": proto_user._password, + } + ) + + assert form.is_valid() + assert form.clean_username() == proto_user.username + + # Creating a user. + form.save() + + # The user with proto_user params already exists, + # hence cannot be created. + form = UserCreationForm( + { + "username": proto_user.username, + "password1": proto_user._password, + "password2": proto_user._password, + } + ) + + assert not form.is_valid() + assert len(form.errors) == 1 + assert "username" in form.errors diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/test_models.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/test_models.py index 13121a01..54863632 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/test_models.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/test_models.py @@ -1,16 +1,8 @@ -from test_plus.test import TestCase +import pytest +from django.conf import settings + +pytestmark = pytest.mark.django_db -class TestUser(TestCase): - - def setUp(self): - self.user = self.make_user() - - def test__str__(self): - self.assertEqual( - self.user.__str__(), - "testuser", # This is the default username for self.make_user() - ) - - def test_get_absolute_url(self): - self.assertEqual(self.user.get_absolute_url(), "/users/testuser/") +def test_user_get_absolute_url(user: settings.AUTH_USER_MODEL): + assert user.get_absolute_url() == f"/users/{user.username}/" 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 6b072436..20bd3dba 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,44 +1,28 @@ +import pytest +from django.conf import settings from django.urls import reverse, resolve -from test_plus.test import TestCase +pytestmark = pytest.mark.django_db -class TestUserURLs(TestCase): - """Test URL patterns for users app.""" +def test_detail(user: settings.AUTH_USER_MODEL): + assert ( + reverse("users:detail", kwargs={"username": user.username}) + == f"/users/{user.username}/" + ) + assert resolve(f"/users/{user.username}/").view_name == "users:detail" - def setUp(self): - self.user = self.make_user() - def test_list_reverse(self): - """users:list should reverse to /users/.""" - self.assertEqual(reverse("users:list"), "/users/") +def test_list(): + assert reverse("users:list") == "/users/" + assert resolve("/users/").view_name == "users:list" - def test_list_resolve(self): - """/users/ should resolve to users:list.""" - self.assertEqual(resolve("/users/").view_name, "users:list") - def test_redirect_reverse(self): - """users:redirect should reverse to /users/~redirect/.""" - self.assertEqual(reverse("users:redirect"), "/users/~redirect/") +def test_update(): + assert reverse("users:update") == "/users/~update/" + assert resolve("/users/~update/").view_name == "users:update" - def test_redirect_resolve(self): - """/users/~redirect/ should resolve to users:redirect.""" - self.assertEqual(resolve("/users/~redirect/").view_name, "users:redirect") - def test_detail_reverse(self): - """users:detail should reverse to /users/testuser/.""" - self.assertEqual( - reverse("users:detail", kwargs={"username": "testuser"}), "/users/testuser/" - ) - - def test_detail_resolve(self): - """/users/testuser/ should resolve to users:detail.""" - self.assertEqual(resolve("/users/testuser/").view_name, "users:detail") - - def test_update_reverse(self): - """users:update should reverse to /users/~update/.""" - self.assertEqual(reverse("users:update"), "/users/~update/") - - def test_update_resolve(self): - """/users/~update/ should resolve to users:update.""" - self.assertEqual(resolve("/users/~update/").view_name, "users:update") +def test_redirect(): + assert reverse("users:redirect") == "/users/~redirect/" + assert resolve("/users/~redirect/").view_name == "users:redirect" 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 07cbda66..0992e462 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 @@ -1,52 +1,53 @@ +import pytest +from django.conf import settings from django.test import RequestFactory -from test_plus.test import TestCase +from {{ cookiecutter.project_slug }}.users.views import UserRedirectView, UserUpdateView -from ..views import UserRedirectView, UserUpdateView +pytestmark = pytest.mark.django_db -class BaseUserTestCase(TestCase): +class TestUserUpdateView: + """ + TODO: + extracting view initialization code as class-scoped fixture + would be great if only pytest-django supported non-function-scoped + fixture db access -- this is a work-in-progress for now: + https://github.com/pytest-dev/pytest-django/pull/258 + """ - def setUp(self): - self.user = self.make_user() - self.factory = RequestFactory() + def test_get_success_url( + self, user: settings.AUTH_USER_MODEL, request_factory: RequestFactory + ): + view = UserUpdateView() + request = request_factory.get("/fake-url/") + request.user = user - -class TestUserRedirectView(BaseUserTestCase): - - def test_get_redirect_url(self): - # Instantiate the view directly. Never do this outside a test! - view = UserRedirectView() - # Generate a fake request - request = self.factory.get("/fake-url") - # Attach the user to the request - request.user = self.user - # Attach the request to the view view.request = request - # Expect: '/users/testuser/', as that is the default username for - # self.make_user() - self.assertEqual(view.get_redirect_url(), "/users/testuser/") + + assert view.get_success_url() == f"/users/{user.username}/" + + def test_get_object( + self, user: settings.AUTH_USER_MODEL, request_factory: RequestFactory + ): + view = UserUpdateView() + request = request_factory.get("/fake-url/") + request.user = user + + view.request = request + + assert view.get_object() == user -class TestUserUpdateView(BaseUserTestCase): +class TestUserRedirectView: - def setUp(self): - # call BaseUserTestCase.setUp() - super(TestUserUpdateView, self).setUp() - # Instantiate the view directly. Never do this outside a test! - self.view = UserUpdateView() - # Generate a fake request - request = self.factory.get("/fake-url") - # Attach the user to the request - request.user = self.user - # Attach the request to the view - self.view.request = request + def test_get_redirect_url( + self, user: settings.AUTH_USER_MODEL, request_factory: RequestFactory + ): + view = UserRedirectView() + request = request_factory.get("/fake-url") + request.user = user - def test_get_success_url(self): - # Expect: '/users/testuser/', as that is the default username for - # self.make_user() - self.assertEqual(self.view.get_success_url(), "/users/testuser/") + view.request = request - def test_get_object(self): - # Expect: self.user, as that is the request's user object - self.assertEqual(self.view.get_object(), self.user) + assert view.get_redirect_url() == f"/users/{user.username}/" diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/urls.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/urls.py index fef93ad5..2502a0c0 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/urls.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/urls.py @@ -1,15 +1,16 @@ from django.urls import path -from . import views +from {{ cookiecutter.project_slug }}.users.views import ( + user_list_view, + user_redirect_view, + user_update_view, + user_detail_view, +) app_name = "users" urlpatterns = [ - path("", view=views.UserListView.as_view(), name="list"), - path("~redirect/", view=views.UserRedirectView.as_view(), name="redirect"), - path("~update/", view=views.UserUpdateView.as_view(), name="update"), - path( - "/", - view=views.UserDetailView.as_view(), - name="detail", - ), + path("", view=user_list_view, name="list"), + path("~redirect/", view=user_redirect_view, name="redirect"), + path("~update/", view=user_update_view, name="update"), + path("/", view=user_detail_view, name="detail"), ] diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/views.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/views.py index a9038b71..35e26e94 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/views.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/views.py @@ -1,43 +1,52 @@ +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, ListView, RedirectView, UpdateView -from .models import User +User = get_user_model() class UserDetailView(LoginRequiredMixin, DetailView): + model = User - # These next two lines tell the view to index lookups by username slug_field = "username" slug_url_kwarg = "username" +user_detail_view = UserDetailView.as_view() + + +class UserListView(LoginRequiredMixin, ListView): + + model = User + slug_field = "username" + slug_url_kwarg = "username" + + +user_list_view = UserListView.as_view() + + +class UserUpdateView(LoginRequiredMixin, UpdateView): + + model = User + fields = ["name"] + + def get_success_url(self): + return reverse("users:detail", kwargs={"username": self.request.user.username}) + + def get_object(self): + return User.objects.get(username=self.request.user.username) + + +user_update_view = UserUpdateView.as_view() + + class UserRedirectView(LoginRequiredMixin, RedirectView): + permanent = False def get_redirect_url(self): return reverse("users:detail", kwargs={"username": self.request.user.username}) -class UserUpdateView(LoginRequiredMixin, UpdateView): - - fields = ["name"] - - # we already imported User in the view code above, remember? - model = User - - # send the user back to their own page after a successful update - - def get_success_url(self): - return reverse("users:detail", kwargs={"username": self.request.user.username}) - - def get_object(self): - # Only get the User record for the user making the request - return User.objects.get(username=self.request.user.username) - - -class UserListView(LoginRequiredMixin, ListView): - model = User - # These next two lines tell the view to index lookups by username - slug_field = "username" - slug_url_kwarg = "username" +user_redirect_view = UserRedirectView.as_view()