form.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660
  1. import warnings
  2. from wtforms import fields, validators
  3. from sqlalchemy import Boolean, Column
  4. from flask_admin import form
  5. from flask_admin.model.form import (converts, ModelConverterBase,
  6. InlineModelConverterBase, FieldPlaceholder)
  7. from flask_admin.model.fields import AjaxSelectField, AjaxSelectMultipleField
  8. from flask_admin.model.helpers import prettify_name
  9. from flask_admin._backwards import get_property
  10. from flask_admin._compat import iteritems
  11. from .validators import Unique
  12. from .fields import (QuerySelectField, QuerySelectMultipleField,
  13. InlineModelFormList, InlineHstoreList, HstoreForm)
  14. from flask_admin.model.fields import InlineFormField
  15. from .tools import (has_multiple_pks, filter_foreign_columns,
  16. get_field_with_path, is_association_proxy, is_relationship)
  17. from .ajax import create_ajax_loader
  18. class AdminModelConverter(ModelConverterBase):
  19. """
  20. SQLAlchemy model to form converter
  21. """
  22. def __init__(self, session, view):
  23. super(AdminModelConverter, self).__init__()
  24. self.session = session
  25. self.view = view
  26. def _get_label(self, name, field_args):
  27. """
  28. Label for field name. If it is not specified explicitly,
  29. then the views prettify_name method is used to find it.
  30. :param field_args:
  31. Dictionary with additional field arguments
  32. """
  33. if 'label' in field_args:
  34. return field_args['label']
  35. column_labels = get_property(self.view, 'column_labels', 'rename_columns')
  36. if column_labels:
  37. return column_labels.get(name)
  38. prettify_override = getattr(self.view, 'prettify_name', None)
  39. if prettify_override:
  40. return prettify_override(name)
  41. return prettify_name(name)
  42. def _get_description(self, name, field_args):
  43. if 'description' in field_args:
  44. return field_args['description']
  45. column_descriptions = getattr(self.view, 'column_descriptions', None)
  46. if column_descriptions:
  47. return column_descriptions.get(name)
  48. def _get_field_override(self, name):
  49. form_overrides = getattr(self.view, 'form_overrides', None)
  50. if form_overrides:
  51. return form_overrides.get(name)
  52. return None
  53. def _model_select_field(self, prop, multiple, remote_model, **kwargs):
  54. loader = getattr(self.view, '_form_ajax_refs', {}).get(prop.key)
  55. if loader:
  56. if multiple:
  57. return AjaxSelectMultipleField(loader, **kwargs)
  58. else:
  59. return AjaxSelectField(loader, **kwargs)
  60. if 'query_factory' not in kwargs:
  61. kwargs['query_factory'] = lambda: self.session.query(remote_model)
  62. if multiple:
  63. return QuerySelectMultipleField(**kwargs)
  64. else:
  65. return QuerySelectField(**kwargs)
  66. def _convert_relation(self, name, prop, property_is_association_proxy, kwargs):
  67. # Check if relation is specified
  68. form_columns = getattr(self.view, 'form_columns', None)
  69. if form_columns and name not in form_columns:
  70. return None
  71. remote_model = prop.mapper.class_
  72. column = prop.local_remote_pairs[0][0]
  73. # If this relation points to local column that's not foreign key, assume
  74. # that it is backref and use remote column data
  75. if not column.foreign_keys:
  76. column = prop.local_remote_pairs[0][1]
  77. kwargs['label'] = self._get_label(name, kwargs)
  78. kwargs['description'] = self._get_description(name, kwargs)
  79. # determine optional/required, or respect existing
  80. requirement_options = (validators.Optional, validators.InputRequired)
  81. requirement_validator_specified = any(isinstance(v, requirement_options) for v in kwargs['validators'])
  82. if property_is_association_proxy or column.nullable or prop.direction.name != 'MANYTOONE':
  83. kwargs['allow_blank'] = True
  84. if not requirement_validator_specified:
  85. kwargs['validators'].append(validators.Optional())
  86. else:
  87. kwargs['allow_blank'] = False
  88. if not requirement_validator_specified:
  89. kwargs['validators'].append(validators.InputRequired())
  90. # Override field type if necessary
  91. override = self._get_field_override(prop.key)
  92. if override:
  93. return override(**kwargs)
  94. multiple = (property_is_association_proxy or
  95. (prop.direction.name in ('ONETOMANY', 'MANYTOMANY') and prop.uselist))
  96. return self._model_select_field(prop, multiple, remote_model, **kwargs)
  97. def convert(self, model, mapper, name, prop, field_args, hidden_pk):
  98. # Properly handle forced fields
  99. if isinstance(prop, FieldPlaceholder):
  100. return form.recreate_field(prop.field)
  101. kwargs = {
  102. 'validators': [],
  103. 'filters': []
  104. }
  105. if field_args:
  106. kwargs.update(field_args)
  107. if kwargs['validators']:
  108. # Create a copy of the list since we will be modifying it.
  109. kwargs['validators'] = list(kwargs['validators'])
  110. # Check if it is relation or property
  111. if hasattr(prop, 'direction') or is_association_proxy(prop):
  112. property_is_association_proxy = is_association_proxy(prop)
  113. if property_is_association_proxy:
  114. if not hasattr(prop.remote_attr, 'prop'):
  115. raise Exception('Association proxy referencing another association proxy is not supported.')
  116. prop = prop.remote_attr.prop
  117. return self._convert_relation(name, prop, property_is_association_proxy, kwargs)
  118. elif hasattr(prop, 'columns'): # Ignore pk/fk
  119. # Check if more than one column mapped to the property
  120. if len(prop.columns) > 1:
  121. columns = filter_foreign_columns(model.__table__, prop.columns)
  122. if len(columns) > 1:
  123. warnings.warn('Can not convert multiple-column properties (%s.%s)' % (model, prop.key))
  124. return None
  125. column = columns[0]
  126. else:
  127. # Grab column
  128. column = prop.columns[0]
  129. form_columns = getattr(self.view, 'form_columns', None) or ()
  130. # Do not display foreign keys - use relations, except when explicitly instructed
  131. if column.foreign_keys and prop.key not in form_columns:
  132. return None
  133. # Only display "real" columns
  134. if not isinstance(column, Column):
  135. return None
  136. unique = False
  137. if column.primary_key:
  138. if hidden_pk:
  139. # If requested to add hidden field, show it
  140. return fields.HiddenField()
  141. else:
  142. # By default, don't show primary keys either
  143. # If PK is not explicitly allowed, ignore it
  144. if prop.key not in form_columns:
  145. return None
  146. # Current Unique Validator does not work with multicolumns-pks
  147. if not has_multiple_pks(model):
  148. kwargs['validators'].append(Unique(self.session,
  149. model,
  150. column))
  151. unique = True
  152. # If field is unique, validate it
  153. if column.unique and not unique:
  154. kwargs['validators'].append(Unique(self.session,
  155. model,
  156. column))
  157. optional_types = getattr(self.view, 'form_optional_types', (Boolean,))
  158. if (
  159. not column.nullable and
  160. not isinstance(column.type, optional_types) and
  161. not column.default and
  162. not column.server_default
  163. ):
  164. kwargs['validators'].append(validators.InputRequired())
  165. # Apply label and description if it isn't inline form field
  166. if self.view.model == mapper.class_:
  167. kwargs['label'] = self._get_label(prop.key, kwargs)
  168. kwargs['description'] = self._get_description(prop.key, kwargs)
  169. # Figure out default value
  170. default = getattr(column, 'default', None)
  171. value = None
  172. if default is not None:
  173. value = getattr(default, 'arg', None)
  174. if value is not None:
  175. if getattr(default, 'is_callable', False):
  176. value = lambda: default.arg(None) # noqa: E731
  177. else:
  178. if not getattr(default, 'is_scalar', True):
  179. value = None
  180. if value is not None:
  181. kwargs['default'] = value
  182. # Check nullable
  183. if column.nullable:
  184. kwargs['validators'].append(validators.Optional())
  185. # Override field type if necessary
  186. override = self._get_field_override(prop.key)
  187. if override:
  188. return override(**kwargs)
  189. # Check choices
  190. form_choices = getattr(self.view, 'form_choices', None)
  191. if mapper.class_ == self.view.model and form_choices:
  192. choices = form_choices.get(prop.key)
  193. if choices:
  194. return form.Select2Field(
  195. choices=choices,
  196. allow_blank=column.nullable,
  197. **kwargs
  198. )
  199. # Run converter
  200. converter = self.get_converter(column)
  201. if converter is None:
  202. return None
  203. return converter(model=model, mapper=mapper, prop=prop,
  204. column=column, field_args=kwargs)
  205. return None
  206. @classmethod
  207. def _string_common(cls, column, field_args, **extra):
  208. if isinstance(column.type.length, int) and column.type.length:
  209. field_args['validators'].append(validators.Length(max=column.type.length))
  210. @converts('String') # includes VARCHAR, CHAR, and Unicode
  211. def conv_String(self, column, field_args, **extra):
  212. if hasattr(column.type, 'enums'):
  213. accepted_values = list(column.type.enums)
  214. field_args['choices'] = [(f, f) for f in column.type.enums]
  215. if column.nullable:
  216. field_args['allow_blank'] = column.nullable
  217. accepted_values.append(None)
  218. field_args['validators'].append(validators.AnyOf(accepted_values))
  219. return form.Select2Field(**field_args)
  220. if column.nullable:
  221. filters = field_args.get('filters', [])
  222. filters.append(lambda x: x or None)
  223. field_args['filters'] = filters
  224. self._string_common(column=column, field_args=field_args, **extra)
  225. return fields.StringField(**field_args)
  226. @converts('Text', 'LargeBinary', 'Binary') # includes UnicodeText
  227. def conv_Text(self, field_args, **extra):
  228. self._string_common(field_args=field_args, **extra)
  229. return fields.TextAreaField(**field_args)
  230. @converts('Boolean', 'sqlalchemy.dialects.mssql.base.BIT')
  231. def conv_Boolean(self, field_args, **extra):
  232. return fields.BooleanField(**field_args)
  233. @converts('Date')
  234. def convert_date(self, field_args, **extra):
  235. field_args['widget'] = form.DatePickerWidget()
  236. return fields.DateField(**field_args)
  237. @converts('DateTime') # includes TIMESTAMP
  238. def convert_datetime(self, field_args, **extra):
  239. return form.DateTimeField(**field_args)
  240. @converts('Time')
  241. def convert_time(self, field_args, **extra):
  242. return form.TimeField(**field_args)
  243. @converts('Integer') # includes BigInteger and SmallInteger
  244. def handle_integer_types(self, column, field_args, **extra):
  245. unsigned = getattr(column.type, 'unsigned', False)
  246. if unsigned:
  247. field_args['validators'].append(validators.NumberRange(min=0))
  248. return fields.IntegerField(**field_args)
  249. @converts('Numeric') # includes DECIMAL, Float/FLOAT, REAL, and DOUBLE
  250. def handle_decimal_types(self, column, field_args, **extra):
  251. # override default decimal places limit, use database defaults instead
  252. field_args.setdefault('places', None)
  253. return fields.DecimalField(**field_args)
  254. @converts('sqlalchemy.dialects.postgresql.base.INET')
  255. def conv_PGInet(self, field_args, **extra):
  256. field_args.setdefault('label', u'IP Address')
  257. field_args['validators'].append(validators.IPAddress())
  258. return fields.StringField(**field_args)
  259. @converts('sqlalchemy.dialects.postgresql.base.MACADDR')
  260. def conv_PGMacaddr(self, field_args, **extra):
  261. field_args.setdefault('label', u'MAC Address')
  262. field_args['validators'].append(validators.MacAddress())
  263. return fields.StringField(**field_args)
  264. @converts('sqlalchemy.dialects.postgresql.base.UUID')
  265. def conv_PGUuid(self, field_args, **extra):
  266. field_args.setdefault('label', u'UUID')
  267. field_args['validators'].append(validators.UUID())
  268. return fields.StringField(**field_args)
  269. @converts('sqlalchemy.dialects.postgresql.base.ARRAY',
  270. 'sqlalchemy.sql.sqltypes.ARRAY')
  271. def conv_ARRAY(self, field_args, **extra):
  272. return form.Select2TagsField(save_as_list=True, **field_args)
  273. @converts('HSTORE')
  274. def conv_HSTORE(self, field_args, **extra):
  275. inner_form = field_args.pop('form', HstoreForm)
  276. return InlineHstoreList(InlineFormField(inner_form), **field_args)
  277. @converts('JSON')
  278. def convert_JSON(self, field_args, **extra):
  279. return form.JSONField(**field_args)
  280. def _resolve_prop(prop):
  281. """
  282. Resolve proxied property
  283. :param prop:
  284. Property to resolve
  285. """
  286. # Try to see if it is proxied property
  287. if hasattr(prop, '_proxied_property'):
  288. return prop._proxied_property
  289. return prop
  290. # Get list of fields and generate form
  291. def get_form(model, converter,
  292. base_class=form.BaseForm,
  293. only=None,
  294. exclude=None,
  295. field_args=None,
  296. hidden_pk=False,
  297. ignore_hidden=True,
  298. extra_fields=None):
  299. """
  300. Generate form from the model.
  301. :param model:
  302. Model to generate form from
  303. :param converter:
  304. Converter class to use
  305. :param base_class:
  306. Base form class
  307. :param only:
  308. Include fields
  309. :param exclude:
  310. Exclude fields
  311. :param field_args:
  312. Dictionary with additional field arguments
  313. :param hidden_pk:
  314. Generate hidden field with model primary key or not
  315. :param ignore_hidden:
  316. If set to True (default), will ignore properties that start with underscore
  317. """
  318. # TODO: Support new 0.8 API
  319. if not hasattr(model, '_sa_class_manager'):
  320. raise TypeError('model must be a sqlalchemy mapped model')
  321. mapper = model._sa_class_manager.mapper
  322. field_args = field_args or {}
  323. properties = ((p.key, p) for p in mapper.iterate_properties)
  324. if only:
  325. def find(name):
  326. # If field is in extra_fields, it has higher priority
  327. if extra_fields and name in extra_fields:
  328. return name, FieldPlaceholder(extra_fields[name])
  329. column, path = get_field_with_path(model, name, return_remote_proxy_attr=False)
  330. if path and not (is_relationship(column) or is_association_proxy(column)):
  331. raise Exception("form column is located in another table and "
  332. "requires inline_models: {0}".format(name))
  333. if is_association_proxy(column):
  334. return name, column
  335. relation_name = column.key
  336. if column is not None and hasattr(column, 'property'):
  337. return relation_name, column.property
  338. raise ValueError('Invalid model property name %s.%s' % (model, name))
  339. # Filter properties while maintaining property order in 'only' list
  340. properties = (find(x) for x in only)
  341. elif exclude:
  342. properties = (x for x in properties if x[0] not in exclude)
  343. field_dict = {}
  344. for name, p in properties:
  345. # Ignore protected properties
  346. if ignore_hidden and name.startswith('_'):
  347. continue
  348. prop = _resolve_prop(p)
  349. field = converter.convert(model, mapper, name, prop, field_args.get(name), hidden_pk)
  350. if field is not None:
  351. field_dict[name] = field
  352. # Contribute extra fields
  353. if not only and extra_fields:
  354. for name, field in iteritems(extra_fields):
  355. field_dict[name] = form.recreate_field(field)
  356. return type(model.__name__ + 'Form', (base_class, ), field_dict)
  357. class InlineModelConverter(InlineModelConverterBase):
  358. """
  359. Inline model form helper.
  360. """
  361. inline_field_list_type = InlineModelFormList
  362. """
  363. Used field list type.
  364. If you want to do some custom rendering of inline field lists,
  365. you can create your own wtforms field and use it instead
  366. """
  367. def __init__(self, session, view, model_converter):
  368. """
  369. Constructor.
  370. :param session:
  371. SQLAlchemy session
  372. :param view:
  373. Flask-Admin view object
  374. :param model_converter:
  375. Model converter class. Will be automatically instantiated with
  376. appropriate `InlineFormAdmin` instance.
  377. """
  378. super(InlineModelConverter, self).__init__(view)
  379. self.session = session
  380. self.model_converter = model_converter
  381. def get_info(self, p):
  382. info = super(InlineModelConverter, self).get_info(p)
  383. # Special case for model instances
  384. if info is None:
  385. if hasattr(p, '_sa_class_manager'):
  386. return self.form_admin_class(p)
  387. else:
  388. model = getattr(p, 'model', None)
  389. if model is None:
  390. raise Exception('Unknown inline model admin: %s' % repr(p))
  391. attrs = dict()
  392. for attr in dir(p):
  393. if not attr.startswith('_') and attr != 'model':
  394. attrs[attr] = getattr(p, attr)
  395. return self.form_admin_class(model, **attrs)
  396. info = self.form_admin_class(model, **attrs)
  397. # Resolve AJAX FKs
  398. info._form_ajax_refs = self.process_ajax_refs(info)
  399. return info
  400. def process_ajax_refs(self, info):
  401. refs = getattr(info, 'form_ajax_refs', None)
  402. result = {}
  403. if refs:
  404. for name, opts in iteritems(refs):
  405. new_name = '%s-%s' % (info.model.__name__.lower(), name)
  406. loader = None
  407. if isinstance(opts, dict):
  408. loader = create_ajax_loader(info.model, self.session, new_name, name, opts)
  409. else:
  410. loader = opts
  411. result[name] = loader
  412. self.view._form_ajax_refs[new_name] = loader
  413. return result
  414. def _calculate_mapping_key_pair(self, model, info):
  415. """
  416. Calculate mapping property key pair between `model` and inline model,
  417. including the forward one for `model` and the reverse one for inline model.
  418. Override the method to map your own inline models.
  419. :param model:
  420. Model class
  421. :param info:
  422. The InlineFormAdmin instance
  423. :return:
  424. A tuple of forward property key and reverse property key
  425. """
  426. mapper = model._sa_class_manager.mapper
  427. # Find property from target model to current model
  428. # Use the base mapper to support inheritance
  429. target_mapper = info.model._sa_class_manager.mapper.base_mapper
  430. reverse_prop = None
  431. for prop in target_mapper.iterate_properties:
  432. if hasattr(prop, 'direction') and prop.direction.name in ('MANYTOONE', 'MANYTOMANY'):
  433. if issubclass(model, prop.mapper.class_):
  434. reverse_prop = prop
  435. break
  436. else:
  437. raise Exception('Cannot find reverse relation for model %s' % info.model)
  438. # Find forward property
  439. forward_prop = None
  440. if prop.direction.name == 'MANYTOONE':
  441. candidate = 'ONETOMANY'
  442. else:
  443. candidate = 'MANYTOMANY'
  444. for prop in mapper.iterate_properties:
  445. if hasattr(prop, 'direction') and prop.direction.name == candidate:
  446. if prop.mapper.class_ == target_mapper.class_:
  447. forward_prop = prop
  448. break
  449. else:
  450. raise Exception('Cannot find forward relation for model %s' % info.model)
  451. return forward_prop.key, reverse_prop.key
  452. def contribute(self, model, form_class, inline_model):
  453. """
  454. Generate form fields for inline forms and contribute them to
  455. the `form_class`
  456. :param converter:
  457. ModelConverterBase instance
  458. :param session:
  459. SQLAlchemy session
  460. :param model:
  461. Model class
  462. :param form_class:
  463. Form to add properties to
  464. :param inline_model:
  465. Inline model. Can be one of:
  466. - ``tuple``, first value is related model instance,
  467. second is dictionary with options
  468. - ``InlineFormAdmin`` instance
  469. - Model class
  470. :return:
  471. Form class
  472. """
  473. info = self.get_info(inline_model)
  474. forward_prop_key, reverse_prop_key = self._calculate_mapping_key_pair(model, info)
  475. # Remove reverse property from the list
  476. ignore = [reverse_prop_key]
  477. if info.form_excluded_columns:
  478. exclude = ignore + list(info.form_excluded_columns)
  479. else:
  480. exclude = ignore
  481. # Create converter
  482. converter = self.model_converter(self.session, info)
  483. # Create form
  484. child_form = info.get_form()
  485. if child_form is None:
  486. child_form = get_form(info.model,
  487. converter,
  488. base_class=info.form_base_class or form.BaseForm,
  489. only=info.form_columns,
  490. exclude=exclude,
  491. field_args=info.form_args,
  492. hidden_pk=True,
  493. extra_fields=info.form_extra_fields)
  494. # Post-process form
  495. child_form = info.postprocess_form(child_form)
  496. kwargs = dict()
  497. label = self.get_label(info, forward_prop_key)
  498. if label:
  499. kwargs['label'] = label
  500. if self.view.form_args:
  501. field_args = self.view.form_args.get(forward_prop_key, {})
  502. kwargs.update(**field_args)
  503. # Contribute field
  504. setattr(form_class,
  505. forward_prop_key,
  506. self.inline_field_list_type(child_form,
  507. self.session,
  508. info.model,
  509. reverse_prop_key,
  510. info,
  511. **kwargs))
  512. return form_class