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