| [15153] | 1 | # -*- coding: utf-8 -*- |
|---|
| [17777] | 2 | # Copyright (c) 2016-2020 Cinc |
|---|
| 3 | # All rights reserved. |
|---|
| 4 | # |
|---|
| 5 | # Redistribution and use in source and binary forms, with or without |
|---|
| 6 | # modification, are permitted provided that the following conditions |
|---|
| 7 | # are met: |
|---|
| 8 | # 1. Redistributions of source code must retain the above copyright |
|---|
| 9 | # notice, this list of conditions and the following disclaimer. |
|---|
| 10 | # 2. Redistributions in binary form must reproduce the above copyright |
|---|
| 11 | # notice, this list of conditions and the following disclaimer in the |
|---|
| 12 | # documentation and/or other materials provided with the distribution. |
|---|
| 13 | # 3. The name of the author may not be used to endorse or promote products |
|---|
| 14 | # derived from this software without specific prior written permission. |
|---|
| 15 | # |
|---|
| 16 | # THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR |
|---|
| 17 | # IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES |
|---|
| 18 | # OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. |
|---|
| 19 | # IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, |
|---|
| 20 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT |
|---|
| 21 | # NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, |
|---|
| 22 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY |
|---|
| 23 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT |
|---|
| 24 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF |
|---|
| 25 | # THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
|---|
| [15153] | 26 | |
|---|
| 27 | __author__ = 'Cinc' |
|---|
| 28 | |
|---|
| 29 | from trac.core import implements, TracError |
|---|
| 30 | from trac.resource import ResourceNotFound |
|---|
| 31 | from trac.ticket.admin import MilestoneAdminPanel |
|---|
| 32 | from trac.ticket.model import Milestone |
|---|
| 33 | from trac.util.datefmt import parse_date |
|---|
| [16172] | 34 | from trac.util.translation import _ |
|---|
| [15153] | 35 | from trac.web.api import IRequestFilter |
|---|
| [17778] | 36 | from trac.web.chrome import Chrome, ITemplateProvider, \ |
|---|
| [16623] | 37 | add_script, add_script_data, add_notice, add_stylesheet |
|---|
| [15153] | 38 | from trac.wiki import WikiSystem, WikiPage |
|---|
| 39 | from string import Template |
|---|
| 40 | |
|---|
| [17778] | 41 | class JTransformer(object): |
|---|
| 42 | """Class modelled after the Genshi Transformer class. Instead of an xpath it uses a |
|---|
| 43 | selector usable by jQuery. |
|---|
| 44 | You may use cssify (https://github.com/santiycr/cssify) to convert a xpath to a selector.""" |
|---|
| [15438] | 45 | |
|---|
| [17778] | 46 | def __init__(self, xpath): |
|---|
| 47 | self.css = xpath # xpath must be a css selector for jQuery |
|---|
| 48 | |
|---|
| 49 | def after(self, html): |
|---|
| 50 | return {'pos': 'after', 'css': self.css, 'html': html} |
|---|
| 51 | |
|---|
| 52 | def before(self, html): |
|---|
| 53 | return {'pos': 'before', 'css': self.css, 'html': html} |
|---|
| 54 | |
|---|
| 55 | |
|---|
| [15153] | 56 | class MilestoneTemplatePlugin(MilestoneAdminPanel): |
|---|
| 57 | """Use templates when creating milestones. |
|---|
| 58 | |
|---|
| [15438] | 59 | Any wiki page with a name starting with ''!MilestoneTemplates/'' can be used as a template. This works in a |
|---|
| [15153] | 60 | similar way as wiki templates work (see PageTemplates). |
|---|
| 61 | |
|---|
| 62 | Examples for milestone templates: |
|---|
| 63 | |
|---|
| [15438] | 64 | * !MilestoneTemplates/MsTemplate |
|---|
| 65 | * !MilestoneTemplates/MyMilestoneTemplate |
|---|
| [15153] | 66 | |
|---|
| 67 | Milestone template may use the variable ''$MILESTONE'' for inserting the chosen milestone name into the |
|---|
| 68 | description. This may be useful if the template contains a TracQuery with milestone parameter. |
|---|
| 69 | |
|---|
| 70 | {{{ |
|---|
| 71 | [[TicketQuery(max=5,status=closed,milestone=$MILESTONE,format=table,col=resolution|summary|milestone)]] |
|---|
| 72 | }}} |
|---|
| 73 | """ |
|---|
| 74 | |
|---|
| [17778] | 75 | implements(IRequestFilter, ITemplateProvider) |
|---|
| [15153] | 76 | |
|---|
| 77 | MILESTONE_TEMPLATES_PREFIX = u'MilestoneTemplates/' |
|---|
| 78 | |
|---|
| [17777] | 79 | admin_page_template = u"""<div class="field"> |
|---|
| [15153] | 80 | <label>Template: |
|---|
| 81 | <select id="ms-templates" name="template"> |
|---|
| 82 | <option value="">(blank)</option> |
|---|
| [17777] | 83 | {options} |
|---|
| [15153] | 84 | </select> |
|---|
| 85 | <span class="hint">For Milestone description</span> |
|---|
| 86 | </label> |
|---|
| [17777] | 87 | </div>""" |
|---|
| 88 | admin_option_tmpl = u"""<option value="{templ}"> |
|---|
| 89 | {templ} |
|---|
| 90 | </option>""" |
|---|
| 91 | |
|---|
| 92 | milestone_page_template = u"""<div class="field"> |
|---|
| [15153] | 93 | <p>Or use a template for the description:</p> |
|---|
| 94 | <label>Template: |
|---|
| 95 | <select id="ms-templates" name="template"> |
|---|
| [17777] | 96 | <option value=""{sel}>(Use given Description)</option> |
|---|
| 97 | {options} |
|---|
| [15153] | 98 | </select> |
|---|
| 99 | <span class="hint">The description will be replaced with the template contents on submit.</span> |
|---|
| 100 | </label> |
|---|
| [17777] | 101 | </div>""" |
|---|
| 102 | milestone_option_tmpl = u"""<option value="{templ}"{sel}> |
|---|
| 103 | {templ} |
|---|
| 104 | </option>""" |
|---|
| 105 | |
|---|
| [15438] | 106 | preview_tmpl = u""" |
|---|
| 107 | <div id="mschange" class="ticketdraft" style="display: none"> |
|---|
| 108 | <h3 id="ms-change-h3">Preview:</h3> |
|---|
| 109 | <div class="notes-preview comment searchable"> |
|---|
| 110 | </div> |
|---|
| 111 | </div> |
|---|
| 112 | """ |
|---|
| 113 | def __init__(self): |
|---|
| 114 | # Let our component handle the milestone stuff |
|---|
| 115 | if self.env.is_enabled(MilestoneAdminPanel): |
|---|
| 116 | self.env.disable_component(MilestoneAdminPanel) |
|---|
| [15153] | 117 | |
|---|
| [17778] | 118 | self.is_trac_1_2 = self.env.trac_version[:3] == '1.2' |
|---|
| 119 | |
|---|
| [15153] | 120 | def render_admin_panel(self, req, cat, page, version): |
|---|
| 121 | req.perm.require('MILESTONE_VIEW') |
|---|
| 122 | |
|---|
| 123 | templ = req.args.get('template', '') |
|---|
| 124 | if req.method == 'POST' and templ: |
|---|
| 125 | if req.args.get('add') and req.args.get('name'): |
|---|
| 126 | req.perm.require('MILESTONE_CREATE') |
|---|
| 127 | name = req.args.get('name') |
|---|
| 128 | try: |
|---|
| 129 | mil = Milestone(self.env, name=name) |
|---|
| 130 | except ResourceNotFound: |
|---|
| 131 | mil = Milestone(self.env) |
|---|
| 132 | mil.name = name |
|---|
| 133 | mil.description = self.get_description_from_template(req, templ, name) |
|---|
| 134 | if req.args.get('duedate'): |
|---|
| 135 | mil.due = parse_date(req.args.get('duedate'), |
|---|
| 136 | req.tz, 'datetime') |
|---|
| 137 | mil.insert() |
|---|
| 138 | add_notice(req, _(u'The milestone "%(name)s" has been ' |
|---|
| 139 | u'added.', name=name)) |
|---|
| 140 | req.redirect(req.href.admin(cat, page)) |
|---|
| 141 | else: |
|---|
| 142 | if mil.name is None: |
|---|
| 143 | raise TracError(_(u'Invalid milestone name.')) |
|---|
| 144 | raise TracError(_(u'Milestone %(name)s already exists.', |
|---|
| 145 | name=name)) |
|---|
| 146 | return super(MilestoneAdminPanel, self).render_admin_panel(req, cat, page, version) |
|---|
| 147 | |
|---|
| 148 | def get_description_from_template(self, req, template, ms_name): |
|---|
| 149 | """Get template text from wiki and replace $MILESTONE with given milestone name |
|---|
| 150 | |
|---|
| 151 | :param req: Request object |
|---|
| 152 | :param template: template name to be used. This is a wiki page name. |
|---|
| 153 | :param ms_name: milestone name chosen by the user |
|---|
| 154 | |
|---|
| 155 | :return |
|---|
| 156 | """ |
|---|
| 157 | template_page = WikiPage(self.env, self.MILESTONE_TEMPLATES_PREFIX+template) |
|---|
| 158 | if template_page and template_page.exists and \ |
|---|
| [16623] | 159 | 'WIKI_VIEW' in req.perm(template_page.resource): |
|---|
| [15153] | 160 | return Template(template_page.text).safe_substitute(MILESTONE=ms_name) |
|---|
| 161 | return u"" |
|---|
| 162 | |
|---|
| [16623] | 163 | def _add_preview(self, req): |
|---|
| 164 | Chrome(self.env).add_auto_preview(req) |
|---|
| 165 | scr_data = {'ms_preview_renderer': req.href.wiki_render()} |
|---|
| [15438] | 166 | add_script_data(req, scr_data) |
|---|
| 167 | add_script(req, 'mstemplate/js/ms_preview.js') |
|---|
| 168 | add_stylesheet(req, 'common/css/ticket.css') |
|---|
| 169 | add_stylesheet(req, 'mstemplate/css/ms_preview.css') |
|---|
| 170 | |
|---|
| [15153] | 171 | def get_milestone_templates(self, req): |
|---|
| 172 | """Get milestone templates from wiki. You need WIKI_VIEW oermission to use templates""" |
|---|
| 173 | prefix = self.MILESTONE_TEMPLATES_PREFIX |
|---|
| 174 | ws = WikiSystem(self.env) |
|---|
| 175 | templates = [template[len(prefix):] |
|---|
| 176 | for template in ws.get_pages(prefix) |
|---|
| 177 | if 'WIKI_VIEW' in req.perm('wiki', template)] |
|---|
| 178 | return templates |
|---|
| 179 | |
|---|
| [17777] | 180 | def create_admin_page_select_ctrl(self, templates): |
|---|
| 181 | """Create a select control to be added to 'Add milestone' page in the admin area. |
|---|
| [15153] | 182 | |
|---|
| [17777] | 183 | :param templates: list of templates (wiki page names) |
|---|
| 184 | :return <div> tag holding a select control with label (unicode) |
|---|
| 185 | """ |
|---|
| 186 | tmpl = self.admin_page_template |
|---|
| 187 | opt = '' |
|---|
| 188 | for item in templates: |
|---|
| 189 | opt += self.admin_option_tmpl.format(templ=item) |
|---|
| 190 | return tmpl.format(options=opt) |
|---|
| [15153] | 191 | |
|---|
| [17777] | 192 | def create_milestone_page_select_ctrl(self, templates, cur_sel=None): |
|---|
| 193 | """Create a select control to be added to 'Add milestone' page or 'Edit milestone' page. USed when creating |
|---|
| 194 | milestones from the Roadmap page. |
|---|
| 195 | |
|---|
| 196 | :param templates: list of templates (wiki page names) |
|---|
| 197 | :param cur_sel: template selected by the user or None if using description field. Note that this is always |
|---|
| 198 | None for admin page |
|---|
| 199 | :return <div> tag holding a select control with label (unicode) |
|---|
| [15153] | 200 | """ |
|---|
| [17777] | 201 | tmpl = self.milestone_page_template |
|---|
| 202 | opt = '' |
|---|
| 203 | for item in templates: |
|---|
| 204 | opt += self.milestone_option_tmpl.format(templ=item, sel=' selected="selected"' if cur_sel == item else '') |
|---|
| 205 | return tmpl.format(sel=' selected="selected"' if not cur_sel else '', options=opt) |
|---|
| [15153] | 206 | |
|---|
| 207 | # IRequestFilter methods |
|---|
| 208 | |
|---|
| 209 | def pre_process_request(self, req, handler): |
|---|
| [16623] | 210 | """Add the template contents as description when adding from the |
|---|
| 211 | roadmap page. |
|---|
| 212 | """ |
|---|
| [15153] | 213 | # Fill milestone description with contents of template. |
|---|
| 214 | if req.method == 'POST' and self._is_valid_request(req): |
|---|
| 215 | template = req.args.get('template') |
|---|
| 216 | if req.args.get('add') and template: |
|---|
| 217 | # The name of the milestone is given as a parameter to the template |
|---|
| 218 | req.args[u'description'] = self.get_description_from_template(req, template, req.args.get('name')) |
|---|
| 219 | return handler |
|---|
| 220 | |
|---|
| 221 | @staticmethod |
|---|
| 222 | def _is_valid_request(req): |
|---|
| 223 | """Check request for correct path and valid form token""" |
|---|
| [16623] | 224 | if req.path_info.startswith('/milestone') and \ |
|---|
| 225 | req.args.get('__FORM_TOKEN') == req.form_token: |
|---|
| [15153] | 226 | return True |
|---|
| 227 | return False |
|---|
| 228 | |
|---|
| 229 | def post_process_request(self, req, template, data, content_type): |
|---|
| [17778] | 230 | filter_list = [] |
|---|
| 231 | if template and data: |
|---|
| 232 | self.filter_html(req, template, data, filter_list) |
|---|
| 233 | if filter_list: |
|---|
| 234 | add_script_data(req, {'ms_filter': filter_list}) |
|---|
| 235 | add_script(req, 'mstemplate/js/ms_add_select.js') |
|---|
| 236 | |
|---|
| [15153] | 237 | return template, data, content_type |
|---|
| [15438] | 238 | |
|---|
| [17778] | 239 | def filter_html(self, req, filename, data, filter_lst): |
|---|
| 240 | """Create filter information for Javascript. The filters may specify some html to be appended or prepended |
|---|
| 241 | to DOM elements using jQuery $().before() and $().after() statements. A separate script file takes |
|---|
| 242 | the filter data and executes jQuery code.""" |
|---|
| 243 | |
|---|
| 244 | path = req.path_info.split('/') |
|---|
| 245 | if filename == 'admin_milestones.html': |
|---|
| 246 | # Milestone creation from admin page |
|---|
| 247 | view = data.get('view') |
|---|
| 248 | if view == 'list': |
|---|
| 249 | templates = self.get_milestone_templates(req) |
|---|
| 250 | if templates: |
|---|
| 251 | # xpath: //form[@id="addmilestone"]//div[@class="buttons"] |
|---|
| 252 | xform = JTransformer('form#addmilestone div.buttons') |
|---|
| 253 | filter_lst.append(xform.before(self.create_admin_page_select_ctrl(templates))) |
|---|
| 254 | elif view == 'detail': |
|---|
| 255 | if self.is_trac_1_2: |
|---|
| 256 | # Add preview div |
|---|
| 257 | self._add_preview(req) |
|---|
| 258 | # xpath: //form[@id="edit"]//textarea |
|---|
| 259 | xform = JTransformer('form#edit textarea') |
|---|
| 260 | filter_lst.append(xform.after(self.preview_tmpl)) |
|---|
| 261 | elif len(path) > 1 and path[1] == 'milestone': |
|---|
| 262 | templates = self.get_milestone_templates(req) |
|---|
| 263 | action = req.args.get('action') |
|---|
| 264 | |
|---|
| 265 | if templates and action == 'new': |
|---|
| 266 | # Milestone creation from roadmap page |
|---|
| 267 | # xpath: //form[@id="edit"]//div[contains(@class, "description")] |
|---|
| 268 | xform = JTransformer('form#edit div[class*=description]') |
|---|
| 269 | filter_lst.append(xform.after(self.create_milestone_page_select_ctrl(templates))) |
|---|
| 270 | if self.is_trac_1_2: |
|---|
| 271 | # Preview |
|---|
| 272 | self._add_preview(req) |
|---|
| 273 | filter_lst.append(xform.after(self.preview_tmpl)) |
|---|
| 274 | |
|---|
| 275 | elif templates and action == 'edit': |
|---|
| 276 | self._add_preview(req) |
|---|
| 277 | # xpath: //form[@id="edit"]//textarea |
|---|
| 278 | xform = JTransformer('form#edit textarea') |
|---|
| 279 | if req.method == "POST": |
|---|
| 280 | # Milestone creation from roadmap page. Duplicate name redirected to edit page. |
|---|
| 281 | filter_lst.append(xform.after(self.create_milestone_page_select_ctrl(templates, |
|---|
| 282 | req.args.get('template', None) |
|---|
| 283 | ))) |
|---|
| 284 | if self.is_trac_1_2: |
|---|
| 285 | # Preview area |
|---|
| 286 | self._add_preview(req) |
|---|
| 287 | filter_lst.append(xform.after(self.preview_tmpl)) |
|---|
| 288 | return filter_lst |
|---|
| 289 | |
|---|
| [15438] | 290 | # ITemplateProvider methods |
|---|
| 291 | |
|---|
| 292 | def get_templates_dirs(self): |
|---|
| 293 | return [] |
|---|
| 294 | |
|---|
| 295 | def get_htdocs_dirs(self): |
|---|
| 296 | from pkg_resources import resource_filename |
|---|
| [16172] | 297 | return [('mstemplate', resource_filename(__name__, 'htdocs'))] |
|---|