Commit 4383eef3 authored by Serge S. Koval's avatar Serge S. Koval

Changed inline form conversion logic, fixed peewee support

parent c1d85d3c
...@@ -13,3 +13,5 @@ make.bat ...@@ -13,3 +13,5 @@ make.bat
venv venv
*.sqlite *.sqlite
*.sublime-* *.sublime-*
.coverage
from flask.ext.admin.babel import gettext from flask.ext.admin.babel import gettext
from flask.ext.admin.model import filters from flask.ext.admin.model import filters
from .tools import parse_like_term
class BasePeeweeFilter(filters.BaseFilter): class BasePeeweeFilter(filters.BaseFilter):
...@@ -44,7 +45,8 @@ class FilterNotEqual(BasePeeweeFilter): ...@@ -44,7 +45,8 @@ class FilterNotEqual(BasePeeweeFilter):
class FilterLike(BasePeeweeFilter): class FilterLike(BasePeeweeFilter):
def apply(self, query, value): def apply(self, query, value):
return query.filter(self.column ** value) term = parse_like_term(value)
return query.filter(self.column ** term)
def operation(self): def operation(self):
return gettext('contains') return gettext('contains')
...@@ -52,7 +54,8 @@ class FilterLike(BasePeeweeFilter): ...@@ -52,7 +54,8 @@ class FilterLike(BasePeeweeFilter):
class FilterNotLike(BasePeeweeFilter): class FilterNotLike(BasePeeweeFilter):
def apply(self, query, value): def apply(self, query, value):
return query.filter(~(self.column ** value)) term = parse_like_term(value)
return query.filter(~(self.column ** term))
def operation(self): def operation(self):
return gettext('not contains') return gettext('not contains')
......
...@@ -5,7 +5,7 @@ from peewee import DateTimeField, DateField, TimeField, BaseModel, ForeignKeyFie ...@@ -5,7 +5,7 @@ from peewee import DateTimeField, DateField, TimeField, BaseModel, ForeignKeyFie
from wtfpeewee.orm import ModelConverter, model_form from wtfpeewee.orm import ModelConverter, model_form
from flask.ext.admin import form from flask.ext.admin import form
from flask.ext.admin.model.form import InlineFormAdmin from flask.ext.admin.model.form import InlineFormAdmin, InlineModelConverterBase
from flask.ext.admin.model.fields import InlineModelFormField from flask.ext.admin.model.fields import InlineModelFormField
from flask.ext.admin.model.widgets import InlineFormListWidget from flask.ext.admin.model.widgets import InlineFormListWidget
...@@ -29,7 +29,8 @@ class InlineModelFormList(fields.FieldList): ...@@ -29,7 +29,8 @@ class InlineModelFormList(fields.FieldList):
def process(self, formdata, data=None): def process(self, formdata, data=None):
if not formdata: if not formdata:
data = self.model.select().where(user=data).execute() attr = getattr(self.model, self.prop)
data = self.model.select().where(attr == data).execute()
else: else:
data = None data = None
...@@ -41,7 +42,8 @@ class InlineModelFormList(fields.FieldList): ...@@ -41,7 +42,8 @@ class InlineModelFormList(fields.FieldList):
def save_related(self, obj): def save_related(self, obj):
model_id = getattr(obj, self._pk) model_id = getattr(obj, self._pk)
values = self.model.select().where(user=model_id).execute() attr = getattr(self.model, self.prop)
values = self.model.select().where(attr == model_id).execute()
pk_map = dict((str(getattr(v, self._pk)), v) for v in values) pk_map = dict((str(getattr(v, self._pk)), v) for v in values)
...@@ -85,37 +87,18 @@ class CustomModelConverter(ModelConverter): ...@@ -85,37 +87,18 @@ class CustomModelConverter(ModelConverter):
return field.name, form.TimeField(**kwargs) return field.name, form.TimeField(**kwargs)
def contribute_inline(model, form_class, inline_models): class InlineModelConverter(InlineModelConverterBase):
# Contribute columns def contribute(self, converter, model, form_class, inline_model):
for p in inline_models:
# Figure out settings
if isinstance(p, tuple):
info = InlineFormAdmin(p[0], **p[1])
elif isinstance(p, InlineFormAdmin):
info = p
elif isinstance(p, BaseModel):
info = InlineFormAdmin(p)
else:
model = getattr(p, 'model', None)
if model is None:
raise Exception('Unknown inline model admin: %s' % repr(p))
attrs = dict()
for attr in dir(p):
if not attr.startswith('_') and attr != model:
attrs[attr] = getattr(p, attr)
info = InlineFormAdmin(model, **attrs)
# Find property from target model to current model # Find property from target model to current model
reverse_field = None reverse_field = None
info = self.get_info(inline_model)
for field in info.model._meta.get_fields(): for field in info.model._meta.get_fields():
field_type = type(field) field_type = type(field)
if field_type == ForeignKeyField: if field_type == ForeignKeyField:
if field.to == model: if field.rel_model == model:
reverse_field = field reverse_field = field
break break
else: else:
...@@ -130,7 +113,6 @@ def contribute_inline(model, form_class, inline_models): ...@@ -130,7 +113,6 @@ def contribute_inline(model, form_class, inline_models):
exclude = ignore exclude = ignore
# Create field # Create field
converter = CustomModelConverter()
child_form = model_form(info.model, child_form = model_form(info.model,
base_class=form.BaseForm, base_class=form.BaseForm,
only=info.form_columns, only=info.form_columns,
...@@ -140,7 +122,6 @@ def contribute_inline(model, form_class, inline_models): ...@@ -140,7 +122,6 @@ def contribute_inline(model, form_class, inline_models):
converter=converter) converter=converter)
prop_name = 'fa_%s' % model.__name__ prop_name = 'fa_%s' % model.__name__
setattr(form_class, setattr(form_class,
prop_name, prop_name,
InlineModelFormList(child_form, InlineModelFormList(child_form,
...@@ -148,11 +129,11 @@ def contribute_inline(model, form_class, inline_models): ...@@ -148,11 +129,11 @@ def contribute_inline(model, form_class, inline_models):
reverse_field.name, reverse_field.name,
label=info.model.__name__)) label=info.model.__name__))
setattr(field.to, setattr(field.rel_model,
prop_name, prop_name,
property(lambda self: self.id)) property(lambda self: self.id))
return form_class return form_class
def save_inline(form, model): def save_inline(form, model):
......
...@@ -5,3 +5,14 @@ def get_primary_key(model): ...@@ -5,3 +5,14 @@ def get_primary_key(model):
for n, f in model._meta.get_sorted_fields(): for n, f in model._meta.get_sorted_fields():
if type(f) == PrimaryKeyField: if type(f) == PrimaryKeyField:
return n return n
def parse_like_term(term):
if term.startswith('^'):
stmt = '%s%%' % term[1:]
elif term.startswith('='):
stmt = term[1:]
else:
stmt = '%%%s%%' % term
return stmt
import logging
from flask import flash from flask import flash
from flask.ext.admin import form from flask.ext.admin import form
...@@ -9,8 +11,8 @@ from wtfpeewee.orm import model_form ...@@ -9,8 +11,8 @@ from wtfpeewee.orm import model_form
from flask.ext.admin.actions import action from flask.ext.admin.actions import action
from flask.ext.admin.contrib.peeweemodel import filters from flask.ext.admin.contrib.peeweemodel import filters
from .form import CustomModelConverter, contribute_inline, save_inline from .form import CustomModelConverter, InlineModelConverter, save_inline
from .tools import get_primary_key from .tools import get_primary_key, parse_like_term
class ModelView(BaseModelView): class ModelView(BaseModelView):
...@@ -45,6 +47,20 @@ class ModelView(BaseModelView): ...@@ -45,6 +47,20 @@ class ModelView(BaseModelView):
model_form_converter = MyModelConverter model_form_converter = MyModelConverter
""" """
inline_model_form_converter = InlineModelConverter
"""
Inline model conversion class. If you need some kind of post-processing for inline
forms, you can customize behavior by doing something like this::
class MyInlineModelConverter(AdminModelConverter):
def post_process(self, form_class, info):
form_class.value = wtf.TextField('value')
return form_class
class MyAdminView(ModelView):
inline_model_form_converter = MyInlineModelConverter
"""
filter_converter = filters.FilterConverter() filter_converter = filters.FilterConverter()
""" """
Field to filter converter. Field to filter converter.
...@@ -165,8 +181,8 @@ class ModelView(BaseModelView): ...@@ -165,8 +181,8 @@ class ModelView(BaseModelView):
raise Exception('Failed to find field for filter: %s' % name) raise Exception('Failed to find field for filter: %s' % name)
# Check if field is in different model # Check if field is in different model
if attr.model != self.model: if attr.model_class != self.model:
visible_name = '%s / %s' % (self.get_column_name(attr.model.__name__), visible_name = '%s / %s' % (self.get_column_name(attr.model_class.__name__),
self.get_column_name(attr.name)) self.get_column_name(attr.name))
else: else:
if not isinstance(name, basestring): if not isinstance(name, basestring):
...@@ -193,16 +209,28 @@ class ModelView(BaseModelView): ...@@ -193,16 +209,28 @@ class ModelView(BaseModelView):
converter=self.model_form_converter()) converter=self.model_form_converter())
if self.inline_models: if self.inline_models:
form_class = contribute_inline(self.model, form_class, self.inline_models) form_class = self.scaffold_inline_form_models(form_class)
return form_class
def scaffold_inline_form_models(self, form_class):
converter = self.model_form_converter()
inline_converter = self.inline_model_form_converter()
for m in self.inline_models:
form_class = inline_converter.contribute(converter,
self.model,
form_class,
m)
return form_class return form_class
def _handle_join(self, query, field, joins): def _handle_join(self, query, field, joins):
if field.model != self.model: if field.model_class != self.model:
model_name = field.model.__name__ model_name = field.model_class.__name__
if model_name not in joins: if model_name not in joins:
query = query.join(field.model) query = query.join(field.model_class)
joins.add(model_name) joins.add(model_name)
return query return query
...@@ -215,12 +243,14 @@ class ModelView(BaseModelView): ...@@ -215,12 +243,14 @@ class ModelView(BaseModelView):
# Search # Search
if self._search_supported and search: if self._search_supported and search:
terms = search.split(' ') values = search.split(' ')
for term in terms: for value in values:
if not term: if not value:
continue continue
term = parse_like_term(value)
stmt = None stmt = None
for field in self._search_fields: for field in self._search_fields:
query = self._handle_join(query, field, joins) query = self._handle_join(query, field, joins)
...@@ -250,14 +280,13 @@ class ModelView(BaseModelView): ...@@ -250,14 +280,13 @@ class ModelView(BaseModelView):
sort_field = self._sortable_columns[sort_column] sort_field = self._sortable_columns[sort_column]
if isinstance(sort_field, basestring): if isinstance(sort_field, basestring):
query = query.order_by((sort_field, sort_desc and 'desc' or 'asc')) field = getattr(self.model, sort_field)
query = query.order_by(field.desc() if sort_desc else field.asc())
elif isinstance(sort_field, Field): elif isinstance(sort_field, Field):
if sort_field.model != self.model: if sort_field.model_class != self.model:
query = self._handle_join(query, sort_field, joins) query = self._handle_join(query, sort_field, joins)
query = query.order_by((sort_field.model, sort_field.name, sort_desc and 'desc' or 'asc')) query = query.order_by(sort_field.desc() if sort_desc else sort_field.asc())
else:
query = query.order_by((sort_column, sort_desc and 'desc' or 'asc'))
# Pagination # Pagination
if page is not None: if page is not None:
...@@ -286,6 +315,7 @@ class ModelView(BaseModelView): ...@@ -286,6 +315,7 @@ class ModelView(BaseModelView):
return True return True
except Exception, ex: except Exception, ex:
flash(gettext('Failed to create model. %(error)s', error=str(ex)), 'error') flash(gettext('Failed to create model. %(error)s', error=str(ex)), 'error')
logging.exception('Failed to create model')
return False return False
def update_model(self, form, model): def update_model(self, form, model):
...@@ -300,6 +330,7 @@ class ModelView(BaseModelView): ...@@ -300,6 +330,7 @@ class ModelView(BaseModelView):
return True return True
except Exception, ex: except Exception, ex:
flash(gettext('Failed to update model. %(error)s', error=str(ex)), 'error') flash(gettext('Failed to update model. %(error)s', error=str(ex)), 'error')
logging.exception('Failed to update model')
return False return False
def delete_model(self, model): def delete_model(self, model):
...@@ -309,6 +340,7 @@ class ModelView(BaseModelView): ...@@ -309,6 +340,7 @@ class ModelView(BaseModelView):
return True return True
except Exception, ex: except Exception, ex:
flash(gettext('Failed to delete model. %(error)s', error=str(ex)), 'error') flash(gettext('Failed to delete model. %(error)s', error=str(ex)), 'error')
logging.exception('Failed to delete model')
return False return False
# Default model actions # Default model actions
......
...@@ -2,7 +2,7 @@ from wtforms import fields, validators ...@@ -2,7 +2,7 @@ from wtforms import fields, validators
from sqlalchemy import Boolean, Column from sqlalchemy import Boolean, Column
from flask.ext.admin import form from flask.ext.admin import form
from flask.ext.admin.model.form import converts, ModelConverterBase, InlineFormAdmin from flask.ext.admin.model.form import converts, ModelConverterBase, InlineModelConverterBase
from .validators import Unique from .validators import Unique
from .fields import QuerySelectField, QuerySelectMultipleField, InlineModelFormList from .fields import QuerySelectField, QuerySelectMultipleField, InlineModelFormList
...@@ -248,6 +248,7 @@ class AdminModelConverter(ModelConverterBase): ...@@ -248,6 +248,7 @@ class AdminModelConverter(ModelConverterBase):
def conv_ARRAY(self, field_args, **extra): def conv_ARRAY(self, field_args, **extra):
return form.Select2TagsField(save_as_list=True, **field_args) return form.Select2TagsField(save_as_list=True, **field_args)
# Get list of fields and generate form # Get list of fields and generate form
def get_form(model, converter, def get_form(model, converter,
base_class=form.BaseForm, base_class=form.BaseForm,
...@@ -319,53 +320,40 @@ def get_form(model, converter, ...@@ -319,53 +320,40 @@ def get_form(model, converter,
return type(model.__name__ + 'Form', (base_class, ), field_dict) return type(model.__name__ + 'Form', (base_class, ), field_dict)
def contribute_inline(session, model, form_class, inline_models): class InlineModelConverter(InlineModelConverterBase):
""" """
Generate form fields for inline forms and contribute them to Inline model form helper.
the `form_class`
:param session:
SQLAlchemy session
:param model:
Model class
:param form_class:
Form to add properties to
:param inline_models:
List of inline model definitions. Can be one of:
- ``tuple``, first value is related model instance,
second is dictionary with options
- ``InlineFormAdmin`` instance
- Model class
:return:
Form class
""" """
def __init__(self, session):
self.session = session
# Get mapper def contribute(self, converter, model, form_class, inline_model):
mapper = model._sa_class_manager.mapper """
Generate form fields for inline forms and contribute them to
# Contribute columns the `form_class`
for p in inline_models:
# Figure out settings :param converter:
if isinstance(p, tuple): ModelConverterBase instance
info = InlineFormAdmin(p[0], **p[1]) :param session:
elif isinstance(p, InlineFormAdmin): SQLAlchemy session
info = p :param model:
elif hasattr(p, '_sa_class_manager'): Model class
info = InlineFormAdmin(p) :param form_class:
else: Form to add properties to
model = getattr(p, 'model', None) :param inline_model:
Inline model. Can be one of:
if model is None:
raise Exception('Unknown inline model admin: %s' % repr(p)) - ``tuple``, first value is related model instance,
second is dictionary with options
attrs = dict() - ``InlineFormAdmin`` instance
for attr in dir(p): - Model class
if not attr.startswith('_') and attr != 'model':
attrs[attr] = getattr(p, attr) :return:
Form class
info = InlineFormAdmin(model, **attrs) """
mapper = model._sa_class_manager.mapper
info = self.get_info(inline_model)
# Find property from target model to current model # Find property from target model to current model
target_mapper = info.model._sa_class_manager.mapper target_mapper = info.model._sa_class_manager.mapper
...@@ -399,8 +387,7 @@ def contribute_inline(session, model, form_class, inline_models): ...@@ -399,8 +387,7 @@ def contribute_inline(session, model, form_class, inline_models):
else: else:
exclude = ignore exclude = ignore
# Create field # Create form
converter = AdminModelConverter(session, info)
child_form = get_form(info.model, child_form = get_form(info.model,
converter, converter,
only=info.form_columns, only=info.form_columns,
...@@ -408,11 +395,15 @@ def contribute_inline(session, model, form_class, inline_models): ...@@ -408,11 +395,15 @@ def contribute_inline(session, model, form_class, inline_models):
field_args=info.form_args, field_args=info.form_args,
hidden_pk=True) hidden_pk=True)
# Post-process form
child_form = info.postprocess_form(child_form)
# Contribute field
setattr(form_class, setattr(form_class,
forward_prop.key, forward_prop.key,
InlineModelFormList(child_form, InlineModelFormList(child_form,
session, self.session,
info.model, info.model,
forward_prop.key)) forward_prop.key))
return form_class return form_class
...@@ -124,6 +124,20 @@ class ModelView(BaseModelView): ...@@ -124,6 +124,20 @@ class ModelView(BaseModelView):
model_form_converter = MyModelConverter model_form_converter = MyModelConverter
""" """
inline_model_form_converter = form.InlineModelConverter
"""
Inline model conversion class. If you need some kind of post-processing for inline
forms, you can customize behavior by doing something like this::
class MyInlineModelConverter(AdminModelConverter):
def post_process(self, form_class, info):
form_class.value = wtf.TextField('value')
return form_class
class MyAdminView(ModelView):
inline_model_form_converter = MyInlineModelConverter
"""
filter_converter = filters.FilterConverter() filter_converter = filters.FilterConverter()
""" """
Field to filter converter. Field to filter converter.
...@@ -425,8 +439,25 @@ class ModelView(BaseModelView): ...@@ -425,8 +439,25 @@ class ModelView(BaseModelView):
field_args=self.form_args) field_args=self.form_args)
if self.inline_models: if self.inline_models:
form_class = form.contribute_inline(self.session, self.model, form_class = self.scaffold_inline_form_models(form_class)
form_class, self.inline_models)
return form_class
def scaffold_inline_form_models(self, form_class):
"""
Contribute inline models to the form
:param form_class:
Form class
"""
converter = self.model_form_converter(self.session, self)
inline_converter = self.inline_model_form_converter(self.session)
for m in self.inline_models:
form_class = inline_converter.contribute(converter,
self.model,
form_class,
m)
return form_class return form_class
......
...@@ -40,6 +40,22 @@ class InlineFormAdmin(object): ...@@ -40,6 +40,22 @@ class InlineFormAdmin(object):
for k, v in kwargs.iteritems(): for k, v in kwargs.iteritems():
setattr(self, k, v) setattr(self, k, v)
def postprocess_form(self, form_class):
"""
Post process form. Use this to contribute fields.
For example::
class MyInlineForm(InlineFormAdmin):
def postprocess_form(self, form):
form.value = wtf.TextField('value')
return form
class MyAdmin(ModelView):
inline_models = (MyInlineForm(ValueModel),)
"""
return form_class
class ModelConverterBase(object): class ModelConverterBase(object):
def __init__(self, converters=None, use_mro=True): def __init__(self, converters=None, use_mro=True):
...@@ -80,3 +96,36 @@ class ModelConverterBase(object): ...@@ -80,3 +96,36 @@ class ModelConverterBase(object):
only=None, exclude=None, only=None, exclude=None,
field_args=None): field_args=None):
raise NotImplemented() raise NotImplemented()
class InlineModelConverterBase(object):
def get_info(self, p):
"""
Figure out InlineFormAdmin information.
:param p:
Inline model. Can be one of:
- ``tuple``, first value is related model instance,
second is dictionary with options
- ``InlineFormAdmin`` instance
- Model class
"""
if isinstance(p, tuple):
return InlineFormAdmin(p[0], **p[1])
elif isinstance(p, InlineFormAdmin):
return p
elif hasattr(p, '_sa_class_manager'):
return InlineFormAdmin(p)
else:
model = getattr(p, 'model', None)
if model is None:
raise Exception('Unknown inline model admin: %s' % repr(p))
attrs = dict()
for attr in dir(p):
if not attr.startswith('_') and attr != 'model':
attrs[attr] = getattr(p, attr)
return InlineFormAdmin(model, **attrs)
from flask import Flask
from flask.ext.admin import Admin
import peewee
def setup():
app = Flask(__name__)
app.config['SECRET_KEY'] = '1'
app.config['CSRF_ENABLED'] = False
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///'
db = peewee.SqliteDatabase(':memory:')
admin = Admin(app)
return app, db, admin
from nose.tools import eq_, ok_
import peewee
from flask.ext import wtf
from flask.ext.admin.contrib.peeweemodel import ModelView
from . import setup
class CustomModelView(ModelView):
def __init__(self, model,
name=None, category=None, endpoint=None, url=None,
**kwargs):
for k, v in kwargs.iteritems():
setattr(self, k, v)
super(CustomModelView, self).__init__(model,
name, category,
endpoint, url)
def create_models(db):
class BaseModel(peewee.Model):
class Meta:
database = db
class Model1(BaseModel):
def __init__(self, test1=None, test2=None, test3=None, test4=None):
super(Model1, self).__init__()
self.test1 = test1
self.test2 = test2
self.test3 = test3
self.test4 = test4
test1 = peewee.CharField(max_length=20)
test2 = peewee.CharField(max_length=20)
test3 = peewee.TextField(null=True)
test4 = peewee.TextField(null=True)
class Model2(BaseModel):
int_field = peewee.IntegerField()
bool_field = peewee.BooleanField()
Model1.create_table()
Model2.create_table()
return Model1, Model2
def test_model():
app, db, admin = setup()
Model1, Model2 = create_models(db)
view = CustomModelView(Model1)
admin.add_view(view)
eq_(view.model, Model1)
eq_(view.name, 'Model1')
eq_(view.endpoint, 'model1view')
eq_(view._primary_key, 'id')
ok_('test1' in view._sortable_columns)
ok_('test2' in view._sortable_columns)
ok_('test3' in view._sortable_columns)
ok_('test4' in view._sortable_columns)
ok_(view._create_form_class is not None)
ok_(view._edit_form_class is not None)
eq_(view._search_supported, False)
eq_(view._filters, None)
# Verify form
eq_(view._create_form_class.test1.field_class, wtf.TextField)
eq_(view._create_form_class.test2.field_class, wtf.TextField)
eq_(view._create_form_class.test3.field_class, wtf.TextAreaField)
eq_(view._create_form_class.test4.field_class, wtf.TextAreaField)
# Make some test clients
client = app.test_client()
rv = client.get('/admin/model1view/')
eq_(rv.status_code, 200)
rv = client.get('/admin/model1view/new/')
eq_(rv.status_code, 200)
rv = client.post('/admin/model1view/new/',
data=dict(test1='test1large', test2='test2'))
eq_(rv.status_code, 302)
model = Model1.select().get()
eq_(model.test1, 'test1large')
eq_(model.test2, 'test2')
eq_(model.test3, None)
eq_(model.test4, None)
rv = client.get('/admin/model1view/')
eq_(rv.status_code, 200)
ok_('test1large' in rv.data)
url = '/admin/model1view/edit/?id=%s' % model.id
rv = client.get(url)
eq_(rv.status_code, 200)
rv = client.post(url,
data=dict(test1='test1small', test2='test2large'))
eq_(rv.status_code, 302)
model = Model1.select().get()
eq_(model.test1, 'test1small')
eq_(model.test2, 'test2large')
eq_(model.test3, None)
eq_(model.test4, None)
url = '/admin/model1view/delete/?id=%s' % model.id
rv = client.post(url)
eq_(rv.status_code, 302)
eq_(Model1.select().count(), 0)
...@@ -5,6 +5,7 @@ from flask.ext.admin.contrib.sqlamodel import ModelView ...@@ -5,6 +5,7 @@ from flask.ext.admin.contrib.sqlamodel import ModelView
from . import setup from . import setup
class CustomModelView(ModelView): class CustomModelView(ModelView):
def __init__(self, model, session, def __init__(self, model, session,
name=None, category=None, endpoint=None, url=None, name=None, category=None, endpoint=None, url=None,
...@@ -16,6 +17,7 @@ class CustomModelView(ModelView): ...@@ -16,6 +17,7 @@ class CustomModelView(ModelView):
name, category, name, category,
endpoint, url) endpoint, url)
def create_models(db): def create_models(db):
class Model1(db.Model): class Model1(db.Model):
def __init__(self, test1=None, test2=None, test3=None, test4=None): def __init__(self, test1=None, test2=None, test3=None, test4=None):
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment