From f8e09d6bc95061460ebb8661e9fd69a5e3e37522 Mon Sep 17 00:00:00 2001 From: aleontiev Date: Tue, 21 Mar 2017 02:40:42 -0700 Subject: [PATCH 01/13] relationship support for getter/setter --- dynamic_rest/fields/fields.py | 102 +++++++++++++++++++++++++++------- dynamic_rest/filters.py | 3 + dynamic_rest/renderers.py | 7 ++- dynamic_rest/serializers.py | 13 +++++ tests/serializers.py | 35 +++++++++--- tests/test_api.py | 31 ++++++++++- 6 files changed, 161 insertions(+), 30 deletions(-) diff --git a/dynamic_rest/fields/fields.py b/dynamic_rest/fields/fields.py index edd8066f..acf9899c 100644 --- a/dynamic_rest/fields/fields.py +++ b/dynamic_rest/fields/fields.py @@ -11,8 +11,11 @@ from dynamic_rest.bases import DynamicSerializerBase from dynamic_rest.conf import settings +from dynamic_rest.routers import DynamicRouter from dynamic_rest.fields.common import WithRelationalFieldMixin -from dynamic_rest.meta import is_field_remote, get_model_field +from dynamic_rest.meta import ( + is_field_remote, get_model_field, get_related_model +) class DynamicField(fields.Field): @@ -78,9 +81,11 @@ class DynamicRelationField(WithRelationalFieldMixin, DynamicField): def __init__( self, - serializer_class, + serializer_class=None, many=False, queryset=None, + getter=None, + setter=None, embed=False, sideloading=None, debug=False, @@ -96,6 +101,14 @@ def __init__( sideloading: if True, force sideloading all the way down. if False, force embedding all the way down. This overrides the "embed" option if set. + getter: name of a method to call on the parent serializer for + reading related objects. + If source is '*', this will default to 'get_$FIELD_NAME'. + setter: name of a method to call on the parent serializer for + saving related objects. + If source is '*', this will default to 'set_$FIELD_NAME'. + debug: if True, representation will include a meta key with extra + instance information. embed: If True, always embed related object(s). Will not sideload, and will include the full object unless specifically excluded. """ @@ -105,8 +118,18 @@ def __init__( self.sideloading = sideloading self.debug = debug self.embed = embed if sideloading is None else not sideloading - if '.' in kwargs.get('source', ''): - raise Exception('Nested relationships are not supported') + source = kwargs.get('source', '') + self.getter = getter + self.setter = setter + if getter or setter: + # dont bind to fields + kwargs['source'] = '*' + elif source == '*': + # use default getter/setter + self.getter = self.getter or '*' + self.setter = self.setter or '*' + if '.' in source: + raise AttributeError('Nested relationship sources not supported') if 'link' in kwargs: self.link = kwargs.pop('link') super(DynamicRelationField, self).__init__(**kwargs) @@ -116,22 +139,39 @@ def get_model(self): """Get the child serializer's model.""" return getattr(self.serializer_class.Meta, 'model', None) + @property + def parent_model(self): + if not hasattr(self, '_parent_model'): + self._parent_model = getattr(self.parent.Meta, 'model', None) + return self._parent_model + + @property + def model_field(self): + if not hasattr(self, '_model_field'): + try: + self._model_field = get_model_field( + self.parent_model, self.source + ) + except: + self._model_field = None + return self._model_field + def bind(self, *args, **kwargs): """Bind to the parent serializer.""" if self.bound: # Prevent double-binding return super(DynamicRelationField, self).bind(*args, **kwargs) self.bound = True - parent_model = getattr(self.parent.Meta, 'model', None) - remote = is_field_remote(parent_model, self.source) + if self.source == '*': + if self.getter == '*': + self.getter = 'get_%s' % self.field_name + if self.setter == '*': + self.setter = 'set_%s' % self.field_name + return - try: - model_field = get_model_field(parent_model, self.source) - except: - # model field may not be available for m2o fields with no - # related_name - model_field = None + remote = is_field_remote(self.parent_model, self.source) + model_field = self.model_field # Infer `required` and `allow_null` if 'required' not in self.kwargs and ( @@ -147,8 +187,6 @@ def bind(self, *args, **kwargs): ): self.allow_null = True - self.model_field = model_field - @property def root_serializer(self): """Return the root serializer (serializer for the primary resource).""" @@ -275,20 +313,29 @@ def to_representation(self, instance): serializer = self.serializer model = serializer.get_model() source = self.source - if not self.kwargs['many'] and serializer.id_only(): + if ( + not self.getter and + not self.kwargs['many'] and + serializer.id_only() + ): # attempt to optimize by reading the related ID directly # from the current instance rather than from the related object source_id = '%s_id' % source if hasattr(instance, source_id): return getattr(instance, source_id) - if model is None: - related = getattr(instance, source) + if self.getter: + getter = getattr(self.parent, self.getter) + related = getter(instance) else: - try: + # use source to read the relationship + if model is None: related = getattr(instance, source) - except model.DoesNotExist: - return None + else: + try: + related = getattr(instance, source) + except model.DoesNotExist: + return None if related is None: return None @@ -296,7 +343,7 @@ def to_representation(self, instance): return serializer.to_representation(related) except Exception as e: # Provide more context to help debug these cases - if serializer.debug: + if getattr(serializer, 'debug', None): import traceback traceback.print_exc() raise Exception( @@ -325,6 +372,13 @@ def to_internal_value_single(self, data, serializer): def to_internal_value(self, data): """Return the underlying object(s), given the serialized form.""" + if self.setter: + setter = getattr(self.parent, self.setter) + self.parent.add_post_save( + lambda instance: setter(instance, data) + ) + raise fields.SkipField() + if self.kwargs['many']: serializer = self.serializer.child if not isinstance(data, list): @@ -344,6 +398,12 @@ def serializer_class(self): Resolves string imports. """ serializer_class = self._serializer_class + if serializer_class is None: + serializer_class = DynamicRouter.get_canonical_serializer( + None, + model=get_related_model(self.model_field) + ) + if not isinstance(serializer_class, six.string_types): return serializer_class diff --git a/dynamic_rest/filters.py b/dynamic_rest/filters.py index 439f8b2d..d8e876ec 100644 --- a/dynamic_rest/filters.py +++ b/dynamic_rest/filters.py @@ -375,6 +375,9 @@ def _build_requested_prefetches( 'nested relationship values ' 'are not supported' ) + if source == '*': + # ignore custom getter/setter + continue if source in prefetches: # ignore duplicated sources diff --git a/dynamic_rest/renderers.py b/dynamic_rest/renderers.py index c0e887ad..84e997e5 100644 --- a/dynamic_rest/renderers.py +++ b/dynamic_rest/renderers.py @@ -1,8 +1,13 @@ """This module contains custom renderer classes.""" from rest_framework.renderers import ( BrowsableAPIRenderer, - AdminRenderer ) +try: + from rest_framework.renderers import AdminRenderer +except: + class AdminRenderer(BrowsableAPIRenderer): + pass + from dynamic_rest.utils import unpack diff --git a/dynamic_rest/serializers.py b/dynamic_rest/serializers.py index 9b312827..c4399e86 100644 --- a/dynamic_rest/serializers.py +++ b/dynamic_rest/serializers.py @@ -579,6 +579,17 @@ def to_internal_value(self, data): return value + def add_post_save(self, fn): + if not hasattr(self, '_post_save'): + self._post_save = [] + self._post_save.append(fn) + + def do_post_save(self, instance): + if hasattr(self, '_post_save'): + for fn in self._post_save: + fn(instance) + self._post_save = [] + def save(self, *args, **kwargs): """Serializer save that address prefetch issues.""" update = getattr(self, 'instance', None) is not None @@ -589,6 +600,8 @@ def save(self, *args, **kwargs): *args, **kwargs ) + self.do_post_save(instance) + view = self._context.get('view') if update and view: # Reload the object on update diff --git a/tests/serializers.py b/tests/serializers.py index 95351d42..32a72a6b 100644 --- a/tests/serializers.py +++ b/tests/serializers.py @@ -96,33 +96,38 @@ class Meta: 'members', 'users', 'loc1users', - 'loc1usersLambda' + 'loc1usersLambda', + 'loc1usersGetter', ) permissions = DynamicRelationField( 'PermissionSerializer', many=True, deferred=True) + + # Infer serializer from source members = DynamicRelationField( - 'UserSerializer', source='users', many=True, - deferred=True) + deferred=True + ) # Intentional duplicate of 'users': users = DynamicRelationField( - 'UserSerializer', many=True, - deferred=True) + deferred=True + ) - # For testing default queryset on relations: + # Queryset for get filter loc1users = DynamicRelationField( 'UserSerializer', source='users', many=True, queryset=User.objects.filter(location_id=1), - deferred=True) + deferred=True + ) + # Dynamic queryset through lambda loc1usersLambda = DynamicRelationField( 'UserSerializer', source='users', @@ -130,6 +135,22 @@ class Meta: queryset=lambda srlzr: User.objects.filter(location_id=1), deferred=True) + # Custom getter/setter + loc1usersGetter = DynamicRelationField( + 'UserSerializer', + source='*', + requires=['users.*'], + required=False, + deferred=True, + many=True + ) + + def get_loc1usersGetter(self, instance): + return [u for u in instance.users.all() if u.location_id == 1] + + def set_loc1usersGetter(self, instance, user_ids): + instance.users = user_ids + class UserSerializer(DynamicModelSerializer): diff --git a/tests/test_api.py b/tests/test_api.py index a7461ebd..51181f8c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -535,6 +535,21 @@ def test_post(self): } }) + def test_post_with_related_setter(self): + data = { + 'name': 'test', + 'loc1usersGetter': [1] + } + response = self.client.post( + '/groups/', json.dumps(data), content_type='application/json' + ) + self.assertEqual(201, response.status_code) + content = json.loads(response.content.decode('utf-8')) + self.assertEqual( + [1], + content['group']['loc1usersGetter'] + ) + def test_put(self): group = Group.objects.create(name='test group') data = { @@ -544,7 +559,7 @@ def test_put(self): '/groups/%s/' % group.pk, json.dumps(data), content_type='application/json') - self.assertEquals(200, response.status_code) + self.assertEquals(200, response.status_code, response.content) updated_group = Group.objects.get(pk=group.pk) self.assertEquals(updated_group.name, data['name']) @@ -565,6 +580,20 @@ def test_get_with_default_lambda_queryset(self): content['groups'][0]['loc1usersLambda'] ) + def test_get_with_related_getter(self): + url = '/groups/?filter{id}=1&include[]=loc1usersGetter.location.*' + response = self.client.get(url) + content = json.loads(response.content.decode('utf-8')) + self.assertEqual(200, response.status_code) + self.assertEqual( + [1, 2], + content['groups'][0]['loc1usersGetter'] + ) + self.assertEqual( + 1, + content['locations'][0]['id'] + ) + def test_get_with_default_queryset_filtered(self): """ Make sure filter can be added to relational fields with default From fe7dc7b6e2159fc78db2456f73a51c95f0a9f063 Mon Sep 17 00:00:00 2001 From: aleontiev Date: Tue, 21 Mar 2017 03:24:20 -0700 Subject: [PATCH 02/13] passing tests with self links, catch missing AdminRenderer --- dynamic_rest/conf.py | 2 +- dynamic_rest/renderers.py | 3 ++- dynamic_rest/serializers.py | 8 +++++--- tests/test_api.py | 12 ++++++------ 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/dynamic_rest/conf.py b/dynamic_rest/conf.py index 2eb3d44b..a410981b 100644 --- a/dynamic_rest/conf.py +++ b/dynamic_rest/conf.py @@ -22,7 +22,7 @@ 'ENABLE_LINKS': True, # ENABLE_SELF_LINKS: enable/disable links to self - 'ENABLE_SELF_LINKS': False, + 'ENABLE_SELF_LINKS': True, # ENABLE_SERIALIZER_CACHE: enable/disable caching of related serializers 'ENABLE_SERIALIZER_CACHE': True, diff --git a/dynamic_rest/renderers.py b/dynamic_rest/renderers.py index 84e997e5..a9ee91d7 100644 --- a/dynamic_rest/renderers.py +++ b/dynamic_rest/renderers.py @@ -5,6 +5,7 @@ try: from rest_framework.renderers import AdminRenderer except: + # DRF < 3.3 class AdminRenderer(BrowsableAPIRenderer): pass @@ -30,7 +31,7 @@ def get_context(self, data, media_type, context): class DynamicAdminRenderer(AdminRenderer): - """Admin renderer override.""" + """Admin renderer.""" template = 'dynamic_rest/admin.html' COLUMN_BLACKLIST = ('id', 'links') diff --git a/dynamic_rest/serializers.py b/dynamic_rest/serializers.py index c4399e86..14292e67 100644 --- a/dynamic_rest/serializers.py +++ b/dynamic_rest/serializers.py @@ -542,9 +542,11 @@ def to_representation(self, instance): self ).to_representation(instance) - if settings.ENABLE_LINKS: - # TODO: Make this function configurable to support other - # formats like JSON API link objects. + query_params = self.get_request_attribute('query_params', {}) + if ( + settings.ENABLE_LINKS and + 'exclude_links' not in query_params + ): representation = merge_link_object( self, representation, instance ) diff --git a/tests/test_api.py b/tests/test_api.py index 51181f8c..b356323c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1291,7 +1291,7 @@ def setUp(self): self.fixture = create_fixture() def test_sort(self): - url = '/dogs/?sort[]=name' + url = '/dogs/?sort[]=name&exclude_links' # 2 queries - one for getting dogs, one for the meta (count) with self.assertNumQueries(2): response = self.client.get(url) @@ -1327,7 +1327,7 @@ def test_sort(self): self.assertEquals(expected_response, actual_response) def test_sort_reverse(self): - url = '/dogs/?sort[]=-name' + url = '/dogs/?sort[]=-name&exclude_links' # 2 queries - one for getting dogs, one for the meta (count) with self.assertNumQueries(2): response = self.client.get(url) @@ -1363,7 +1363,7 @@ def test_sort_reverse(self): self.assertEquals(expected_response, actual_response) def test_sort_multiple(self): - url = '/dogs/?sort[]=-name&sort[]=-origin' + url = '/dogs/?sort[]=-name&sort[]=-origin&exclude_links' # 2 queries - one for getting dogs, one for the meta (count) with self.assertNumQueries(2): response = self.client.get(url) @@ -1399,7 +1399,7 @@ def test_sort_multiple(self): self.assertEquals(expected_response, actual_response) def test_sort_rewrite(self): - url = '/dogs/?sort[]=fur' + url = '/dogs/?sort[]=fur&exclude_links' # 2 queries - one for getting dogs, one for the meta (count) with self.assertNumQueries(2): response = self.client.get(url) @@ -1453,7 +1453,7 @@ def setUp(self): self.fixture = create_fixture() def test_sort(self): - url = '/horses' + url = '/horses?exclude_links' # 1 query - one for getting horses # (the viewset as features specified, so no meta is returned) with self.assertNumQueries(1): @@ -1495,7 +1495,7 @@ def setUp(self): self.fixture = create_fixture() def test_sort(self): - url = '/zebras?sort[]=-name' + url = '/zebras?sort[]=-name&exclude_links' # 1 query - one for getting zebras # (the viewset as features specified, so no meta is returned) with self.assertNumQueries(1): From 50224baf2f731d7685a1c2739c0c3139c03faec0 Mon Sep 17 00:00:00 2001 From: aleontiev Date: Tue, 21 Mar 2017 03:47:17 -0700 Subject: [PATCH 03/13] added changelog --- CHANGELOG.md | 12 ++++++++++++ dynamic_rest/conf.py | 2 +- tests/settings.py | 1 + 3 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..e04fb1dc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +1.6.3 + +- Added `ENABLE_SELF_LINKS` option, default False. + When enabled, links will include reference to the current resource. + +- Made `serializer_class` optional on `DynamicRelationField`. + `DynamicRelationField` will attempt to infer `serializer_class` from the + `source` using `DynamicRouter.get_canonical_serializer`. + +- Added `getter`/`setter` support to `DynamicRelationField`, allowing + for custom relationship getting and setting. This can be useful for simplifying + complex "through"-relations. diff --git a/dynamic_rest/conf.py b/dynamic_rest/conf.py index a410981b..2eb3d44b 100644 --- a/dynamic_rest/conf.py +++ b/dynamic_rest/conf.py @@ -22,7 +22,7 @@ 'ENABLE_LINKS': True, # ENABLE_SELF_LINKS: enable/disable links to self - 'ENABLE_SELF_LINKS': True, + 'ENABLE_SELF_LINKS': False, # ENABLE_SERIALIZER_CACHE: enable/disable caching of related serializers 'ENABLE_SERIALIZER_CACHE': True, diff --git a/tests/settings.py b/tests/settings.py index 7d2a8d95..489e52c7 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -71,5 +71,6 @@ DYNAMIC_REST = { 'ENABLE_LINKS': True, + 'ENABLE_SELF_LINKS': True 'DEBUG': os.environ.get('DYNAMIC_REST_DEBUG', 'false').lower() == 'true' } From e65e98e58981f25ca3f11c031a597659916350a1 Mon Sep 17 00:00:00 2001 From: aleontiev Date: Tue, 21 Mar 2017 03:51:33 -0700 Subject: [PATCH 04/13] fix typo --- tests/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/settings.py b/tests/settings.py index 489e52c7..7205c43c 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -71,6 +71,6 @@ DYNAMIC_REST = { 'ENABLE_LINKS': True, - 'ENABLE_SELF_LINKS': True + 'ENABLE_SELF_LINKS': True, 'DEBUG': os.environ.get('DYNAMIC_REST_DEBUG', 'false').lower() == 'true' } From 3d72a08f6723d08d0fad9608c87dbe351e820aa2 Mon Sep 17 00:00:00 2001 From: aleontiev Date: Tue, 21 Mar 2017 03:56:52 -0700 Subject: [PATCH 05/13] change renderer order --- dynamic_rest/viewsets.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dynamic_rest/viewsets.py b/dynamic_rest/viewsets.py index 5f9b4e3d..c722e593 100644 --- a/dynamic_rest/viewsets.py +++ b/dynamic_rest/viewsets.py @@ -84,8 +84,8 @@ class WithDynamicViewSetMixin(object): metadata_class = DynamicMetadata renderer_classes = ( JSONRenderer, - DynamicAdminRenderer, - DynamicBrowsableAPIRenderer + DynamicBrowsableAPIRenderer, + DynamicAdminRenderer ) features = (INCLUDE, EXCLUDE, FILTER, PAGE, PER_PAGE, SORT, SIDELOADING) meta = None From 2f9d5502cb691ae1d01f75d72bbcb5745f55978c Mon Sep 17 00:00:00 2001 From: aleontiev Date: Tue, 21 Mar 2017 04:25:01 -0700 Subject: [PATCH 06/13] optional get on results --- dynamic_rest/renderers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dynamic_rest/renderers.py b/dynamic_rest/renderers.py index a9ee91d7..02ca49f5 100644 --- a/dynamic_rest/renderers.py +++ b/dynamic_rest/renderers.py @@ -52,7 +52,7 @@ def process(result): # to account for the DREST envelope # (data is stored one level deeper than expected in the response) - results = context['results'] + results = context.get('results') if isinstance(results, list): for result in results: process(result) From 7d15baa6bbcd549e8730634f244bfa672f01a522 Mon Sep 17 00:00:00 2001 From: aleontiev Date: Tue, 21 Mar 2017 14:10:25 -0700 Subject: [PATCH 07/13] add vertical/horizontal field copies --- dynamic_rest/fields/fields.py | 2 +- dynamic_rest/renderers.py | 53 +++++++++++++---- .../dynamic_rest/horizontal/checkbox.html | 21 +++++++ .../horizontal/checkbox_multiple.html | 39 +++++++++++++ .../dynamic_rest/horizontal/fieldset.html | 16 ++++++ .../dynamic_rest/horizontal/form.html | 6 ++ .../dynamic_rest/horizontal/input.html | 21 +++++++ .../horizontal/list_fieldset.html | 13 +++++ .../dynamic_rest/horizontal/radio.html | 57 +++++++++++++++++++ .../dynamic_rest/horizontal/select.html | 36 ++++++++++++ .../dynamic_rest/horizontal/select2.html | 21 +++++++ .../horizontal/select_multiple.html | 38 +++++++++++++ .../dynamic_rest/horizontal/textarea.html | 21 +++++++ .../dynamic_rest/vertical/checkbox.html | 18 ++++++ .../vertical/checkbox_multiple.html | 37 ++++++++++++ .../dynamic_rest/vertical/fieldset.html | 15 +++++ .../templates/dynamic_rest/vertical/form.html | 6 ++ .../dynamic_rest/vertical/input.html | 17 ++++++ .../dynamic_rest/vertical/list_fieldset.html | 9 +++ .../dynamic_rest/vertical/radio.html | 57 +++++++++++++++++++ .../dynamic_rest/vertical/select.html | 34 +++++++++++ .../dynamic_rest/vertical/select2.html | 18 ++++++ .../vertical/select_multiple.html | 33 +++++++++++ .../dynamic_rest/vertical/textarea.html | 17 ++++++ dynamic_rest/viewsets.py | 10 ---- tests/settings.py | 2 +- 26 files changed, 595 insertions(+), 22 deletions(-) create mode 100644 dynamic_rest/templates/dynamic_rest/horizontal/checkbox.html create mode 100644 dynamic_rest/templates/dynamic_rest/horizontal/checkbox_multiple.html create mode 100644 dynamic_rest/templates/dynamic_rest/horizontal/fieldset.html create mode 100644 dynamic_rest/templates/dynamic_rest/horizontal/form.html create mode 100644 dynamic_rest/templates/dynamic_rest/horizontal/input.html create mode 100644 dynamic_rest/templates/dynamic_rest/horizontal/list_fieldset.html create mode 100644 dynamic_rest/templates/dynamic_rest/horizontal/radio.html create mode 100644 dynamic_rest/templates/dynamic_rest/horizontal/select.html create mode 100644 dynamic_rest/templates/dynamic_rest/horizontal/select2.html create mode 100644 dynamic_rest/templates/dynamic_rest/horizontal/select_multiple.html create mode 100644 dynamic_rest/templates/dynamic_rest/horizontal/textarea.html create mode 100644 dynamic_rest/templates/dynamic_rest/vertical/checkbox.html create mode 100644 dynamic_rest/templates/dynamic_rest/vertical/checkbox_multiple.html create mode 100644 dynamic_rest/templates/dynamic_rest/vertical/fieldset.html create mode 100644 dynamic_rest/templates/dynamic_rest/vertical/form.html create mode 100644 dynamic_rest/templates/dynamic_rest/vertical/input.html create mode 100644 dynamic_rest/templates/dynamic_rest/vertical/list_fieldset.html create mode 100644 dynamic_rest/templates/dynamic_rest/vertical/radio.html create mode 100644 dynamic_rest/templates/dynamic_rest/vertical/select.html create mode 100644 dynamic_rest/templates/dynamic_rest/vertical/select2.html create mode 100644 dynamic_rest/templates/dynamic_rest/vertical/select_multiple.html create mode 100644 dynamic_rest/templates/dynamic_rest/vertical/textarea.html diff --git a/dynamic_rest/fields/fields.py b/dynamic_rest/fields/fields.py index acf9899c..ff050a91 100644 --- a/dynamic_rest/fields/fields.py +++ b/dynamic_rest/fields/fields.py @@ -11,7 +11,6 @@ from dynamic_rest.bases import DynamicSerializerBase from dynamic_rest.conf import settings -from dynamic_rest.routers import DynamicRouter from dynamic_rest.fields.common import WithRelationalFieldMixin from dynamic_rest.meta import ( is_field_remote, get_model_field, get_related_model @@ -397,6 +396,7 @@ def serializer_class(self): Resolves string imports. """ + from dynamic_rest.routers import DynamicRouter serializer_class = self._serializer_class if serializer_class is None: serializer_class = DynamicRouter.get_canonical_serializer( diff --git a/dynamic_rest/renderers.py b/dynamic_rest/renderers.py index 02ca49f5..6731b31c 100644 --- a/dynamic_rest/renderers.py +++ b/dynamic_rest/renderers.py @@ -1,4 +1,5 @@ """This module contains custom renderer classes.""" +from copy import copy from rest_framework.renderers import ( BrowsableAPIRenderer, ) @@ -7,9 +8,11 @@ except: # DRF < 3.3 class AdminRenderer(BrowsableAPIRenderer): - pass + format = 'admin' +from rest_framework.renderers import HTMLFormRenderer, ClassLookupDict from dynamic_rest.utils import unpack +from dynamic_rest.fields import DynamicRelationField class DynamicBrowsableAPIRenderer(BrowsableAPIRenderer): @@ -29,10 +32,33 @@ def get_context(self, data, media_type, context): context['directory'] = get_directory(request) return context + def render_form_for_serializer(self, serializer): + if hasattr(serializer, 'initial_data'): + serializer.is_valid() + + form_renderer = self.form_renderer_class() + return form_renderer.render( + serializer.data, + self.accepted_media_type, + {'style': {'template_pack': 'dynamic_rest/horizontal'}} + ) + + +class DynamicHTMLFormRenderer(HTMLFormRenderer): + template_pack = 'rest_framework/vertical' + + +DynamicHTMLFormRenderer.default_style = ClassLookupDict( + copy(DynamicHTMLFormRenderer.default_style.mapping) +) +DynamicHTMLFormRenderer.default_style[DynamicRelationField] = { + 'base_template': 'select2.html' +} + class DynamicAdminRenderer(AdminRenderer): """Admin renderer.""" - + form_renderer_class = DynamicHTMLFormRenderer template = 'dynamic_rest/admin.html' COLUMN_BLACKLIST = ('id', 'links') @@ -53,11 +79,12 @@ def process(result): # to account for the DREST envelope # (data is stored one level deeper than expected in the response) results = context.get('results') - if isinstance(results, list): - for result in results: - process(result) - else: - process(results) + if results: + if isinstance(results, list): + for result in results: + process(result) + else: + process(results) context['columns'] = [ c for c in context['columns'] if c not in self.COLUMN_BLACKLIST @@ -67,6 +94,12 @@ def process(result): def render_form_for_serializer(self, serializer): serializer.disable_envelope() - return super( - DynamicAdminRenderer, self - ).render_form_for_serializer(serializer) + if hasattr(serializer, 'initial_data'): + serializer.is_valid() + + form_renderer = self.form_renderer_class() + return form_renderer.render( + serializer.data, + self.accepted_media_type, + {'style': {'template_pack': 'dynamic_rest/horizontal'}} + ) diff --git a/dynamic_rest/templates/dynamic_rest/horizontal/checkbox.html b/dynamic_rest/templates/dynamic_rest/horizontal/checkbox.html new file mode 100644 index 00000000..faaebb88 --- /dev/null +++ b/dynamic_rest/templates/dynamic_rest/horizontal/checkbox.html @@ -0,0 +1,21 @@ +
+ {% if field.label %} + + {% endif %} + +
+ + + {% if field.errors %} + {% for error in field.errors %} + {{ error }} + {% endfor %} + {% endif %} + + {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} +
+
diff --git a/dynamic_rest/templates/dynamic_rest/horizontal/checkbox_multiple.html b/dynamic_rest/templates/dynamic_rest/horizontal/checkbox_multiple.html new file mode 100644 index 00000000..7c7e5732 --- /dev/null +++ b/dynamic_rest/templates/dynamic_rest/horizontal/checkbox_multiple.html @@ -0,0 +1,39 @@ +{% load rest_framework %} + +
+ {% if field.label %} + + {% endif %} + +
+ {% if style.inline %} + {% for key, text in field.choices.items %} + + {% endfor %} + {% else %} + {% for key, text in field.choices.items %} +
+ +
+ {% endfor %} + {% endif %} + + {% if field.errors %} + {% for error in field.errors %} + {{ error }} + {% endfor %} + {% endif %} + + {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} +
+
diff --git a/dynamic_rest/templates/dynamic_rest/horizontal/fieldset.html b/dynamic_rest/templates/dynamic_rest/horizontal/fieldset.html new file mode 100644 index 00000000..1747d255 --- /dev/null +++ b/dynamic_rest/templates/dynamic_rest/horizontal/fieldset.html @@ -0,0 +1,16 @@ +{% load rest_framework %} +
+ {% if field.label %} +
+ + {{ field.label }} + +
+ {% endif %} + + {% for nested_field in field %} + {% if not nested_field.read_only %} + {% render_field nested_field style=style %} + {% endif %} + {% endfor %} +
diff --git a/dynamic_rest/templates/dynamic_rest/horizontal/form.html b/dynamic_rest/templates/dynamic_rest/horizontal/form.html new file mode 100644 index 00000000..13fc807e --- /dev/null +++ b/dynamic_rest/templates/dynamic_rest/horizontal/form.html @@ -0,0 +1,6 @@ +{% load rest_framework %} +{% for field in form %} + {% if not field.read_only %} + {% render_field field style=style %} + {% endif %} +{% endfor %} diff --git a/dynamic_rest/templates/dynamic_rest/horizontal/input.html b/dynamic_rest/templates/dynamic_rest/horizontal/input.html new file mode 100644 index 00000000..a6d657d7 --- /dev/null +++ b/dynamic_rest/templates/dynamic_rest/horizontal/input.html @@ -0,0 +1,21 @@ +
+ {% if field.label %} + + {% endif %} + +
+ + + {% if field.errors %} + {% for error in field.errors %} + {{ error }} + {% endfor %} + {% endif %} + + {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} +
+
diff --git a/dynamic_rest/templates/dynamic_rest/horizontal/list_fieldset.html b/dynamic_rest/templates/dynamic_rest/horizontal/list_fieldset.html new file mode 100644 index 00000000..962c333c --- /dev/null +++ b/dynamic_rest/templates/dynamic_rest/horizontal/list_fieldset.html @@ -0,0 +1,13 @@ +{% load rest_framework %} + +
+ {% if field.label %} +
+ + {{ field.label }} + +
+ {% endif %} + +

Lists are not currently supported in HTML input.

+
diff --git a/dynamic_rest/templates/dynamic_rest/horizontal/radio.html b/dynamic_rest/templates/dynamic_rest/horizontal/radio.html new file mode 100644 index 00000000..e9922dd0 --- /dev/null +++ b/dynamic_rest/templates/dynamic_rest/horizontal/radio.html @@ -0,0 +1,57 @@ +{% load i18n %} +{% load rest_framework %} + +{% trans "None" as none_choice %} + +
+ {% if field.label %} + + {% endif %} + +
+ {% if style.inline %} + {% if field.allow_null or field.allow_blank %} + + {% endif %} + + {% for key, text in field.choices.items %} + + {% endfor %} + {% else %} + {% if field.allow_null or field.allow_blank %} +
+ +
+ {% endif %} + {% for key, text in field.choices.items %} +
+ +
+ {% endfor %} + {% endif %} + + {% if field.errors %} + {% for error in field.errors %} + {{ error }} + {% endfor %} + {% endif %} + + {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} +
+
diff --git a/dynamic_rest/templates/dynamic_rest/horizontal/select.html b/dynamic_rest/templates/dynamic_rest/horizontal/select.html new file mode 100644 index 00000000..7a3db2db --- /dev/null +++ b/dynamic_rest/templates/dynamic_rest/horizontal/select.html @@ -0,0 +1,36 @@ +{% load rest_framework %} + +
+ {% if field.label %} + + {% endif %} + +
+ + + {% if field.errors %} + {% for error in field.errors %} + {{ error }} + {% endfor %} + {% endif %} + + {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} +
+
diff --git a/dynamic_rest/templates/dynamic_rest/horizontal/select2.html b/dynamic_rest/templates/dynamic_rest/horizontal/select2.html new file mode 100644 index 00000000..443eb60b --- /dev/null +++ b/dynamic_rest/templates/dynamic_rest/horizontal/select2.html @@ -0,0 +1,21 @@ +
+ {% if field.label %} + + {% endif %} + hi there +
+ + + {% if field.errors %} + {% for error in field.errors %} + {{ error }} + {% endfor %} + {% endif %} + + {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} +
+
diff --git a/dynamic_rest/templates/dynamic_rest/horizontal/select_multiple.html b/dynamic_rest/templates/dynamic_rest/horizontal/select_multiple.html new file mode 100644 index 00000000..36ff9fd0 --- /dev/null +++ b/dynamic_rest/templates/dynamic_rest/horizontal/select_multiple.html @@ -0,0 +1,38 @@ +{% load i18n %} +{% load rest_framework %} + +{% trans "No items to select." as no_items %} + +
+ {% if field.label %} + + {% endif %} + +
+ + + {% if field.errors %} + {% for error in field.errors %} + {{ error }} + {% endfor %} + {% endif %} + + {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} +
+
diff --git a/dynamic_rest/templates/dynamic_rest/horizontal/textarea.html b/dynamic_rest/templates/dynamic_rest/horizontal/textarea.html new file mode 100644 index 00000000..b279a2f6 --- /dev/null +++ b/dynamic_rest/templates/dynamic_rest/horizontal/textarea.html @@ -0,0 +1,21 @@ +
+ {% if field.label %} + + {% endif %} + +
+ + + {% if field.errors %} + {% for error in field.errors %} + {{ error }} + {% endfor %} + {% endif %} + + {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} +
+
diff --git a/dynamic_rest/templates/dynamic_rest/vertical/checkbox.html b/dynamic_rest/templates/dynamic_rest/vertical/checkbox.html new file mode 100644 index 00000000..827ad8af --- /dev/null +++ b/dynamic_rest/templates/dynamic_rest/vertical/checkbox.html @@ -0,0 +1,18 @@ +
+
+ +
+ + {% if field.errors %} + {% for error in field.errors %} + {{ error }} + {% endfor %} + {% endif %} + + {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} +
diff --git a/dynamic_rest/templates/dynamic_rest/vertical/checkbox_multiple.html b/dynamic_rest/templates/dynamic_rest/vertical/checkbox_multiple.html new file mode 100644 index 00000000..7a43b3f5 --- /dev/null +++ b/dynamic_rest/templates/dynamic_rest/vertical/checkbox_multiple.html @@ -0,0 +1,37 @@ +{% load rest_framework %} + +
+ {% if field.label %} + + {% endif %} + + {% if style.inline %} +
+ {% for key, text in field.choices.items %} + + {% endfor %} +
+ {% else %} + {% for key, text in field.choices.items %} +
+ +
+ {% endfor %} + {% endif %} + + {% if field.errors %} + {% for error in field.errors %} + {{ error }} + {% endfor %} + {% endif %} + + {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} +
diff --git a/dynamic_rest/templates/dynamic_rest/vertical/fieldset.html b/dynamic_rest/templates/dynamic_rest/vertical/fieldset.html new file mode 100644 index 00000000..4ec2a5d4 --- /dev/null +++ b/dynamic_rest/templates/dynamic_rest/vertical/fieldset.html @@ -0,0 +1,15 @@ +{% load rest_framework %} + +
+ {% if field.label %} + + {{ field.label }} + + {% endif %} + + {% for nested_field in field %} + {% if not nested_field.read_only %} + {% render_field nested_field style=style %} + {% endif %} + {% endfor %} +
diff --git a/dynamic_rest/templates/dynamic_rest/vertical/form.html b/dynamic_rest/templates/dynamic_rest/vertical/form.html new file mode 100644 index 00000000..13fc807e --- /dev/null +++ b/dynamic_rest/templates/dynamic_rest/vertical/form.html @@ -0,0 +1,6 @@ +{% load rest_framework %} +{% for field in form %} + {% if not field.read_only %} + {% render_field field style=style %} + {% endif %} +{% endfor %} diff --git a/dynamic_rest/templates/dynamic_rest/vertical/input.html b/dynamic_rest/templates/dynamic_rest/vertical/input.html new file mode 100644 index 00000000..a7cff2ca --- /dev/null +++ b/dynamic_rest/templates/dynamic_rest/vertical/input.html @@ -0,0 +1,17 @@ +
+ {% if field.label %} + + {% endif %} + + + + {% if field.errors %} + {% for error in field.errors %} + {{ error }} + {% endfor %} + {% endif %} + + {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} +
diff --git a/dynamic_rest/templates/dynamic_rest/vertical/list_fieldset.html b/dynamic_rest/templates/dynamic_rest/vertical/list_fieldset.html new file mode 100644 index 00000000..f2b615fe --- /dev/null +++ b/dynamic_rest/templates/dynamic_rest/vertical/list_fieldset.html @@ -0,0 +1,9 @@ +
+ {% if field.label %} + + {{ field.label }} + + {% endif %} + +

Lists are not currently supported in HTML input.

+
diff --git a/dynamic_rest/templates/dynamic_rest/vertical/radio.html b/dynamic_rest/templates/dynamic_rest/vertical/radio.html new file mode 100644 index 00000000..6e5d2232 --- /dev/null +++ b/dynamic_rest/templates/dynamic_rest/vertical/radio.html @@ -0,0 +1,57 @@ +{% load i18n %} +{% load rest_framework %} +{% trans "None" as none_choice %} + +
+ {% if field.label %} + + {% endif %} + + {% if style.inline %} +
+ {% if field.allow_null or field.allow_blank %} + + {% endif %} + + {% for key, text in field.choices.items %} + + {% endfor %} +
+ {% else %} + {% if field.allow_null or field.allow_blank %} +
+ +
+ {% endif %} + + {% for key, text in field.choices.items %} +
+ +
+ {% endfor %} + {% endif %} + + {% if field.errors %} + {% for error in field.errors %} + {{ error }} + {% endfor %} + {% endif %} + + {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} +
diff --git a/dynamic_rest/templates/dynamic_rest/vertical/select.html b/dynamic_rest/templates/dynamic_rest/vertical/select.html new file mode 100644 index 00000000..6ccaaf27 --- /dev/null +++ b/dynamic_rest/templates/dynamic_rest/vertical/select.html @@ -0,0 +1,34 @@ +{% load rest_framework %} + +
+ {% if field.label %} + + {% endif %} + + + + {% if field.errors %} + {% for error in field.errors %} + {{ error }} + {% endfor %} + {% endif %} + + {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} +
diff --git a/dynamic_rest/templates/dynamic_rest/vertical/select2.html b/dynamic_rest/templates/dynamic_rest/vertical/select2.html new file mode 100644 index 00000000..f466487b --- /dev/null +++ b/dynamic_rest/templates/dynamic_rest/vertical/select2.html @@ -0,0 +1,18 @@ +
+ {% if field.label %} + + {% endif %} + + hi there + + + {% if field.errors %} + {% for error in field.errors %} + {{ error }} + {% endfor %} + {% endif %} + + {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} +
diff --git a/dynamic_rest/templates/dynamic_rest/vertical/select_multiple.html b/dynamic_rest/templates/dynamic_rest/vertical/select_multiple.html new file mode 100644 index 00000000..b77c4be3 --- /dev/null +++ b/dynamic_rest/templates/dynamic_rest/vertical/select_multiple.html @@ -0,0 +1,33 @@ +{% load i18n %} +{% load rest_framework %} +{% trans "No items to select." as no_items %} + +
+ {% if field.label %} + + {% endif %} + + + + {% if field.errors %} + {% for error in field.errors %}{{ error }}{% endfor %} + {% endif %} + + {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} +
diff --git a/dynamic_rest/templates/dynamic_rest/vertical/textarea.html b/dynamic_rest/templates/dynamic_rest/vertical/textarea.html new file mode 100644 index 00000000..fea94cd0 --- /dev/null +++ b/dynamic_rest/templates/dynamic_rest/vertical/textarea.html @@ -0,0 +1,17 @@ +
+ {% if field.label %} + + {% endif %} + + + + {% if field.errors %} + {% for error in field.errors %}{{ error }}{% endfor %} + {% endif %} + + {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} +
diff --git a/dynamic_rest/viewsets.py b/dynamic_rest/viewsets.py index c722e593..941f1c0f 100644 --- a/dynamic_rest/viewsets.py +++ b/dynamic_rest/viewsets.py @@ -7,7 +7,6 @@ from rest_framework.exceptions import ValidationError from rest_framework.renderers import ( BrowsableAPIRenderer, - JSONRenderer, ) from rest_framework.response import Response from rest_framework.request import is_form_media_type @@ -16,10 +15,6 @@ from dynamic_rest.metadata import DynamicMetadata from dynamic_rest.pagination import DynamicPageNumberPagination from dynamic_rest.processors import SideloadingProcessor -from dynamic_rest.renderers import ( - DynamicBrowsableAPIRenderer, - DynamicAdminRenderer -) from dynamic_rest.utils import is_truthy UPDATE_REQUEST_METHODS = ('PUT', 'PATCH', 'POST') @@ -82,11 +77,6 @@ class WithDynamicViewSetMixin(object): # TODO: add support for `sort{}` pagination_class = DynamicPageNumberPagination metadata_class = DynamicMetadata - renderer_classes = ( - JSONRenderer, - DynamicBrowsableAPIRenderer, - DynamicAdminRenderer - ) features = (INCLUDE, EXCLUDE, FILTER, PAGE, PER_PAGE, SORT, SIDELOADING) meta = None filter_backends = (DynamicFilterBackend, DynamicSortingFilter) diff --git a/tests/settings.py b/tests/settings.py index 7205c43c..69a2c1a0 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -46,7 +46,7 @@ 'DEFAULT_RENDERER_CLASSES': ( 'rest_framework.renderers.JSONRenderer', 'dynamic_rest.renderers.DynamicAdminRenderer', - 'dynamic_rest.renderers.DynamicBrowsableAPIRenderer' + 'dynamic_rest.renderers.DynamicBrowsableAPIRenderer', ) } From 34f5401dd5471974547ae153430c44df7d4e96c9 Mon Sep 17 00:00:00 2001 From: aleontiev Date: Tue, 21 Mar 2017 14:36:03 -0700 Subject: [PATCH 08/13] remove 1.7 from tox --- tox.ini | 2 -- 1 file changed, 2 deletions(-) diff --git a/tox.ini b/tox.ini index 0adbdd8d..4993252a 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,6 @@ addopts=--tb=short [tox] envlist = py27-lint, - {py27,py33,py34}-django17-drf{31,32,33}, {py27,py33,py34,py35}-django18-drf{31,32,33,34}, {py27,py34,py35}-django19-drf{32,33,34}, {py27,py34,py35}-django110-drf34, @@ -15,7 +14,6 @@ commands = ./runtests.py --fast {posargs} --coverage -rw setenv = PYTHONDONTWRITEBYTECODE=1 deps = - django17: Django==1.7.11 django18: Django==1.8.14 django19: Django==1.9.9 django110: Django==1.10.0 From 3ded4a5d59e36e0aff92b0461b16137a7e38fd96 Mon Sep 17 00:00:00 2001 From: aleontiev Date: Tue, 21 Mar 2017 15:13:33 -0700 Subject: [PATCH 09/13] refactor field code locations --- dynamic_rest/fields/__init__.py | 6 +- dynamic_rest/fields/base.py | 119 ++++++++++++++++++ dynamic_rest/fields/common.py | 17 --- dynamic_rest/fields/generic.py | 6 +- .../fields/{fields.py => relation.py} | 109 +--------------- dynamic_rest/renderers.py | 3 +- dynamic_rest/viewsets.py | 19 +++ 7 files changed, 152 insertions(+), 127 deletions(-) create mode 100644 dynamic_rest/fields/base.py delete mode 100644 dynamic_rest/fields/common.py rename dynamic_rest/fields/{fields.py => relation.py} (79%) diff --git a/dynamic_rest/fields/__init__.py b/dynamic_rest/fields/__init__.py index 49c3095a..d51012b7 100644 --- a/dynamic_rest/fields/__init__.py +++ b/dynamic_rest/fields/__init__.py @@ -1,2 +1,4 @@ -from dynamic_rest.fields.fields import * # noqa -from dynamic_rest.fields.generic import * # noqa +# flake8: noqa +from .base import DynamicField, DynamicMethodField, CountField +from .relation import DynamicRelationField +from .generic import DynamicGenericRelationField diff --git a/dynamic_rest/fields/base.py b/dynamic_rest/fields/base.py new file mode 100644 index 00000000..8c9ab4b7 --- /dev/null +++ b/dynamic_rest/fields/base.py @@ -0,0 +1,119 @@ +from rest_framework.serializers import SerializerMethodField +from rest_framework import fields + + +class DynamicField(fields.Field): + + """ + Generic field base to capture additional custom field attributes. + """ + + def __init__( + self, + requires=None, + deferred=None, + field_type=None, + immutable=False, + **kwargs + ): + """ + Arguments: + deferred: Whether or not this field is deferred. + Deferred fields are not included in the response, + unless explicitly requested. + field_type: Field data type, if not inferrable from model. + requires: List of fields that this field depends on. + Processed by the view layer during queryset build time. + """ + self.requires = requires + self.deferred = deferred + self.field_type = field_type + self.immutable = immutable + self.kwargs = kwargs + super(DynamicField, self).__init__(**kwargs) + + def to_representation(self, value): + return value + + def to_internal_value(self, data): + return data + + +class DynamicComputedField(DynamicField): + pass + + +class DynamicMethodField(SerializerMethodField, DynamicField): + pass + + +class CountField(DynamicComputedField): + + """ + Computed field that counts the number of elements in another field. + """ + + def __init__(self, serializer_source, *args, **kwargs): + """ + Arguments: + serializer_source: A serializer field. + unique: Whether or not to perform a count of distinct elements. + """ + self.field_type = int + # Use `serializer_source`, which indicates a field at the API level, + # instead of `source`, which indicates a field at the model level. + self.serializer_source = serializer_source + # Set `source` to an empty value rather than the field name to avoid + # an attempt to look up this field. + kwargs['source'] = '' + self.unique = kwargs.pop('unique', True) + return super(CountField, self).__init__(*args, **kwargs) + + def get_attribute(self, obj): + source = self.serializer_source + if source not in self.parent.fields: + return None + value = self.parent.fields[source].get_attribute(obj) + data = self.parent.fields[source].to_representation(value) + + # How to count None is undefined... let the consumer decide. + if data is None: + return None + + # Check data type. Technically len() works on dicts, strings, but + # since this is a "count" field, we'll limit to list, set, tuple. + if not isinstance(data, (list, set, tuple)): + raise TypeError( + "'%s' is %s. Must be list, set or tuple to be countable." % ( + source, type(data) + ) + ) + + if self.unique: + # Try to create unique set. This may fail if `data` contains + # non-hashable elements (like dicts). + try: + data = set(data) + except TypeError: + pass + + return len(data) + + +class WithRelationalFieldMixin(object): + """Mostly code shared by DynamicRelationField and + DynamicGenericRelationField. + """ + + def _get_request_fields_from_parent(self): + """Get request fields from the parent serializer.""" + if not self.parent: + return None + + if not getattr(self.parent, 'request_fields'): + return None + + if not isinstance(self.parent.request_fields, dict): + return None + + return self.parent.request_fields.get(self.field_name) diff --git a/dynamic_rest/fields/common.py b/dynamic_rest/fields/common.py deleted file mode 100644 index 6cfab182..00000000 --- a/dynamic_rest/fields/common.py +++ /dev/null @@ -1,17 +0,0 @@ -class WithRelationalFieldMixin(object): - """Mostly code shared by DynamicRelationField and - DynamicGenericRelationField. - """ - - def _get_request_fields_from_parent(self): - """Get request fields from the parent serializer.""" - if not self.parent: - return None - - if not getattr(self.parent, 'request_fields'): - return None - - if not isinstance(self.parent.request_fields, dict): - return None - - return self.parent.request_fields.get(self.field_name) diff --git a/dynamic_rest/fields/generic.py b/dynamic_rest/fields/generic.py index 07d94072..ead29705 100644 --- a/dynamic_rest/fields/generic.py +++ b/dynamic_rest/fields/generic.py @@ -2,9 +2,7 @@ from rest_framework.exceptions import ValidationError -from dynamic_rest.fields.common import WithRelationalFieldMixin -from dynamic_rest.fields.fields import DynamicField -from dynamic_rest.routers import DynamicRouter +from .base import WithRelationalFieldMixin, DynamicField from dynamic_rest.tagged import TaggedDict @@ -66,6 +64,7 @@ def get_pk_object(self, type_key, id_value): def to_representation(self, instance): try: # Find serializer for the instance + from dynamic_rest.routers import DynamicRouter serializer_class = DynamicRouter.get_canonical_serializer( resource_key=None, instance=instance @@ -115,6 +114,7 @@ def to_internal_value(self, data): model_name = data.get('type', None) model_id = data.get('id', None) if model_name and model_id: + from dynamic_rest.routers import DynamicRouter serializer_class = DynamicRouter.get_canonical_serializer( resource_key=None, resource_name=model_name diff --git a/dynamic_rest/fields/fields.py b/dynamic_rest/fields/relation.py similarity index 79% rename from dynamic_rest/fields/fields.py rename to dynamic_rest/fields/relation.py index ff050a91..93a348b4 100644 --- a/dynamic_rest/fields/fields.py +++ b/dynamic_rest/fields/relation.py @@ -1,65 +1,19 @@ -"""This module contains custom field classes.""" - import importlib import pickle from django.utils import six from django.utils.functional import cached_property -from rest_framework import fields from rest_framework.exceptions import NotFound, ParseError -from rest_framework.serializers import SerializerMethodField - +from rest_framework import fields from dynamic_rest.bases import DynamicSerializerBase from dynamic_rest.conf import settings -from dynamic_rest.fields.common import WithRelationalFieldMixin from dynamic_rest.meta import ( - is_field_remote, get_model_field, get_related_model + is_field_remote, + get_model_field, + get_related_model ) - -class DynamicField(fields.Field): - - """ - Generic field base to capture additional custom field attributes. - """ - - def __init__( - self, - requires=None, - deferred=None, - field_type=None, - immutable=False, - **kwargs - ): - """ - Arguments: - deferred: Whether or not this field is deferred. - Deferred fields are not included in the response, - unless explicitly requested. - field_type: Field data type, if not inferrable from model. - requires: List of fields that this field depends on. - Processed by the view layer during queryset build time. - """ - self.requires = requires - self.deferred = deferred - self.field_type = field_type - self.immutable = immutable - self.kwargs = kwargs - super(DynamicField, self).__init__(**kwargs) - - def to_representation(self, value): - return value - - def to_internal_value(self, data): - return data - - -class DynamicComputedField(DynamicField): - pass - - -class DynamicMethodField(SerializerMethodField, DynamicField): - pass +from .base import DynamicField, WithRelationalFieldMixin class DynamicRelationField(WithRelationalFieldMixin, DynamicField): @@ -423,56 +377,3 @@ def serializer_class(self): self._serializer_class = serializer_class return serializer_class - - -class CountField(DynamicComputedField): - - """ - Computed field that counts the number of elements in another field. - """ - - def __init__(self, serializer_source, *args, **kwargs): - """ - Arguments: - serializer_source: A serializer field. - unique: Whether or not to perform a count of distinct elements. - """ - self.field_type = int - # Use `serializer_source`, which indicates a field at the API level, - # instead of `source`, which indicates a field at the model level. - self.serializer_source = serializer_source - # Set `source` to an empty value rather than the field name to avoid - # an attempt to look up this field. - kwargs['source'] = '' - self.unique = kwargs.pop('unique', True) - return super(CountField, self).__init__(*args, **kwargs) - - def get_attribute(self, obj): - source = self.serializer_source - if source not in self.parent.fields: - return None - value = self.parent.fields[source].get_attribute(obj) - data = self.parent.fields[source].to_representation(value) - - # How to count None is undefined... let the consumer decide. - if data is None: - return None - - # Check data type. Technically len() works on dicts, strings, but - # since this is a "count" field, we'll limit to list, set, tuple. - if not isinstance(data, (list, set, tuple)): - raise TypeError( - "'%s' is %s. Must be list, set or tuple to be countable." % ( - source, type(data) - ) - ) - - if self.unique: - # Try to create unique set. This may fail if `data` contains - # non-hashable elements (like dicts). - try: - data = set(data) - except TypeError: - pass - - return len(data) diff --git a/dynamic_rest/renderers.py b/dynamic_rest/renderers.py index 6731b31c..73bfc9c7 100644 --- a/dynamic_rest/renderers.py +++ b/dynamic_rest/renderers.py @@ -2,6 +2,8 @@ from copy import copy from rest_framework.renderers import ( BrowsableAPIRenderer, + HTMLFormRenderer, + ClassLookupDict ) try: from rest_framework.renderers import AdminRenderer @@ -10,7 +12,6 @@ class AdminRenderer(BrowsableAPIRenderer): format = 'admin' -from rest_framework.renderers import HTMLFormRenderer, ClassLookupDict from dynamic_rest.utils import unpack from dynamic_rest.fields import DynamicRelationField diff --git a/dynamic_rest/viewsets.py b/dynamic_rest/viewsets.py index 941f1c0f..3cfbccf2 100644 --- a/dynamic_rest/viewsets.py +++ b/dynamic_rest/viewsets.py @@ -1,5 +1,6 @@ """This module contains custom viewset classes.""" import csv +import inflection from django.core.exceptions import ObjectDoesNotExist from django.http import QueryDict from django.utils import six @@ -21,6 +22,24 @@ DELETE_REQUEST_METHOD = 'DELETE' +def get_view_name(view_cls, suffix=None): + serializer_class = getattr(view_cls, 'serializer_class', None) + suffix = suffix or '' + if serializer_class: + serializer = view_cls.serializer_class() + if suffix.lower() == 'list': + name = serializer.get_plural_name() + else: + name = serializer.get_name() + else: + name = view_cls.__name__ + name = ( + inflection.pluralize(name) + if suffix.lower() == 'list' else name + ) + return name.title() + + class QueryParams(QueryDict): """ Extension of Django's QueryDict. Instantiated from a DRF Request From 9177c5dc4e7dfaed7e541335265bc0a733cf40d5 Mon Sep 17 00:00:00 2001 From: aleontiev Date: Tue, 21 Mar 2017 15:37:34 -0700 Subject: [PATCH 10/13] test groups.csv --- tests/groups.csv | 3 +++ tests/test_viewsets.py | 17 +++++++++++------ 2 files changed, 14 insertions(+), 6 deletions(-) create mode 100644 tests/groups.csv diff --git a/tests/groups.csv b/tests/groups.csv new file mode 100644 index 00000000..721fe5bb --- /dev/null +++ b/tests/groups.csv @@ -0,0 +1,3 @@ +name,random_input +foo,f +bar,b diff --git a/tests/test_viewsets.py b/tests/test_viewsets.py index 02626ac4..4b831eed 100644 --- a/tests/test_viewsets.py +++ b/tests/test_viewsets.py @@ -226,18 +226,23 @@ def test_post_single(self): self.assertEqual(1, Group.objects.all().count()) def test_csv_upload(self): - file = SimpleUploadedFile( - 'test.csv', - 'name,random_input\nfoo,f\nbar,b', - 'text/csv' - ) + with open('tests/groups.csv') as infile: + content = infile.read() + file = SimpleUploadedFile( + 'groups.csv', + content, + 'text/csv' + ) + response = self.client.post( '/groups/', data={ 'file': file } ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual( + response.status_code, status.HTTP_201_CREATED, response.content + ) self.assertEqual(2, Group.objects.count()) def test_post_bulk_from_resource_plural_name(self): From 4ff6a8b154287d4c66a1df8c8226f98906ed2745 Mon Sep 17 00:00:00 2001 From: aleontiev Date: Tue, 21 Mar 2017 16:05:40 -0700 Subject: [PATCH 11/13] attempt to fix SUF for py3 --- tests/test_viewsets.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/test_viewsets.py b/tests/test_viewsets.py index 4b831eed..9694bf40 100644 --- a/tests/test_viewsets.py +++ b/tests/test_viewsets.py @@ -227,23 +227,23 @@ def test_post_single(self): def test_csv_upload(self): with open('tests/groups.csv') as infile: - content = infile.read() file = SimpleUploadedFile( - 'groups.csv', - content, - 'text/csv' + infile.name, + infile.read(), ) - response = self.client.post( - '/groups/', - data={ - 'file': file - } - ) - self.assertEqual( - response.status_code, status.HTTP_201_CREATED, response.content - ) - self.assertEqual(2, Group.objects.count()) + response = self.client.post( + '/groups/', + data={ + 'file': file + } + ) + self.assertEqual( + response.status_code, + status.HTTP_201_CREATED, + response.content + ) + self.assertEqual(2, Group.objects.count()) def test_post_bulk_from_resource_plural_name(self): data = { From 857ece3964ef8351f8cd7b1f5b0ca6781969b559 Mon Sep 17 00:00:00 2001 From: aleontiev Date: Tue, 21 Mar 2017 17:31:57 -0700 Subject: [PATCH 12/13] py3 fix --- dynamic_rest/viewsets.py | 7 +++++-- tests/test_viewsets.py | 8 +------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/dynamic_rest/viewsets.py b/dynamic_rest/viewsets.py index 3cfbccf2..9a108e89 100644 --- a/dynamic_rest/viewsets.py +++ b/dynamic_rest/viewsets.py @@ -1,5 +1,6 @@ """This module contains custom viewset classes.""" import csv +from io import StringIO import inflection from django.core.exceptions import ObjectDoesNotExist from django.http import QueryDict @@ -413,14 +414,16 @@ def _is_csv_upload(self, request): if is_form_media_type(request.content_type): if ( 'file' in request.data and - request.data['file'].name.endswith('.csv') + request.data['file'].name.lower().endswith('.csv') ): return True return False def _get_bulk_payload_csv(self, request): file = request.data['file'] - reader = csv.DictReader(file) + reader = csv.DictReader( + StringIO(file.read().decode('utf-8')) + ) return [r for r in reader] def _get_bulk_payload_json(self, request): diff --git a/tests/test_viewsets.py b/tests/test_viewsets.py index 9694bf40..a9abfc78 100644 --- a/tests/test_viewsets.py +++ b/tests/test_viewsets.py @@ -4,7 +4,6 @@ from django.test.client import RequestFactory from rest_framework import exceptions, status from rest_framework.request import Request -from django.core.files.uploadedfile import SimpleUploadedFile from dynamic_rest.filters import DynamicFilterBackend, FilterNode from tests.models import Dog, Group, User @@ -226,12 +225,7 @@ def test_post_single(self): self.assertEqual(1, Group.objects.all().count()) def test_csv_upload(self): - with open('tests/groups.csv') as infile: - file = SimpleUploadedFile( - infile.name, - infile.read(), - ) - + with open('tests/groups.csv', 'rb') as file: response = self.client.post( '/groups/', data={ From 0f9b7bdde9f539f8c680427bca0645d54b3a4544 Mon Sep 17 00:00:00 2001 From: aleontiev Date: Tue, 21 Mar 2017 18:22:56 -0700 Subject: [PATCH 13/13] add back django 1.7 --- tox.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tox.ini b/tox.ini index 4993252a..0adbdd8d 100644 --- a/tox.ini +++ b/tox.ini @@ -4,6 +4,7 @@ addopts=--tb=short [tox] envlist = py27-lint, + {py27,py33,py34}-django17-drf{31,32,33}, {py27,py33,py34,py35}-django18-drf{31,32,33,34}, {py27,py34,py35}-django19-drf{32,33,34}, {py27,py34,py35}-django110-drf34, @@ -14,6 +15,7 @@ commands = ./runtests.py --fast {posargs} --coverage -rw setenv = PYTHONDONTWRITEBYTECODE=1 deps = + django17: Django==1.7.11 django18: Django==1.8.14 django19: Django==1.9.9 django110: Django==1.10.0