diff --git a/{{cookiecutter.project_slug}}/config/settings/base.py b/{{cookiecutter.project_slug}}/config/settings/base.py index 292c1729d..2e7d11d1c 100644 --- a/{{cookiecutter.project_slug}}/config/settings/base.py +++ b/{{cookiecutter.project_slug}}/config/settings/base.py @@ -85,6 +85,7 @@ THIRD_PARTY_APPS = [ {%- if cookiecutter.use_celery == 'y' -%} "django_celery_beat", {%- endif %} + "graphene_django", ] LOCAL_APPS = [ @@ -325,5 +326,47 @@ STATICFILES_FINDERS += ["compressor.finders.CompressorFinder"] # https://github.com/ottoyiu/django-cors-headers#cors_origin_allow_all CORS_ORIGIN_ALLOW_ALL = True {%- endif %} + +# DJANGO REST FRAMEWORK +# ------------------------------------------------------------------------------ +# http://www.django-rest-framework.org/ +REST_FRAMEWORK = { + "DEFAULT_PERMISSION_CLASSES": ( + "rest_framework.permissions.IsAuthenticated", + ), + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework.authentication.SessionAuthentication", + "rest_framework.authentication.BasicAuthentication", + "oauth2_provider.contrib.rest_framework.OAuth2Authentication", + "rest_framework_jwt.authentication.JSONWebTokenAuthentication", + ), + "DEFAULT_RENDERER_CLASSES": ( + "rest_framework.renderers.JSONRenderer", + "rest_framework.renderers.BrowsableAPIRenderer", + "rest_framework.renderers.AdminRenderer", + ), + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination", + "PAGE_SIZE": 100, + "COERCE_DECIMAL_TO_STRING": False, + "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning", + # NOTE: See: https://www.django-rest-framework.org/community/3.10-announcement/#continuing-to-use-coreapi + "DEFAULT_SCHEMA_CLASS": "rest_framework.schemas.coreapi.AutoSchema", +} + +# Graphene Setup +# ------------------------------------------------------------------------------ +# See: http://docs.graphene-python.org/projects/django/en/latest/tutorial-plain/#update-settings +GRAPHENE = { + "SCHEMA": "{{ cookiecutter.project_slug }}.graphql.schema.schema", + 'SCHEMA_OUTPUT': 'frontend/src/apollo/schema.json', + 'SCHEMA_INDENT': 2, + "MIDDLEWARE": [ + "graphene_django.debug.DjangoDebugMiddleware", + ] +} +# NOTE: As Graphene schema gets larger, it needs more room to run the recursive graphql queries +# See: https://github.com/graphql-python/graphene/issues/663 +GRAPHENE_RECURSION_LIMIT = env.int("GRAPHENE_RECURSION_LIMIT", default=3500) + # Your stuff... # ------------------------------------------------------------------------------ diff --git a/{{cookiecutter.project_slug}}/config/urls.py b/{{cookiecutter.project_slug}}/config/urls.py index 0cc2e566f..bf170d14f 100644 --- a/{{cookiecutter.project_slug}}/config/urls.py +++ b/{{cookiecutter.project_slug}}/config/urls.py @@ -2,9 +2,12 @@ from django.conf import settings from django.urls import include, path, re_path from django.conf.urls.static import static from django.contrib import admin +from django.views.decorators.csrf import csrf_exempt from django.views.generic import TemplateView from django.views import defaults as default_views +from graphene_file_upload.django import FileUploadGraphQLView +from rest_framework.documentation import include_docs_urls from rest_framework.routers import DefaultRouter @@ -14,16 +17,22 @@ router = DefaultRouter(trailing_slash=False) urlpatterns = [ path("", TemplateView.as_view(template_name="pages/home.html"), name="home"), re_path(r'^app/(?P.*)$', TemplateView.as_view(template_name="index.html"), name='app'), + + # APIs path("api/", include(router.urls)), - path( - "about/", TemplateView.as_view(template_name="pages/about.html"), name="about" - ), - # Django Admin, use {% raw %}{% url 'admin:index' %}{% endraw %} - path(settings.ADMIN_URL, admin.site.urls), - # User management + path("api-docs/", include_docs_urls(title="{{ cookiecutter.project_name }} REST API", public=False)), + path("graphql/", csrf_exempt(FileUploadGraphQLView.as_view(graphiql=True, pretty=True))), + + # User management from django-all-auth + path("about/", TemplateView.as_view(template_name="pages/about.html"), name="about"), path("users/", include("{{ cookiecutter.project_slug }}.users.urls", namespace="users")), path("accounts/", include("allauth.urls")), + + # Django Admin, use {% raw %}{% url 'admin:index' %}{% endraw %} + path(settings.ADMIN_URL, admin.site.urls), + # Your stuff: custom urls includes go here + ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) if settings.DEBUG: diff --git a/{{cookiecutter.project_slug}}/requirements/base.txt b/{{cookiecutter.project_slug}}/requirements/base.txt index 980380838..ae03b3e0c 100644 --- a/{{cookiecutter.project_slug}}/requirements/base.txt +++ b/{{cookiecutter.project_slug}}/requirements/base.txt @@ -29,6 +29,11 @@ django-compressor==2.3 # https://github.com/django-compressor/django-compressor {%- endif %} django-redis==4.10.0 # https://github.com/niwinz/django-redis +# GraphQL +graphene-django==2.6.0 # http://docs.graphene-python.org/projects/django/en/latest/ +graphene-django-optimizer==0.6.0 # https://github.com/tfoxy/graphene-django-optimizer +graphene-file-upload==1.2.2 # https://github.com/lmcgartland/graphene-file-upload + # Django REST Framework djangorestframework==3.10.3 # https://github.com/encode/django-rest-framework coreapi==2.3.3 # https://github.com/core-api/python-client diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/graphql/__init__.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/graphql/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/graphql/conversions.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/graphql/conversions.py new file mode 100644 index 000000000..99096664f --- /dev/null +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/graphql/conversions.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +from django.contrib.postgres.fields import ArrayField, JSONField +from django.db.models import FileField +from django.forms import Field + +from django_filters import Filter +from graphene import Float, Int, JSONString, List, String +from graphene_django.converter import convert_django_field +from graphene_django.forms.converter import convert_form_field + + +# NOTE: This needs to be done before importing from queries +# SEE: https://github.com/graphql-python/graphene-django/issues/18 +@convert_django_field.register(ArrayField) +def convert_array_to_list(field, registry=None): + return List(of_type=String, description=field.help_text, required=not field.null) + + +@convert_django_field.register(JSONField) +def convert_jsonb_to_string(field, registry=None): + return JSONString(description=field.help_text, required=not field.null) + + +@convert_django_field.register(FileField) +def convert_file_to_string(field, registry=None): + return String(description=field.help_text, required=not field.null) + + +def generate_list_filter_class(inner_type): + """ + Returns a Filter class that will resolve into a List(`inner_type`) graphene + type. + + This allows us to do things like use `__in` filters that accept graphene + lists instead of a comma delimited value string that's interpolated into + a list by django_filters.BaseCSVFilter (which is used to define + django_filters.BaseInFilter) + """ + + form_field = type( + "List{}FormField".format(inner_type.__name__), + (Field,), + {}, + ) + filter_class = type( + "{}ListFilter".format(inner_type.__name__), + (Filter,), + { + "field_class": form_field, + "__doc__": ( + "{0}ListFilter is a small extension of a raw django_filters.Filter " + "that allows us to express graphql List({0}) arguments using FilterSets." + "Note that the given values are passed directly into queryset filters." + ).format(inner_type.__name__), + }, + ) + convert_form_field.register(form_field)( + lambda x: List(inner_type, required=x.required) + ) + + return filter_class + + +FloatListFilter = generate_list_filter_class(Float) +IntListFilter = generate_list_filter_class(Int) +StringListFilter = generate_list_filter_class(String) diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/graphql/mutation.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/graphql/mutation.py new file mode 100644 index 000000000..f9eb5febc --- /dev/null +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/graphql/mutation.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +from graphene import Field, ObjectType +from graphene_django.debug import DjangoDebug + + +class Mutation(ObjectType): + debug = Field(DjangoDebug, name='__debug') diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/graphql/query.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/graphql/query.py new file mode 100644 index 000000000..c7d5d6103 --- /dev/null +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/graphql/query.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +from graphene import Field, ObjectType +from graphene_django.debug import DjangoDebug + + +class Query(ObjectType): + debug = Field(DjangoDebug, name='__debug') diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/graphql/schema.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/graphql/schema.py new file mode 100644 index 000000000..18d20011f --- /dev/null +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/graphql/schema.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +import sys + +from django.conf import settings + +from graphene import Schema + +# NOTE: Conversions need to happen before importing from queries/mutations +from . import conversions # NOQA +from . import mutation, query + + +# NOTE: As Graphene schema gets larger, it needs more room to run the recursive graphql queries +# See: https://github.com/graphql-python/graphene/issues/663 +sys.setrecursionlimit(settings.GRAPHENE_RECURSION_LIMIT) + + +schema = Schema(query=query.Query, mutation=mutation.Mutation)