|
1 # copyright 2003-2012 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 """the facets box and some basic facets""" |
|
19 |
|
20 __docformat__ = "restructuredtext en" |
|
21 from cubicweb import _ |
|
22 |
|
23 from warnings import warn |
|
24 |
|
25 from logilab.mtconverter import xml_escape |
|
26 from logilab.common.decorators import cachedproperty |
|
27 from logilab.common.registry import objectify_predicate, yes |
|
28 |
|
29 from cubicweb import tags |
|
30 from cubicweb.predicates import (non_final_entity, multi_lines_rset, |
|
31 match_context_prop, relation_possible) |
|
32 from cubicweb.utils import json_dumps |
|
33 from cubicweb.uilib import css_em_num_value |
|
34 from cubicweb.view import AnyRsetView |
|
35 from cubicweb.web import component, facet as facetbase |
|
36 from cubicweb.web.views.ajaxcontroller import ajaxfunc |
|
37 |
|
38 def facets(req, rset, context, mainvar=None, **kwargs): |
|
39 """return the base rql and a list of widgets for facets applying to the |
|
40 given rset/context (cached version of :func:`_facet`) |
|
41 |
|
42 :param req: A :class:`~cubicweb.req.RequestSessionBase` object |
|
43 :param rset: A :class:`~cubicweb.rset.ResultSet` |
|
44 :param context: A string that match the ``__regid__`` of a ``FacetFilter`` |
|
45 :param mainvar: A string that match a select var from the rset |
|
46 """ |
|
47 try: |
|
48 cache = req.__rset_facets |
|
49 except AttributeError: |
|
50 cache = req.__rset_facets = {} |
|
51 try: |
|
52 return cache[(rset, context, mainvar)] |
|
53 except KeyError: |
|
54 facets = _facets(req, rset, context, mainvar, **kwargs) |
|
55 cache[(rset, context, mainvar)] = facets |
|
56 return facets |
|
57 |
|
58 def _facets(req, rset, context, mainvar, **kwargs): |
|
59 """return the base rql and a list of widgets for facets applying to the |
|
60 given rset/context |
|
61 |
|
62 :param req: A :class:`~cubicweb.req.RequestSessionBase` object |
|
63 :param rset: A :class:`~cubicweb.rset.ResultSet` |
|
64 :param context: A string that match the ``__regid__`` of a ``FacetFilter`` |
|
65 :param mainvar: A string that match a select var from the rset |
|
66 """ |
|
67 ### initialisation |
|
68 # XXX done by selectors, though maybe necessary when rset has been hijacked |
|
69 # (e.g. contextview_selector matched) |
|
70 origqlst = rset.syntax_tree() |
|
71 # union not yet supported |
|
72 if len(origqlst.children) != 1: |
|
73 req.debug('facette disabled on union request %s', origqlst) |
|
74 return None, () |
|
75 rqlst = origqlst.copy() |
|
76 select = rqlst.children[0] |
|
77 filtered_variable, baserql = facetbase.init_facets(rset, select, mainvar) |
|
78 ### Selection |
|
79 possible_facets = req.vreg['facets'].poss_visible_objects( |
|
80 req, rset=rset, rqlst=origqlst, select=select, |
|
81 context=context, filtered_variable=filtered_variable, **kwargs) |
|
82 wdgs = [(facet, facet.get_widget()) for facet in possible_facets] |
|
83 return baserql, [wdg for facet, wdg in wdgs if wdg is not None] |
|
84 |
|
85 |
|
86 @objectify_predicate |
|
87 def contextview_selector(cls, req, rset=None, row=None, col=None, view=None, |
|
88 **kwargs): |
|
89 if view: |
|
90 try: |
|
91 getcontext = getattr(view, 'filter_box_context_info') |
|
92 except AttributeError: |
|
93 return 0 |
|
94 rset = getcontext()[0] |
|
95 if rset is None or rset.rowcount < 2: |
|
96 return 0 |
|
97 wdgs = facets(req, rset, cls.__regid__, view=view)[1] |
|
98 return len(wdgs) |
|
99 return 0 |
|
100 |
|
101 @objectify_predicate |
|
102 def has_facets(cls, req, rset=None, **kwargs): |
|
103 if rset is None or rset.rowcount < 2: |
|
104 return 0 |
|
105 wdgs = facets(req, rset, cls.__regid__, **kwargs)[1] |
|
106 return len(wdgs) |
|
107 |
|
108 |
|
109 def filter_hiddens(w, baserql, wdgs, **kwargs): |
|
110 kwargs['facets'] = ','.join(wdg.facet.__regid__ for wdg in wdgs) |
|
111 kwargs['baserql'] = baserql |
|
112 for key, val in kwargs.items(): |
|
113 w(u'<input type="hidden" name="%s" value="%s" />' % ( |
|
114 key, xml_escape(val))) |
|
115 |
|
116 |
|
117 class FacetFilterMixIn(object): |
|
118 """Mixin Class to generate Facet Filter Form |
|
119 |
|
120 To generate the form, you need to explicitly call the following method: |
|
121 |
|
122 .. automethod:: generate_form |
|
123 |
|
124 The most useful function to override is: |
|
125 |
|
126 .. automethod:: layout_widgets |
|
127 """ |
|
128 |
|
129 needs_js = ['cubicweb.ajax.js', 'cubicweb.facets.js'] |
|
130 needs_css = ['cubicweb.facets.css'] |
|
131 |
|
132 def generate_form(self, w, rset, divid, vid, vidargs=None, mainvar=None, |
|
133 paginate=False, cssclass='', hiddens=None, **kwargs): |
|
134 """display a form to filter some view's content |
|
135 |
|
136 :param w: Write function |
|
137 |
|
138 :param rset: ResultSet to be filtered |
|
139 |
|
140 :param divid: Dom ID of the div where the rendering of the view is done. |
|
141 :type divid: string |
|
142 |
|
143 :param vid: ID of the view display in the div |
|
144 :type vid: string |
|
145 |
|
146 :param paginate: Is the view paginated? |
|
147 :type paginate: boolean |
|
148 |
|
149 :param cssclass: Additional css classes to put on the form. |
|
150 :type cssclass: string |
|
151 |
|
152 :param hiddens: other hidden parametters to include in the forms. |
|
153 :type hiddens: dict from extra keyword argument |
|
154 """ |
|
155 # XXX Facet.context property hijacks an otherwise well-behaved |
|
156 # vocabulary with its own notions |
|
157 # Hence we whack here to avoid a clash |
|
158 kwargs.pop('context', None) |
|
159 baserql, wdgs = facets(self._cw, rset, context=self.__regid__, |
|
160 mainvar=mainvar, **kwargs) |
|
161 assert wdgs |
|
162 self._cw.add_js(self.needs_js) |
|
163 self._cw.add_css(self.needs_css) |
|
164 self._cw.html_headers.define_var('facetLoadingMsg', |
|
165 self._cw._('facet-loading-msg')) |
|
166 if vidargs is not None: |
|
167 warn("[3.14] vidargs is deprecated. Maybe you're using some TableView?", |
|
168 DeprecationWarning, stacklevel=2) |
|
169 else: |
|
170 vidargs = {} |
|
171 vidargs = dict((k, v) for k, v in vidargs.items() if v) |
|
172 facetargs = xml_escape(json_dumps([divid, vid, paginate, vidargs])) |
|
173 w(u'<form id="%sForm" class="%s" method="post" action="" ' |
|
174 'cubicweb:facetargs="%s" >' % (divid, cssclass, facetargs)) |
|
175 w(u'<fieldset>') |
|
176 if hiddens is None: |
|
177 hiddens = {} |
|
178 if mainvar: |
|
179 hiddens['mainvar'] = mainvar |
|
180 filter_hiddens(w, baserql, wdgs, **hiddens) |
|
181 self.layout_widgets(w, self.sorted_widgets(wdgs)) |
|
182 |
|
183 # <Enter> is supposed to submit the form only if there is a single |
|
184 # input:text field. However most browsers will submit the form |
|
185 # on <Enter> anyway if there is an input:submit field. |
|
186 # |
|
187 # see: http://www.w3.org/MarkUp/html-spec/html-spec_8.html#SEC8.2 |
|
188 # |
|
189 # Firefox 7.0.1 does not submit form on <Enter> if there is more than a |
|
190 # input:text field and not input:submit but does it if there is an |
|
191 # input:submit. |
|
192 # |
|
193 # IE 6 or Firefox 2 behave the same way. |
|
194 w(u'<input type="submit" class="hidden" />') |
|
195 # |
|
196 w(u'</fieldset>\n') |
|
197 w(u'</form>\n') |
|
198 |
|
199 def sorted_widgets(self, wdgs): |
|
200 """sort widgets: by default sort by widget height, then according to |
|
201 widget.order (the original widgets order) |
|
202 """ |
|
203 return sorted(wdgs, key=lambda x: 99 * (not x.facet.start_unfolded) or x.height ) |
|
204 |
|
205 def layout_widgets(self, w, wdgs): |
|
206 """layout widgets: by default simply render each of them |
|
207 (i.e. succession of <div>) |
|
208 """ |
|
209 for wdg in wdgs: |
|
210 wdg.render(w=w) |
|
211 |
|
212 |
|
213 class FilterBox(FacetFilterMixIn, component.CtxComponent): |
|
214 """filter results of a query""" |
|
215 __regid__ = 'facet.filterbox' |
|
216 __select__ = ((non_final_entity() & has_facets()) |
|
217 | contextview_selector()) # can't use has_facets because of |
|
218 # contextview mecanism |
|
219 context = 'left' # XXX doesn't support 'incontext', only 'left' or 'right' |
|
220 title = _('facet.filters') |
|
221 visible = True # functionality provided by the search box by default |
|
222 order = 1 |
|
223 |
|
224 bk_linkbox_template = u'<div class="facetTitle">%s</div>' |
|
225 |
|
226 def render_body(self, w, **kwargs): |
|
227 req = self._cw |
|
228 rset, vid, divid, paginate = self._get_context() |
|
229 assert len(rset) > 1 |
|
230 if vid is None: |
|
231 vid = req.form.get('vid') |
|
232 if self.bk_linkbox_template and req.vreg.schema['Bookmark'].has_perm(req, 'add'): |
|
233 w(self.bookmark_link(rset)) |
|
234 w(self.focus_link(rset)) |
|
235 hiddens = {} |
|
236 for param in ('subvid', 'vtitle'): |
|
237 if param in req.form: |
|
238 hiddens[param] = req.form[param] |
|
239 self.generate_form(w, rset, divid, vid, paginate=paginate, |
|
240 hiddens=hiddens, **self.cw_extra_kwargs) |
|
241 |
|
242 def _get_context(self): |
|
243 view = self.cw_extra_kwargs.get('view') |
|
244 context = getattr(view, 'filter_box_context_info', lambda: None)() |
|
245 if context: |
|
246 rset, vid, divid, paginate = context |
|
247 else: |
|
248 rset = self.cw_rset |
|
249 vid, divid = None, 'pageContent' |
|
250 paginate = view and view.paginable |
|
251 return rset, vid, divid, paginate |
|
252 |
|
253 def bookmark_link(self, rset): |
|
254 req = self._cw |
|
255 bk_path = u'rql=%s' % req.url_quote(rset.printable_rql()) |
|
256 if req.form.get('vid'): |
|
257 bk_path += u'&vid=%s' % req.url_quote(req.form['vid']) |
|
258 bk_path = u'view?' + bk_path |
|
259 bk_title = req._('my custom search') |
|
260 linkto = u'bookmarked_by:%s:subject' % req.user.eid |
|
261 bkcls = req.vreg['etypes'].etype_class('Bookmark') |
|
262 bk_add_url = bkcls.cw_create_url(req, path=bk_path, title=bk_title, |
|
263 __linkto=linkto) |
|
264 bk_base_url = bkcls.cw_create_url(req, title=bk_title, __linkto=linkto) |
|
265 bk_link = u'<a cubicweb:target="%s" id="facetBkLink" href="%s">%s</a>' % ( |
|
266 xml_escape(bk_base_url), xml_escape(bk_add_url), |
|
267 req._('bookmark this search')) |
|
268 return self.bk_linkbox_template % bk_link |
|
269 |
|
270 def focus_link(self, rset): |
|
271 return self.bk_linkbox_template % tags.a(self._cw._('focus on this selection'), |
|
272 href=self._cw.url(), id='focusLink') |
|
273 |
|
274 class FilterTable(FacetFilterMixIn, AnyRsetView): |
|
275 __regid__ = 'facet.filtertable' |
|
276 __select__ = has_facets() |
|
277 average_perfacet_uncomputable_overhead = .3 |
|
278 |
|
279 def call(self, vid, divid, vidargs=None, cssclass=''): |
|
280 hiddens = self.cw_extra_kwargs.setdefault('hiddens', {}) |
|
281 hiddens['fromformfilter'] = '1' |
|
282 self.generate_form(self.w, self.cw_rset, divid, vid, vidargs=vidargs, |
|
283 cssclass=cssclass, **self.cw_extra_kwargs) |
|
284 |
|
285 @cachedproperty |
|
286 def per_facet_height_overhead(self): |
|
287 return (css_em_num_value(self._cw.vreg, 'facet_MarginBottom', .2) + |
|
288 css_em_num_value(self._cw.vreg, 'facet_Padding', .2) + |
|
289 self.average_perfacet_uncomputable_overhead) |
|
290 |
|
291 def layout_widgets(self, w, wdgs): |
|
292 """layout widgets: put them in a table where each column should have |
|
293 sum(wdg.height) < wdg_stack_size. |
|
294 """ |
|
295 w(u'<div class="filter">\n') |
|
296 widget_queue = [] |
|
297 queue_height = 0 |
|
298 wdg_stack_size = facetbase._DEFAULT_FACET_GROUP_HEIGHT |
|
299 for wdg in wdgs: |
|
300 height = wdg.height + self.per_facet_height_overhead |
|
301 if queue_height + height <= wdg_stack_size: |
|
302 widget_queue.append(wdg) |
|
303 queue_height += height |
|
304 continue |
|
305 w(u'<div class="facetGroup">') |
|
306 for queued in widget_queue: |
|
307 queued.render(w=w) |
|
308 w(u'</div>') |
|
309 widget_queue = [wdg] |
|
310 queue_height = height |
|
311 if widget_queue: |
|
312 w(u'<div class="facetGroup">') |
|
313 for queued in widget_queue: |
|
314 queued.render(w=w) |
|
315 w(u'</div>') |
|
316 w(u'</div>\n') |
|
317 |
|
318 # python-ajax remote functions used by facet widgets ######################### |
|
319 |
|
320 @ajaxfunc(output_type='json') |
|
321 def filter_build_rql(self, names, values): |
|
322 form = self._rebuild_posted_form(names, values) |
|
323 self._cw.form = form |
|
324 builder = facetbase.FilterRQLBuilder(self._cw) |
|
325 return builder.build_rql() |
|
326 |
|
327 @ajaxfunc(output_type='json') |
|
328 def filter_select_content(self, facetids, rql, mainvar): |
|
329 # Union unsupported yet |
|
330 select = self._cw.vreg.parse(self._cw, rql).children[0] |
|
331 filtered_variable = facetbase.get_filtered_variable(select, mainvar) |
|
332 facetbase.prepare_select(select, filtered_variable) |
|
333 update_map = {} |
|
334 for fid in facetids: |
|
335 fobj = facetbase.get_facet(self._cw, fid, select, filtered_variable) |
|
336 update_map[fid] = fobj.possible_values() |
|
337 return update_map |
|
338 |
|
339 |
|
340 |
|
341 # facets ###################################################################### |
|
342 |
|
343 class CWSourceFacet(facetbase.RelationFacet): |
|
344 __regid__ = 'cw_source-facet' |
|
345 rtype = 'cw_source' |
|
346 target_attr = 'name' |
|
347 |
|
348 class CreatedByFacet(facetbase.RelationFacet): |
|
349 __regid__ = 'created_by-facet' |
|
350 rtype = 'created_by' |
|
351 target_attr = 'login' |
|
352 |
|
353 class InGroupFacet(facetbase.RelationFacet): |
|
354 __regid__ = 'in_group-facet' |
|
355 rtype = 'in_group' |
|
356 target_attr = 'name' |
|
357 |
|
358 class InStateFacet(facetbase.RelationAttributeFacet): |
|
359 __regid__ = 'in_state-facet' |
|
360 rtype = 'in_state' |
|
361 target_attr = 'name' |
|
362 |
|
363 |
|
364 # inherit from RelationFacet to benefit from its possible_values implementation |
|
365 class ETypeFacet(facetbase.RelationFacet): |
|
366 __regid__ = 'etype-facet' |
|
367 __select__ = yes() |
|
368 order = 1 |
|
369 rtype = 'is' |
|
370 target_attr = 'name' |
|
371 |
|
372 @property |
|
373 def title(self): |
|
374 return self._cw._('entity type') |
|
375 |
|
376 def vocabulary(self): |
|
377 """return vocabulary for this facet, eg a list of 2-uple (label, value) |
|
378 """ |
|
379 etypes = self.cw_rset.column_types(0) |
|
380 return sorted((self._cw._(etype), etype) for etype in etypes) |
|
381 |
|
382 def add_rql_restrictions(self): |
|
383 """add restriction for this facet into the rql syntax tree""" |
|
384 value = self._cw.form.get(self.__regid__) |
|
385 if not value: |
|
386 return |
|
387 self.select.add_type_restriction(self.filtered_variable, value) |
|
388 |
|
389 def possible_values(self): |
|
390 """return a list of possible values (as string since it's used to |
|
391 compare to a form value in javascript) for this facet |
|
392 """ |
|
393 select = self.select |
|
394 select.save_state() |
|
395 try: |
|
396 facetbase.cleanup_select(select, self.filtered_variable) |
|
397 etype_var = facetbase.prepare_vocabulary_select( |
|
398 select, self.filtered_variable, self.rtype, self.role) |
|
399 attrvar = select.make_variable() |
|
400 select.add_selected(attrvar) |
|
401 select.add_relation(etype_var, 'name', attrvar) |
|
402 return [etype for _, etype in self.rqlexec(select.as_string())] |
|
403 finally: |
|
404 select.recover() |
|
405 |
|
406 |
|
407 class HasTextFacet(facetbase.AbstractFacet): |
|
408 __select__ = relation_possible('has_text', 'subject') & match_context_prop() |
|
409 __regid__ = 'has_text-facet' |
|
410 rtype = 'has_text' |
|
411 role = 'subject' |
|
412 order = 0 |
|
413 |
|
414 @property |
|
415 def wdgclass(self): |
|
416 return facetbase.FacetStringWidget |
|
417 |
|
418 @property |
|
419 def title(self): |
|
420 return self._cw._('has_text') |
|
421 |
|
422 def get_widget(self): |
|
423 """return the widget instance to use to display this facet |
|
424 |
|
425 default implentation expects a .vocabulary method on the facet and |
|
426 return a combobox displaying this vocabulary |
|
427 """ |
|
428 return self.wdgclass(self) |
|
429 |
|
430 def add_rql_restrictions(self): |
|
431 """add restriction for this facet into the rql syntax tree""" |
|
432 value = self._cw.form.get(self.__regid__) |
|
433 if not value: |
|
434 return |
|
435 self.select.add_constant_restriction(self.filtered_variable, 'has_text', value, 'String') |