From 0a76329f2e729e83f0090a4508bebb947a6baee7 Mon Sep 17 00:00:00 2001 From: Sanyam Khurana <8039608+CuriousLearner@users.noreply.github.com> Date: Fri, 6 Jan 2023 15:41:38 +0530 Subject: [PATCH 1/9] feat(docker): Use GDAL/postgis when postgis is enabled (#464) > Why was this change necessary? If postgis is enabled, the docker setup does not work. > How does it address the problem? This PR enables use of postgis docker image for the database container & install GDAL for Django to communicate with the DB. > Are there any side effects? None. --- .../compose/dev/django/Dockerfile | 3 +++ .../compose/dev/postgres/Dockerfile | 2 +- {{cookiecutter.github_repository}}/compose/local/Dockerfile | 3 +++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/{{cookiecutter.github_repository}}/compose/dev/django/Dockerfile b/{{cookiecutter.github_repository}}/compose/dev/django/Dockerfile index 72027a2a..251ecd78 100644 --- a/{{cookiecutter.github_repository}}/compose/dev/django/Dockerfile +++ b/{{cookiecutter.github_repository}}/compose/dev/django/Dockerfile @@ -28,6 +28,9 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ # Versatile image field & pillow \ libmagic1 \ libmagic-dev \ + {% if cookiecutter.add_postgis.lower() == "y" %}# GDAL postgres requirements + gdal-bin \ + libgdal-dev \{% endif %} # cleaning up unused files && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ && rm -rf /var/lib/apt/lists/* diff --git a/{{cookiecutter.github_repository}}/compose/dev/postgres/Dockerfile b/{{cookiecutter.github_repository}}/compose/dev/postgres/Dockerfile index 9a3f7065..2bde1a0e 100644 --- a/{{cookiecutter.github_repository}}/compose/dev/postgres/Dockerfile +++ b/{{cookiecutter.github_repository}}/compose/dev/postgres/Dockerfile @@ -1,4 +1,4 @@ -FROM postgres:13 +{% if cookiecutter.add_postgis.lower() == "y" %}FROM postgis/postgis:13-3.3{% else %}FROM {{cookiecutter.add_postgis}}postgres:13{% endif %} COPY ./compose/dev/postgres/maintenance /usr/local/bin/maintenance RUN chmod +x /usr/local/bin/maintenance/* diff --git a/{{cookiecutter.github_repository}}/compose/local/Dockerfile b/{{cookiecutter.github_repository}}/compose/local/Dockerfile index 29f5045d..76c6640c 100644 --- a/{{cookiecutter.github_repository}}/compose/local/Dockerfile +++ b/{{cookiecutter.github_repository}}/compose/local/Dockerfile @@ -27,6 +27,9 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ # Versatile image field & pillow \ libmagic1 \ libmagic-dev \ + {% if cookiecutter.add_postgis.lower() == "y" %}# GDAL postgres requirements + gdal-bin \ + libgdal-dev \{% endif %} # cleaning up unused files && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ && rm -rf /var/lib/apt/lists/* From ecd043536e66cb9a31cc22330175291e1d7539a5 Mon Sep 17 00:00:00 2001 From: Sanyam Khurana <8039608+CuriousLearner@users.noreply.github.com> Date: Mon, 6 Feb 2023 18:54:55 +0530 Subject: [PATCH 2/9] fix(compose): Use postgis protocol for connection (#467) > Why was this change necessary? If postgis is enabled, the compose setup does not correctly use the postgis protocol for DB connection. > How does it address the problem? Depending on if `add_postgis` is chosen, the connection string will have corresponding protocol chosen. > Are there any side effects? None. --- .../compose/dev/django/entrypoint | 2 +- {{cookiecutter.github_repository}}/compose/local/entrypoint | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/{{cookiecutter.github_repository}}/compose/dev/django/entrypoint b/{{cookiecutter.github_repository}}/compose/dev/django/entrypoint index 4ec8b7a0..c881cee3 100644 --- a/{{cookiecutter.github_repository}}/compose/dev/django/entrypoint +++ b/{{cookiecutter.github_repository}}/compose/dev/django/entrypoint @@ -12,7 +12,7 @@ if [ -z "${POSTGRES_USER}" ]; then base_postgres_image_default_user='postgres' export POSTGRES_USER="${base_postgres_image_default_user}" fi -export DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}" +export DATABASE_URL="{% if cookiecutter.add_postgis.lower() == 'y' %}postgis{% else %}postgres{% endif %}://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}" postgres_ready() { python << END diff --git a/{{cookiecutter.github_repository}}/compose/local/entrypoint b/{{cookiecutter.github_repository}}/compose/local/entrypoint index a25d3749..54e8fe0d 100644 --- a/{{cookiecutter.github_repository}}/compose/local/entrypoint +++ b/{{cookiecutter.github_repository}}/compose/local/entrypoint @@ -12,7 +12,7 @@ if [ -z "${POSTGRES_USER}" ]; then base_postgres_image_default_user='postgres' export POSTGRES_USER="${base_postgres_image_default_user}" fi -export DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}" +export DATABASE_URL="{% if cookiecutter.add_postgis.lower() == 'y' %}postgis{% else %}postgres{% endif %}://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}" postgres_ready() { python << END From 43e41a2840a1d72fbc300c411a0b96ec307636d9 Mon Sep 17 00:00:00 2001 From: Sanyam Khurana <8039608+CuriousLearner@users.noreply.github.com> Date: Mon, 27 Feb 2023 21:56:59 +0530 Subject: [PATCH 3/9] fix(Makefile): Update poetry export command to use --with option (#468) > Why was this change necessary? The `--dev` option in `poetry export` command is deprecated. A warning is presented while using this option. > How does it address the problem? It updates the `poetry export` command to use the newer `--with` option. > Are there any side effects? None. --- README.md | 2 +- {{cookiecutter.github_repository}}/Makefile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b8eb5b20..9cfc99b1 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ For compatibility, `requirements.txt` and `requirements_dev.txt` can be updated ```bash poetry export -f requirements.txt -o requirements.txt -poetry export -f requirements.txt -o requirements_dev.txt --dev +poetry export -f requirements.txt -o requirements_dev.txt --with dev ``` or diff --git a/{{cookiecutter.github_repository}}/Makefile b/{{cookiecutter.github_repository}}/Makefile index b5a3e75b..57f8b5ee 100644 --- a/{{cookiecutter.github_repository}}/Makefile +++ b/{{cookiecutter.github_repository}}/Makefile @@ -51,7 +51,7 @@ regenerate: ## Delete and create new database. .PHONY: regenerate generate_requirements: - poetry export -f requirements.txt -o requirements_dev.txt --dev + poetry export -f requirements.txt -o requirements_dev.txt --with dev poetry export -f requirements.txt -o requirements.txt update_libs: ## update libs + generate new lockfile & requirements From 56334b6f7ace00fd6a9f6c4906559d546deb11bc Mon Sep 17 00:00:00 2001 From: Sanyam Khurana <8039608+CuriousLearner@users.noreply.github.com> Date: Fri, 3 Mar 2023 17:25:09 +0530 Subject: [PATCH 4/9] fix(docker-start): Redirect access/error logs to std-out (#470) > Why was this change necessary? When deployed on services like ECS, the logs aren't captured since they are not on the standard output and standard error streams. > How does it address the problem? Redirect output/error to std-out/std-err streams. > Are there any side effects? None. --- {{cookiecutter.github_repository}}/compose/dev/django/start | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/{{cookiecutter.github_repository}}/compose/dev/django/start b/{{cookiecutter.github_repository}}/compose/dev/django/start index d2c305b9..a1b3d54f 100644 --- a/{{cookiecutter.github_repository}}/compose/dev/django/start +++ b/{{cookiecutter.github_repository}}/compose/dev/django/start @@ -7,4 +7,4 @@ set -o nounset python /app/manage.py collectstatic --noinput # /usr/local/bin/gunicorn asgi --bind 0.0.0.0:5000 --chdir=/app -k uvicorn.workers.UvicornWorker -/usr/local/bin/gunicorn wsgi --bind 0.0.0.0:5000 --chdir=/app +/usr/local/bin/gunicorn wsgi --bind 0.0.0.0:5000 --chdir=/app --access-logfile - --error-logfile - From 72953301679cb71cf07f109783d00406d3e897d3 Mon Sep 17 00:00:00 2001 From: Suneet Choudhary Date: Tue, 28 Mar 2023 14:27:05 +0530 Subject: [PATCH 5/9] feat: add GraphQL API implementation using django-graphene (#449) > Are there any side effects? None --------- Co-authored-by: Saurabh Kumar Co-authored-by: Sanyam Khurana Co-authored-by: sahithchandan --- README.md | 1 + cookiecutter-test-config.yaml | 4 + cookiecutter.json | 1 + hooks/post_gen_project.sh | 6 + run_test.sh | 2 +- .../.env.sample | 2 +- .../compose/dev/django/Dockerfile | 2 +- .../compose/local/Dockerfile | 2 +- .../docs/graphql/0-overview.md | 64 +++++ .../docs/graphql/1-auth.md | 144 +++++++++++ .../docs/graphql/2-users.md | 79 ++++++ .../docs/graphql/errors.md | 27 ++ .../docs/graphql/errors_handling.md | 126 +++++++++ {{cookiecutter.github_repository}}/mkdocs.yml | 10 +- .../pyproject.toml | 23 +- .../settings/common.py | 43 ++- .../tests/conftest.py | 60 +++++ .../tests/graphql/__init__.py | 1 + .../tests/graphql/test_auth.py | 244 ++++++++++++++++++ .../tests/graphql/test_current_user_api.py | 41 +++ .../tests/graphql/test_users_list_api.py | 66 +++++ .../base/api/schemas.py | 1 + .../graphql/__init__.py | 1 + .../graphql/api.py | 16 ++ .../graphql/decorators.py | 37 +++ .../graphql/middleware.py | 25 ++ .../graphql/users/mutations.py | 121 +++++++++ .../graphql/users/resolvers.py | 5 + .../graphql/users/schema.py | 44 ++++ .../graphql/users/types.py | 34 +++ .../graphql/utils.py | 61 +++++ .../{{cookiecutter.main_module}}/urls.py | 23 +- .../users/auth/backends.py | 32 +-- .../users/auth/utils.py | 14 + .../users/services.py | 4 + 35 files changed, 1322 insertions(+), 44 deletions(-) create mode 100644 cookiecutter-test-config.yaml create mode 100644 {{cookiecutter.github_repository}}/docs/graphql/0-overview.md create mode 100644 {{cookiecutter.github_repository}}/docs/graphql/1-auth.md create mode 100644 {{cookiecutter.github_repository}}/docs/graphql/2-users.md create mode 100644 {{cookiecutter.github_repository}}/docs/graphql/errors.md create mode 100644 {{cookiecutter.github_repository}}/docs/graphql/errors_handling.md create mode 100644 {{cookiecutter.github_repository}}/tests/graphql/__init__.py create mode 100644 {{cookiecutter.github_repository}}/tests/graphql/test_auth.py create mode 100644 {{cookiecutter.github_repository}}/tests/graphql/test_current_user_api.py create mode 100644 {{cookiecutter.github_repository}}/tests/graphql/test_users_list_api.py create mode 100644 {{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/__init__.py create mode 100644 {{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/api.py create mode 100644 {{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/decorators.py create mode 100644 {{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/middleware.py create mode 100644 {{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/users/mutations.py create mode 100644 {{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/users/resolvers.py create mode 100644 {{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/users/schema.py create mode 100644 {{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/users/types.py create mode 100644 {{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/utils.py diff --git a/README.md b/README.md index 9cfc99b1..0a1dee08 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ - Postgis Setup - Newrelic - Sentry +- [GraphQL](https://graphql.org/) support via [Graphene-Django](https://docs.graphene-python.org/projects/django/en/latest/) (Optional) - pre-commit hooks diff --git a/cookiecutter-test-config.yaml b/cookiecutter-test-config.yaml new file mode 100644 index 00000000..9035c70a --- /dev/null +++ b/cookiecutter-test-config.yaml @@ -0,0 +1,4 @@ +default_context: + enable_whitenoise: "y" + add_celery: "y" + add_graphql: "y" diff --git a/cookiecutter.json b/cookiecutter.json index e61e18ef..93a561b9 100644 --- a/cookiecutter.json +++ b/cookiecutter.json @@ -18,6 +18,7 @@ , "add_sentry": "y" , "add_django_auth_wall": "y" , "add_celery": "n" + , "add_graphql": "n" , "add_pre_commit": "y" , "add_docker": "y" , "pagination": ["LimitOffsetPagination", "CursorPagination"] diff --git a/hooks/post_gen_project.sh b/hooks/post_gen_project.sh index af6c0a0a..c2a4b1a0 100755 --- a/hooks/post_gen_project.sh +++ b/hooks/post_gen_project.sh @@ -45,6 +45,12 @@ if echo "{{ cookiecutter.add_docker }}" | grep -iq "^n"; then rm -rf .envs compose local.yml dev.yml docs/backend/docker_setup.md fi +if echo "{{ cookiecutter.add_graphql }}" | grep -iq "^n"; then + rm -rf {{ cookiecutter.main_module }}/graphql + rm -rm {{ cookiecutter.main_module }}/docs/graphql + rm -rf tests/graphql +fi + if echo "$yn" | grep -iq "^y"; then echo "==> Checking system dependencies. You may need to enter your sudo password." diff --git a/run_test.sh b/run_test.sh index d32c0d2e..b3e4b1ff 100755 --- a/run_test.sh +++ b/run_test.sh @@ -19,7 +19,7 @@ fi rm -rf hello-world-backend/; # Generate new code, (it also creates db, migrate and install dependencies) -yes 'y' | cookiecutter . --no-input +yes 'y' | cookiecutter . --no-input --config-file cookiecutter-test-config.yaml # Run the tests present inside generate project cd hello-world-backend; diff --git a/{{cookiecutter.github_repository}}/.env.sample b/{{cookiecutter.github_repository}}/.env.sample index 26ccf0f1..45d1c449 100644 --- a/{{cookiecutter.github_repository}}/.env.sample +++ b/{{cookiecutter.github_repository}}/.env.sample @@ -28,7 +28,7 @@ # DJANGO_AWS_S3_HOST='' # DJANGO_AWS_S3_REGION_NAME='' -# Django Rest Framework +# APIs # ============================== # API_DEBUG=False diff --git a/{{cookiecutter.github_repository}}/compose/dev/django/Dockerfile b/{{cookiecutter.github_repository}}/compose/dev/django/Dockerfile index 251ecd78..6f9adf0b 100644 --- a/{{cookiecutter.github_repository}}/compose/dev/django/Dockerfile +++ b/{{cookiecutter.github_repository}}/compose/dev/django/Dockerfile @@ -3,7 +3,7 @@ ARG PYTHON_VERSION=3.9-slim-buster # define an alias for the specfic python version used in this file. FROM python:${PYTHON_VERSION} as python -ENV POETRY_VERSION=1.2.0 +ENV POETRY_VERSION=1.3.2 ARG BUILD_ENVIRONMENT=dev ARG APP_HOME=/app diff --git a/{{cookiecutter.github_repository}}/compose/local/Dockerfile b/{{cookiecutter.github_repository}}/compose/local/Dockerfile index 76c6640c..082e2ca1 100644 --- a/{{cookiecutter.github_repository}}/compose/local/Dockerfile +++ b/{{cookiecutter.github_repository}}/compose/local/Dockerfile @@ -6,7 +6,7 @@ FROM python:${PYTHON_VERSION} as python ARG BUILD_ENVIRONMENT=local ARG APP_HOME=/app -ENV POETRY_VERSION=1.2.0 +ENV POETRY_VERSION=1.3.2 ENV PYTHONUNBUFFERED 1 ENV PYTHONDONTWRITEBYTECODE 1 ENV BUILD_ENV ${BUILD_ENVIRONMENT} diff --git a/{{cookiecutter.github_repository}}/docs/graphql/0-overview.md b/{{cookiecutter.github_repository}}/docs/graphql/0-overview.md new file mode 100644 index 00000000..4c665f30 --- /dev/null +++ b/{{cookiecutter.github_repository}}/docs/graphql/0-overview.md @@ -0,0 +1,64 @@ +# GraphQL + +## Authentication + +For all auth related requests (`login`, `register` etc), clients need to refer to [docs mentioned here](1-auth.md). +For clients to make authenticated requests, the `auth_token` value (received from the `login` endpoint) should be included in the `Authorization` HTTP header. The value should be prefixed by the string literal `Bearer`, with whitespace separating the two strings. + +## API Endpoint + +``` +POST /graphql +``` + +All the queries and mutations will be a POST request to the above endpoint. We've documented a sample header and payload to be sent with the request. + +__Headers__ + +```json +{ + "Authorization": "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2F1dGhlbnRpY2F0aW9uX2lkIjoiNzY1MjE3YTgtNzU5OS00ZTI1LTljMjQtYjdjOTJlODc4MjAxIn0.972Irua8Ql0NRf_KxgYI7q1imPBkf2XJG25L94JM8Hw" +} +``` + +__Payload__ + +```json +{ + "query": "query MyInfo { me { id firstName lastName email } }", + "variables":null, + "operationName":"MyInfo" +} +``` + +## Pagination + +Pagination is required in most queries that return lists of items in the GraphQL API. It limits the number of results returned by the server to a more manageable size and avoids data flow disruptions. + +There are two types of lists in GraphQL: + +- `[Foo]` is a simple list. It is used to query a list containing several items. An excellent example of a simple list could be a query for product variants which returns a list with a manageable number of results. +- `FooConnection` represents a more complex list. When queried, it will return an unknown or large number of results. + +Pagination is used to help you handle large amounts of items returned by the connection list type. + +The pagination model is based on the [GraphQL Connection Specification](https://relay.dev/graphql/connections.htm). Its schema looks like this: + +``` +type FooConnection { + pageInfo: PageInfo! + edges: [FooEdge!]! +} + +type PageInfo { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + endCursor: String +} + +type FooEdge { + node: Foo! + cursor: String! +} +``` diff --git a/{{cookiecutter.github_repository}}/docs/graphql/1-auth.md b/{{cookiecutter.github_repository}}/docs/graphql/1-auth.md new file mode 100644 index 00000000..3b536337 --- /dev/null +++ b/{{cookiecutter.github_repository}}/docs/graphql/1-auth.md @@ -0,0 +1,144 @@ +# Authentication + +!!!info + For API overview and usages, check out [this page](0-overview.md). + + +## Register + +__Request__ +``` +mutation { + signup ( + input: { + email: "test@example.com", + firstName: "a", + lastName: "b", + password: "password" + } + ) { + user { + id + email + } + } +} +``` + +__Response__ +```json +{ + "data": { + "signup": { + "user": { + "id": "f1b234c8-8bdf-4a33-bfae-a1929c2e8ca0", + "email": "test@example.com" + } + } + } +} +``` + + +## Login +__Request__ +``` +mutation { + login ( + input: { + email: "test@example.com", + password: "password" + } + ) { + user { + id, email, firstName, lastName, authToken + } + } +} +``` + +__Response__ + +```json +{ + "data": { + "login": { + "user": { + "id": "f1b234c8-8bdf-4a33-bfae-a1929c2e8ca0", + "email": "test@example.com", + "firstName": "Dave", + "lastName": "", + "authToken": "eyJhbGciO..." + } + } + } +} +``` + + +## Request Password Reset + +__Request__ +``` +mutation RequestPasswordReset { + passwordReset ( + input: { + email: "test@example.com" + } + ) { + message + } +} +``` + +__Response__ + +```json +{ + "data": { + "passwordReset": { + "message": "Further instructions will be sent to the email if it exists" + } + } +} +``` + + +## Password Change +(requires authentication) + +__Request__ +``` +mutation PasswordChange { + passwordChange ( + input: { + currentPassword: "password", newPassword:"newpassword" + } + ) { + user { + email + firstName + lastName + authToken + } + } +} +``` + +__Response__ + +```json +{ + "data": { + "login": { + "user": { + "id": "f1b234c8-8bdf-4a33-bfae-a1929c2e8ca0", + "email": "test@example.com", + "firstName": "Dave", + "lastName": "", + "authToken": "eyJhbGciO..." + } + } + } +} +``` diff --git a/{{cookiecutter.github_repository}}/docs/graphql/2-users.md b/{{cookiecutter.github_repository}}/docs/graphql/2-users.md new file mode 100644 index 00000000..958d050d --- /dev/null +++ b/{{cookiecutter.github_repository}}/docs/graphql/2-users.md @@ -0,0 +1,79 @@ +## Current User +(requires authentication) + +__Request__ +``` +query { + me { + id + firstName + lastName + email + } +} +``` + +__Response__ + +```json +{ + "data": { + "me": { + "id": "Q3VycmVudFVzZXI6M2MzYjVhMmUtMWM0MC00MTQzLTk1N2ItYjVlYTAzOWU0NzVi", + "first_name": "John", + "last_name": "Hawley", + "email": "john@localhost.com" + } + } +} +``` + + +## All Users +(requires authentication and superuser privilege) + +__Request__ +``` +query { + users { + totalCount, + edgeCount, + edges { + node { + id, + firstName, + lastName + } + } + } +} +``` + +__Response__ + +```json +{ + "data": { + "users": { + "totalCount": 2, + "edgeCount": 2, + "edges": [ + { + "node": { + "id": "VXNlckNvbm5lY3Rpb246M2MzYjVhMmUtMWM0MC00MTQzLTk1N2ItYjVlYTAzOWU0NzVi", + "firstName": "first name", + "lastName": "last name" + } + }, + { + "node": { + "id": "VXNlckNvbm5lY3Rpb246ZjU4N2IyY2EtNThmMS00NTE3LTgyMTEtYzczODA3YTI1ZTU1", + "firstName": "fueled", + "lastName": "user" + } + } + ] + } + } +} +``` diff --git a/{{cookiecutter.github_repository}}/docs/graphql/errors.md b/{{cookiecutter.github_repository}}/docs/graphql/errors.md new file mode 100644 index 00000000..ac6e27a2 --- /dev/null +++ b/{{cookiecutter.github_repository}}/docs/graphql/errors.md @@ -0,0 +1,27 @@ +# Errors + +## Generic Errors + + +For `/graphql` requests, the API will return the error in the following format: + +```json +{ + "errors": [ + { + "message": "You do not have permission to perform this action", + "locations": [ + { + "line": 33, + "column": 3 + } + ], + "path": [ + "users" + ] + } + ] +} +``` + +__NOTE__: The copy for most of these error messages can be changed by backend developers. diff --git a/{{cookiecutter.github_repository}}/docs/graphql/errors_handling.md b/{{cookiecutter.github_repository}}/docs/graphql/errors_handling.md new file mode 100644 index 00000000..1a5b4d1b --- /dev/null +++ b/{{cookiecutter.github_repository}}/docs/graphql/errors_handling.md @@ -0,0 +1,126 @@ +# Error Handling + +There are several error types in the GraphQL API, and you may come across different ones depending on the operations you are trying to perform. + +The GraphQL API handles the following three types of errors: + +## Query-level errors + +This error occurs if you provide wrong or unrecognized input data while performing a specified operation. GraphQL checks the syntax as you write, and if you are trying to execute an unknown operation, the editor you are using will notify you. If you proceed with sending the request, you will get a syntax error. + +Below is an example of an error triggered by the wrong syntax. The following query tries to fetch the fullName field, which doesn't exist on the User type: + + +``` +query { + me { + fullName + } +} +``` + +Sending this query to the server would result in the following syntax error: + +```json +{ + "error": { + "errors": [ + { + "message": "Cannot query field \"fullName\" on type \"User\". Did you mean \"firstName\" or \"lastName\"?", + "locations": [ + { + "line": 3, + "column": 5 + } + ] + } + ] + } +} +``` + +## Data-level errors + +This error occurs when the user passes invalid data as the mutation input. For example, using an email address that is associated with a user account to create a secondary account will throw a validation error since the email should be unique within a user account. + +Validation errors are part of the schema, meaning we need to include them in the query to get them explicitly. In all mutations, for example, you can obtain them through the `errors` field. + +Below is an example of an error triggered by validation issues: + +``` +mutation { + accountRegister( + input: { + email: "customer@example.com" + password: "" + redirectUrl: "http://example.com/reset-password/" + } + ) { + user { + email + } + errors { + field + code + } + } +} +``` + +Validation errors are returned in a dedicated error field inside mutation results: + +```json +{ + "data": { + "accountRegister": { + "user": null, + "errors": [ + { + "field": "email", + "code": "UNIQUE" + } + ] + } + } +} +``` + +## Permission errors + +This error occurs when you are trying to perform a specific operation but are not authorized to do so; in other words, you have no sufficient permissions assigned. + +Below is an example of an error triggered by insufficient authorization. The `users` query requires appropriate admin permissions: + +``` +query { + users(first: 20) { + edges { + node { + id + } + } + } +} +``` + +```json +{ + "errors": [ + { + "message": "You do not have permission to perform this action", + "locations": [ + { + "line": 33, + "column": 3 + } + ], + "path": [ + "users" + ] + } + ], + "data": { + "users": null + } +} +``` diff --git a/{{cookiecutter.github_repository}}/mkdocs.yml b/{{cookiecutter.github_repository}}/mkdocs.yml index 12a9e4dc..6fd4859e 100644 --- a/{{cookiecutter.github_repository}}/mkdocs.yml +++ b/{{cookiecutter.github_repository}}/mkdocs.yml @@ -6,7 +6,15 @@ repo_url: https://github.com/{{ cookiecutter.github_username }}/{{ cookiecutter. nav: - Introduction: index.md -- API: +{%- if cookiecutter.add_graphql == "y" %} +- Graphql API: + - Overview: graphql/0-overview.md + - Authentication: graphql/1-auth.md + - Current User: graphql/2-users.md + - Errors: graphql/errors.md + - Error Handling: graphql/errors_handling.md +{%- endif %} +- REST API: - Overview: api/0-overview.md - Authentication: api/1-auth.md - Current User: api/2-current-user.md diff --git a/{{cookiecutter.github_repository}}/pyproject.toml b/{{cookiecutter.github_repository}}/pyproject.toml index 86c956ce..80f07f94 100644 --- a/{{cookiecutter.github_repository}}/pyproject.toml +++ b/{{cookiecutter.github_repository}}/pyproject.toml @@ -9,6 +9,7 @@ python = "~3.9" Django = "~3.2.15" django-environ = "^0.9" django-sites = "^0.11" +django-filter = "^21.1" argon2-cffi = "^21.3" python-dotenv = "^0.21" django-cors-headers = "^3.13" @@ -18,7 +19,7 @@ whitenoise = "^6.2" # Extensions # ------------------------------------- -pytz = "^2022.2" +pytz = "^2022.7" # Models # ------------------------------------- @@ -31,10 +32,16 @@ django-versatileimagefield = "^2.2" # REST APIs # ------------------------------------- -djangorestframework = "^3.13" +djangorestframework = "3.13.1" drf-yasg = "^1.21" +# GraphQL APIs +{% if cookiecutter.add_graphql == "y" -%} +graphene-django = "3.0.0" +{%- endif %} + + # Documentation # ------------------------------------- mkdocs = "^1.2" @@ -57,7 +64,7 @@ django-log-request-id = "^2.0" # ------------------------------------- {%- if cookiecutter.add_celery.lower() == "y" %} celery = {extras = ["redis"], version = "~5.2.7"} -flower = "~1.0.0" +flower = "~1.2.0" {%- endif %} # Auth Stuff @@ -72,7 +79,7 @@ django-mail-templated = "^2.6" # ------------------------------------- gunicorn = "~20.1.0" django-storages = "^1.13" -boto3 = "~1.24.67" +boto3 = "~1.26.47" # Caching # ------------------------------------- @@ -91,7 +98,7 @@ uWSGI = "^2.0" # Logging # ------------------------------------- -newrelic = "~8.0.0.179" +newrelic = "~8.5.0" {% endif %} [tool.poetry.dev-dependencies] @@ -99,13 +106,13 @@ newrelic = "~8.0.0.179" pre-commit = "^2.20" {%- endif %} {% if cookiecutter.add_ansible.lower() == 'y' %} -ansible = "~6.3" +ansible = "~7.1.0" {%- endif %} # Documentation # ------------------------------------- isort = "^5.10" -black = "~22.8.0" +black = "~22.12.0" flake8 = "^5.0" # Debugging @@ -120,7 +127,7 @@ pytest-django = "^4.5" pytest-cov = "^3.0" django-dynamic-fixture = "^3.1" pytest-mock = "^3.8" -mypy = "~0.971" +mypy = "~0.991" django-stubs = "^1.12" # Versioning diff --git a/{{cookiecutter.github_repository}}/settings/common.py b/{{cookiecutter.github_repository}}/settings/common.py index f296886c..0948f88b 100644 --- a/{{cookiecutter.github_repository}}/settings/common.py +++ b/{{cookiecutter.github_repository}}/settings/common.py @@ -16,7 +16,7 @@ # ========================================================================== # List of strings representing installed apps. # See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps -INSTALLED_APPS = [ +DJANGO_APPS = [ "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", @@ -25,27 +25,41 @@ "django.contrib.messages", "django.contrib.staticfiles", "django.contrib.admin", - # "django.contrib.humanize", # Useful template tags - "{{ cookiecutter.main_module }}.base", - "{{ cookiecutter.main_module }}.users", +] + +THIRD_PARTY_APPS = [ "rest_framework", # http://www.django-rest-framework.org/ +{%- if cookiecutter.add_graphql == "y" %} + "django_filters", + "graphene_django", +{%- endif %} + "mail_templated", # https://github.com/artemrizhov/django-mail-templated + "django_extensions", # http://django-extensions.readthedocs.org/ "drf_yasg", "versatileimagefield", # https://github.com/WGBH/django-versatileimagefield/ "corsheaders", # https://github.com/ottoyiu/django-cors-headers/ {%- if cookiecutter.add_sentry == "y" %} "raven.contrib.django.raven_compat", {%- endif %} - "mail_templated", # https://github.com/artemrizhov/django-mail-templated - "django_extensions", # http://django-extensions.readthedocs.org/ ] +LOCAL_APPS = [ + "{{ cookiecutter.main_module }}.base", + "{{ cookiecutter.main_module }}.users", +] + +INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS + + # INSTALLED APPS CONFIGURATION # ========================================================================== # django.contrib.auth # ------------------------------------------------------------------------------ AUTH_USER_MODEL = "users.User" -AUTHENTICATION_BACKENDS = ("django.contrib.auth.backends.ModelBackend",) +AUTHENTICATION_BACKENDS = [ + "django.contrib.auth.backends.ModelBackend", +] PASSWORD_HASHERS = [ "django.contrib.auth.hashers.Argon2PasswordHasher", @@ -99,7 +113,7 @@ "DEFAULT_AUTHENTICATION_CLASSES": ( "rest_framework.authentication.BasicAuthentication", # Primary api authentication - "{{ cookiecutter.main_module }}.users.auth.backends.UserTokenAuthentication", + "{{ cookiecutter.main_module }}.users.auth.backends.RestJWTAuthentication", # Mainly used for api debug. "rest_framework.authentication.SessionAuthentication", ), @@ -107,6 +121,19 @@ "EXCEPTION_HANDLER": "{{ cookiecutter.main_module }}.base.exceptions.exception_handler", } +{%- if cookiecutter.add_graphql == "y" %} +GRAPHENE = { + # The location of the top-level Schema class. + "SCHEMA": "{{ cookiecutter.main_module }}.graphql.api.schema", + + # The maximum size of objects that can be requested through a relay connection. + "RELAY_CONNECTION_MAX_LIMIT": 100, + "MIDDLEWARE": [ + "{{ cookiecutter.main_module }}.graphql.middleware.JSONWebTokenMiddleware" + ], +} +{%- endif %} + # https://django-rest-swagger.readthedocs.io/en/latest/settings/ SWAGGER_SETTINGS = { "LOGIN_URL": "rest_framework:login", diff --git a/{{cookiecutter.github_repository}}/tests/conftest.py b/{{cookiecutter.github_repository}}/tests/conftest.py index bdf982d0..dfc17574 100644 --- a/{{cookiecutter.github_repository}}/tests/conftest.py +++ b/{{cookiecutter.github_repository}}/tests/conftest.py @@ -7,10 +7,19 @@ # Standard Library import functools +{%- if cookiecutter.add_graphql == "y" %} +import json +{%- endif %} from unittest import mock # Third Party Stuff import pytest +{%- if cookiecutter.add_graphql == "y" %} +from django.core.serializers.json import DjangoJSONEncoder +from django.urls import reverse + +GRAPHQL_API_PATH = reverse("graphql") +{%- endif %} class PartialMethodCaller: @@ -78,4 +87,55 @@ def json(self): obj=self, content_type='application/json;charset="utf-8"' ) +{%- if cookiecutter.add_graphql == "y" %} + + def post_graphql( + self, + query, + op_name=None, + variables=None, + input_data=None, + graphql_url=GRAPHQL_API_PATH, + **extra, + ): + """Dedicated helper for posting GraphQL queries. + + Args: + query (string) - GraphQL query to run + op_name (string) - If the query is a mutation or named query, you must + supply the op_name. For annon queries ("{ ... }"), + should be None (default). + variables (dict) - If provided, the "variables" field in GraphQL will be + set to this value. + input_data (dict) - If provided, the $input variable in GraphQL will be set + to this value. If both ``input_data`` and ``variables``, + are provided, the ``input`` field in the ``variables`` + dict will be overwritten with this value. + graphql_url (string) - URL to graphql endpoint. Defaults to "/graphql". + + Sets the `application/json` content type and json dumps the variables + if present. + """ + + data = {"query": query} + + if op_name: + data["operationName"] = op_name + if variables is not None: + data["variables"] = variables + if input_data: + if "variables" in data: + data["variables"]["input"] = input_data + else: + data["variables"] = {"input": input_data} + + response = super().post( + graphql_url, + json.dumps(data, cls=DjangoJSONEncoder), + content_type="application/json", + **extra, + ) + return response +{%- endif %} + return _Client() diff --git a/{{cookiecutter.github_repository}}/tests/graphql/__init__.py b/{{cookiecutter.github_repository}}/tests/graphql/__init__.py new file mode 100644 index 00000000..40a96afc --- /dev/null +++ b/{{cookiecutter.github_repository}}/tests/graphql/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/{{cookiecutter.github_repository}}/tests/graphql/test_auth.py b/{{cookiecutter.github_repository}}/tests/graphql/test_auth.py new file mode 100644 index 00000000..27bbf1e1 --- /dev/null +++ b/{{cookiecutter.github_repository}}/tests/graphql/test_auth.py @@ -0,0 +1,244 @@ +# Standard Library +import json + +# Third Party Stuff +import pytest +from django.urls import reverse + +from tests import factories as f + +pytestmark = pytest.mark.django_db + + +def get_user_signup_query(email, password, **kwargs): + return ''' + mutation signUp ( + $email: String = "''' + email + '''", + $password: String = "''' + password + '''", + $firstName: String = "''' + kwargs.get("first_name", "first name") + '''", + $lastName: String = "''' + kwargs.get("last_name", "last name") + '''" + ){ + signup ( + input: { + email: $email, + password: $password, + firstName: $firstName, + lastName: $lastName + } + ) { + user { + id + email + } + } + } + ''' + + +def get_user_login_query(email, password): + return ''' + mutation Login ( + $email: String = "''' + email + '''", + $password: String = "''' + password + '''" + ) { + login ( + input: { + email: $email, + password: $password + } + ) { + user { + id + email + firstName + lastName + authToken + } + } + } + ''' + + +def get_user_change_password(current_password, new_password): + return ''' + mutation PasswordChange ( + $currentPassword: String = "''' + current_password + '''", + $newPassword: String = "''' + new_password + '''" + ) { + passwordChange ( + input: { + currentPassword: $currentPassword, newPassword: $newPassword + } + ) { + user { + email + firstName + lastName + authToken + } + } + } + ''' + + +def get_request_password_reset(email): + return ''' + mutation RequestPasswordReset ( + $email: String = "''' + email + '''", + ){ + passwordReset ( + input: { + email: $email + } + ) { + message + } + } + ''' + + +def get_password_reset_confirm(new_password, token): + return ''' + mutation PasswordResetConfirm ( + $newPassword: String = "''' + new_password + '''", + $token: String = "''' + token + '''" + ){ + passwordResetConfirm ( + input: { + newPassword: $newPassword + token: $token + } + ) { + message + } + } + ''' + + +def test_user_registration(client): + graphql_query = get_user_signup_query( + email="test@example.com", firstName="a", lastName="b", password="password") + response = client.post_graphql( + graphql_query, + variables={} + ) + assert response.status_code == 200 + + # should return user id and email + response_data = json.loads(response.content) + expected_keys = ["id", "email"] + assert "errors" not in response_data.keys() + assert set(expected_keys).issubset(response_data["data"]["signup"]["user"].keys()) + assert response_data["data"]["signup"]["user"]["email"] == "test@example.com" + + +def test_user_registration_with_invalid_email(client): + graphql_query = get_user_signup_query( + email="test@example.com", firstName="a", lastName="b", password="password") + + # create existing user with the same email address + f.create_user(email="test@example.com") + + response = client.post_graphql(graphql_query) + + # should return an error for email exists + response_data = json.loads(response.content) + assert "errors" in response_data.keys() + assert "User with email already exists" == response_data["errors"][0]["message"] + + +def test_user_login(client): + graphql_query = get_user_login_query( + email="test@example.com", + password="password" + ) + f.create_user(email="test@example.com", password="password") + + response = client.post_graphql(graphql_query) + assert response.status_code == 200 + + # should return token in response + response_data = json.loads(response.content) + expected_keys = ["authToken", "email", "firstName", "lastName"] + assert "errors" not in response_data.keys() + assert set(expected_keys).issubset(response_data["data"]["login"]["user"].keys()) + + +def test_user_login_with_incorrect_creds(client): + graphql_query = get_user_login_query( + email="test@example.com", + password="incorrect_password" + ) + f.create_user(email="test@example.com", password="password") + + response = client.post_graphql(graphql_query) + assert response.status_code == 200 + + # should return token in response + response_data = json.loads(response.content) + assert "errors" in response_data.keys() + assert ( + "Invalid username/password. Please try again!" + == response_data["errors"][0]["message"] + ) + + +def test_user_password_change(client): + graphql_query = get_user_change_password(current_password="pass123word", new_password="new123password") + user = f.create_user(email="test@example.com", password="pass123word") + + response = client.post_graphql(graphql_query) + response_data = json.loads(response.content) + assert "errors" in response_data.keys() + + client.login(user) + + response = client.post_graphql(graphql_query) + assert response.status_code == 200 + + # should return token in response + response_data = json.loads(response.content) + assert "errors" not in response_data.keys() + expected_keys = ["authToken", "email", "firstName", "lastName"] + assert set(expected_keys).issubset( + response_data["data"]["passwordChange"]["user"].keys() + ) + + +def test_user_request_password_reset(client): + graphql_query = get_request_password_reset(email="test@example.com") + f.create_user(email="test@example.com", password="pass123word") + + response = client.post_graphql(graphql_query) + response_data = json.loads(response.content) + assert "errors" not in response_data.keys() + assert ( + "Further instructions will be sent to the email if it exists" + == response_data["data"]["passwordReset"]["message"] + ) + + +def test_user_password_reset_confirm(client, settings, mocker): + url = reverse("auth-password-reset") + user = f.create_user(email="test@example.com", password="pass123word") + mock_email = mocker.patch("{{cookiecutter.main_module}}.users.auth.services.send_mail") + + response = client.json.post(url, json.dumps({"email": user.email})) + assert response.status_code == 200 + assert mock_email.call_count == 1 + + args, kwargs = mock_email.call_args + assert user.email in kwargs.get("recipient_list") + + # get the context passed to template + token = kwargs["context"]["token"] + + graphql_query = get_password_reset_confirm(new_password="newPassword124", token=token) + + response = client.post_graphql(graphql_query) + response_data = json.loads(response.content) + assert "errors" not in response_data.keys() + assert ( + "Password reset successfully." + == response_data["data"]["passwordResetConfirm"]["message"] + ) diff --git a/{{cookiecutter.github_repository}}/tests/graphql/test_current_user_api.py b/{{cookiecutter.github_repository}}/tests/graphql/test_current_user_api.py new file mode 100644 index 00000000..e93b02c5 --- /dev/null +++ b/{{cookiecutter.github_repository}}/tests/graphql/test_current_user_api.py @@ -0,0 +1,41 @@ +# Standard Library +import json + +# Third Party Stuff +import pytest + +from tests import factories as f + +pytestmark = pytest.mark.django_db + + +def test_get_current_user_api(client): + graphql_query = """ + query{ + me{ + email, + firstName, + lastName + } + } + """ + + user = f.create_user(email="test@example.com") + + response = client.post_graphql(graphql_query) + assert response.status_code == 200 + + # should return an error without auth + response_data = json.loads(response.content) + assert "errors" in response_data.keys() + + client.login(user) + response = client.post_graphql(graphql_query) + assert response.status_code == 200 + + # should return user + response_data = json.loads(response.content) + expected_keys = ["email", "firstName", "lastName"] + assert "errors" not in response_data.keys() + assert set(expected_keys).issubset(response_data["data"]["me"].keys()) + assert response_data["data"]["me"]["email"] == "test@example.com" diff --git a/{{cookiecutter.github_repository}}/tests/graphql/test_users_list_api.py b/{{cookiecutter.github_repository}}/tests/graphql/test_users_list_api.py new file mode 100644 index 00000000..0a6e1c85 --- /dev/null +++ b/{{cookiecutter.github_repository}}/tests/graphql/test_users_list_api.py @@ -0,0 +1,66 @@ +# Standard Library +import json + +# Third Party Stuff +import pytest + +from tests import factories as f + +pytestmark = pytest.mark.django_db + + +def test_get_current_user_api(client): + graphql_query = """ + query users($first: Int = 1, $after: String = ""){ + users(first: $first, after: $after){ + totalCount, + edgeCount, + edges { + node { + id, + firstName, + lastName + } + cursor + }, + pageInfo{ + startCursor, + endCursor, + hasNextPage, + hasPreviousPage + } + } + } + """ + + user = f.create_user(email="test@example.com") + f.create_user(email="test2@example.com") + + # should return an error without auth + response = client.post_graphql(graphql_query) + assert response.status_code == 200 + response_data = json.loads(response.content) + assert "errors" in response_data.keys() + + client.login(user) + + # should return permission issue error + response = client.post_graphql(graphql_query) + response_data = json.loads(response.content) + assert "errors" in response_data.keys() + assert ( + "You do not have permission to perform this action." + == response_data["errors"][0]["message"] + ) + + user.is_superuser = True + user.save() + + # should return user list + response = client.post_graphql(graphql_query) + response_data = json.loads(response.content) + assert response.status_code == 200 + data = response_data["data"] + assert data["users"]["totalCount"] == 2 + assert data["users"]["edgeCount"] == 1 + assert data["users"]["pageInfo"]["hasNextPage"] is True diff --git a/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/base/api/schemas.py b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/base/api/schemas.py index 1d5c6661..94e82930 100644 --- a/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/base/api/schemas.py +++ b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/base/api/schemas.py @@ -3,6 +3,7 @@ from drf_yasg.views import get_schema_view from drf_yasg import openapi + schema_view = get_schema_view( openapi.Info( title="{{ cookiecutter.project_name }} API", diff --git a/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/__init__.py b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/__init__.py new file mode 100644 index 00000000..40a96afc --- /dev/null +++ b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/api.py b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/api.py new file mode 100644 index 00000000..69dfa2fa --- /dev/null +++ b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/api.py @@ -0,0 +1,16 @@ +# Third Party Stuff +import graphene +from graphene_django.debug import DjangoDebug + +from .users.schema import UserQueries, UserMutations + + +class Query(UserQueries): + debug = graphene.Field(DjangoDebug, name="_debug") + + +class Mutation(UserMutations): + pass + + +schema = graphene.Schema(query=Query, mutation=Mutation) diff --git a/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/decorators.py b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/decorators.py new file mode 100644 index 00000000..49be2464 --- /dev/null +++ b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/decorators.py @@ -0,0 +1,37 @@ +from functools import wraps +from {{cookiecutter.main_module}}.base import exceptions + +try: + from graphql.execution.execute import GraphQLResolveInfo +except ImportError: + from graphql.execution.base import ResolveInfo as GraphQLResolveInfo + + +def context(f): + def decorator(func): + def wrapper(*args, **kwargs): + info = next(arg for arg in args if isinstance(arg, GraphQLResolveInfo)) + return func(info.context, *args, **kwargs) + + return wrapper + + return decorator + + +def user_passes_test(test_func, exc=exceptions.PermissionDenied): + def decorator(f): + @wraps(f) + @context(f) + def wrapper(context, *args, **kwargs): + if test_func(context.user): + return f(*args, **kwargs) + raise exc + + return wrapper + + return decorator + + +login_required = user_passes_test(lambda u: u.is_authenticated) +staff_member_required = user_passes_test(lambda u: u.is_staff) +superuser_required = user_passes_test(lambda u: u.is_superuser) diff --git a/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/middleware.py b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/middleware.py new file mode 100644 index 00000000..3c2c7d32 --- /dev/null +++ b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/middleware.py @@ -0,0 +1,25 @@ +from django.contrib.auth import authenticate +from {{cookiecutter.main_module}}.users.auth.tokens import get_user_for_token +from {{cookiecutter.main_module}}.users.auth.utils import get_http_authorization + + +def _authenticate(request): + is_anonymous = not hasattr(request, "user") or request.user.is_anonymous + return is_anonymous and get_http_authorization(request) is not None + + +class JSONWebTokenMiddleware: + def __init__(self): + self.cached_allow_any = set() + + def resolve(self, next, root, info, **kwargs): + context = info.context + + if _authenticate(context): + + token = get_http_authorization(context) + user = get_user_for_token(token, "authentication") + if user is not None: + context.user = user + + return next(root, info, **kwargs) diff --git a/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/users/mutations.py b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/users/mutations.py new file mode 100644 index 00000000..beecc425 --- /dev/null +++ b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/users/mutations.py @@ -0,0 +1,121 @@ +import graphene +from django.db import transaction +from django.contrib.auth import password_validation +from graphene import relay +from graphql import GraphQLError + +from .types import CurrentUser, AuthenticatedUser +from {{cookiecutter.main_module}}.users import services as user_services +from {{cookiecutter.main_module}}.users.auth import tokens, services as auth_services + + +class SignUp(relay.ClientIDMutation): + class Input: + email = graphene.String(required=True) + password = graphene.String(required=True) + first_name = graphene.String() + last_name = graphene.String() + + @staticmethod + def validate_email(email): + if user_services.get_user_by_email(email): + raise GraphQLError("User with email already exists") + + user = graphene.Field(CurrentUser) + + @classmethod + @transaction.atomic + def mutate_and_get_payload(cls, root, info, **data): + cls.validate_email(data["email"]) + user = user_services.create_user_account(**data) + return SignUp(user=user) + + +class Login(relay.ClientIDMutation): + class Input: + email = graphene.String(required=True) + password = graphene.String(required=True) + + @staticmethod + def validate_email(email): + if not user_services.get_user_by_email(email): + raise GraphQLError("User with email doesn't exist") + + user = graphene.Field(AuthenticatedUser) + + @classmethod + def mutate_and_get_payload(cls, root, info, **data): + cls.validate_email(data["email"]) + user = user_services.get_and_authenticate_user(**data) + return Login(user=user) + +class PasswordChange(relay.ClientIDMutation): + class Input: + current_password = graphene.String(required=True) + new_password = graphene.String(required=True) + + user = graphene.Field(AuthenticatedUser) + + @classmethod + def mutate_and_get_payload(cls, root, info, **data): + user = info.context.user + current_password = data["current_password"] + new_password = data["new_password"] + + if not user.check_password(current_password): + raise GraphQLError("invalid_password") + + password_validation.validate_password(new_password, user) + + user.set_password(new_password) + user.save(update_fields=["password"]) + return PasswordChange(user=user) + + +class RequestPasswordReset(relay.ClientIDMutation): + class Input: + email = graphene.String( + required=True, + description="Email of the user that will be used for password recovery.", + ) + + message = graphene.String() + + @classmethod + def clean_user(cls, email): + user = user_services.get_user_by_email(email) + if not user: + raise GraphQLError("User with this email doesn't exist") + if not user.is_active: + raise GraphQLError("User with this email is inactive") + return user + + @classmethod + def mutate_and_get_payload(cls, root, info, **data): + email = data["email"] + user = cls.clean_user(email) + + auth_services.send_password_reset_mail(user) + return RequestPasswordReset( + message="Further instructions will be sent to the email if it exists" + ) + + +class PasswordResetConfirm(relay.ClientIDMutation): + class Input: + token = graphene.String(required=True) + new_password = graphene.String(required=True) + + message = graphene.String() + + @classmethod + def mutate_and_get_payload(cls, root, info, **data): + new_password = data["new_password"] + token = data["token"] + + user = tokens.get_user_for_password_reset_token(token) + password_validation.validate_password(new_password, user) + + user.set_password(new_password) + user.save(update_fields=["password"]) + return PasswordResetConfirm(message="Password reset successfully.") diff --git a/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/users/resolvers.py b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/users/resolvers.py new file mode 100644 index 00000000..c2591150 --- /dev/null +++ b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/users/resolvers.py @@ -0,0 +1,5 @@ +from django.contrib.auth import get_user_model + + +def get_all_users(info): + return get_user_model().objects.all() diff --git a/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/users/schema.py b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/users/schema.py new file mode 100644 index 00000000..6bb7bda2 --- /dev/null +++ b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/users/schema.py @@ -0,0 +1,44 @@ +import graphene +from graphene import relay +from graphene_django.filter import DjangoFilterConnectionField +from {{cookiecutter.main_module}}.graphql.decorators import login_required, superuser_required +from {{cookiecutter.main_module}}.graphql.utils import filter_objects +from {{cookiecutter.main_module}}.users.models import User + +from .types import UserConnection, CurrentUser +from .resolvers import get_all_users +from .mutations import SignUp, Login, PasswordChange, RequestPasswordReset, PasswordResetConfirm + + +class UserQueries(graphene.ObjectType): + me = graphene.Field( + CurrentUser, description="Return the currently authenticated user" + ) + users = DjangoFilterConnectionField( + UserConnection, description="Return list of all Users" + ) + user_details = relay.Node.Field(UserConnection) + + @login_required + def resolve_me(self, info): + return info.context.user + + @superuser_required + def resolve_users(self, info, **kwargs): + qs = get_all_users(info) + # add filters + return qs + + @superuser_required + def resolve_user_details(self, info, **kwargs): + return filter_objects( + User, kwargs['id'] + ).first() + + +class UserMutations(graphene.ObjectType): + signup = SignUp.Field() + login = Login.Field() + password_change = PasswordChange.Field() + password_reset = RequestPasswordReset.Field() + password_reset_confirm = PasswordResetConfirm.Field() diff --git a/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/users/types.py b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/users/types.py new file mode 100644 index 00000000..c502a9e0 --- /dev/null +++ b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/users/types.py @@ -0,0 +1,34 @@ +import graphene +from graphene import relay +from graphene_django.types import DjangoObjectType + +from {{cookiecutter.main_module}}.graphql.utils import CountableConnectionBase +from {{cookiecutter.main_module}}.users.models import User +from {{cookiecutter.main_module}}.users.auth import tokens + + +class CurrentUser(DjangoObjectType): + class Meta: + model = User + fields = ["id", "first_name", "last_name", "email"] + interfaces = [relay.Node] + + +class AuthenticatedUser(DjangoObjectType): + auth_token = graphene.String() + class Meta: + model = User + fields = ["id", "first_name", "last_name", "email"] + interfaces = [relay.Node] + + def resolve_auth_token(self, info): + return tokens.get_token_for_user(self, "authentication") + + +class UserConnection(DjangoObjectType): + class Meta: + model = User + fields = ["id", "first_name", "last_name"] + filter_fields = {"id": ["exact"]} + interfaces = (relay.Node,) + connection_class = CountableConnectionBase diff --git a/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/utils.py b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/utils.py new file mode 100644 index 00000000..276765d8 --- /dev/null +++ b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/utils.py @@ -0,0 +1,61 @@ +import graphene +from graphene import relay +from graphql_relay import from_global_id +from graphql.error import GraphQLError +from django.core.exceptions import ValidationError + + +def filter_objects(object_name, relay_ids, otherwise=None): + if not isinstance(relay_ids, list): + relay_ids = [relay_ids] + try: + object_ids = [from_global_id(relay_id)[1] for relay_id in relay_ids] + return object_name.filter.with_ids(object_ids) + except: # noqa + return otherwise + + +class CountableConnectionBase(relay.Connection): + """ + Extend connection class to display + total count and edges count in paginated results + """ + + class Meta: + abstract = True + + total_count = graphene.Int() + edge_count = graphene.Int() + + @classmethod + def resolve_total_count(cls, root, info, **kwargs): + return root.length + + @classmethod + def resolve_edge_count(cls, root, info, **kwargs): + return len(root.edges) + + +def validate_one_of_args_is_in_query(*args): + # split args into a list with 2-element tuples: + # [(arg1_name, arg1_value), (arg2_name, arg2_value), ...] + splitted_args = [args[i : i + 2] for i in range(0, len(args), 2)] # noqa: E203 + # filter trueish values from each tuple + filter_args = list(filter(lambda item: bool(item[1]) is True, splitted_args)) + + if len(filter_args) > 1: + rest_args = ", ".join([f"'{item[0]}'" for item in filter_args[1:]]) + raise GraphQLError( + f"Argument '{filter_args[0][0]}' cannot be combined with {rest_args}" + ) + + if not filter_args: + required_args = ", ".join([f"'{item[0]}'" for item in splitted_args]) + raise GraphQLError(f"At least one of arguments is required: {required_args}.") + + +def validate_one_of_args_is_in_mutation(error_class, *args): + try: + validate_one_of_args_is_in_query(*args) + except GraphQLError as e: + raise ValidationError(str(e), code=error_class.GRAPHQL_ERROR) diff --git a/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/urls.py b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/urls.py index c9bce54b..f7482d92 100644 --- a/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/urls.py +++ b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/urls.py @@ -1,9 +1,9 @@ -# -*- coding: utf-8 -*- -"""Root url routering file. +"""Root url routing file. You should put the url config in their respective app putting only a -refernce to them here. +reference to them here. """ +# Standard Library from typing import TYPE_CHECKING, List, Union # Third Party Stuff @@ -13,11 +13,17 @@ from django.urls import include, path, re_path from django.views.generic import TemplateView +{%- if cookiecutter.add_graphql == "y" %} +from django.views.decorators.csrf import csrf_exempt +from graphene_django.views import GraphQLView +{%- endif %} + from . import api_urls from .base import views as base_views from .base.api import schemas as api_schemas if TYPE_CHECKING: + # Third Party Stuff from django.urls import URLPattern, URLResolver URL = Union[URLPattern, URLResolver] @@ -44,6 +50,13 @@ ), # Rest API path("api/", include(api_urls)), +{%- if cookiecutter.add_graphql == "y" %} + path( + "graphql/", + csrf_exempt(GraphQLView.as_view(graphiql=settings.API_DEBUG)), + name="graphql", + ), +{% endif %} # Django Admin path("{}/".format(settings.DJANGO_ADMIN_URL), admin.site.urls), ] @@ -51,12 +64,13 @@ if settings.API_DEBUG: urlpatterns += [ # Browsable API - path("schema/", api_schemas.schema_view, name="schema"), + path("api/schema/", api_schemas.schema_view.as_view(), name="schema"), path("api-playground/", api_schemas.swagger_schema_view, name="api-playground"), path("api/auth-n/", include("rest_framework.urls", namespace="rest_framework")), ] if settings.DEBUG: + # Third Party Stuff from django.urls import get_callable from django.views import defaults as dj_default_views @@ -84,6 +98,7 @@ # Django Debug Toolbar if "debug_toolbar" in settings.INSTALLED_APPS: + # Third Party Stuff import debug_toolbar urlpatterns += [path("__debug__/", include(debug_toolbar.urls))] diff --git a/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/users/auth/backends.py b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/users/auth/backends.py index 85ee9aa7..1510ef4c 100644 --- a/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/users/auth/backends.py +++ b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/users/auth/backends.py @@ -15,37 +15,31 @@ selfcontained tokens. This trust tokes from external fraudulent modifications. """ - -# Standard Library -import re - # Third Party Stuff from rest_framework.authentication import BaseAuthentication from .tokens import get_user_for_token +from .utils import get_http_authorization -class UserTokenAuthentication(BaseAuthentication): - """Self-contained stateles authentication implementation that work similar to OAuth2. - - It uses json web tokens (https://github.com/jpadilla/pyjwt) for trust - data stored in the token. - """ - - auth_rx = re.compile(r"^Bearer (.+)$") - +class JWTAuthenticationMixin: def authenticate(self, request): - if "HTTP_AUTHORIZATION" not in request.META: + token = get_http_authorization(request) + if not token: return None - token_rx_match = self.auth_rx.search(request.META["HTTP_AUTHORIZATION"]) - if not token_rx_match: - return None - - token = token_rx_match.group(1) user = get_user_for_token(token, "authentication") return (user, token) def authenticate_header(self, request): return 'Bearer realm="api"' + + +class RestJWTAuthentication(JWTAuthenticationMixin, BaseAuthentication): + """Self-contained stateles authentication implementation that work similar to OAuth2. + + It uses json web tokens (https://github.com/jpadilla/pyjwt) for trust + data stored in the token. + """ + pass diff --git a/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/users/auth/utils.py b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/users/auth/utils.py index 41b0a011..07832ecc 100644 --- a/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/users/auth/utils.py +++ b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/users/auth/utils.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- # Standard Library +import re from uuid import UUID # Third Party Stuff @@ -21,3 +22,16 @@ def decode_uuid_from_base64(uuid_value: str): return UUID(force_str(urlsafe_base64_decode(uuid_value))) except (ValueError, OverflowError, TypeError): return None + + +def get_http_authorization(request): + auth_rx = re.compile(r"^Bearer (.+)$") + if request is None or "HTTP_AUTHORIZATION" not in request.META: + return None + + token_rx_match = auth_rx.search(request.META["HTTP_AUTHORIZATION"]) + if not token_rx_match: + return None + + token = token_rx_match.group(1) + return token diff --git a/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/users/services.py b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/users/services.py index 687b4c5e..7abacea7 100644 --- a/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/users/services.py +++ b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/users/services.py @@ -22,3 +22,7 @@ def create_user_account(email, password, first_name="", last_name=""): def get_user_by_email(email: str): return get_user_model().objects.filter(email__iexact=email).first() + + +def get_active_user_by_id(user_id): + return get_user_model().objects.filter(id=user_id, is_active=True).first() From 2afafb2caaab745a986749d3227ab54cc41637bd Mon Sep 17 00:00:00 2001 From: John Taylor <62556052+john-fueled@users.noreply.github.com> Date: Tue, 18 Apr 2023 09:57:46 -0400 Subject: [PATCH 6/9] fix(docker): Fixed an issue with the Postgres Docker image name (#474) > Why was this change necessary? When not choosing to add Postgis, the resulting dev Dockerfile for Postgres ends up prefixed with `n`. This results in a 404 when trying to download the Docker image. What happens: ```dockerfile FROM npostgres:13 ``` Expected: ```dockerfile FROM postgres:13 ``` > How does it address the problem? Removes the template prefix of `cookiecutter.add_postgis` value. > Are there any side effects? No --- .../compose/dev/postgres/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/{{cookiecutter.github_repository}}/compose/dev/postgres/Dockerfile b/{{cookiecutter.github_repository}}/compose/dev/postgres/Dockerfile index 2bde1a0e..748c9cfd 100644 --- a/{{cookiecutter.github_repository}}/compose/dev/postgres/Dockerfile +++ b/{{cookiecutter.github_repository}}/compose/dev/postgres/Dockerfile @@ -1,4 +1,4 @@ -{% if cookiecutter.add_postgis.lower() == "y" %}FROM postgis/postgis:13-3.3{% else %}FROM {{cookiecutter.add_postgis}}postgres:13{% endif %} +{% if cookiecutter.add_postgis.lower() == "y" %}FROM postgis/postgis:13-3.3{% else %}FROM postgres:13{% endif %} COPY ./compose/dev/postgres/maintenance /usr/local/bin/maintenance RUN chmod +x /usr/local/bin/maintenance/* From d305b932009c7a7d2906af89e22a5ef96638832d Mon Sep 17 00:00:00 2001 From: Suneet Choudhary Date: Mon, 27 Nov 2023 17:25:55 +0530 Subject: [PATCH 7/9] feat(async): add uvicorn, asgi, upgrade django=4.1 (#473) > Why was this change necessary? Current version of django(3.2) doesn't support async tasks. > How does it address the problem? After upgrading to django>4.1 and running asgi server, the application can run tasks asynchronously. > Are there any side effects? Uvicorn config `--limit-concurrency` is not yet supported when running with Gunicorn. Need to test provisioner scripts before merging --- README.md | 2 +- cookiecutter-test-config.yaml | 1 + cookiecutter.json | 1 + hooks/post_gen_project.sh | 6 ++++ {{cookiecutter.github_repository}}/asgi.py | 15 ++++++++++ .../compose/dev/django/start | 7 +++-- .../compose/local/start | 6 +++- .../docs/backend/server_config.md | 10 +++++++ .../roles/nginx/templates/site.443.conf.j2 | 23 +++++++++++++++ .../roles/nginx/templates/site.80.conf.j2 | 9 ++++-- .../roles/project_data/defaults/main.yml | 15 ++++++++++ .../roles/project_data/tasks/asgi-setup.yml | 29 +++++++++++++++++++ .../roles/project_data/tasks/main.yml | 18 ++++++++++-- .../project_data/templates/django.asgi.ini.j2 | 18 ++++++++++++ .../pyproject.toml | 9 ++++-- .../graphql/api.py | 2 +- .../graphql/decorators.py | 2 +- .../graphql/middleware.py | 1 - .../graphql/users/mutations.py | 23 +++++++-------- .../graphql/users/schema.py | 18 ++++++++---- .../graphql/users/types.py | 3 +- .../graphql/utils.py | 6 ++-- 22 files changed, 186 insertions(+), 38 deletions(-) create mode 100644 {{cookiecutter.github_repository}}/asgi.py create mode 100644 {{cookiecutter.github_repository}}/provisioner/roles/project_data/tasks/asgi-setup.yml create mode 100644 {{cookiecutter.github_repository}}/provisioner/roles/project_data/templates/django.asgi.ini.j2 diff --git a/README.md b/README.md index 0a1dee08..7bf445dd 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ ## Features -- Django 3.2.x +- Django 4.1.x - Python 3.9.x - [Poetry][poetry] Support - Support for [black](https://pypi.org/project/black/)! diff --git a/cookiecutter-test-config.yaml b/cookiecutter-test-config.yaml index 9035c70a..05a32c0d 100644 --- a/cookiecutter-test-config.yaml +++ b/cookiecutter-test-config.yaml @@ -2,3 +2,4 @@ default_context: enable_whitenoise: "y" add_celery: "y" add_graphql: "y" + add_asgi: "y" diff --git a/cookiecutter.json b/cookiecutter.json index 93a561b9..f983e873 100644 --- a/cookiecutter.json +++ b/cookiecutter.json @@ -19,6 +19,7 @@ , "add_django_auth_wall": "y" , "add_celery": "n" , "add_graphql": "n" + , "add_asgi": "n" , "add_pre_commit": "y" , "add_docker": "y" , "pagination": ["LimitOffsetPagination", "CursorPagination"] diff --git a/hooks/post_gen_project.sh b/hooks/post_gen_project.sh index c2a4b1a0..27f13ed4 100755 --- a/hooks/post_gen_project.sh +++ b/hooks/post_gen_project.sh @@ -51,6 +51,12 @@ if echo "{{ cookiecutter.add_graphql }}" | grep -iq "^n"; then rm -rf tests/graphql fi +if echo "{{ cookiecutter.add_asgi }}" | grep -iq "^n"; then + rm -rf asgi.py +else + rm -rf wsgi.py +fi + if echo "$yn" | grep -iq "^y"; then echo "==> Checking system dependencies. You may need to enter your sudo password." diff --git a/{{cookiecutter.github_repository}}/asgi.py b/{{cookiecutter.github_repository}}/asgi.py new file mode 100644 index 00000000..f26ec7ca --- /dev/null +++ b/{{cookiecutter.github_repository}}/asgi.py @@ -0,0 +1,15 @@ +# Standard Library +import os + +# Third Party Stuff +from django.core.asgi import get_asgi_application +from dotenv import load_dotenv + +# Read .env file and set key/value inside it as environment variables +# see: http://github.com/theskumar/python-dotenv +load_dotenv(os.path.join(os.path.dirname(__file__), ".env")) + +# We defer to a DJANGO_SETTINGS_MODULE already in the environment. +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.production") + +application = get_asgi_application() diff --git a/{{cookiecutter.github_repository}}/compose/dev/django/start b/{{cookiecutter.github_repository}}/compose/dev/django/start index a1b3d54f..675e4763 100644 --- a/{{cookiecutter.github_repository}}/compose/dev/django/start +++ b/{{cookiecutter.github_repository}}/compose/dev/django/start @@ -6,5 +6,8 @@ set -o nounset python /app/manage.py collectstatic --noinput -# /usr/local/bin/gunicorn asgi --bind 0.0.0.0:5000 --chdir=/app -k uvicorn.workers.UvicornWorker -/usr/local/bin/gunicorn wsgi --bind 0.0.0.0:5000 --chdir=/app --access-logfile - --error-logfile - +{%- if cookiecutter.add_asgi.lower() == "y" %} +gunicorn asgi --bind 0.0.0.0:8000 --chdir=/app -k uvicorn.workers.UvicornWorker +{%- else %} +gunicorn wsgi --bind 0.0.0.0:8000 --chdir=/app --access-logfile - --error-logfile - +{%- endif %} diff --git a/{{cookiecutter.github_repository}}/compose/local/start b/{{cookiecutter.github_repository}}/compose/local/start index eab8a3eb..a3e3a5af 100644 --- a/{{cookiecutter.github_repository}}/compose/local/start +++ b/{{cookiecutter.github_repository}}/compose/local/start @@ -6,5 +6,9 @@ set -o nounset python manage.py migrate -#! uvicorn config.asgi:application --host 0.0.0.0 --reload + +{%- if cookiecutter.add_asgi.lower() == "y" %} +uvicorn config.asgi:application --host 0.0.0.0 --reload +{%- else %} python manage.py runserver_plus 0.0.0.0:8000 +{%- endif %} diff --git a/{{cookiecutter.github_repository}}/docs/backend/server_config.md b/{{cookiecutter.github_repository}}/docs/backend/server_config.md index f99012b6..7e259456 100644 --- a/{{cookiecutter.github_repository}}/docs/backend/server_config.md +++ b/{{cookiecutter.github_repository}}/docs/backend/server_config.md @@ -4,13 +4,23 @@ Our overall stack looks like this: +{%- if cookiecutter.add_asgi.lower() == 'y' %} +``` +the web client <-> the web server (nginx) <-> the socket <-> ASGI <-> Django +``` +{%- else %} ``` the web client <-> the web server (nginx) <-> the socket <-> uWSGI <-> Django ``` +{%- endif %} A web server faces the outside world. It can serve files (HTML, images, CSS, etc) directly from the file system. However, it can’t talk directly to Django applications; it needs something that will run the application, feed it requests from web clients (such as browsers) and return responses. +{%- if cookiecutter.add_asgi.lower() == 'y' %} +ASGI (ASGI stands for Asynchronous Server Gateway interface) which runs through Gunicorn running the actual Django instance. ASGI is an interface and sit in between the web server (NGINX) and the Django application. It creates a Unix socket, and serves responses to the web server via the asgi protocol. +{%- else %} uWSGI is a [WSGI](https://en.wikipedia.org/wiki/Web_Server_Gateway_Interface) implementation, it creates a Unix socket, and serves responses to the web server via the uwsgi protocol. +{%- endif %} ## Third Party Services diff --git a/{{cookiecutter.github_repository}}/provisioner/roles/nginx/templates/site.443.conf.j2 b/{{cookiecutter.github_repository}}/provisioner/roles/nginx/templates/site.443.conf.j2 index d6619e7b..4fb83d32 100644 --- a/{{cookiecutter.github_repository}}/provisioner/roles/nginx/templates/site.443.conf.j2 +++ b/{{cookiecutter.github_repository}}/provisioner/roles/nginx/templates/site.443.conf.j2 @@ -51,11 +51,23 @@ server { # Setup named location for Django requests and handle proxy details location / { + {%- if cookiecutter.add_asgi.lower() == 'y' %} + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_redirect off; + proxy_buffering off; + proxy_pass http://uvicorn; + + {%- else %} uwsgi_pass unix:///tmp/uwsgi-{{ project_namespace }}.sock; include /etc/nginx/uwsgi_params; # set correct scheme uwsgi_param UWSGI_SCHEME $http_x_forwarded_proto; + {%- endif %} } {% endraw %} {%- if cookiecutter.enable_whitenoise.lower() == 'n' %} @@ -68,3 +80,14 @@ server { }{% endraw %} {%- endif %} } + +{%- if cookiecutter.add_asgi.lower() == 'y' %} +upstream uvicorn { + {% raw %}server unix://{{ asgi_socket }};{% endraw %} +} + +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} +{%- endif %} diff --git a/{{cookiecutter.github_repository}}/provisioner/roles/nginx/templates/site.80.conf.j2 b/{{cookiecutter.github_repository}}/provisioner/roles/nginx/templates/site.80.conf.j2 index 78bc0d98..40a4a79c 100644 --- a/{{cookiecutter.github_repository}}/provisioner/roles/nginx/templates/site.80.conf.j2 +++ b/{{cookiecutter.github_repository}}/provisioner/roles/nginx/templates/site.80.conf.j2 @@ -12,8 +12,13 @@ server { {% endif %} {% if vm and (nginx_cert.stat.exists == false or nginx_key.stat.exists == false) %} - location / { - uwsgi_pass unix:///tmp/uwsgi-{{ project_namespace }}.sock; + location / {{% endraw %} + {%- if cookiecutter.add_asgi.lower() == 'y' %} + {%raw%}proxy_pass unix://{{ asgi_socket }};{% endraw %} + {%- else %} + {%raw%}uwsgi_pass unix:///tmp/uwsgi-{{ project_namespace }}.sock;{% endraw %} + {%- endif %} + {% raw %} include /etc/nginx/uwsgi_params; # set correct scheme diff --git a/{{cookiecutter.github_repository}}/provisioner/roles/project_data/defaults/main.yml b/{{cookiecutter.github_repository}}/provisioner/roles/project_data/defaults/main.yml index 67012813..e571cf5c 100644 --- a/{{cookiecutter.github_repository}}/provisioner/roles/project_data/defaults/main.yml +++ b/{{cookiecutter.github_repository}}/provisioner/roles/project_data/defaults/main.yml @@ -4,7 +4,20 @@ pg_db: "{{ project_namespace }}" pg_user: dev pg_password: password django_requirements_file: requirements.txt +{% endraw %} +{%- if cookiecutter.add_asgi.lower() == 'y' %} +# asgi related variables +asgi_user: www-data +asgi_group: www-data +asgi_workers: 2 +{% raw %} +asgi_socket: /tmp/django-{{ domain_name }}-asgi.sock +{% endraw %} +asgi_user: www-data +asgi_group: www-data +asgi_workers: 2 +{% else %} # uwsgi related variables uwsgi_user: www-data uwsgi_group: www-data @@ -19,9 +32,11 @@ uwsgi_keepalive: 2 uwsgi_loglevel: info uwsgi_conf_path: /etc/uwsgi-emperor/vassals uwsgi_emperor_pid_file: /run/uwsgi-emperor.pid +{% raw %} uwsgi_socket: "/tmp/uwsgi-{{ project_namespace }}.sock" uwsgi_pid_file: "/tmp/uwsgi-{{ project_namespace }}.pid" uwsgi_log_dir: /var/log/uwsgi uwsgi_log_file: "{{ uwsgi_log_dir }}/{{ project_namespace }}.log" {% endraw %} +{% endif %} diff --git a/{{cookiecutter.github_repository}}/provisioner/roles/project_data/tasks/asgi-setup.yml b/{{cookiecutter.github_repository}}/provisioner/roles/project_data/tasks/asgi-setup.yml new file mode 100644 index 00000000..f6a00c53 --- /dev/null +++ b/{{cookiecutter.github_repository}}/provisioner/roles/project_data/tasks/asgi-setup.yml @@ -0,0 +1,29 @@ +{% raw %}--- +- name: apt_get install asgi packages + apt: pkg={{ item }} state=present + with_items: + - uuid-dev + - libcap-dev + - libpcre3-dev + tags: ["configure"] + +- name: make sure project directory is owned by asgi group + file: path={{ project_path }} state=directory owner={{user}} group={{asgi_group}} recurse=yes + tags: ["configure"] + +- name: copy django-asgi logrotate + template: src=django.logrotate.j2 + dest=/etc/logrotate.d/asgi-{{ deploy_environment}}-{{project_name}}-django + mode=644 + tags: ["configure"] + +- name: make sure log directory exists + file: path={{ project_log_dir }} state=directory owner={{asgi_user}} group={{asgi_group}} mode=751 recurse=yes + tags: ["configure"] + +- name: copy Django asgi service to systemd + template: src=django.asgi.ini.j2 + dest=/etc/systemd/system/asgi-{{project_namespace}}.service + mode=644 + tags: ["deploy"] +{% endraw %} diff --git a/{{cookiecutter.github_repository}}/provisioner/roles/project_data/tasks/main.yml b/{{cookiecutter.github_repository}}/provisioner/roles/project_data/tasks/main.yml index ec32104e..86ea1ef2 100644 --- a/{{cookiecutter.github_repository}}/provisioner/roles/project_data/tasks/main.yml +++ b/{{cookiecutter.github_repository}}/provisioner/roles/project_data/tasks/main.yml @@ -48,18 +48,30 @@ become: false tags: ['deploy'] -- import_tasks: uwsgi-setup.yml - - name: Run compilemessages for static translations django_manage: command=compilemessages app_path={{ project_path }} virtualenv={{ venv_path }} become: false tags: ['deploy'] +{% endraw %} +{%- if cookiecutter.add_asgi.lower() == 'y' %} +- import_tasks: asgi-setup.yml + +- name: Reload asgi processes +{% raw %} + systemd: state=restarted name=asgi-{{ project_namespace }} +{% endraw %} +{%- else %} +- import_tasks: uwsgi-setup.yml + +{% raw %} - name: Reload uwsgi processes command: uwsgi --reload {{ uwsgi_pid_file }} become: true when: not uwsgiconf.changed - tags: ['deploy']{% endraw %} +{% endraw %} +{%- endif %} + tags: ['deploy'] {%- if cookiecutter.add_celery.lower() == 'y' %} notify: reload celery # reload celery everytime uwsgi conf changes {%- endif %} diff --git a/{{cookiecutter.github_repository}}/provisioner/roles/project_data/templates/django.asgi.ini.j2 b/{{cookiecutter.github_repository}}/provisioner/roles/project_data/templates/django.asgi.ini.j2 new file mode 100644 index 00000000..0c84aceb --- /dev/null +++ b/{{cookiecutter.github_repository}}/provisioner/roles/project_data/templates/django.asgi.ini.j2 @@ -0,0 +1,18 @@ +{% raw %}[Unit] +Description={{ project_namespace }} gunicorn daemon +After=network.target + +[Service] +Environment=LC_ALL=en_US.utf-8 +Environment=LANG=en_US.utf-8 +StandardOutput=syslog +StandardError=syslog +SyslogIdentifier=gunicorn +User={{ asgi_user }} +Group={{ asgi_group }} +WorkingDirectory={{ project_path }} +ExecStart={{ venv_path }}/bin/gunicorn -w {{ asgi_workers }} --bind unix://{{ asgi_socket }} --access-logfile {{project_log_dir}}/asgi.log --capture-output --error-logfile {{project_log_dir}}/asgi-errors.log -k uvicorn.workers.UvicornWorker asgi:application + +[Install] +WantedBy=multi-user.target +{% endraw %} diff --git a/{{cookiecutter.github_repository}}/pyproject.toml b/{{cookiecutter.github_repository}}/pyproject.toml index 80f07f94..e2d19637 100644 --- a/{{cookiecutter.github_repository}}/pyproject.toml +++ b/{{cookiecutter.github_repository}}/pyproject.toml @@ -6,7 +6,7 @@ authors = ["{{cookiecutter.default_from_email}}"] [tool.poetry.dependencies] python = "~3.9" -Django = "~3.2.15" +Django = "~4.1" django-environ = "^0.9" django-sites = "^0.11" django-filter = "^21.1" @@ -14,7 +14,7 @@ argon2-cffi = "^21.3" python-dotenv = "^0.21" django-cors-headers = "^3.13" {% if cookiecutter.enable_whitenoise.lower() == 'y' -%} -whitenoise = "^6.2" +whitenoise = "^6.4.0" {%- endif %} # Extensions @@ -32,7 +32,7 @@ django-versatileimagefield = "^2.2" # REST APIs # ------------------------------------- -djangorestframework = "3.13.1" +djangorestframework = "3.14" drf-yasg = "^1.21" @@ -78,6 +78,9 @@ django-mail-templated = "^2.6" # Static Files and Media Storage # ------------------------------------- gunicorn = "~20.1.0" +{%- if cookiecutter.add_asgi.lower() == "y" %} +uvicorn = "^0.21.0" +{%- endif %} django-storages = "^1.13" boto3 = "~1.26.47" diff --git a/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/api.py b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/api.py index 69dfa2fa..f3671ad6 100644 --- a/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/api.py +++ b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/api.py @@ -2,7 +2,7 @@ import graphene from graphene_django.debug import DjangoDebug -from .users.schema import UserQueries, UserMutations +from .users.schema import UserMutations, UserQueries class Query(UserQueries): diff --git a/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/decorators.py b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/decorators.py index 49be2464..46e96a4c 100644 --- a/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/decorators.py +++ b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/decorators.py @@ -4,7 +4,7 @@ try: from graphql.execution.execute import GraphQLResolveInfo except ImportError: - from graphql.execution.base import ResolveInfo as GraphQLResolveInfo + from graphql.execution.base import ResolveInfo as GraphQLResolveInfo # type: ignore def context(f): diff --git a/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/middleware.py b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/middleware.py index 3c2c7d32..18752491 100644 --- a/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/middleware.py +++ b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/middleware.py @@ -1,4 +1,3 @@ -from django.contrib.auth import authenticate from {{cookiecutter.main_module}}.users.auth.tokens import get_user_for_token from {{cookiecutter.main_module}}.users.auth.utils import get_http_authorization diff --git a/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/users/mutations.py b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/users/mutations.py index beecc425..5479e2ed 100644 --- a/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/users/mutations.py +++ b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/users/mutations.py @@ -4,9 +4,10 @@ from graphene import relay from graphql import GraphQLError -from .types import CurrentUser, AuthenticatedUser from {{cookiecutter.main_module}}.users import services as user_services -from {{cookiecutter.main_module}}.users.auth import tokens, services as auth_services +from {{cookiecutter.main_module}}.users.auth import tokens +from {{cookiecutter.main_module}}.users.auth import services as auth_services +from .types import AuthenticatedUser, CurrentUser class SignUp(relay.ClientIDMutation): @@ -44,11 +45,12 @@ def validate_email(email): user = graphene.Field(AuthenticatedUser) @classmethod - def mutate_and_get_payload(cls, root, info, **data): - cls.validate_email(data["email"]) - user = user_services.get_and_authenticate_user(**data) + def mutate_and_get_payload(cls, root, info, email, password): + cls.validate_email(email) + user = user_services.get_and_authenticate_user(email, password) return Login(user=user) + class PasswordChange(relay.ClientIDMutation): class Input: current_password = graphene.String(required=True) @@ -57,10 +59,8 @@ class Input: user = graphene.Field(AuthenticatedUser) @classmethod - def mutate_and_get_payload(cls, root, info, **data): + def mutate_and_get_payload(cls, root, info, current_password, new_password): user = info.context.user - current_password = data["current_password"] - new_password = data["new_password"] if not user.check_password(current_password): raise GraphQLError("invalid_password") @@ -91,8 +91,7 @@ def clean_user(cls, email): return user @classmethod - def mutate_and_get_payload(cls, root, info, **data): - email = data["email"] + def mutate_and_get_payload(cls, root, info, email): user = cls.clean_user(email) auth_services.send_password_reset_mail(user) @@ -109,9 +108,7 @@ class Input: message = graphene.String() @classmethod - def mutate_and_get_payload(cls, root, info, **data): - new_password = data["new_password"] - token = data["token"] + def mutate_and_get_payload(cls, root, info, token, new_password): user = tokens.get_user_for_password_reset_token(token) password_validation.validate_password(new_password, user) diff --git a/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/users/schema.py b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/users/schema.py index 6bb7bda2..8244679f 100644 --- a/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/users/schema.py +++ b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/users/schema.py @@ -1,13 +1,19 @@ import graphene from graphene import relay from graphene_django.filter import DjangoFilterConnectionField + from {{cookiecutter.main_module}}.graphql.decorators import login_required, superuser_required from {{cookiecutter.main_module}}.graphql.utils import filter_objects from {{cookiecutter.main_module}}.users.models import User - -from .types import UserConnection, CurrentUser +from .mutations import ( + Login, + PasswordChange, + PasswordResetConfirm, + RequestPasswordReset, + SignUp, +) from .resolvers import get_all_users -from .mutations import SignUp, Login, PasswordChange, RequestPasswordReset, PasswordResetConfirm +from .types import CurrentUser, UserConnection class UserQueries(graphene.ObjectType): @@ -17,7 +23,7 @@ class UserQueries(graphene.ObjectType): users = DjangoFilterConnectionField( UserConnection, description="Return list of all Users" ) - user_details = relay.Node.Field(UserConnection) + user_details = graphene.Field(UserConnection, user_id=graphene.ID()) @login_required def resolve_me(self, info): @@ -30,9 +36,9 @@ def resolve_users(self, info, **kwargs): return qs @superuser_required - def resolve_user_details(self, info, **kwargs): + def resolve_user_details(self, info, user_id): return filter_objects( - User, kwargs['id'] + User, user_id ).first() diff --git a/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/users/types.py b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/users/types.py index c502a9e0..00f9d988 100644 --- a/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/users/types.py +++ b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/users/types.py @@ -3,8 +3,8 @@ from graphene_django.types import DjangoObjectType from {{cookiecutter.main_module}}.graphql.utils import CountableConnectionBase -from {{cookiecutter.main_module}}.users.models import User from {{cookiecutter.main_module}}.users.auth import tokens +from {{cookiecutter.main_module}}.users.models import User class CurrentUser(DjangoObjectType): @@ -16,6 +16,7 @@ class Meta: class AuthenticatedUser(DjangoObjectType): auth_token = graphene.String() + class Meta: model = User fields = ["id", "first_name", "last_name", "email"] diff --git a/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/utils.py b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/utils.py index 276765d8..e82107ae 100644 --- a/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/utils.py +++ b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/utils.py @@ -1,8 +1,8 @@ import graphene +from django.core.exceptions import ValidationError from graphene import relay -from graphql_relay import from_global_id from graphql.error import GraphQLError -from django.core.exceptions import ValidationError +from graphql_relay import from_global_id def filter_objects(object_name, relay_ids, otherwise=None): @@ -10,7 +10,7 @@ def filter_objects(object_name, relay_ids, otherwise=None): relay_ids = [relay_ids] try: object_ids = [from_global_id(relay_id)[1] for relay_id in relay_ids] - return object_name.filter.with_ids(object_ids) + return object_name.objects.filter(id__in=object_ids) except: # noqa return otherwise From 23840aa86d165cc8025096ad9606d947df8f32f8 Mon Sep 17 00:00:00 2001 From: John Taylor <62556052+john-fueled@users.noreply.github.com> Date: Wed, 20 Dec 2023 08:51:05 -0500 Subject: [PATCH 8/9] feat(fly-io): Added fly.io template files (#475) Added fly.io template files for a deployable Dockerfile and Github actions. > Why was this change necessary? Spin up a hosted development environment fast. > How does it address the problem? Adds fly.io as an optional deployment destination. > Are there any side effects? The local and dev Dockerfiles do not have the same configuration that Fly.io expects. When deploying the `dev` Dockerfile, Fly.io deploys hang due to the `entrypoint` script running the gunicorn command which never exits. Therefore a separate `compose/fly` folder has the necessary changes to pass the build process. Addresses #466 --------- Co-authored-by: Suneet Choudhary --- .gitignore | 4 ++ README.md | 1 + cookiecutter.json | 1 + hooks/post_gen_project.sh | 5 ++ .../.github/workflows/fly.yml | 15 +++++ {{cookiecutter.github_repository}}/README.md | 36 +++++++++-- .../compose/fly/django/Dockerfile | 64 +++++++++++++++++++ 7 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 {{cookiecutter.github_repository}}/.github/workflows/fly.yml create mode 100644 {{cookiecutter.github_repository}}/compose/fly/django/Dockerfile diff --git a/.gitignore b/.gitignore index 132f213e..fd82c3f6 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,7 @@ Session.vim # Pycharm project modules .idea/ + + +### VSCode +.vscode/ \ No newline at end of file diff --git a/README.md b/README.md index 7bf445dd..1ae69a7c 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ ### Optional - Heroku Setup +- Fly Setup - Ubuntu 20 LTS via [Ansible] - Celery with flower integration. - AWS S3 media storage diff --git a/cookiecutter.json b/cookiecutter.json index f983e873..8d1f77cf 100644 --- a/cookiecutter.json +++ b/cookiecutter.json @@ -10,6 +10,7 @@ , "add_newrelic" : "y" , "add_postgis": "n" , "add_heroku": "n" + , "add_fly": "n" , "enable_whitenoise": "n" , "add_ansible": "y" , "letsencrypt": "y" diff --git a/hooks/post_gen_project.sh b/hooks/post_gen_project.sh index 27f13ed4..bbaf9820 100755 --- a/hooks/post_gen_project.sh +++ b/hooks/post_gen_project.sh @@ -29,6 +29,11 @@ if echo "{{ cookiecutter.add_heroku }}" | grep -iq "^n"; then rm -rf uwsgi.ini Procfile runtime.txt bin/post_compile fi +if echo "{{ cookiecutter.add_fly }}" | grep -iq "^n"; then + rm .github/workflows/fly.yml + rm -rf compose/fly +fi + if echo "{{ cookiecutter.add_ansible }}" | grep -iq "^n"; then rm -rf provisioner Vagrantfile ansible.cfg fi diff --git a/{{cookiecutter.github_repository}}/.github/workflows/fly.yml b/{{cookiecutter.github_repository}}/.github/workflows/fly.yml new file mode 100644 index 00000000..13e98718 --- /dev/null +++ b/{{cookiecutter.github_repository}}/.github/workflows/fly.yml @@ -0,0 +1,15 @@ +name: Fly Deploy +on: + push: + branches: + - master +jobs: + deploy: + name: Deploy app + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: superfly/flyctl-actions/setup-flyctl@master + - run: flyctl deploy --dockerfile ./compose/fly/django/Dockerfile + env: + FLY_API_TOKEN: ${{ "{{" }} secrets.FLY_API_TOKEN {{ "}}" }} diff --git a/{{cookiecutter.github_repository}}/README.md b/{{cookiecutter.github_repository}}/README.md index 592b7e8c..1e8ce89a 100644 --- a/{{cookiecutter.github_repository}}/README.md +++ b/{{cookiecutter.github_repository}}/README.md @@ -1,7 +1,6 @@ -{{ cookiecutter.project_name }} -============================== +# {{ cookiecutter.project_name }} -__Version:__ {{ cookiecutter.version }} +**Version:** {{ cookiecutter.version }} {{ cookiecutter.project_description }} @@ -9,7 +8,7 @@ __Version:__ {{ cookiecutter.version }} {% if cookiecutter.add_docker == 'y' %} !!! note - For setting up locally using `Docker`, check [here](docs/backend/docker_setup.md) +For setting up locally using `Docker`, check [here](docs/backend/docker_setup.md) {% endif %} Minimum requirements: **pip, python3.9, poetry, redis & [PostgreSQL 11][install-postgres]{% if cookiecutter.add_postgis.lower() == "y" %} with postgis-2.4{% endif %}**, setup is tested on Mac OSX only. @@ -43,7 +42,7 @@ Running `poetry lock` generates `poetry.lock` which has all versions pinned. You can install Poetry by using `pip install --pre poetry` or by following the official installation guide [here](https://github.com/python-poetry/poetry#installation). -*Tip:* We recommend that you use this workflow and keep `pyproject.toml` as well as `poetry.lock` under version control to make sure all computers and environments run exactly the same code. +_Tip:_ We recommend that you use this workflow and keep `pyproject.toml` as well as `poetry.lock` under version control to make sure all computers and environments run exactly the same code. ### Other tools @@ -61,13 +60,38 @@ poetry export --without-hashes -f requirements.txt -o requirements_dev.txt --wit , respectively. - ## Deploying Project The deployment are managed via travis, but for the first time you'll need to set the configuration values on each of the server. Check out detailed server setup instruction [here](docs/backend/server_config.md). +{% if cookiecutter.add_fly == 'y' %} + +### Develop on Fly.io + +Create a [fly.io](https://fly.io) account. + +Install `flyctl` and run the following commands to set up the Fly.io application, it will ask a series of questions regarding deployment configuration. + +``` +brew install flyctl +fly lauch +``` + +When ready to deploy, simply run the command using the Fly Dockerfile: + +``` +flyctl deploy --dockerfile ./compose/fly/django/Dockerfile +``` + +There is also a Github Action provided `.github/workflows/fly.yml` to deploy the application on the `master` branch. In order to deploy from CI: + +1. Create Fly Access Token [here](https://fly.io/user/personal_access_tokens). +2. Add the `FLY_API_TOKEN` to the Github repo secrets [here](https://github.com/{{cookiecutter.github_username}}/{{cookiecutter.github_repository}}/settings/secrets/actions) + +{% endif %} + ## How to release {{ cookiecutter.project_name }} Execute the following commands: diff --git a/{{cookiecutter.github_repository}}/compose/fly/django/Dockerfile b/{{cookiecutter.github_repository}}/compose/fly/django/Dockerfile new file mode 100644 index 00000000..73dffc89 --- /dev/null +++ b/{{cookiecutter.github_repository}}/compose/fly/django/Dockerfile @@ -0,0 +1,64 @@ +ARG PYTHON_VERSION=3.9-slim-buster + +# define an alias for the specfic python version used in this file. +FROM python:${PYTHON_VERSION} as python + +ENV POETRY_VERSION=1.3.2 + +ARG BUILD_ENVIRONMENT=dev +ARG APP_HOME=/app + +ENV PYTHONUNBUFFERED 1 +ENV PYTHONDONTWRITEBYTECODE 1 +ENV BUILD_ENV ${BUILD_ENVIRONMENT} + +WORKDIR ${APP_HOME} + +RUN addgroup --system django \ + && adduser --system --ingroup django django + +# Install required system dependencies +RUN apt-get update && apt-get install --no-install-recommends -y \ + # dependencies for building Python packages + build-essential \ + # psycopg2 dependencies + libpq-dev \ + # Translations dependencies + gettext \ + # Versatile image field & pillow \ + libmagic1 \ + libmagic-dev \ + + # cleaning up unused files + && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ + && rm -rf /var/lib/apt/lists/* + +# Install Poetry +RUN pip install --no-cache-dir poetry==${POETRY_VERSION} + +COPY poetry.lock pyproject.toml ${APP_HOME}/ + +# Project initialization: +RUN poetry config virtualenvs.create false && poetry install --no-interaction --no-ansi + +COPY --chown=django:django ./compose/dev/django/celery/worker/start /start-celeryworker +RUN chmod +x /start-celeryworker + + +COPY --chown=django:django ./compose/dev/django/celery/beat/start /start-celerybeat +RUN chmod +x /start-celerybeat + + +COPY ./compose/dev/django/celery/flower/start /start-flower +RUN chmod +x /start-flower + +COPY --chown=django:django . ${APP_HOME} + +# make django owner of the WORKDIR directory as well. +RUN chown django:django ${APP_HOME} + +RUN python manage.py collectstatic --noinput + +EXPOSE 8000 + +CMD ["gunicorn", "--bind", ":8000", "--workers", "2", "wsgi:application"] From 8dbac349b61cc04ce51faa2e2f42a90744dd0ab7 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Wed, 20 Dec 2023 19:50:35 +0530 Subject: [PATCH 9/9] docs: update history.md --- HISTORY.md | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 5 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index 543d5a87..abf01165 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -2,13 +2,74 @@ History ---------------------- Note: This file is autogenerated with [generate-history.sh](generate-history.sh) -### 2022-01-05 +### 2023-12-20 + - feat(fly-io): Added fly.io template files (#475) ([John Taylor]) -- Integrate poetry and update makefile commands. ([Sanyam Khurana]) -- Move isort/black config from .editorconfig to .pyproject.toml. ([Sanyam Khurana]) +### 2023-11-27 + - feat(async): add uvicorn, asgi, upgrade django=4.1 (#473) ([Suneet Choudhary]) -### 2022-01-11 -- Fix the use of letsencrypt tasks to run only on hosts configured with `use_letsencrypt`. ([Sanyam Khurana]) +### 2023-04-18 + - fix(docker): Fixed an issue with the Postgres Docker image name (#474) ([John Taylor]) + +### 2023-03-28 + - feat: add GraphQL API implementation using django-graphene (#449) ([Suneet Choudhary]) + +### 2023-03-03 + - fix(docker-start): Redirect access/error logs to std-out (#470) ([Sanyam Khurana]) + +### 2023-02-27 + - fix(Makefile): Update poetry export command to use --with option (#468) ([Sanyam Khurana]) + +### 2023-02-06 + - fix(compose): Use postgis protocol for connection (#467) ([Sanyam Khurana]) + +### 2023-01-06 + - feat(docker): Use GDAL/postgis when postgis is enabled (#464) ([Sanyam Khurana]) + +### 2022-12-15 + - Configure Renovate (#459) ([renovate[bot]]) + +### 2022-12-07 + - fix(users/api): Make code conformant to PEP8 ([Sanyam Khurana]) + - chore: Add docker-compose to run all services through docker (#440) ([Sanyam Khurana]) + +### 2022-10-27 + - fix(pyproject.toml): Add dependencies for mkdocs (#458) ([Sanyam Khurana]) + +### 2022-10-25 + - docs(README): Update docs to drop legacy poetry command (#457) ([Sanyam Khurana]) + - fix(github-actions): Use poetry to install requirements & run tests (#456) ([Sanyam Khurana]) + +### 2022-09-27 + - docs(coding_rules): correct class names to be PascalCase (#455) ([Sanyam Khurana]) + +### 2022-09-16 + - ci(github-actions): ensure poetry is installed lint action (#453) ([Sanyam Khurana]) + +### 2022-09-14 + - chore: make the relative and absolute imports consistent (#450) ([Sahith Chandan Mekala]) + - upgrade packages (#451) ([Sahith Chandan Mekala]) + +### 2022-06-18 + - upgrade(requirements): black 21.12b0 => 22.3.0 (#447) ([Akash Mishra]) + +### 2022-02-16 + - feat(setup): Add dependency management with poetry (#444) ([Sanyam Khurana]) + +### 2022-01-17 + - chore: update packages - celery, ansible (#445) ([Suneet Choudhary]) + +### 2022-01-13 + - fix(nginx/tasks): Run letsencrypt only for hosts with use_letsencrypt config (#446) ([Sanyam Khurana]) + +### 2022-01-04 + - Fix(API-docs): deprecated rest_framework_swagger (#441) ([Suneet Choudhary]) + +### 2021-12-17 + - feat: update python dependencies (#443) ([Saurabh Kumar]) + +### 2021-12-14 + - chore(CI): add python cache with pip ([Saurabh Kumar]) ### 2021-12-07 - chore: Update django-sites to 0.11 for Django 3.x (#442) ([Sanyam Khurana])