mirror of
https://github.com/encode/django-rest-framework.git
synced 2024-11-26 11:33:59 +03:00
More tests, getting new serialization into resource
This commit is contained in:
parent
a2575c1191
commit
4d12679675
|
@ -3,7 +3,7 @@ The ``authentication`` module provides a set of pluggable authentication classes
|
||||||
|
|
||||||
Authentication behavior is provided by adding the ``AuthMixin`` class to a ``View`` .
|
Authentication behavior is provided by adding the ``AuthMixin`` class to a ``View`` .
|
||||||
|
|
||||||
The set of authentication methods which are used is then specified by setting
|
The set of authentication methods which are used is then specified by setting the
|
||||||
``authentication`` attribute on the ``View`` class, and listing a set of authentication classes.
|
``authentication`` attribute on the ``View`` class, and listing a set of authentication classes.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@ -81,7 +81,7 @@ class UserLoggedInAuthenticaton(BaseAuthenticaton):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def authenticate(self, request):
|
def authenticate(self, request):
|
||||||
# TODO: Switch this back to request.POST, and let MultiPartParser deal with the consequences.
|
# TODO: Switch this back to request.POST, and let FormParser/MultiPartParser deal with the consequences.
|
||||||
if getattr(request, 'user', None) and request.user.is_active:
|
if getattr(request, 'user', None) and request.user.is_active:
|
||||||
# If this is a POST request we enforce CSRF validation.
|
# If this is a POST request we enforce CSRF validation.
|
||||||
if request.method.upper() == 'POST':
|
if request.method.upper() == 'POST':
|
||||||
|
|
|
@ -33,7 +33,7 @@ __all__ = (
|
||||||
|
|
||||||
class RequestMixin(object):
|
class RequestMixin(object):
|
||||||
"""
|
"""
|
||||||
Mixin class to provide request parsing behaviour.
|
Mixin class to provide request parsing behavior.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
USE_FORM_OVERLOADING = True
|
USE_FORM_OVERLOADING = True
|
||||||
|
@ -93,6 +93,13 @@ class RequestMixin(object):
|
||||||
if content_length == 0:
|
if content_length == 0:
|
||||||
return None
|
return None
|
||||||
elif hasattr(request, 'read'):
|
elif hasattr(request, 'read'):
|
||||||
|
# UPDATE BASED ON COMMENT BELOW:
|
||||||
|
#
|
||||||
|
# Yup, this was a bug in Django - fixed and waiting check in - see ticket 15785.
|
||||||
|
# http://code.djangoproject.com/ticket/15785
|
||||||
|
#
|
||||||
|
# COMMENT:
|
||||||
|
#
|
||||||
# It's not at all clear if this needs to be byte limited or not.
|
# It's not at all clear if this needs to be byte limited or not.
|
||||||
# Maybe I'm just being dumb but it looks to me like there's some issues
|
# Maybe I'm just being dumb but it looks to me like there's some issues
|
||||||
# with that in Django.
|
# with that in Django.
|
||||||
|
@ -117,8 +124,6 @@ class RequestMixin(object):
|
||||||
#except (ValueError, TypeError):
|
#except (ValueError, TypeError):
|
||||||
# content_length = 0
|
# content_length = 0
|
||||||
# self._stream = LimitedStream(request, content_length)
|
# self._stream = LimitedStream(request, content_length)
|
||||||
#
|
|
||||||
# UPDATE: http://code.djangoproject.com/ticket/15785
|
|
||||||
self._stream = request
|
self._stream = request
|
||||||
else:
|
else:
|
||||||
self._stream = StringIO(request.raw_post_data)
|
self._stream = StringIO(request.raw_post_data)
|
||||||
|
@ -290,6 +295,10 @@ class ResponseMixin(object):
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: This should be simpler now.
|
||||||
|
# Add a handles_response() to the renderer, then iterate through the
|
||||||
|
# acceptable media types, ordered by how specific they are,
|
||||||
|
# calling handles_response on each renderer.
|
||||||
def _determine_renderer(self, request):
|
def _determine_renderer(self, request):
|
||||||
"""
|
"""
|
||||||
Return the appropriate renderer for the output, given the client's 'Accept' header,
|
Return the appropriate renderer for the output, given the client's 'Accept' header,
|
||||||
|
@ -321,7 +330,7 @@ class ResponseMixin(object):
|
||||||
qvalue = Decimal('1.0')
|
qvalue = Decimal('1.0')
|
||||||
|
|
||||||
if len(components) > 1:
|
if len(components) > 1:
|
||||||
# Parse items that have a qvalue eg text/html;q=0.9
|
# Parse items that have a qvalue eg 'text/html; q=0.9'
|
||||||
try:
|
try:
|
||||||
(q, num) = components[-1].split('=')
|
(q, num) = components[-1].split('=')
|
||||||
if q == 'q':
|
if q == 'q':
|
||||||
|
@ -356,10 +365,10 @@ class ResponseMixin(object):
|
||||||
|
|
||||||
raise ErrorResponse(status.HTTP_406_NOT_ACCEPTABLE,
|
raise ErrorResponse(status.HTTP_406_NOT_ACCEPTABLE,
|
||||||
{'detail': 'Could not satisfy the client\'s Accept header',
|
{'detail': 'Could not satisfy the client\'s Accept header',
|
||||||
'available_types': self.renderted_media_types})
|
'available_types': self.rendered_media_types})
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def renderted_media_types(self):
|
def rendered_media_types(self):
|
||||||
"""
|
"""
|
||||||
Return an list of all the media types that this resource can render.
|
Return an list of all the media types that this resource can render.
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -24,6 +24,7 @@ from urllib import quote_plus
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'BaseRenderer',
|
'BaseRenderer',
|
||||||
|
'TemplateRenderer',
|
||||||
'JSONRenderer',
|
'JSONRenderer',
|
||||||
'DocumentingHTMLRenderer',
|
'DocumentingHTMLRenderer',
|
||||||
'DocumentingXHTMLRenderer',
|
'DocumentingXHTMLRenderer',
|
||||||
|
@ -87,10 +88,12 @@ class DocumentingTemplateRenderer(BaseRenderer):
|
||||||
template = None
|
template = None
|
||||||
|
|
||||||
def _get_content(self, view, request, obj, media_type):
|
def _get_content(self, view, request, obj, media_type):
|
||||||
"""Get the content as if it had been rendered by a non-documenting renderer.
|
"""
|
||||||
|
Get the content as if it had been rendered by a non-documenting renderer.
|
||||||
|
|
||||||
(Typically this will be the content as it would have been if the Resource had been
|
(Typically this will be the content as it would have been if the Resource had been
|
||||||
requested with an 'Accept: */*' header, although with verbose style formatting if appropriate.)"""
|
requested with an 'Accept: */*' header, although with verbose style formatting if appropriate.)
|
||||||
|
"""
|
||||||
|
|
||||||
# Find the first valid renderer and render the content. (Don't use another documenting renderer.)
|
# Find the first valid renderer and render the content. (Don't use another documenting renderer.)
|
||||||
renderers = [renderer for renderer in view.renderers if not isinstance(renderer, DocumentingTemplateRenderer)]
|
renderers = [renderer for renderer in view.renderers if not isinstance(renderer, DocumentingTemplateRenderer)]
|
||||||
|
@ -106,9 +109,11 @@ class DocumentingTemplateRenderer(BaseRenderer):
|
||||||
|
|
||||||
|
|
||||||
def _get_form_instance(self, view):
|
def _get_form_instance(self, view):
|
||||||
"""Get a form, possibly bound to either the input or output data.
|
"""
|
||||||
|
Get a form, possibly bound to either the input or output data.
|
||||||
In the absence on of the Resource having an associated form then
|
In the absence on of the Resource having an associated form then
|
||||||
provide a form that can be used to submit arbitrary content."""
|
provide a form that can be used to submit arbitrary content.
|
||||||
|
"""
|
||||||
|
|
||||||
# Get the form instance if we have one bound to the input
|
# Get the form instance if we have one bound to the input
|
||||||
form_instance = getattr(view, 'bound_form_instance', None)
|
form_instance = getattr(view, 'bound_form_instance', None)
|
||||||
|
@ -138,8 +143,10 @@ class DocumentingTemplateRenderer(BaseRenderer):
|
||||||
|
|
||||||
|
|
||||||
def _get_generic_content_form(self, view):
|
def _get_generic_content_form(self, view):
|
||||||
"""Returns a form that allows for arbitrary content types to be tunneled via standard HTML forms
|
"""
|
||||||
(Which are typically application/x-www-form-urlencoded)"""
|
Returns a form that allows for arbitrary content types to be tunneled via standard HTML forms
|
||||||
|
(Which are typically application/x-www-form-urlencoded)
|
||||||
|
"""
|
||||||
|
|
||||||
# If we're not using content overloading there's no point in supplying a generic form,
|
# If we're not using content overloading there's no point in supplying a generic form,
|
||||||
# as the view won't treat the form's value as the content of the request.
|
# as the view won't treat the form's value as the content of the request.
|
||||||
|
@ -197,8 +204,8 @@ class DocumentingTemplateRenderer(BaseRenderer):
|
||||||
template = loader.get_template(self.template)
|
template = loader.get_template(self.template)
|
||||||
context = RequestContext(self.view.request, {
|
context = RequestContext(self.view.request, {
|
||||||
'content': content,
|
'content': content,
|
||||||
'resource': self.view,
|
'resource': self.view, # TODO: rename to view
|
||||||
'request': self.view.request,
|
'request': self.view.request, # TODO: remove
|
||||||
'response': self.view.response,
|
'response': self.view.response,
|
||||||
'description': description,
|
'description': description,
|
||||||
'name': name,
|
'name': name,
|
||||||
|
|
|
@ -1,16 +1,89 @@
|
||||||
from django.db.models import Model
|
from django.db import models
|
||||||
from django.db.models.query import QuerySet
|
from django.db.models.query import QuerySet
|
||||||
from django.db.models.fields.related import RelatedField
|
from django.db.models.fields.related import RelatedField
|
||||||
|
from django.utils.encoding import smart_unicode
|
||||||
|
|
||||||
import decimal
|
import decimal
|
||||||
import inspect
|
import inspect
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _model_to_dict(instance, fields=None, exclude=None):
|
||||||
|
"""
|
||||||
|
This is a clone of Django's ``django.forms.model_to_dict`` except that it
|
||||||
|
doesn't coerce related objects into primary keys.
|
||||||
|
"""
|
||||||
|
opts = instance._meta
|
||||||
|
data = {}
|
||||||
|
for f in opts.fields + opts.many_to_many:
|
||||||
|
if not f.editable:
|
||||||
|
continue
|
||||||
|
if fields and not f.name in fields:
|
||||||
|
continue
|
||||||
|
if exclude and f.name in exclude:
|
||||||
|
continue
|
||||||
|
if isinstance(f, models.ForeignKey):
|
||||||
|
data[f.name] = getattr(instance, f.name)
|
||||||
|
else:
|
||||||
|
data[f.name] = f.value_from_object(instance)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _object_to_data(obj):
|
||||||
|
"""
|
||||||
|
Convert an object into a serializable representation.
|
||||||
|
"""
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
# dictionaries
|
||||||
|
return dict([ (key, _object_to_data(val)) for key, val in obj.iteritems() ])
|
||||||
|
if isinstance(obj, (tuple, list, set, QuerySet)):
|
||||||
|
# basic iterables
|
||||||
|
return [_object_to_data(item) for item in obj]
|
||||||
|
if isinstance(obj, models.Manager):
|
||||||
|
# Manager objects
|
||||||
|
ret = [_object_to_data(item) for item in obj.all()]
|
||||||
|
if isinstance(obj, models.Model):
|
||||||
|
# Model instances
|
||||||
|
return _object_to_data(_model_to_dict(obj))
|
||||||
|
if isinstance(obj, decimal.Decimal):
|
||||||
|
# Decimals (force to string representation)
|
||||||
|
return str(obj)
|
||||||
|
if inspect.isfunction(obj) and not inspect.getargspec(obj)[0]:
|
||||||
|
# function with no args
|
||||||
|
return _object_to_data(obj())
|
||||||
|
if inspect.ismethod(obj) and len(inspect.getargspec(obj)[0]) == 1:
|
||||||
|
# method with only a 'self' args
|
||||||
|
return _object_to_data(obj())
|
||||||
|
|
||||||
|
# fallback
|
||||||
|
return smart_unicode(obj, strings_only=True)
|
||||||
|
|
||||||
|
|
||||||
# TODO: Replace this with new Serializer code based on Forms API.
|
# TODO: Replace this with new Serializer code based on Forms API.
|
||||||
|
|
||||||
|
#class Resource(object):
|
||||||
|
# def __init__(self, view):
|
||||||
|
# self.view = view
|
||||||
|
#
|
||||||
|
# def object_to_data(self, obj):
|
||||||
|
# pass
|
||||||
|
#
|
||||||
|
# def data_to_object(self, data, files):
|
||||||
|
# pass
|
||||||
|
#
|
||||||
|
#class FormResource(object):
|
||||||
|
# pass
|
||||||
|
#
|
||||||
|
#class ModelResource(object):
|
||||||
|
# pass
|
||||||
|
|
||||||
|
|
||||||
class Resource(object):
|
class Resource(object):
|
||||||
"""A Resource determines how an object maps to a serializable entity.
|
"""
|
||||||
Objects that a resource can act on include plain Python object instances, Django Models, and Django QuerySets."""
|
A Resource determines how a python object maps to some serializable data.
|
||||||
|
Objects that a resource can act on include plain Python object instances, Django Models, and Django QuerySets.
|
||||||
|
"""
|
||||||
|
|
||||||
# The model attribute refers to the Django Model which this Resource maps to.
|
# The model attribute refers to the Django Model which this Resource maps to.
|
||||||
# (The Model's class, rather than an instance of the Model)
|
# (The Model's class, rather than an instance of the Model)
|
||||||
|
@ -50,7 +123,7 @@ class Resource(object):
|
||||||
ret = thing
|
ret = thing
|
||||||
elif isinstance(thing, decimal.Decimal):
|
elif isinstance(thing, decimal.Decimal):
|
||||||
ret = str(thing)
|
ret = str(thing)
|
||||||
elif isinstance(thing, Model):
|
elif isinstance(thing, models.Model):
|
||||||
ret = _model(thing, fields=fields)
|
ret = _model(thing, fields=fields)
|
||||||
#elif isinstance(thing, HttpResponse): TRC
|
#elif isinstance(thing, HttpResponse): TRC
|
||||||
# raise HttpStatusCode(thing)
|
# raise HttpStatusCode(thing)
|
||||||
|
|
|
@ -1,11 +1,14 @@
|
||||||
from django.core.handlers.wsgi import STATUS_CODE_TEXT
|
from django.core.handlers.wsgi import STATUS_CODE_TEXT
|
||||||
|
|
||||||
__all__ =['Response', 'ErrorResponse']
|
__all__ = ('Response', 'ErrorResponse')
|
||||||
|
|
||||||
# TODO: remove raw_content/cleaned_content and just use content?
|
# TODO: remove raw_content/cleaned_content and just use content?
|
||||||
|
|
||||||
class Response(object):
|
class Response(object):
|
||||||
"""An HttpResponse that may include content that hasn't yet been serialized."""
|
"""
|
||||||
|
An HttpResponse that may include content that hasn't yet been serialized.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, status=200, content=None, headers={}):
|
def __init__(self, status=200, content=None, headers={}):
|
||||||
self.status = status
|
self.status = status
|
||||||
self.has_content_body = content is not None
|
self.has_content_body = content is not None
|
||||||
|
@ -15,12 +18,18 @@ class Response(object):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def status_text(self):
|
def status_text(self):
|
||||||
"""Return reason text corresponding to our HTTP response status code.
|
"""
|
||||||
Provided for convenience."""
|
Return reason text corresponding to our HTTP response status code.
|
||||||
|
Provided for convenience.
|
||||||
|
"""
|
||||||
return STATUS_CODE_TEXT.get(self.status, '')
|
return STATUS_CODE_TEXT.get(self.status, '')
|
||||||
|
|
||||||
|
|
||||||
class ErrorResponse(BaseException):
|
class ErrorResponse(BaseException):
|
||||||
"""An exception representing an HttpResponse that should be returned immediately."""
|
"""
|
||||||
|
An exception representing an Response that should be returned immediately.
|
||||||
|
Any content should be serialized as-is, without being filtered.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, status, content=None, headers={}):
|
def __init__(self, status, content=None, headers={}):
|
||||||
self.response = Response(status, content=content, headers=headers)
|
self.response = Response(status, content=content, headers=headers)
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
"""Descriptive HTTP status codes, for code readability.
|
"""
|
||||||
|
Descriptive HTTP status codes, for code readability.
|
||||||
|
|
||||||
See RFC 2616 - Sec 10: http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
|
See RFC 2616 - Sec 10: http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
|
||||||
Also, django.core.handlers.wsgi.STATUS_CODE_TEXT"""
|
Also see django.core.handlers.wsgi.STATUS_CODE_TEXT
|
||||||
|
"""
|
||||||
|
|
||||||
# Verbose format
|
# Verbose format
|
||||||
HTTP_100_CONTINUE = 100
|
HTTP_100_CONTINUE = 100
|
||||||
|
|
|
@ -48,7 +48,7 @@
|
||||||
<h2>GET {{ name }}</h2>
|
<h2>GET {{ name }}</h2>
|
||||||
<div class='submit-row' style='margin: 0; border: 0'>
|
<div class='submit-row' style='margin: 0; border: 0'>
|
||||||
<a href='{{ request.path }}' rel="nofollow" style='float: left'>GET</a>
|
<a href='{{ request.path }}' rel="nofollow" style='float: left'>GET</a>
|
||||||
{% for media_type in resource.renderted_media_types %}
|
{% for media_type in resource.rendered_media_types %}
|
||||||
{% with resource.ACCEPT_QUERY_PARAM|add:"="|add:media_type as param %}
|
{% with resource.ACCEPT_QUERY_PARAM|add:"="|add:media_type as param %}
|
||||||
[<a href='{{ request.path|add_query_param:param }}' rel="nofollow">{{ media_type }}</a>]
|
[<a href='{{ request.path|add_query_param:param }}' rel="nofollow">{{ media_type }}</a>]
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
31
djangorestframework/tests/resource.py
Normal file
31
djangorestframework/tests/resource.py
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
"""Tests for the resource module"""
|
||||||
|
from django.test import TestCase
|
||||||
|
from djangorestframework.resource import _object_to_data
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import decimal
|
||||||
|
|
||||||
|
class TestObjectToData(TestCase):
|
||||||
|
"""Tests for the _object_to_data function"""
|
||||||
|
|
||||||
|
def test_decimal(self):
|
||||||
|
"""Decimals need to be converted to a string representation."""
|
||||||
|
self.assertEquals(_object_to_data(decimal.Decimal('1.5')), '1.5')
|
||||||
|
|
||||||
|
def test_function(self):
|
||||||
|
"""Functions with no arguments should be called."""
|
||||||
|
def foo():
|
||||||
|
return 1
|
||||||
|
self.assertEquals(_object_to_data(foo), 1)
|
||||||
|
|
||||||
|
def test_method(self):
|
||||||
|
"""Methods with only a ``self`` argument should be called."""
|
||||||
|
class Foo(object):
|
||||||
|
def foo(self):
|
||||||
|
return 1
|
||||||
|
self.assertEquals(_object_to_data(Foo().foo), 1)
|
||||||
|
|
||||||
|
def test_datetime(self):
|
||||||
|
"""datetime objects are left as-is."""
|
||||||
|
now = datetime.datetime.now()
|
||||||
|
self.assertEquals(_object_to_data(now), now)
|
|
@ -31,20 +31,24 @@ class FormValidator(BaseValidator):
|
||||||
|
|
||||||
|
|
||||||
def validate(self, content):
|
def validate(self, content):
|
||||||
"""Given some content as input return some cleaned, validated content.
|
"""
|
||||||
|
Given some content as input return some cleaned, validated content.
|
||||||
Raises a ErrorResponse with status code 400 (Bad Request) on failure.
|
Raises a ErrorResponse with status code 400 (Bad Request) on failure.
|
||||||
|
|
||||||
Validation is standard form validation, with an additional constraint that no extra unknown fields may be supplied.
|
Validation is standard form validation, with an additional constraint that no extra unknown fields may be supplied.
|
||||||
|
|
||||||
On failure the ErrorResponse content is a dict which may contain 'errors' and 'field-errors' keys.
|
On failure the ErrorResponse content is a dict which may contain 'errors' and 'field-errors' keys.
|
||||||
If the 'errors' key exists it is a list of strings of non-field errors.
|
If the 'errors' key exists it is a list of strings of non-field errors.
|
||||||
If the 'field-errors' key exists it is a dict of {field name as string: list of errors as strings}."""
|
If the 'field-errors' key exists it is a dict of {field name as string: list of errors as strings}.
|
||||||
|
"""
|
||||||
return self._validate(content)
|
return self._validate(content)
|
||||||
|
|
||||||
def _validate(self, content, allowed_extra_fields=()):
|
def _validate(self, content, allowed_extra_fields=()):
|
||||||
"""Wrapped by validate to hide the extra_fields option that the ModelValidatorMixin uses.
|
"""
|
||||||
|
Wrapped by validate to hide the extra_fields option that the ModelValidatorMixin uses.
|
||||||
extra_fields is a list of fields which are not defined by the form, but which we still
|
extra_fields is a list of fields which are not defined by the form, but which we still
|
||||||
expect to see on the input."""
|
expect to see on the input.
|
||||||
|
"""
|
||||||
bound_form = self.get_bound_form(content)
|
bound_form = self.get_bound_form(content)
|
||||||
|
|
||||||
if bound_form is None:
|
if bound_form is None:
|
||||||
|
@ -138,7 +142,8 @@ class ModelFormValidator(FormValidator):
|
||||||
# TODO: test the different validation here to allow for get get_absolute_url to be supplied on input and not bork out
|
# TODO: test the different validation here to allow for get get_absolute_url to be supplied on input and not bork out
|
||||||
# TODO: be really strict on fields - check they match in the handler methods. (this isn't a validator thing tho.)
|
# TODO: be really strict on fields - check they match in the handler methods. (this isn't a validator thing tho.)
|
||||||
def validate(self, content):
|
def validate(self, content):
|
||||||
"""Given some content as input return some cleaned, validated content.
|
"""
|
||||||
|
Given some content as input return some cleaned, validated content.
|
||||||
Raises a ErrorResponse with status code 400 (Bad Request) on failure.
|
Raises a ErrorResponse with status code 400 (Bad Request) on failure.
|
||||||
|
|
||||||
Validation is standard form or model form validation,
|
Validation is standard form or model form validation,
|
||||||
|
@ -148,7 +153,8 @@ class ModelFormValidator(FormValidator):
|
||||||
|
|
||||||
On failure the ErrorResponse content is a dict which may contain 'errors' and 'field-errors' keys.
|
On failure the ErrorResponse content is a dict which may contain 'errors' and 'field-errors' keys.
|
||||||
If the 'errors' key exists it is a list of strings of non-field errors.
|
If the 'errors' key exists it is a list of strings of non-field errors.
|
||||||
If the 'field-errors' key exists it is a dict of {field name as string: list of errors as strings}."""
|
If the 'field-errors' key exists it is a dict of {field name as string: list of errors as strings}.
|
||||||
|
"""
|
||||||
return self._validate(content, allowed_extra_fields=self._property_fields_set)
|
return self._validate(content, allowed_extra_fields=self._property_fields_set)
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user