| 1 | # -*- coding: utf-8 -*- |
|---|
| 2 | |
|---|
| 3 | import re |
|---|
| 4 | |
|---|
| 5 | from genshi.builder import Markup, tag |
|---|
| 6 | from pkg_resources import resource_filename |
|---|
| 7 | |
|---|
| 8 | from trac.core import implements |
|---|
| 9 | from trac.util.datefmt import format_datetime |
|---|
| 10 | from trac.resource import get_resource_description, \ |
|---|
| 11 | get_resource_shortname, get_resource_url |
|---|
| 12 | from trac.search.api import ISearchSource, shorten_result |
|---|
| 13 | from trac.util.datefmt import to_datetime |
|---|
| 14 | from trac.web.api import IRequestFilter, IRequestHandler |
|---|
| 15 | from trac.web.chrome import ITemplateProvider, add_ctxtnav, add_stylesheet |
|---|
| 16 | |
|---|
| 17 | from api import FormDBUser, _, dgettext |
|---|
| 18 | from compat import json |
|---|
| 19 | from model import Form |
|---|
| 20 | from util import resource_from_page |
|---|
| 21 | |
|---|
| 22 | tfpageRE = re.compile('/form(/\d+|$)') |
|---|
| 23 | |
|---|
| 24 | |
|---|
| 25 | class FormUI(FormDBUser): |
|---|
| 26 | """Provides form views for reviewing and managing TracForm data. |
|---|
| 27 | |
|---|
| 28 | Extensions for the Trac web user interface display saved field values, |
|---|
| 29 | metadata and history. TracSearch support for TracForms is included here |
|---|
| 30 | and administrative actions to revert form changes are embedded as well. |
|---|
| 31 | """ |
|---|
| 32 | |
|---|
| 33 | implements(IRequestFilter, IRequestHandler, ISearchSource, |
|---|
| 34 | ITemplateProvider) |
|---|
| 35 | |
|---|
| 36 | # IRequestFilter methods |
|---|
| 37 | |
|---|
| 38 | def pre_process_request(self, req, handler): |
|---|
| 39 | return handler |
|---|
| 40 | |
|---|
| 41 | def post_process_request(self, req, template, data, content_type): |
|---|
| 42 | env = self.env |
|---|
| 43 | page = req.path_info |
|---|
| 44 | realm, resource_id = resource_from_page(env, page) |
|---|
| 45 | # break (recursive) search for form in forms realm |
|---|
| 46 | if tfpageRE.match(page) == None and resource_id is not None: |
|---|
| 47 | if page == '/wiki' or page == '/wiki/': |
|---|
| 48 | page = '/wiki/WikiStart' |
|---|
| 49 | form = Form(env, realm, resource_id) |
|---|
| 50 | if 'FORM_VIEW' in req.perm(form.resource): |
|---|
| 51 | if len(form.siblings) == 0: |
|---|
| 52 | # no form record found for this parent resource |
|---|
| 53 | return (template, data, content_type) |
|---|
| 54 | elif form.resource.id is not None: |
|---|
| 55 | # single form record found |
|---|
| 56 | href = req.href.form(form.resource.id) |
|---|
| 57 | else: |
|---|
| 58 | # multiple form records found |
|---|
| 59 | href = req.href.form(action='select', realm=realm, |
|---|
| 60 | resource_id=resource_id) |
|---|
| 61 | add_ctxtnav(req, _("Form details"), href=href, |
|---|
| 62 | title=_("Review form data")) |
|---|
| 63 | elif page.startswith('/form') and not resource_id == '': |
|---|
| 64 | form = Form(env, form_id=resource_id) |
|---|
| 65 | parent = form.resource.parent |
|---|
| 66 | if len(form.siblings) > 1: |
|---|
| 67 | href = req.href.form(action='select', realm=parent.realm, |
|---|
| 68 | resource_id=parent.id) |
|---|
| 69 | add_ctxtnav(req, _("Back to forms list"), href=href) |
|---|
| 70 | return (template, data, content_type) |
|---|
| 71 | |
|---|
| 72 | # ITemplateProvider methods |
|---|
| 73 | |
|---|
| 74 | def get_htdocs_dirs(self): |
|---|
| 75 | """Return static resources for TracForms.""" |
|---|
| 76 | return [('tracforms', resource_filename(__name__, 'htdocs'))] |
|---|
| 77 | |
|---|
| 78 | def get_templates_dirs(self): |
|---|
| 79 | """Return template directory for TracForms.""" |
|---|
| 80 | return [resource_filename(__name__, 'templates')] |
|---|
| 81 | |
|---|
| 82 | # IRequestHandler methods |
|---|
| 83 | |
|---|
| 84 | def match_request(self, req): |
|---|
| 85 | if req.path_info == '/form': |
|---|
| 86 | return True |
|---|
| 87 | match = re.match('/form/(\d+)$', req.path_info) |
|---|
| 88 | if match: |
|---|
| 89 | if match.group(1): |
|---|
| 90 | req.args['id'] = match.group(1) |
|---|
| 91 | return True |
|---|
| 92 | |
|---|
| 93 | def process_request(self, req): |
|---|
| 94 | env = self.env |
|---|
| 95 | id_hint = req.args.get('id') |
|---|
| 96 | if id_hint is not None and Form.id_is_valid(id_hint): |
|---|
| 97 | form_id = int(id_hint) |
|---|
| 98 | else: |
|---|
| 99 | form_id = None |
|---|
| 100 | if form_id is not None: |
|---|
| 101 | form = Form(env, form_id=form_id) |
|---|
| 102 | if req.method == 'POST': |
|---|
| 103 | req.perm(form.resource).require('FORM_RESET') |
|---|
| 104 | return self._do_reset(env, req, form) |
|---|
| 105 | |
|---|
| 106 | req.perm(form.resource).require('FORM_VIEW') |
|---|
| 107 | return self._do_view(env, req, form) |
|---|
| 108 | |
|---|
| 109 | if req.args.get('action') == 'select': |
|---|
| 110 | realm=req.args.get('realm') |
|---|
| 111 | resource_id=req.args.get('resource_id') |
|---|
| 112 | if realm is not None and resource_id is not None: |
|---|
| 113 | form = Form(env, realm, resource_id) |
|---|
| 114 | req.perm(form.resource).require('FORM_VIEW') |
|---|
| 115 | return self._do_switch(env, req, form) |
|---|
| 116 | |
|---|
| 117 | def _do_view(self, env, req, form): |
|---|
| 118 | data = {'_dgettext': dgettext} |
|---|
| 119 | form_id = form.resource.id |
|---|
| 120 | data['page_title'] = get_resource_description(env, form.resource, |
|---|
| 121 | href=req.href) |
|---|
| 122 | data['title'] = get_resource_shortname(env, form.resource) |
|---|
| 123 | # prime list with current state |
|---|
| 124 | subcontext, author, time = self.get_tracform_meta(form_id)[3:6] |
|---|
| 125 | if not subcontext == '': |
|---|
| 126 | data['subcontext'] = subcontext |
|---|
| 127 | state = self.get_tracform_state(form_id) |
|---|
| 128 | data['fields'] = self._render_fields(form_id, state) |
|---|
| 129 | history = [{'author': author, 'time': time, |
|---|
| 130 | 'old_state': state}] |
|---|
| 131 | # add recorded old_state |
|---|
| 132 | records = self.get_tracform_history(form_id) |
|---|
| 133 | for author, time, old_state in records: |
|---|
| 134 | history.append({'author': author, 'time': time, |
|---|
| 135 | 'old_state': old_state}) |
|---|
| 136 | data['history'] = parse_history(history) |
|---|
| 137 | # show reset button in case of existing data and proper permission |
|---|
| 138 | data['allow_reset'] = req.perm(form.resource) \ |
|---|
| 139 | .has_permission('FORM_RESET') and form.has_data |
|---|
| 140 | add_stylesheet(req, 'tracforms/tracforms.css') |
|---|
| 141 | return 'form.html', data, None |
|---|
| 142 | |
|---|
| 143 | def _do_switch(self, env, req, form): |
|---|
| 144 | data = {'_dgettext': dgettext} |
|---|
| 145 | data['page_title'] = get_resource_description(env, form.resource, |
|---|
| 146 | href=req.href) |
|---|
| 147 | data['title'] = get_resource_shortname(env, form.resource) |
|---|
| 148 | data['siblings'] = [] |
|---|
| 149 | for sibling in form.siblings: |
|---|
| 150 | form_id = tag.strong(tag.a( |
|---|
| 151 | _("Form %(form_id)s", form_id=sibling[0]), |
|---|
| 152 | href=req.href.form(sibling[0]))) |
|---|
| 153 | if sibling[1] == '': |
|---|
| 154 | data['siblings'].append(form_id) |
|---|
| 155 | else: |
|---|
| 156 | # TRANSLATOR: Form list entry for form select page |
|---|
| 157 | data['siblings'].append(tag(Markup(_( |
|---|
| 158 | "%(form_id)s (subcontext = '%(subcontext)s')", |
|---|
| 159 | form_id=form_id, subcontext = sibling[1])))) |
|---|
| 160 | add_stylesheet(req, 'tracforms/tracforms.css') |
|---|
| 161 | return 'switch.html', data, None |
|---|
| 162 | |
|---|
| 163 | def _do_reset(self, env, req, form): |
|---|
| 164 | author = req.authname |
|---|
| 165 | if 'rewind' in req.args: |
|---|
| 166 | step = -1 |
|---|
| 167 | elif 'reset' in req.args: |
|---|
| 168 | step = 0 |
|---|
| 169 | if form.resource.id is not None: |
|---|
| 170 | self.reset_tracform(form.resource.id, author=author, step=step) |
|---|
| 171 | else: |
|---|
| 172 | self.reset_tracform(tuple([form.parent.realm, form.parent.id]), |
|---|
| 173 | author=author, step=step) |
|---|
| 174 | return self._do_view(env, req, form) |
|---|
| 175 | |
|---|
| 176 | def _render_fields(self, form_id, state): |
|---|
| 177 | fields = json.loads(state is not None and state or '{}') |
|---|
| 178 | rendered = [] |
|---|
| 179 | for name, value in fields.iteritems(): |
|---|
| 180 | if value == 'on': |
|---|
| 181 | value = _("checked (checkbox)") |
|---|
| 182 | elif value == '': |
|---|
| 183 | value = _("empty (text field)") |
|---|
| 184 | else: |
|---|
| 185 | value = '\'' + value + '\'' |
|---|
| 186 | author, time = self.get_tracform_fieldinfo(form_id, name) |
|---|
| 187 | rendered.append( |
|---|
| 188 | {'name': name, 'value': value, |
|---|
| 189 | 'author': tag.span(tag(_("by %(author)s", author=author)), |
|---|
| 190 | class_='author'), |
|---|
| 191 | 'time': time is not None and tag.span( |
|---|
| 192 | format_datetime(time), class_='date') or None}) |
|---|
| 193 | return rendered |
|---|
| 194 | |
|---|
| 195 | # ISearchSource methods |
|---|
| 196 | |
|---|
| 197 | def get_search_filters(self, req): |
|---|
| 198 | if 'FORM_VIEW' in req.perm: |
|---|
| 199 | # TRANSLATOR: The realm name used as TracSearch filter label |
|---|
| 200 | yield ('form', _("Forms")) |
|---|
| 201 | |
|---|
| 202 | def get_search_results(self, req, terms, filters): |
|---|
| 203 | if not 'form' in filters: |
|---|
| 204 | return |
|---|
| 205 | env = self.env |
|---|
| 206 | results = self.search_tracforms(env, terms) |
|---|
| 207 | |
|---|
| 208 | for id, realm, parent, subctxt, state, author, updated_on in results: |
|---|
| 209 | # DEVEL: support for handling form revisions not implemented yet |
|---|
| 210 | #form = Form(env, realm, parent, subctxt, id, version) |
|---|
| 211 | form = Form(env, realm, parent, subctxt, id) |
|---|
| 212 | if 'FORM_VIEW' in req.perm(form): |
|---|
| 213 | form = form.resource |
|---|
| 214 | # build a more human-readable form values representation, |
|---|
| 215 | # especially with unicode character escapes removed |
|---|
| 216 | state = _render_values(state) |
|---|
| 217 | yield (get_resource_url(env, form, req.href), |
|---|
| 218 | get_resource_description(env, form), |
|---|
| 219 | to_datetime(updated_on), author, |
|---|
| 220 | shorten_result(state, terms)) |
|---|
| 221 | |
|---|
| 222 | |
|---|
| 223 | def parse_history(changes, fieldwise=False): |
|---|
| 224 | """Versatile history parser for TracForms. |
|---|
| 225 | |
|---|
| 226 | Returns either a list of dicts for changeset display in form view or |
|---|
| 227 | a dict of field change lists for stepwise form reset. |
|---|
| 228 | """ |
|---|
| 229 | fieldhistory = {} |
|---|
| 230 | history = [] |
|---|
| 231 | if not fieldwise == False: |
|---|
| 232 | def _add_change(fieldhistory, field, author, time, old, new): |
|---|
| 233 | if field not in fieldhistory.keys(): |
|---|
| 234 | fieldhistory[field] = [{'author': author, 'time': time, |
|---|
| 235 | 'old': old, 'new': new}] |
|---|
| 236 | else: |
|---|
| 237 | fieldhistory[field].append({'author': author, 'time': time, |
|---|
| 238 | 'old': old, 'new': new}) |
|---|
| 239 | return fieldhistory |
|---|
| 240 | |
|---|
| 241 | new_fields = None |
|---|
| 242 | for changeset in changes: |
|---|
| 243 | # break down old and new version |
|---|
| 244 | try: |
|---|
| 245 | old_fields = json.loads(changeset.get('old_state', '{}')) |
|---|
| 246 | except ValueError: |
|---|
| 247 | # skip invalid history |
|---|
| 248 | old_fields = {} |
|---|
| 249 | pass |
|---|
| 250 | if new_fields is None: |
|---|
| 251 | # first loop cycle: only load values for comparison next time |
|---|
| 252 | new_fields = old_fields |
|---|
| 253 | last_author = changeset['author'] |
|---|
| 254 | last_change = changeset['time'] |
|---|
| 255 | continue |
|---|
| 256 | updated_fields = {} |
|---|
| 257 | for field, old_value in old_fields.iteritems(): |
|---|
| 258 | new_value = new_fields.get(field) |
|---|
| 259 | if new_value != old_value: |
|---|
| 260 | if fieldwise == False: |
|---|
| 261 | change = _render_change(old_value, new_value) |
|---|
| 262 | if change is not None: |
|---|
| 263 | updated_fields[field] = change |
|---|
| 264 | else: |
|---|
| 265 | fieldhistory = _add_change(fieldhistory, field, |
|---|
| 266 | last_author, last_change, |
|---|
| 267 | old_value, new_value) |
|---|
| 268 | for field in new_fields: |
|---|
| 269 | if old_fields.get(field) is None: |
|---|
| 270 | if fieldwise == False: |
|---|
| 271 | change = _render_change(None, new_fields[field]) |
|---|
| 272 | if change is not None: |
|---|
| 273 | updated_fields[field] = change |
|---|
| 274 | else: |
|---|
| 275 | fieldhistory = _add_change(fieldhistory, field, |
|---|
| 276 | last_author, last_change, |
|---|
| 277 | None, new_fields[field]) |
|---|
| 278 | new_fields = old_fields |
|---|
| 279 | history.append({'author': last_author, |
|---|
| 280 | 'time': format_datetime(last_change), |
|---|
| 281 | 'changes': updated_fields}) |
|---|
| 282 | last_author = changeset['author'] |
|---|
| 283 | last_change = changeset['time'] |
|---|
| 284 | return fieldwise == False and history or fieldhistory |
|---|
| 285 | |
|---|
| 286 | def _render_change(old, new): |
|---|
| 287 | rendered = None |
|---|
| 288 | if old and not new: |
|---|
| 289 | rendered = tag(Markup(_("%(value)s reset to default value", |
|---|
| 290 | value=tag.em(old)))) |
|---|
| 291 | elif new and not old: |
|---|
| 292 | rendered = tag(Markup(_("from default value set to %(value)s", |
|---|
| 293 | value=tag.em(new)))) |
|---|
| 294 | elif old and new: |
|---|
| 295 | if len(old) < 20 and len(new) < 20: |
|---|
| 296 | rendered = tag(Markup(_("changed from %(old)s to %(new)s", |
|---|
| 297 | old=tag.em(old), new=tag.em(new)))) |
|---|
| 298 | else: |
|---|
| 299 | nbsp = Markup('<br />') |
|---|
| 300 | # TRANSLATOR: same as before, but with additional line breaks |
|---|
| 301 | rendered = tag(Markup(_("changed from %(old)s to %(new)s", |
|---|
| 302 | old=tag.em(nbsp, old), |
|---|
| 303 | new=tag.em(nbsp, new)))) |
|---|
| 304 | return rendered |
|---|
| 305 | |
|---|
| 306 | def _render_values(state): |
|---|
| 307 | fields = [] |
|---|
| 308 | for name, value in json.loads(state or '{}').iteritems(): |
|---|
| 309 | fields.append(name + ': ' + value) |
|---|
| 310 | return '; '.join(fields) |
|---|
| 311 | |
|---|