Merge commit '4b6e2b503a4fdc9739574c78b7f953c18def855a'

This commit is contained in:
Trung Dong Huynh 2018-08-29 17:28:29 +01:00
commit 7510e009e6
49 changed files with 522 additions and 428 deletions

View File

@ -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
~~~~~~~~~~~~~~

View File

@ -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/

View File

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

View File

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

View File

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

View File

@ -94,6 +94,7 @@ 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).
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.

View File

@ -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":

View File

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

View File

@ -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; }

View File

@ -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 %}

View File

@ -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 %}

View File

@ -1,18 +1,14 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="tests - all" type="DjangoTestsConfigurationType" factoryName="Django tests" singleton="true">
<configuration default="false" name="pytest: ." type="tests" factoryName="py.test" singleton="true">
<module name="{{ cookiecutter.project_slug }}" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
<env name="DJANGO_SETTINGS_MODULE" value="config.settings.test" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" enabled="false" sample_coverage="true" runner="coverage.py" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<PathMappingSettings>
<option name="pathMappings">
<list>
@ -20,11 +16,10 @@
</list>
</option>
</PathMappingSettings>
<option name="TARGET" value="." />
<option name="SETTINGS_FILE" value="" />
<option name="CUSTOM_SETTINGS" value="false" />
<option name="USE_OPTIONS" value="false" />
<option name="OPTIONS" value="" />
<option name="_new_keywords" value="&quot;&quot;" />
<option name="_new_additionalArguments" value="&quot;&quot;" />
<option name="_new_target" value="&quot;.&quot;" />
<option name="_new_targetType" value="&quot;PATH&quot;" />
<method />
</configuration>
</component>

View File

@ -1,18 +1,14 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="tests - module: users" type="DjangoTestsConfigurationType" factoryName="Django tests" singleton="true">
<configuration default="false" name="pytest: users" type="tests" factoryName="py.test" singleton="true">
<module name="{{ cookiecutter.project_slug }}" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
<env name="DJANGO_SETTINGS_MODULE" value="config.settings.test" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" enabled="false" sample_coverage="true" runner="coverage.py" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<PathMappingSettings>
<option name="pathMappings">
<list>
@ -20,11 +16,10 @@
</list>
</option>
</PathMappingSettings>
<option name="TARGET" value="{{ cookiecutter.project_slug }}.users" />
<option name="SETTINGS_FILE" value="" />
<option name="CUSTOM_SETTINGS" value="false" />
<option name="USE_OPTIONS" value="false" />
<option name="OPTIONS" value="" />
<option name="_new_keywords" value="&quot;&quot;" />
<option name="_new_additionalArguments" value="&quot;&quot;" />
<option name="_new_target" value="&quot;./{{ cookiecutter.project_slug }}/users/&quot;" />
<option name="_new_targetType" value="&quot;PATH&quot;" />
<method />
</configuration>
</component>

View File

@ -1,30 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="tests - class: TestUser" type="DjangoTestsConfigurationType" factoryName="Django tests" singleton="true">
<module name="{{ cookiecutter.project_slug }}" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
<env name="DJANGO_SETTINGS_MODULE" value="config.settings.test" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" enabled="false" sample_coverage="true" runner="coverage.py" />
<PathMappingSettings>
<option name="pathMappings">
<list>
<mapping local-root="$PROJECT_DIR$" remote-root="/app" />
</list>
</option>
</PathMappingSettings>
<option name="TARGET" value="{{ cookiecutter.project_slug }}.users.tests.test_models.TestUser" />
<option name="SETTINGS_FILE" value="" />
<option name="CUSTOM_SETTINGS" value="false" />
<option name="USE_OPTIONS" value="false" />
<option name="OPTIONS" value="" />
<method />
</configuration>
</component>

View File

