diff --git a/README.md b/README.md index a454eeb..06acc58 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ This package add some extra functionalities to graphene-django to facilitate the 2. Allows to define DjangoRestFramework serializers based Mutations. 3. Allows use Directives on Queries and Fragments. -**NOTE:** Subscription support was moved to [graphene-django-subscriptions](https://github.com/eamigo86/graphene-django-subscriptions) due incompatibility with subscriptions on graphene-django>=2.0 +**NOTE:** Subscription support was moved to [graphene-django-subscriptions](https://github +.com/eamigo86/graphene-django-subscriptions). ## Installation @@ -52,6 +53,8 @@ for DjangoListObjectType classes pagination definitions on settings.py like this 'DEFAULT_PAGINATION_CLASS': 'graphene_django_extras.paginations.LimitOffsetGraphqlPagination', 'DEFAULT_PAGE_SIZE': 20, 'MAX_PAGE_SIZE': 50, + 'CACHE_ACTIVE': True, + 'CACHE_TIMEOUT': 300 # seconds } ``` @@ -468,9 +471,6 @@ And we get this output data: ``` As we see, the directives is a easy way to format output data on queries, and it's can be put together like a chain. -**IMPORTANT NOTE**: The *date* directive only work with datetime returned as String Type and take a string of tokens, -this tokens are the common of JavaScript date format. - **List of possible date's tokens**: "YYYY", "YY", "WW", "W", "DD", "DDDD", "d", "ddd", "dddd", "MM", "MMM", "MMMM", "HH", "hh", "mm", "ss", "A", "ZZ", "z". @@ -483,6 +483,18 @@ You can use this shortcuts too: ## Change Log: +#### v0.3.0: + 1. Added Binary graphql type. A BinaryArray is used to convert a Django BinaryField to the string form. + 2. Added 'CACHE_ACTIVE' and 'CACHE_TIMEOUT' config options to GRAPHENE_DJANGO_EXTRAS settings for activate cache and + define a expire time. Default values are: CACHE_ACTIVE=False, CACHE_TIMEOUT=300 (seconds). Only available for + Queries. + 3. Updated Date directive for use with Django TimeField, DateField, and DateTimeField. + 4. Updated ExtraGraphQLView and AuthenticatedGraphQLView to allow use subscription requests on graphene-django >=2.0 + 5. Updated setup dependence to graphene-django>=2.0. + +#### v0.2.2: + 1. Fixed performance bug on some queries when request nested ManyToMany fields. + #### v0.2.1: 1. Fixed bug with default PaginationClass and DjangoFilterPaginateListField. diff --git a/README.rst b/README.rst index 575a926..b8a5830 100644 --- a/README.rst +++ b/README.rst @@ -7,7 +7,7 @@ This package add some extra functionalities to **graphene-django** to facilitate 2. Allows to define DjangoRestFramework serializers based Mutations. 3. Allows use Directives on Queries and Fragments. -**NOTE:** Subscription support was moved to `graphene-django-subscriptions `_ due incompatibility with subscriptions on graphene-django>=2.0 +**NOTE:** Subscription support was moved to `graphene-django-subscriptions `_ Installation: ------------- @@ -55,6 +55,8 @@ DjangoListObjectType classes pagination definitions on settings.py like this: 'DEFAULT_PAGINATION_CLASS': 'graphene_django_extras.paginations.LimitOffsetGraphqlPagination', 'DEFAULT_PAGE_SIZE': 20, 'MAX_PAGE_SIZE': 50, + 'CACHE_ACTIVE': True, + 'CACHE_TIMEOUT': 300 # seconds } ******************** @@ -510,9 +512,6 @@ And we get this output data: As we see, the directives is a easy way to format output data on queries, and it's can be put together like a chain. -**IMPORTANT NOTE**: The *date* directive only work with datetime returned as Graphene String Type not with normal -Graphene DateTime, Time or Date Types and take a string of tokens, this tokens are the common of JavaScript date format. - **List of possible date's tokens**: "YYYY", "YY", "WW", "W", "DD", "DDDD", "d", "ddd", "dddd", "MM", "MMM", "MMMM", "HH", "hh", "mm", "ss", "A", "ZZ", "z". @@ -526,6 +525,20 @@ You can use this shortcuts too: Change Log: ----------- +******* +v0.3.0: +******* +1. Added Binary graphql type. A BinaryArray is used to convert a Django BinaryField to the string form. +2. Added 'CACHE_ACTIVE' and 'CACHE_TIMEOUT' config options to GRAPHENE_DJANGO_EXTRAS settings for activate cache queries result and define a expire time. Default values are: CACHE_ACTIVE=False, CACHE_TIMEOUT=300 (5 minutes). +3. Updated Date directive for use with Django TimeField, DateField, and DateTimeField. +4. Updated ExtraGraphQLView and AuthenticatedGraphQLView to allow use subscription requests on graphene-django >=2.0 +5. Updated setup dependence to graphene-django>=2.0. + +******* +v0.2.2: +******* +1. Fixed performance bug on some queries when request nested ManyToMany fields. + ******* v0.2.1: ******* diff --git a/graphene_django_extras/__init__.py b/graphene_django_extras/__init__.py index 8533de3..d02b17d 100644 --- a/graphene_django_extras/__init__.py +++ b/graphene_django_extras/__init__.py @@ -9,7 +9,7 @@ from .paginations import LimitOffsetGraphqlPagination, PageGraphqlPagination, CursorGraphqlPagination from .types import DjangoObjectType, DjangoInputObjectType, DjangoListObjectType, DjangoSerializerType -VERSION = (0, 2, 2, 'final', '') +VERSION = (0, 3, 0, 'final', '') __version__ = get_version(VERSION) diff --git a/graphene_django_extras/base_types.py b/graphene_django_extras/base_types.py index dfcaf9d..c79e026 100644 --- a/graphene_django_extras/base_types.py +++ b/graphene_django_extras/base_types.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import +import binascii import datetime -import graphene -from graphene import Scalar +import graphene from graphene.utils.str_converters import to_camel_case from graphql.language import ast @@ -17,39 +17,6 @@ ) -class Date(Scalar): - ''' - The `Date` scalar type represents a Date - value as specified by - [iso8601](https://en.wikipedia.org/wiki/ISO_8601). - ''' - epoch_time = '00:00:00' - - @staticmethod - def serialize(date): - if isinstance(date, datetime.datetime): - date = date.date() - - assert isinstance(date, datetime.date), ( - 'Received not compatible date "{}"'.format(repr(date)) - ) - return date.isoformat() - - @classmethod - def parse_literal(cls, node): - if isinstance(node, ast.StringValue): - return cls.parse_value(node.value) - - @classmethod - def parse_value1(cls, value): - dt = iso8601.parse_date('{}T{}'.format(value, cls.epoch_time)) - return datetime.date(dt.year, dt.month, dt.day) - - @staticmethod - def parse_value(value): - return iso8601.parse_date(value).date() - - def object_type_factory(_type, new_model, new_name=None, new_only_fields=(), new_exclude_fields=(), new_filter_fields=None, new_registry=None, new_skip_registry=False): @@ -144,3 +111,116 @@ class GenericForeignKeyInputType(graphene.InputObjectType): class Meta: description = ' Auto generated InputType for a model\'s GenericForeignKey field ' + + +# ************************************************ # +# ************** CUSTOM BASE TYPES *************** # +# ************************************************ # +class CustomDate(object): + + def __init__(self, date): + self.date_str = date + + +class Binary(graphene.Scalar): + """ + BinaryArray is used to convert a Django BinaryField to the string form + """ + @staticmethod + def binary_to_string(value): + return binascii.hexlify(value).decode("utf-8") + + serialize = binary_to_string + parse_value = binary_to_string + + @classmethod + def parse_literal(cls, node): + if isinstance(node, ast.StringValue): + return cls.binary_to_string(node.value) + + +class Time(graphene.Scalar): + """ + The `Time` scalar type represents a Time value as + specified by + [iso8601](https://en.wikipedia.org/wiki/ISO_8601). + """ + epoch_date = '1970-01-01' + + @staticmethod + def serialize(time): + if isinstance(time, CustomDate): + return time.date_str + + assert isinstance(time, datetime.time), ( + 'Received not compatible time "{}"'.format(repr(time)) + ) + return time.isoformat() + + @classmethod + def parse_literal(cls, node): + if isinstance(node, ast.StringValue): + return cls.parse_value(node.value) + + @classmethod + def parse_value(cls, value): + dt = iso8601.parse_date('{}T{}'.format(cls.epoch_date, value)) + return datetime.time(dt.hour, dt.minute, dt.second, dt.microsecond, dt.tzinfo) + + +class Date(graphene.Scalar): + """ + The `Date` scalar type represents a Date + value as specified by + [iso8601](https://en.wikipedia.org/wiki/ISO_8601). + """ + epoch_time = '00:00:00' + + @staticmethod + def serialize(date): + if isinstance(date, CustomDate): + return date.date_str + + if isinstance(date, datetime.datetime): + date = date.date() + + assert isinstance(date, datetime.date), ( + 'Received not compatible date "{}"'.format(repr(date)) + ) + return date.isoformat() + + @classmethod + def parse_literal(cls, node): + if isinstance(node, ast.StringValue): + return cls.parse_value(node.value) + + @staticmethod + def parse_value(value): + return iso8601.parse_date(value).date() + + +class DateTime(graphene.Scalar): + """ + The `DateTime` scalar type represents a DateTime + value as specified by + [iso8601](https://en.wikipedia.org/wiki/ISO_8601). + """ + + @staticmethod + def serialize(dt): + if isinstance(dt, CustomDate): + return dt.date_str + + assert isinstance(dt, (datetime.datetime, datetime.date)), ( + 'Received not compatible datetime "{}"'.format(repr(dt)) + ) + return dt.isoformat() + + @classmethod + def parse_literal(cls, node): + if isinstance(node, ast.StringValue): + return cls.parse_value(node.value) + + @staticmethod + def parse_value(value): + return iso8601.parse_date(value) diff --git a/graphene_django_extras/converter.py b/graphene_django_extras/converter.py index 299c047..5675181 100644 --- a/graphene_django_extras/converter.py +++ b/graphene_django_extras/converter.py @@ -5,21 +5,20 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation, GenericRel from django.db import models from django.utils.encoding import force_text -from graphene import (Field, ID, Boolean, Dynamic, Enum, Float, Int, List, NonNull, String, UUID) -from graphene.types.datetime import DateTime, Time +from graphene import ( + Field, ID, Boolean, Dynamic, Enum, Float, Int, List, NonNull, String, UUID +) from graphene.types.json import JSONString from graphene.utils.str_converters import to_camel_case, to_const from graphene_django.compat import ArrayField, HStoreField, RangeField, JSONField from graphene_django.fields import DjangoListField from graphene_django.utils import import_single_dispatch -from .base_types import GenericForeignKeyType, GenericForeignKeyInputType +from .base_types import ( + GenericForeignKeyType, GenericForeignKeyInputType, DateTime, Time, Date, Binary +) from .fields import DjangoFilterListField from .utils import is_required, get_model_fields, get_related_model -try: - from graphene import Date -except ImportError: - from .base_types import Date singledispatch = import_single_dispatch() @@ -179,6 +178,12 @@ def convert_field_to_nullboolean(field, registry=None, input_flag=None, nested_f required=is_required(field) and input_flag == 'create') +@convert_django_field.register(models.BinaryField) +def convert_binary_to_string(field, registry=None, input_flag=None, nested_fields=False): + return Binary(description=field.help_text or field.verbose_name, + required=is_required(field) and input_flag == 'create') + + @convert_django_field.register(models.DecimalField) @convert_django_field.register(models.FloatField) @convert_django_field.register(models.DurationField) diff --git a/graphene_django_extras/directives/date.py b/graphene_django_extras/directives/date.py index 4d5feb4..dbd135d 100644 --- a/graphene_django_extras/directives/date.py +++ b/graphene_django_extras/directives/date.py @@ -1,10 +1,14 @@ # -*- coding: utf-8 -*- +import time as t from datetime import date, datetime, timedelta, time +import six from dateutil import parser, relativedelta +from django.utils import timezone from graphql import GraphQLArgument, GraphQLString from .base import BaseExtraGraphQLDirective +from ..base_types import CustomDate __author__ = 'Ernesto' __all__ = ('DateGraphQLDirective', ) @@ -90,7 +94,7 @@ def _parse(dt): if isinstance(dt, (int, float)): return datetime.fromtimestamp(dt) if isinstance(dt, (str, bytes)): - return parser.parse(dt) + return parser.parse(dt, default=datetime.now()) return None except ValueError: return None @@ -128,7 +132,8 @@ def _format_time_ago(dt, now=None, full=False, ago_in=False): if not isinstance(dt, timedelta): if now is None: - now = datetime.now() + now = timezone.localtime(timezone=timezone.get_fixed_timezone(-int(t.timezone / 60))) + dt = _parse(dt) now = _parse(now) @@ -145,16 +150,22 @@ def _format_time_ago(dt, now=None, full=False, ago_in=False): def _format_dt(dt, format='default'): - CUSTOM_FORMAT = { - 'time ago': _format_time_ago(dt, full=True, ago_in=True), - 'default': dt.strftime(DEFAULT_DATE_FORMAT), - 'iso': dt.strftime('%Y-%b-%dT%H:%M:%S'), - 'JS': dt.strftime('%a %b %d %Y %H:%M:%S'), - 'javascript': dt.strftime('%a %b %d %Y %H:%M:%S'), - } + if not dt: + return None + + format_lowered = format.lower() - if format.lower() in CUSTOM_FORMAT: - return CUSTOM_FORMAT[format] + if format_lowered == 'default': + return dt.strftime(DEFAULT_DATE_FORMAT) + + if format_lowered == 'time ago': + return _format_time_ago(dt, full=True, ago_in=True) + + if format_lowered == 'iso': + return dt.strftime('%Y-%b-%dT%H:%M:%S') + + if format_lowered in ('js', 'javascript'): + return dt.strftime('%a %b %d %Y %H:%M:%S') if format in FORMATS_MAP: return dt.strftime(FORMATS_MAP[format]) @@ -164,7 +175,7 @@ def _format_dt(dt, format='default'): temp_format = '' translate_format_list = [] for char in format: - if char in (' ', ',', ':', '.', ';', '[', ']', '(', ')', '{', '}', '-', '_'): + if not char.isalpha(): if temp_format != '': translate_format_list.append(FORMATS_MAP.get(temp_format, '')) temp_format = '' @@ -184,9 +195,13 @@ def _format_dt(dt, format='default'): if not wrong_format_flag: if temp_format != '': translate_format_list.append(FORMATS_MAP.get(temp_format, '')) - return dt.strftime(''.join(translate_format_list)) + format_result = ''.join(translate_format_list) + if format_result: + return dt.strftime(''.join(translate_format_list)) + return None - return 'Invalid format string' + # Invalid format string + return None class DateGraphQLDirective(BaseExtraGraphQLDirective): @@ -204,13 +219,15 @@ def get_args(): @staticmethod def resolve(value, directive, root, info, **kwargs): - DEFAULT = datetime.now() format_argument = [arg for arg in directive.arguments if arg.name.value == 'format'] format_argument = format_argument[0] if len(format_argument) > 0 else None format = format_argument.value.value if format_argument else 'default' - dt = parser.parse(value, default=DEFAULT) + dt = _parse(value) try: - return _format_dt(dt, format) or value + result = _format_dt(dt, format) + if isinstance(value, six.string_types): + return result or value + return CustomDate(result or 'Invalid format string') except ValueError: - return 'Invalid format string' + return CustomDate('Invalid format string') diff --git a/graphene_django_extras/settings.py b/graphene_django_extras/settings.py index a7c4a82..7f53aed 100644 --- a/graphene_django_extras/settings.py +++ b/graphene_django_extras/settings.py @@ -12,6 +12,8 @@ 'DEFAULT_PAGE_SIZE': None, 'MAX_PAGE_SIZE': None, 'CLEAN_RESPONSE': False, + 'CACHE_ACTIVE': False, + 'CACHE_TIMEOUT': 300 # seconds (default 5 min) } @@ -30,6 +32,7 @@ def user_settings(self): self._user_settings = getattr(settings, 'GRAPHENE_DJANGO_EXTRAS', {}) return self._user_settings + graphql_api_settings = GraphQLAPISettings(None, DEFAULTS, IMPORT_STRINGS) diff --git a/graphene_django_extras/views.py b/graphene_django_extras/views.py index 3ab052d..8628e67 100644 --- a/graphene_django_extras/views.py +++ b/graphene_django_extras/views.py @@ -1,29 +1,82 @@ # -*- coding: utf-8 -*- +import hashlib + +from django.core.cache import caches from django.views.decorators.csrf import csrf_exempt from graphene_django.views import GraphQLView -# from graphql.execution.executor import execute, subscribe +from graphql import Source, parse, execute +from graphql.execution.executor import subscribe +from graphql.utils.get_operation_ast import get_operation_ast from rest_framework.decorators import ( authentication_classes, permission_classes, api_view, throttle_classes) from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request from rest_framework.settings import api_settings from rest_framework.views import APIView -# from graphql.utils.get_operation_ast import get_operation_ast +from rx import Observable from .settings import graphql_api_settings from .utils import clean_dict class ExtraGraphQLView(GraphQLView, APIView): - """ + + def get_operation_ast(self, request): + data = self.parse_body(request) + query = request.GET.get('query') or data.get('query') + source = Source(query, name='GraphQL request') + + document_ast = parse(source) + operation_ast = get_operation_ast(document_ast, None) + + return operation_ast + + def fetch_cache_key(self, request): + """ Returns a hashed cache key. """ + m = hashlib.md5() + m.update(request.body) + + return m.hexdigest() + + def super_call(self, request, *args, **kwargs): + response = super(ExtraGraphQLView, self).dispatch(request, *args, **kwargs) + + return response + + def dispatch(self, request, *args, **kwargs): + """ Fetches queried data from graphql and returns cached & hashed key. """ + if not graphql_api_settings.CACHE_ACTIVE: + return self.super_call(request, *args, **kwargs) + + cache = caches['default'] + if self.get_operation_ast(request).operation != 'query': + cache.clear() + return self.super_call(request, *args, **kwargs) + + cache_key = '_graplql_{}'.format(self.fetch_cache_key(request)) + response = cache.get(cache_key) + + if not response: + response = self.super_call(request, *args, **kwargs) + + # cache key and value + cache.set(cache_key, response, timeout=graphql_api_settings.CACHE_TIMEOUT) + + return response + def execute(self, *args, **kwargs): operation_ast = get_operation_ast(args[0]) if operation_ast and operation_ast.operation == 'subscription': - return subscribe(self.schema, *args, **kwargs) + result = subscribe(self.schema, *args, **kwargs) + if isinstance(result, Observable): + a = [] + result.subscribe(lambda x: a.append(x)) + if len(a) > 0: + result = a[-1] + return result return execute(self.schema, *args, **kwargs) - """ @classmethod def as_view(cls, *args, **kwargs): @@ -49,8 +102,7 @@ def get_response(self, request, data, show_graphiql=False): response = {} if execution_result.errors: - response['errors'] = [self.format_error( - e) for e in execution_result.errors] + response['errors'] = [self.format_error(e) for e in execution_result.errors] if execution_result.invalid: status_code = 400 diff --git a/setup.py b/setup.py index 2e8b120..277270a 100644 --- a/setup.py +++ b/setup.py @@ -55,11 +55,7 @@ def get_packages(): packages=get_packages(), install_requires=[ - 'six>=1.9.0', - 'graphql-core>=2.0.dev20171009101843', - 'graphene>=2.0.dev20170802065539', - 'graphene-django>=2.0.dev2017083101', - 'Django>=1.8.0', + 'graphene-django>=2.0', 'django-filter>=1.0.4', 'djangorestframework>=3.6.0' ],