| 1 | # -*- coding: utf-8 -*- |
|---|
| 2 | # |
|---|
| 3 | # Copyright (C) 2015 Cinc |
|---|
| 4 | # |
|---|
| 5 | # All rights reserved. |
|---|
| 6 | # |
|---|
| 7 | # This software is licensed as described in the file COPYING, which |
|---|
| 8 | # you should have received as part of this distribution. |
|---|
| 9 | |
|---|
| 10 | import io |
|---|
| 11 | import json |
|---|
| 12 | from ConfigParser import SafeConfigParser, ParsingError |
|---|
| 13 | from pkg_resources import resource_filename |
|---|
| 14 | |
|---|
| 15 | from genshi.output import HTMLSerializer |
|---|
| 16 | from trac.admin import IAdminPanelProvider |
|---|
| 17 | from trac.core import Component, implements |
|---|
| 18 | from trac.ticket.model import Type |
|---|
| 19 | from trac.ticket.default_workflow import get_workflow_config |
|---|
| 20 | from trac.util.html import html as tag |
|---|
| 21 | from trac.util.translation import _, dgettext |
|---|
| 22 | from trac.web.api import IRequestHandler |
|---|
| 23 | from trac.web.chrome import ITemplateProvider, add_script_data, add_script, \ |
|---|
| 24 | add_warning |
|---|
| 25 | |
|---|
| 26 | from workflow import get_workflow_config_by_type, parse_workflow_config |
|---|
| 27 | |
|---|
| 28 | |
|---|
| 29 | def get_workflow_actions_for_error(): |
|---|
| 30 | """This is a small workflow just showing an 'Error' state""" |
|---|
| 31 | txt = "? = Error -> Error" |
|---|
| 32 | return get_workflow_actions_from_text(txt, True) |
|---|
| 33 | |
|---|
| 34 | |
|---|
| 35 | def get_workflow_actions_from_text(wf_txt, is_error_wf=False): |
|---|
| 36 | """Parse workflow actions in a text snippet |
|---|
| 37 | Note that no section header [ticket-workflow_xxx] must be provided""" |
|---|
| 38 | |
|---|
| 39 | def get_line_txt(txt): |
|---|
| 40 | try: |
|---|
| 41 | msg = txt.split(']:')[1] |
|---|
| 42 | except IndexError: |
|---|
| 43 | msg = txt |
|---|
| 44 | |
|---|
| 45 | return msg |
|---|
| 46 | |
|---|
| 47 | error_txt = "" |
|---|
| 48 | config = SafeConfigParser() |
|---|
| 49 | try: |
|---|
| 50 | config.readfp( |
|---|
| 51 | io.BytesIO("[ticket-workflow]\n" + wf_txt.encode('utf-8'))) |
|---|
| 52 | raw_actions = [(key, config.get('ticket-workflow', key)) for key in |
|---|
| 53 | config.options('ticket-workflow')] |
|---|
| 54 | except ParsingError, err: |
|---|
| 55 | error_txt = u"Parsing error: %s" % get_line_txt( |
|---|
| 56 | unicode(err).replace('\\n', '').replace('<???>', '')) |
|---|
| 57 | |
|---|
| 58 | if not is_error_wf: # prevent recursion |
|---|
| 59 | actions, tmp = get_workflow_actions_for_error() |
|---|
| 60 | else: |
|---|
| 61 | actions = [] |
|---|
| 62 | return actions, error_txt |
|---|
| 63 | |
|---|
| 64 | try: |
|---|
| 65 | actions = parse_workflow_config(raw_actions) |
|---|
| 66 | except BaseException, err: |
|---|
| 67 | error_txt = unicode(err) |
|---|
| 68 | if not is_error_wf: # prevent recursion |
|---|
| 69 | actions, tmp = get_workflow_actions_for_error() |
|---|
| 70 | else: |
|---|
| 71 | actions = [] |
|---|
| 72 | |
|---|
| 73 | return actions, error_txt |
|---|
| 74 | |
|---|
| 75 | |
|---|
| 76 | def create_workflow_name(name): |
|---|
| 77 | if name == 'default': |
|---|
| 78 | return 'ticket-workflow' |
|---|
| 79 | else: |
|---|
| 80 | return 'ticket-workflow-%s' % name |
|---|
| 81 | |
|---|
| 82 | |
|---|
| 83 | # This function is taken from WorkflowMacro and modified for the multiple |
|---|
| 84 | # workflow display |
|---|
| 85 | def create_graph_data(self, req, name=''): |
|---|
| 86 | txt = req.args.get('text') |
|---|
| 87 | if txt: |
|---|
| 88 | actions, error_txt = get_workflow_actions_from_text(txt) |
|---|
| 89 | if error_txt: |
|---|
| 90 | t = error_txt |
|---|
| 91 | else: |
|---|
| 92 | t = "New custom workflow (not saved)" |
|---|
| 93 | if not actions: |
|---|
| 94 | # We should never end here... |
|---|
| 95 | actions = get_workflow_config(self.config) |
|---|
| 96 | t = "Custom workflow is broken. Showing default workflow" |
|---|
| 97 | else: |
|---|
| 98 | t = u"" |
|---|
| 99 | print(name) |
|---|
| 100 | if name == 'default': |
|---|
| 101 | actions = get_workflow_config(self.config) |
|---|
| 102 | else: |
|---|
| 103 | actions = get_workflow_config_by_type(self.config, name) |
|---|
| 104 | |
|---|
| 105 | states = list(set( |
|---|
| 106 | [state for action in actions.itervalues() |
|---|
| 107 | for state in action['oldstates']] + [action['newstate'] for action in |
|---|
| 108 | actions.itervalues()])) |
|---|
| 109 | |
|---|
| 110 | action_labels = [action_info['name'] for action_name, action_info in |
|---|
| 111 | actions.items()] |
|---|
| 112 | action_names = actions.keys() |
|---|
| 113 | |
|---|
| 114 | edges = [] |
|---|
| 115 | for name, action in actions.items(): |
|---|
| 116 | new_index = states.index(action['newstate']) |
|---|
| 117 | name_index = action_names.index(name) |
|---|
| 118 | for old_state in action['oldstates']: |
|---|
| 119 | old_index = states.index(old_state) |
|---|
| 120 | edges.append((old_index, new_index, name_index)) |
|---|
| 121 | |
|---|
| 122 | args = {} |
|---|
| 123 | width = args.get('width', 800) |
|---|
| 124 | height = args.get('height', 600) |
|---|
| 125 | graph = {'nodes': states, 'actions': action_labels, 'edges': edges, |
|---|
| 126 | 'width': width, 'height': height} |
|---|
| 127 | graph_id = '%012x' % id(self) # id(graph) |
|---|
| 128 | |
|---|
| 129 | scr_data = {'graph_%s' % graph_id: graph} |
|---|
| 130 | |
|---|
| 131 | res = tag( |
|---|
| 132 | tag.p(t), |
|---|
| 133 | tag.div('', class_='multiple-workflow-graph trac-noscript', |
|---|
| 134 | id='trac-workflow-graph-%s' % graph_id, |
|---|
| 135 | style="display:inline-block;width:%spx;height:%spx" % |
|---|
| 136 | (width, height)), |
|---|
| 137 | tag.noscript( |
|---|
| 138 | tag.div(_("Enable JavaScript to display the workflow graph."), |
|---|
| 139 | class_='system-message'))) |
|---|
| 140 | return res, scr_data, graph |
|---|
| 141 | |
|---|
| 142 | |
|---|
| 143 | def workflow_graph(self, req, name): |
|---|
| 144 | res, scr_data, graph = create_graph_data(self, req, name) |
|---|
| 145 | |
|---|
| 146 | # add_script(req, 'multipleworkflow/js/excanvas.js', ie_if='IE') |
|---|
| 147 | add_script(req, 'multipleworkflow/js/workflow_graph.js') |
|---|
| 148 | add_script_data(req, scr_data) |
|---|
| 149 | |
|---|
| 150 | return res |
|---|
| 151 | |
|---|
| 152 | |
|---|
| 153 | def write_json_response(req, data_dict, httperror=200): |
|---|
| 154 | data = json.dumps(data_dict).encode('utf-8') |
|---|
| 155 | req.send_response(httperror) |
|---|
| 156 | req.send_header('Content-Type', 'application/json; charset=utf-8') |
|---|
| 157 | req.send_header('Content-Length', len(data)) |
|---|
| 158 | req.end_headers() |
|---|
| 159 | req.write(data) |
|---|
| 160 | |
|---|
| 161 | |
|---|
| 162 | class MultipleWorkflowAdminModule(Component): |
|---|
| 163 | """Implements the admin page for workflow editing. See 'Ticket System' |
|---|
| 164 | section. |
|---|
| 165 | """ |
|---|
| 166 | |
|---|
| 167 | implements(IAdminPanelProvider, ITemplateProvider, IRequestHandler) |
|---|
| 168 | |
|---|
| 169 | # IRequestHandler methods |
|---|
| 170 | |
|---|
| 171 | def match_request(self, req): |
|---|
| 172 | return req.path_info == '/multipleworkflow/workflow_render' |
|---|
| 173 | |
|---|
| 174 | def process_request(self, req): |
|---|
| 175 | req.perm.require('TICKET_ADMIN') |
|---|
| 176 | |
|---|
| 177 | div, scr_data, graph = create_graph_data(self, req) |
|---|
| 178 | rendered = "".join(HTMLSerializer()(div.generate())) |
|---|
| 179 | data = {'html': rendered.encode("utf-8"), 'graph_data': graph} |
|---|
| 180 | write_json_response(req, data) |
|---|
| 181 | |
|---|
| 182 | # IAdminPanelProvider methods |
|---|
| 183 | |
|---|
| 184 | def get_admin_panels(self, req): |
|---|
| 185 | if 'TICKET_ADMIN' in req.perm: |
|---|
| 186 | yield ('ticket', dgettext("messages", ("Ticket System")), |
|---|
| 187 | 'workflowadmin', _("Workflows")) |
|---|
| 188 | |
|---|
| 189 | def _get_all_types_with_workflow(self, to_upper=False): |
|---|
| 190 | """Returns a list of all ticket types with custom workflow. |
|---|
| 191 | |
|---|
| 192 | Note that a ticket type is not necessarily available during ticket |
|---|
| 193 | creation if it was deleted in the meantime. |
|---|
| 194 | """ |
|---|
| 195 | types = [] |
|---|
| 196 | for section in self.config.sections(): |
|---|
| 197 | if section.startswith('ticket-workflow-'): |
|---|
| 198 | if to_upper: |
|---|
| 199 | types.append(section[len('ticket-workflow-'):].upper()) |
|---|
| 200 | else: |
|---|
| 201 | types.append(section[len('ticket-workflow-'):]) |
|---|
| 202 | return types |
|---|
| 203 | |
|---|
| 204 | def render_admin_panel(self, req, cat, page, path_info): |
|---|
| 205 | req.perm.assert_permission('TICKET_ADMIN') |
|---|
| 206 | |
|---|
| 207 | if req.method == 'POST': |
|---|
| 208 | if req.args.get('add'): |
|---|
| 209 | cur_types = self._get_all_types_with_workflow(True) |
|---|
| 210 | name = req.args.get('name') |
|---|
| 211 | if name.upper() in cur_types: |
|---|
| 212 | add_warning(req, _( |
|---|
| 213 | "There is already a workflow for ticket type '%s'. " |
|---|
| 214 | "Note that upper/lowercase is ignored"), name) |
|---|
| 215 | else: |
|---|
| 216 | src_section = create_workflow_name(req.args.get('type')) |
|---|
| 217 | # Now copy the workflow |
|---|
| 218 | section = 'ticket-workflow-%s' % name |
|---|
| 219 | for key, val in self.config.options(src_section): |
|---|
| 220 | self.config.set(section, key, val) |
|---|
| 221 | self.config.save() |
|---|
| 222 | elif req.args.get('remove'): |
|---|
| 223 | workflow = 'ticket-workflow-%s' % req.args.get('sel') |
|---|
| 224 | for key, val in self.config.options(workflow): |
|---|
| 225 | self.config.remove(workflow, key) |
|---|
| 226 | self.config.save() |
|---|
| 227 | elif req.args.get('save'): |
|---|
| 228 | name = req.args.get('name', '') |
|---|
| 229 | if name: |
|---|
| 230 | section = 'ticket-workflow-%s' % name |
|---|
| 231 | else: |
|---|
| 232 | # If it's the default workflow the input is disabled and |
|---|
| 233 | # no value sent |
|---|
| 234 | section = 'ticket-workflow' |
|---|
| 235 | |
|---|
| 236 | # Change of workflow name. Remove old data from ini |
|---|
| 237 | if name and name != path_info: |
|---|
| 238 | old_section = 'ticket-workflow-%s' % path_info |
|---|
| 239 | for key, val in self.config.options(old_section): |
|---|
| 240 | self.config.remove(old_section, key) |
|---|
| 241 | |
|---|
| 242 | # Save new workflow |
|---|
| 243 | for key, val in self.config.options(section): |
|---|
| 244 | self.config.remove(section, key) |
|---|
| 245 | for line in req.args.get('workflow-actions').split('\n'): |
|---|
| 246 | try: |
|---|
| 247 | key, val = line.split('=') |
|---|
| 248 | self.config.set(section, key, val) |
|---|
| 249 | except ValueError: |
|---|
| 250 | # Empty line or missing val |
|---|
| 251 | pass |
|---|
| 252 | self.config.save() |
|---|
| 253 | |
|---|
| 254 | req.redirect(req.href.admin(cat, page)) |
|---|
| 255 | |
|---|
| 256 | # GET, show admin page |
|---|
| 257 | data = {'types': self._get_all_types_with_workflow(), |
|---|
| 258 | 'trac_types': [enum.name for enum in Type.select(self.env)]} |
|---|
| 259 | if not path_info: |
|---|
| 260 | data.update({'view': 'list', 'name': 'default'}) |
|---|
| 261 | else: |
|---|
| 262 | data.update({'view': 'detail', |
|---|
| 263 | 'name': path_info, |
|---|
| 264 | 'workflowgraph': workflow_graph(self, req, path_info)}) |
|---|
| 265 | if path_info == 'default': |
|---|
| 266 | data['workflow'] = ["%s = %s\n" % (key, val) for key, val in |
|---|
| 267 | self.config.options('ticket-workflow')] |
|---|
| 268 | else: |
|---|
| 269 | data['workflow'] = ["%s = %s\n" % (key, val) for key, val in |
|---|
| 270 | self.config.options('ticket-workflow-%s' % |
|---|
| 271 | path_info)] |
|---|
| 272 | add_script(req, 'common/js/resizer.js') |
|---|
| 273 | add_script_data(req, { |
|---|
| 274 | 'auto_preview_timeout': 2, |
|---|
| 275 | 'form_token': req.form_token, |
|---|
| 276 | 'trac_types': data['trac_types']}) |
|---|
| 277 | |
|---|
| 278 | return "multipleworkflowadmin.html", data |
|---|
| 279 | |
|---|
| 280 | # ITemplateProvider methods |
|---|
| 281 | |
|---|
| 282 | def get_htdocs_dirs(self): |
|---|
| 283 | return [('multipleworkflow', resource_filename(__name__, 'htdocs'))] |
|---|
| 284 | |
|---|
| 285 | def get_templates_dirs(self): |
|---|
| 286 | return [resource_filename(__name__, 'templates')] |
|---|