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/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]) diff --git a/README.md b/README.md index 57e43dc1..f13db6e9 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/)! @@ -29,6 +29,7 @@ ### Optional - Heroku Setup +- Fly Setup - Ubuntu 20 LTS via [Ansible] - Celery with flower integration. - AWS S3 media storage @@ -36,6 +37,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..05a32c0d --- /dev/null +++ b/cookiecutter-test-config.yaml @@ -0,0 +1,5 @@ +default_context: + enable_whitenoise: "y" + add_celery: "y" + add_graphql: "y" + add_asgi: "y" diff --git a/cookiecutter.json b/cookiecutter.json index e61e18ef..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" @@ -18,6 +19,8 @@ , "add_sentry": "y" , "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 40a77b5b..f094ec70 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 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 ansible.cfg fi @@ -45,6 +50,18 @@ 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 "{{ 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/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}}/.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 4981f93b..17c1695f 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. ## Deploying Project @@ -52,6 +51,32 @@ The deployment are managed via travis, but for the first time you'll need to set 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}}/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/Dockerfile b/{{cookiecutter.github_repository}}/compose/dev/django/Dockerfile index 016b993d..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.3.1 +ENV POETRY_VERSION=1.3.2 ARG BUILD_ENVIRONMENT=dev ARG APP_HOME=/app @@ -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/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/dev/django/start b/{{cookiecutter.github_repository}}/compose/dev/django/start index d2c305b9..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 +{%- 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/dev/postgres/Dockerfile b/{{cookiecutter.github_repository}}/compose/dev/postgres/Dockerfile index 9a3f7065..748c9cfd 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 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/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"] diff --git a/{{cookiecutter.github_repository}}/compose/local/Dockerfile b/{{cookiecutter.github_repository}}/compose/local/Dockerfile index b50cf68f..e1fec074 100644 --- a/{{cookiecutter.github_repository}}/compose/local/Dockerfile +++ b/{{cookiecutter.github_repository}}/compose/local/Dockerfile @@ -5,8 +5,7 @@ FROM python:${PYTHON_VERSION} as python ARG BUILD_ENVIRONMENT=local ARG APP_HOME=/app - -ENV POETRY_VERSION=1.3.1 +ENV POETRY_VERSION=1.3.2 ENV PYTHONUNBUFFERED 1 ENV PYTHONDONTWRITEBYTECODE 1 ENV BUILD_ENV ${BUILD_ENVIRONMENT} @@ -27,6 +26,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/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 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 49c9782b..bcb025b9 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}}/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}}/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 31cb1b43..81fb96c6 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 @@ -11,9 +11,14 @@ server { } {% endif %} - {% if nginx_cert.stat.exists == false or nginx_key.stat.exists == false %} - location / { - uwsgi_pass unix:///tmp/uwsgi-{{ project_namespace }}.sock; + {% if vm and (nginx_cert.stat.exists == false or nginx_key.stat.exists == false) %} + 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 2c02b9c2..6eac5cc3 100644 --- a/{{cookiecutter.github_repository}}/provisioner/roles/project_data/defaults/main.yml +++ b/{{cookiecutter.github_repository}}/provisioner/roles/project_data/defaults/main.yml @@ -3,7 +3,18 @@ pg_hstore: False pg_db: "{{ project_namespace }}" pg_user: dev pg_password: password - +{%- 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 @@ -18,9 +29,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 ff5d9276..00617b9a 100644 --- a/{{cookiecutter.github_repository}}/provisioner/roles/project_data/tasks/main.yml +++ b/{{cookiecutter.github_repository}}/provisioner/roles/project_data/tasks/main.yml @@ -52,8 +52,6 @@ become: false tags: ['deploy'] -- import_tasks: uwsgi-setup.yml - - name: Run compilemessages for static translations command: "poetry run python manage.py compilemessages" args: @@ -61,11 +59,25 @@ 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 86c956ce..e2d19637 100644 --- a/{{cookiecutter.github_repository}}/pyproject.toml +++ b/{{cookiecutter.github_repository}}/pyproject.toml @@ -6,19 +6,20 @@ 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" 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 # ------------------------------------- -pytz = "^2022.2" +pytz = "^2022.7" # Models # ------------------------------------- @@ -31,10 +32,16 @@ django-versatileimagefield = "^2.2" # REST APIs # ------------------------------------- -djangorestframework = "^3.13" +djangorestframework = "3.14" 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 @@ -71,8 +78,11 @@ 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.24.67" +boto3 = "~1.26.47" # Caching # ------------------------------------- @@ -91,7 +101,7 @@ uWSGI = "^2.0" # Logging # ------------------------------------- -newrelic = "~8.0.0.179" +newrelic = "~8.5.0" {% endif %} [tool.poetry.dev-dependencies] @@ -99,13 +109,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 +130,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..f3671ad6 --- /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 UserMutations, UserQueries + + +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..46e96a4c --- /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 # type: ignore + + +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..18752491 --- /dev/null +++ b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/middleware.py @@ -0,0 +1,24 @@ +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..5479e2ed --- /dev/null +++ b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/users/mutations.py @@ -0,0 +1,118 @@ +import graphene +from django.db import transaction +from django.contrib.auth import password_validation +from graphene import relay +from graphql import GraphQLError + +from {{cookiecutter.main_module}}.users import services as user_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): + 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, 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) + new_password = graphene.String(required=True) + + user = graphene.Field(AuthenticatedUser) + + @classmethod + def mutate_and_get_payload(cls, root, info, current_password, new_password): + user = info.context.user + + 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, 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, token, new_password): + + 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..8244679f --- /dev/null +++ b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/users/schema.py @@ -0,0 +1,50 @@ +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 .mutations import ( + Login, + PasswordChange, + PasswordResetConfirm, + RequestPasswordReset, + SignUp, +) +from .resolvers import get_all_users +from .types import CurrentUser, UserConnection + + +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 = graphene.Field(UserConnection, user_id=graphene.ID()) + + @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, user_id): + return filter_objects( + User, user_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..00f9d988 --- /dev/null +++ b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/users/types.py @@ -0,0 +1,35 @@ +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.auth import tokens +from {{cookiecutter.main_module}}.users.models import User + + +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..e82107ae --- /dev/null +++ b/{{cookiecutter.github_repository}}/{{cookiecutter.main_module}}/graphql/utils.py @@ -0,0 +1,61 @@ +import graphene +from django.core.exceptions import ValidationError +from graphene import relay +from graphql.error import GraphQLError +from graphql_relay import from_global_id + + +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.objects.filter(id__in=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()