1 # copyright 2003-2011 LOGILAB S.A. (Paris, FRANCE), all rights reserved. |
|
2 # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr |
|
3 # |
|
4 # This file is part of CubicWeb. |
|
5 # |
|
6 # CubicWeb is free software: you can redistribute it and/or modify it under the |
|
7 # terms of the GNU Lesser General Public License as published by the Free |
|
8 # Software Foundation, either version 2.1 of the License, or (at your option) |
|
9 # any later version. |
|
10 # |
|
11 # CubicWeb is distributed in the hope that it will be useful, but WITHOUT |
|
12 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS |
|
13 # FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more |
|
14 # details. |
|
15 # |
|
16 # You should have received a copy of the GNU Lesser General Public License along |
|
17 # with CubicWeb. If not, see <http://www.gnu.org/licenses/>. |
|
18 """abstract form classes for CubicWeb web client""" |
|
19 __docformat__ = "restructuredtext en" |
|
20 |
|
21 from warnings import warn |
|
22 |
|
23 from six import add_metaclass |
|
24 |
|
25 from logilab.common.decorators import iclassmethod |
|
26 from logilab.common.deprecation import deprecated |
|
27 |
|
28 from cubicweb.appobject import AppObject |
|
29 from cubicweb.view import NOINDEX, NOFOLLOW |
|
30 from cubicweb.web import httpcache, formfields, controller, formwidgets as fwdgs |
|
31 |
|
32 class FormViewMixIn(object): |
|
33 """abstract form view mix-in""" |
|
34 category = 'form' |
|
35 http_cache_manager = httpcache.NoHTTPCacheManager |
|
36 add_to_breadcrumbs = False |
|
37 |
|
38 def html_headers(self): |
|
39 """return a list of html headers (eg something to be inserted between |
|
40 <head> and </head> of the returned page |
|
41 |
|
42 by default forms are neither indexed nor followed |
|
43 """ |
|
44 return [NOINDEX, NOFOLLOW] |
|
45 |
|
46 def linkable(self): |
|
47 """override since forms are usually linked by an action, |
|
48 so we don't want them to be listed by appli.possible_views |
|
49 """ |
|
50 return False |
|
51 |
|
52 |
|
53 ############################################################################### |
|
54 |
|
55 class metafieldsform(type): |
|
56 """metaclass for FieldsForm to retrieve fields defined as class attributes |
|
57 and put them into a single ordered list: '_fields_'. |
|
58 """ |
|
59 def __new__(mcs, name, bases, classdict): |
|
60 allfields = [] |
|
61 for base in bases: |
|
62 if hasattr(base, '_fields_'): |
|
63 allfields += base._fields_ |
|
64 clsfields = (item for item in classdict.items() |
|
65 if isinstance(item[1], formfields.Field)) |
|
66 for fieldname, field in sorted(clsfields, key=lambda x: x[1].creation_rank): |
|
67 if not field.name: |
|
68 field.set_name(fieldname) |
|
69 allfields.append(field) |
|
70 classdict['_fields_'] = allfields |
|
71 return super(metafieldsform, mcs).__new__(mcs, name, bases, classdict) |
|
72 |
|
73 |
|
74 class FieldNotFound(Exception): |
|
75 """raised by field_by_name when a field with the given name has not been |
|
76 found |
|
77 """ |
|
78 |
|
79 @add_metaclass(metafieldsform) |
|
80 class Form(AppObject): |
|
81 __registry__ = 'forms' |
|
82 |
|
83 parent_form = None |
|
84 force_session_key = None |
|
85 domid = 'form' |
|
86 copy_nav_params = False |
|
87 control_fields = set( ('__form_id', '__errorurl', '__domid', |
|
88 '__redirectpath', '_cwmsgid', |
|
89 ) ) |
|
90 |
|
91 def __init__(self, req, rset=None, row=None, col=None, |
|
92 submitmsg=None, mainform=True, **kwargs): |
|
93 # process kwargs first so we can properly pass them to Form and match |
|
94 # order expectation (ie cw_extra_kwargs populated almost first) |
|
95 hiddens, extrakw = self._process_kwargs(kwargs) |
|
96 # now call ancestor init |
|
97 super(Form, self).__init__(req, rset=rset, row=row, col=col, **extrakw) |
|
98 # then continue with further specific initialization |
|
99 self.fields = list(self.__class__._fields_) |
|
100 for key, val in hiddens: |
|
101 self.add_hidden(key, val) |
|
102 if mainform: |
|
103 formid = kwargs.pop('formvid', self.__regid__) |
|
104 self.add_hidden(u'__form_id', formid) |
|
105 self._posting = self._cw.form.get('__form_id') == formid |
|
106 if mainform: |
|
107 self.add_hidden(u'__errorurl', self.session_key()) |
|
108 self.add_hidden(u'__domid', self.domid) |
|
109 self.restore_previous_post(self.session_key()) |
|
110 # XXX why do we need two different variables (mainform and copy_nav_params ?) |
|
111 if self.copy_nav_params: |
|
112 for param in controller.NAV_FORM_PARAMETERS: |
|
113 if not param in kwargs: |
|
114 value = req.form.get(param) |
|
115 if value: |
|
116 self.add_hidden(param, value) |
|
117 if submitmsg is not None: |
|
118 self.set_message(submitmsg) |
|
119 |
|
120 def _process_kwargs(self, kwargs): |
|
121 hiddens = [] |
|
122 extrakw = {} |
|
123 # search for navigation parameters and customization of existing |
|
124 # attributes; remaining stuff goes in extrakwargs |
|
125 for key, val in kwargs.items(): |
|
126 if key in controller.NAV_FORM_PARAMETERS: |
|
127 hiddens.append( (key, val) ) |
|
128 elif key == 'redirect_path': |
|
129 hiddens.append( (u'__redirectpath', val) ) |
|
130 elif hasattr(self.__class__, key) and not key[0] == '_': |
|
131 setattr(self, key, val) |
|
132 else: |
|
133 extrakw[key] = val |
|
134 return hiddens, extrakw |
|
135 |
|
136 def set_message(self, submitmsg): |
|
137 """sets a submitmsg if exists, using _cwmsgid mechanism """ |
|
138 cwmsgid = self._cw.set_redirect_message(submitmsg) |
|
139 self.add_hidden(u'_cwmsgid', cwmsgid) |
|
140 |
|
141 @property |
|
142 def root_form(self): |
|
143 """return the root form""" |
|
144 if self.parent_form is None: |
|
145 return self |
|
146 return self.parent_form.root_form |
|
147 |
|
148 @property |
|
149 def form_valerror(self): |
|
150 """the validation error exception if any""" |
|
151 if self.parent_form is None: |
|
152 # unset if restore_previous_post has not be called |
|
153 return getattr(self, '_form_valerror', None) |
|
154 return self.parent_form.form_valerror |
|
155 |
|
156 @property |
|
157 def form_previous_values(self): |
|
158 """previously posted values (on validation error)""" |
|
159 if self.parent_form is None: |
|
160 # unset if restore_previous_post has not be called |
|
161 return getattr(self, '_form_previous_values', {}) |
|
162 return self.parent_form.form_previous_values |
|
163 |
|
164 @property |
|
165 def posting(self): |
|
166 """return True if the form is being posted, False if it is being |
|
167 generated. |
|
168 """ |
|
169 # XXX check behaviour on regeneration after error |
|
170 if self.parent_form is None: |
|
171 return self._posting |
|
172 return self.parent_form.posting |
|
173 |
|
174 @iclassmethod |
|
175 def _fieldsattr(cls_or_self): |
|
176 if isinstance(cls_or_self, type): |
|
177 fields = cls_or_self._fields_ |
|
178 else: |
|
179 fields = cls_or_self.fields |
|
180 return fields |
|
181 |
|
182 @iclassmethod |
|
183 def field_by_name(cls_or_self, name, role=None): |
|
184 """Return field with the given name and role. |
|
185 |
|
186 Raise :exc:`FieldNotFound` if the field can't be found. |
|
187 """ |
|
188 for field in cls_or_self._fieldsattr(): |
|
189 if field.name == name and field.role == role: |
|
190 return field |
|
191 raise FieldNotFound(name, role) |
|
192 |
|
193 @iclassmethod |
|
194 def fields_by_name(cls_or_self, name, role=None): |
|
195 """Return a list of fields with the given name and role.""" |
|
196 return [field for field in cls_or_self._fieldsattr() |
|
197 if field.name == name and field.role == role] |
|
198 |
|
199 @iclassmethod |
|
200 def remove_field(cls_or_self, field): |
|
201 """Remove the given field.""" |
|
202 cls_or_self._fieldsattr().remove(field) |
|
203 |
|
204 @iclassmethod |
|
205 def append_field(cls_or_self, field): |
|
206 """Append the given field.""" |
|
207 cls_or_self._fieldsattr().append(field) |
|
208 |
|
209 @iclassmethod |
|
210 def insert_field_before(cls_or_self, field, name, role=None): |
|
211 """Insert the given field before the field of given name and role.""" |
|
212 bfield = cls_or_self.field_by_name(name, role) |
|
213 fields = cls_or_self._fieldsattr() |
|
214 fields.insert(fields.index(bfield), field) |
|
215 |
|
216 @iclassmethod |
|
217 def insert_field_after(cls_or_self, field, name, role=None): |
|
218 """Insert the given field after the field of given name and role.""" |
|
219 afield = cls_or_self.field_by_name(name, role) |
|
220 fields = cls_or_self._fieldsattr() |
|
221 fields.insert(fields.index(afield)+1, field) |
|
222 |
|
223 @iclassmethod |
|
224 def add_hidden(cls_or_self, name, value=None, **kwargs): |
|
225 """Append an hidden field to the form. `name`, `value` and extra keyword |
|
226 arguments will be given to the field constructor. The inserted field is |
|
227 returned. |
|
228 """ |
|
229 kwargs.setdefault('ignore_req_params', True) |
|
230 kwargs.setdefault('widget', fwdgs.HiddenInput) |
|
231 field = formfields.StringField(name=name, value=value, **kwargs) |
|
232 if 'id' in kwargs: |
|
233 # by default, hidden input don't set id attribute. If one is |
|
234 # explicitly specified, ensure it will be set |
|
235 field.widget.setdomid = True |
|
236 cls_or_self.append_field(field) |
|
237 return field |
|
238 |
|
239 def session_key(self): |
|
240 """return the key that may be used to store / retreive data about a |
|
241 previous post which failed because of a validation error |
|
242 """ |
|
243 if self.force_session_key is None: |
|
244 return '%s#%s' % (self._cw.url(), self.domid) |
|
245 return self.force_session_key |
|
246 |
|
247 def restore_previous_post(self, sessionkey): |
|
248 # get validation session data which may have been previously set. |
|
249 # deleting validation errors here breaks form reloading (errors are |
|
250 # no more available), they have to be deleted by application's publish |
|
251 # method on successful commit |
|
252 forminfo = self._cw.session.data.pop(sessionkey, None) |
|
253 if forminfo: |
|
254 self._form_previous_values = forminfo['values'] |
|
255 self._form_valerror = forminfo['error'] |
|
256 # if some validation error occurred on entity creation, we have to |
|
257 # get the original variable name from its attributed eid |
|
258 foreid = self.form_valerror.entity |
|
259 for var, eid in forminfo['eidmap'].items(): |
|
260 if foreid == eid: |
|
261 self.form_valerror.eid = var |
|
262 break |
|
263 else: |
|
264 self.form_valerror.eid = foreid |
|
265 else: |
|
266 self._form_previous_values = {} |
|
267 self._form_valerror = None |
|
268 |
|
269 def field_error(self, field): |
|
270 """return field's error if specified in current validation exception""" |
|
271 if self.form_valerror: |
|
272 if field.eidparam and self.edited_entity.eid != self.form_valerror.eid: |
|
273 return None |
|
274 try: |
|
275 return self.form_valerror.errors.pop(field.role_name()) |
|
276 except KeyError: |
|
277 if field.role and field.name in self.form_valerror: |
|
278 warn('%s: errors key of attribute/relation should be suffixed by "-<role>"' |
|
279 % self.form_valerror.__class__, DeprecationWarning) |
|
280 return self.form_valerror.errors.pop(field.name) |
|
281 return None |
|
282 |
|
283 def remaining_errors(self): |
|
284 return sorted(self.form_valerror.errors.items()) |
|