@ -1,30 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="tests - file: test_models" type="DjangoTestsConfigurationType" factoryName="Django tests" singleton="true">
<module name="{{ cookiecutter.project_slug }}" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
<env name="DJANGO_SETTINGS_MODULE" value="config.settings.test" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" enabled="false" sample_coverage="true" runner="coverage.py" />
<PathMappingSettings>
<option name="pathMappings">
<list>
<mapping local-root="$PROJECT_DIR$" remote-root="/app" />
</list>
</option>
</PathMappingSettings>
<option name="TARGET" value="{{ cookiecutter.project_slug }}.users.tests.test_models" />
<option name="SETTINGS_FILE" value="" />
<option name="CUSTOM_SETTINGS" value="false" />
<option name="USE_OPTIONS" value="false" />
<option name="OPTIONS" value="" />
<method />
</configuration>
</component>

View File

@ -1,30 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="tests - specific: test_get_absolute_url" type="DjangoTestsConfigurationType" factoryName="Django tests" singleton="true">
<module name="{{ cookiecutter.project_slug }}" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
<env name="DJANGO_SETTINGS_MODULE" value="config.settings.test" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" enabled="false" sample_coverage="true" runner="coverage.py" />
<PathMappingSettings>
<option name="pathMappings">
<list>
<mapping local-root="$PROJECT_DIR$" remote-root="/app" />
</list>
</option>
</PathMappingSettings>
<option name="TARGET" value="{{ cookiecutter.project_slug }}.users.tests.test_models.TestUser.test_get_absolute_url" />
<option name="SETTINGS_FILE" value="" />
<option name="CUSTOM_SETTINGS" value="false" />
<option name="USE_OPTIONS" value="false" />
<option name="OPTIONS" value="" />
<method />
</configuration>
</component>

View File

@ -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 %}

View File

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

View File

@ -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}"

View File

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

View File

@ -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}"

View File

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

View File

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

View File

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

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

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

View File

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

View File

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

View File

@ -1 +1 @@
python-3.6.5
python-3.6.6

View File

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

View File

@ -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()

View File

@ -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');

View File

@ -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)

View File

@ -17,8 +17,8 @@
{% block css %}
{% endraw %}{% if cookiecutter.custom_bootstrap_compilation == "n" %}{% raw %}
<!-- Latest compiled and minified Bootstrap 4 beta CSS -->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css" integrity="sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M" crossorigin="anonymous">
<!-- Latest compiled and minified Bootstrap 4.1.1 CSS -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.1/css/bootstrap.min.css" integrity="sha384-WskhaSGFgHYWDcbwN70/dfYBj47jz9qbsMId/iRN3ewGhXQFZCSftd1LZCfmhktB" crossorigin="anonymous">
{% endraw %}{% endif %}{% raw %}
<!-- Your stuff: Third-party CSS libraries go here -->
@ -102,10 +102,10 @@
<script src="{% static 'js/vendors.js' %}"></script>
{% endraw %}{% if cookiecutter.use_compressor == "y" %}{% raw %}{% endcompress %}{% endraw %}{% endif %}{% raw %}
{% endraw %}{% else %}{% raw %}
<!-- Required by Bootstrap v4 beta -->
<script src="https://code.jquery.com/jquery-3.2.1.slim.min.js" integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.11.0/umd/popper.min.js" integrity="sha384-b/U6ypiBEHpOf/4+1nzFpr53nxSS+GLCkfwBdFNTxtclqqenISfwAzpKaMNFNmj4" crossorigin="anonymous"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/js/bootstrap.min.js" integrity="sha384-h0AbiXch4ZDo7tp9hKZ4TsHbi047NrKGLO3SEJAg45jXxnGIfYzk4Si90RDIqNm1" crossorigin="anonymous"></script>
<!-- Required by Bootstrap v4.1.1 -->
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js" integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.1/js/bootstrap.min.js" integrity="sha384-smHYKdLADwkXOn1EmN1qk/HfnUcbVRZyYmZ4qpPea6sjB/pTJ0euyQp0Mk8ck+5T" crossorigin="anonymous"></script>
<!-- Your stuff: Third-party javascript libraries go here -->
{% endraw %}{% endif %}{% raw %}

View File

@ -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)

View File

@ -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"]

View File

@ -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:

View File

@ -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"])

View File

@ -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})

View File

@ -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"]

View File

@ -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)

View File

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

View File

@ -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}/"

View File

@ -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"

View File

@ -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}/"

View File

@ -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(
"<str:username>/",
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("<str:username>/", view=user_detail_view, name="detail"),
]

View File

@ -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()