Source code for campos.forms
import contextlib
from qtpy.QtWidgets import (QDialog, QVBoxLayout, QDialogButtonBox, QMessageBox,
QGroupBox, QGridLayout, QHBoxLayout)
from . import sources
from .enums import Validation, ButtonType
from .utils import callable
__author__ = 'Juan Manuel Bermúdez Cabrera'
[docs]class Form(QDialog):
"""Forms are used to arrange fields in order to facilitate
data input and validation.
You can create a form by calling the ``Form`` constructor
and providing fields and options::
fields = [StringField(), SelectField(), FileField(), TextField()]
buttons = ('reset', 'ok', 'cancel')
form = Form(fields=fields, options=buttons)
Or also by calling :func:`from_source` which generates form's fields
introspecting a source object::
class Person:
def __init__(self, name, last_name, age):
self.name = name
self.last_name = last_name
self.age = age
p = Person('Sheldon', 'Cooper', 25)
form = Form.from_source(p)
You can group related fields using :func:`group` method::
form.group('Identification', ['name', 'last_name'])
Also you can find a field contained in the form using its name::
field = form.field('last_name')
and obtain it's value using dot notation::
value = form.last_name
Forms provide validation through :func:`validate` method which is
called automatically when validation is set to 'instant'.
More specialized forms can be created using :class:`CreationForm` and
:class:`EditionForm` subclasses which provide some useful default behaviour
for object creation and modification.
:param validation: validation mechanism used by the form, if it's 'instant'
all fields are checked anytime one of them changes and
corresponding buttons are enabled/disabled accordingly.
If it's 'manual' you should invoke :func:`validate`
method by yourself.
:type validation: :class:`str` or a :class:`~campos.enums.Validation` member
:param fields: fields to add to this form
:type fields: iterable of :class:`.Field`
:param options: options to show in the form, these can be ``QPushButton``
instances or :class:`~campos.enums.ButtonType` enum members
(note that you can use strings too).
If you use ``ButtonType`` members(or string) then you can
connect the button with a callback passed as a keyword
argument.
For instance, if your options are ``['save', 'cancel']``
you can pass two keyword arguments named ``on_save`` and
``on_cancel`` which will be connected to save and cancel
buttons. Note that the keyword(except the ``on_`` part)
matches the option name(``ButtonType`` member's name).
Buttons with a rejection role(accept, cancel, etc) will be
connected to form's ``close()`` method if no callback is
settled for them.
:type options: iterable of :class:`str`, :class:`~campos.enums.ButtonType`
or ``QPushButton``
.. seealso:: :class:`CreationForm` and :class:`EditionForm`
"""
ACCEPTANCE_ROLES = (QDialogButtonBox.AcceptRole, QDialogButtonBox.YesRole,
QDialogButtonBox.ApplyRole)
REJECTION_ROLES = (QDialogButtonBox.RejectRole, QDialogButtonBox.NoRole,
QDialogButtonBox.DestructiveRole)
def __init__(self, options=('ok', 'cancel'), fields=(),
validation='current', **kwargs):
super(Form, self).__init__()
self.members_layout = QVBoxLayout()
self.members_layout.setSpacing(10)
self.button_box = QDialogButtonBox()
layout = QVBoxLayout(self)
layout.addLayout(self.members_layout)
layout.addWidget(self.button_box)
for opt in options:
callback = None
if isinstance(opt, (str, ButtonType)):
if isinstance(opt, str):
callback_kw = 'on_{}'.format(opt.lower())
else:
callback_kw = 'on_{}'.format(opt.name.lower())
callback = kwargs.get(callback_kw)
self.add_button(opt, on_click=callback)
self.fields = []
self._validation = None
self.validation = validation
self.valid = True
for f in fields:
self.add_field(f)
def _enable_acceptance_btns(self, enabled):
for btn in self.button_box.buttons():
if self.button_box.buttonRole(btn) in self.ACCEPTANCE_ROLES:
btn.setEnabled(enabled)
@staticmethod
[docs] def from_source(obj, source_kw={}, form_kw={}):
"""Creates a form introspecting fields from an object.
Fields are generated using a suited :class:`~campos.sources.FieldSource`
instance.
:param obj: object to extract fields from.
:type obj: any
:param source_kw: keyword arguments to pass to
:class:`~campos.sources.FieldSource` constructor
:type source_kw: :class:`dict`
:param form_kw: keyword arguments to pass to :class:`Form` constructor
:type form_kw: :class:`dict`
"""
source = sources.get_fields_source(obj, **source_kw)
fields = form_kw.pop('fields', [])
fields.extend(source.fields.values())
form = Form(fields=fields, **form_kw)
title = type(obj).__name__.capitalize()
form.setWindowTitle(title)
return form
[docs] def add_field(self, field):
"""Adds a field to this form.
:param field: new field
:type field: :class:`~campos.core.Field`
"""
self.members_layout.addWidget(field)
self.fields.append(field)
field.validation = 'manual'
if self.validation == Validation.INSTANT:
field.change_signal.connect(self.validate)
# force validation when new fields are added
self.validate()
[docs] def remove_field(self, name):
"""Removes and returns a field from this form using its name.
:param name: name of the field to remove
:type name: :class:`~core.Field`
:returns: the removed field
:rtype: :class:`~campos.core.Field`
"""
field = self.field(name)
if self.validation == Validation.INSTANT:
field.change_signal.disconnect(self.validate)
self.members_layout.removeWidget(field)
self.fields.remove(field)
return field
[docs] def add_button(self, btn, on_click=None):
"""Adds a new button or option to form's button box and connects it
with a callback. The new button is always returned.
Buttons with a rejection role(accept, cancel, etc) will be connected to
form's ``close()`` method if no callback is settled for them.
:param btn: new option to add, can be a ``QPushButton`` instance or
:class:`~campos.enums.ButtonType` enum members(note that
you can use strings too)
:type btn: :class:`str`, :class:`~campos.enums.ButtonType`
or ``QPushButton``
:param on_click: callback to invoke whenever the button is clicked.
:type on_click: callable
:returns: the new button
:rtype: ``QPushButton``
"""
if isinstance(btn, (str, ButtonType)):
standard_btn = ButtonType.get_member(btn).value
button = self.button_box.addButton(standard_btn)
else:
self.button_box.addButton(btn, QDialogButtonBox.ActionRole)
button = btn
role = self.button_box.buttonRole(button)
if role in self.REJECTION_ROLES and on_click is None:
on_click = self.close
if callable(on_click):
button.clicked.connect(on_click)
elif on_click is not None:
raise ValueError('Expecting callable got {}'.format(type(on_click)))
return button
[docs] def button(self, which):
"""Finds a button given its type.
:param which: button type to find, must be a valid member of
:class:`~campos.enums.ButtonType` enum(note that you can
use strings)
:type which: :class:`str` or :class:`~campos.enums.ButtonType`
:returns: the button which type matches the argument
:rtype: ``QPushButton``
:raises ValueError: if no button of the given type was found
"""
if isinstance(which, (str, ButtonType)):
btype = ButtonType.get_member(which)
standard_btn = btype.value
button = self.button_box.button(standard_btn)
if button is None:
msg = "{} is not present in form's options".format(btype)
raise ValueError(msg)
return button
msg = 'Expecting {} member, got {}'.format(ButtonType.__name__, which)
raise ValueError(msg)
[docs] def field(self, name):
"""Find a field by its name.
:param name: name of the field
:type name: :class:`str`
:return: a field
:rtype: :class:`~campos.core.Field`
:raise ValueError: if there is no field in the form with the given name
"""
for field in self.fields:
if field.name == name:
return field
raise ValueError('No field named {}'.format(name))
def __getattr__(self, name):
try:
return self.field(name).value
except ValueError:
raise AttributeError('No attribute named {}'.format(name))
@property
def validation(self):
"""Validation mechanism used by the form.
If it's 'instant' all fields are checked whenever one of them changes.
If it's 'manual' you should invoke :func:`validate` method by yourself.
:type: :class:`~campos.enums.Validation`
"""
return self._validation
@validation.setter
def validation(self, value):
previous = self._validation
self._validation = Validation.get_member(value)
if self._validation != previous:
if self._validation == Validation.INSTANT:
for f in self.fields:
f.change_signal.connect(self.validate)
elif previous is not None:
for f in self.fields:
f.change_signal.disconnect(self.validate)
[docs] def validate(self, title='Invalid fields', msg=None):
"""Runs validation on every field of this form.
This method is automatically called if form validation
is set to 'instant', all buttons with an acceptance role
are disabled when invalid fields are found.
If form validation is set to 'manual' then a message is
shown when invalid fields are found.
:param title: title of the message shown when invalid fields are found.
Used only when form validation is set to 'manual'
:type title: :class:`str`
:param msg: text to show when invalid fields are found.
Used only when form validation is set to 'manual'
:type msg: :class:`str`
"""
self.valid = True
for field in self.fields:
field.validate()
self.valid &= field.valid
# enable all acceptance buttons
self._enable_acceptance_btns(True)
if not self.valid:
if self.validation == Validation.MANUAL:
text = 'Missing or invalid fields were found, please fix them'
text = text if msg is None else msg
QMessageBox.warning(self, title, text)
else:
self._enable_acceptance_btns(False)
[docs] def group(self, title, fieldnames, layout='vertical'):
"""Groups fields in a common area under a title using chosen layout.
:param title: title of the group
:type title: :class:`str`
:param fieldnames: names of the fields to group
:type fieldnames: iterable of :class:`str`
:param layout: layout manager used to arrange fields inside the group.
Defaults to 'vertical'.
:type layout: one of ('vertical', 'horizontal', 'grid')
"""
group = QGroupBox()
group.setTitle(title)
lay = layout.lower()
fields = (self.field(name) for name in fieldnames)
if lay in ('vertical', 'horizontal'):
lay = QVBoxLayout() if lay == 'vertical' else QHBoxLayout()
for field in fields:
self.members_layout.removeWidget(field)
lay.addWidget(field)
elif lay == 'grid':
lay = QGridLayout()
row, column = 0, 0
for field in fields:
self.members_layout.removeWidget(field)
lay.addWidget(field, row, column)
column += 1
if column >= 2:
row, column = row + 1, 0
else:
msg = "Expecting one of ('vertical', 'horizontal', 'grid') got {}"
raise ValueError(msg.format(layout))
group.setLayout(lay)
self.members_layout.insertWidget(0, group)
[docs]class CreationForm(Form):
"""Form subclass with useful defaults to create new objects.
This form's options defaults to ``('reset', 'save', 'cancel')``.
Also, a :func:`reset` method is included and connected by default
to the reset button to restore all fields in the form to their default
values.
.. seealso:: :class:`EditionForm`
"""
def __init__(self, **kwargs):
kwargs.setdefault('options', ('reset', 'save', 'cancel'))
kwargs.setdefault('on_reset', self.reset)
super(CreationForm, self).__init__(**kwargs)
# reset fields' data every time form closes
self.finished.connect(self.reset)
[docs] def reset(self):
"""Restores all fields in the form to their default values"""
for field in self.fields:
field.value = field.default
@staticmethod
def from_source(obj, source_kw={}, form_kw={}):
source = sources.get_fields_source(obj, **source_kw)
fields = form_kw.pop('fields', [])
fields.extend(source.fields.values())
form_kw = form_kw.copy()
form_kw.setdefault('options', ('reset', 'save', 'cancel'))
form = CreationForm(fields=fields, **form_kw)
title = 'Create {}'.format(type(obj).__name__.capitalize())
form.setWindowTitle(title)
return form
[docs]class EditionForm(Form):
"""Form subclass with useful defaults to edit existing objects.
This form's options defaults to ``('reset', 'save', 'cancel')``.
Also, a :func:`reset` method is included and connected by default
to a reset button to restore all fields in the form to their saved values.
You can edit an existing object using :func:`edit` method which
obtains a value for every field from the object, field names must
be equal to attributes names in the object in order to obtain their
current value::
class Person:
def __init__(self, name, last_name, age):
self.name = name
self.last_name = last_name
self.age = age
billy = Person('Billy', 'Smith', 20)
john = Person('John', 'Bit', 26)
# create form's fields using Person attributes
form = EditionForm.from_source(billy)
# prepares the form for edition and fills fields with current values
form.edit(john)
.. seealso:: :class:`CreationForm`
"""
def __init__(self, **kwargs):
kwargs.setdefault('options', ('reset', 'save', 'cancel'))
kwargs.setdefault('on_reset', self.reset)
super(EditionForm, self).__init__(**kwargs)
self._real_defaults = {}
# reset fields to their real defaults every time form closes
self.finished.connect(self._restore_real_defaults)
[docs] def reset(self):
"""Restores all fields in the form to their saved values if :func:`edit`
method has been called, otherwise restores to default values
"""
for field in self.fields:
field.value = field.default
@staticmethod
def from_source(obj, source_kw={}, form_kw={}):
source = sources.get_fields_source(obj, **source_kw)
fields = form_kw.pop('fields', [])
fields.extend(source.fields.values())
form_kw = form_kw.copy()
form_kw.setdefault('options', ('reset', 'save', 'cancel'))
form = EditionForm(fields=fields, **form_kw)
title = 'Edit {}'.format(type(obj).__name__.capitalize())
form.setWindowTitle(title)
return form
[docs] def edit(self, obj, disabled=()):
"""Puts the form in edition mode, filling fields with object values.
To prevent some of the fields from been modified when editing use
disable keyword and provide the names of the fields. Field names must
match object attributes in order to load values correctly.
:param obj: object used to fill form fields, only those attributes which
match field names will be used.
:type obj: any
:param disabled: names of the fields to be disabled in edition mode.
:type disabled: iterable of :class:`str`
"""
self._real_defaults.clear()
for field in self.fields:
# enable to remove settings from previous editions
field.setEnabled(True)
# save field's real default value
self._real_defaults[field.name] = field.default
# fill default and value properties with object's current values
with contextlib.suppress(AttributeError):
value = getattr(obj, field.name)
field.default = value
field.value = value
# disable if necessary
if field.name in disabled:
field.setEnabled(False)
return self
def _restore_real_defaults(self):
for field in self.fields:
if field.name in self._real_defaults:
field.default = self._real_defaults[field.name]
self.reset()