| 1 | # -*- coding: utf-8 -*- |
|---|
| 2 | # |
|---|
| 3 | # Copyright (C) 2015-2021 Cinc |
|---|
| 4 | # |
|---|
| 5 | # License: 3-clause BSD |
|---|
| 6 | # |
|---|
| 7 | from pkg_resources import get_distribution, parse_version, resource_filename |
|---|
| 8 | from simplemultiproject.api import IRoadmapDataProvider |
|---|
| 9 | from simplemultiproject.compat import JTransformer |
|---|
| 10 | from simplemultiproject.model import Project |
|---|
| 11 | from simplemultiproject.permission import PERM_TEMPLATE, SmpPermissionPolicy |
|---|
| 12 | from simplemultiproject.session import get_project_filter_settings, \ |
|---|
| 13 | get_filter_settings |
|---|
| 14 | from simplemultiproject.smp_model import SmpMilestone, SmpVersion |
|---|
| 15 | from trac.config import OrderedExtensionsOption |
|---|
| 16 | from trac.core import * |
|---|
| 17 | from trac.util.translation import _ |
|---|
| 18 | from trac.web.api import IRequestFilter |
|---|
| 19 | from trac.web.chrome import add_script, add_script_data, add_stylesheet, Chrome, ITemplateProvider |
|---|
| 20 | |
|---|
| 21 | |
|---|
| 22 | __all__ = ['SmpRoadmapModule'] |
|---|
| 23 | |
|---|
| 24 | |
|---|
| 25 | class SmpRoadmapModule(Component): |
|---|
| 26 | """Manage roadmap page for projects. |
|---|
| 27 | |
|---|
| 28 | This component allows to filter roadmap entries by project. It is possible to group entries by project. |
|---|
| 29 | """ |
|---|
| 30 | |
|---|
| 31 | implements(IRequestFilter, IRoadmapDataProvider, ITemplateProvider) |
|---|
| 32 | |
|---|
| 33 | data_provider = OrderedExtensionsOption( |
|---|
| 34 | 'simple-multi-project', 'roadmap_data_provider', IRoadmapDataProvider, |
|---|
| 35 | default="", |
|---|
| 36 | doc="""Specify the order of plugins providing data for roadmap page""") |
|---|
| 37 | |
|---|
| 38 | data_filters = OrderedExtensionsOption( |
|---|
| 39 | 'simple-multi-project', 'roadmap_data_filters', IRoadmapDataProvider, |
|---|
| 40 | default="", |
|---|
| 41 | doc="""Specify the order of plugins filtering data for roadmap page""") |
|---|
| 42 | |
|---|
| 43 | # Api changes regarding Genshi started after v1.2. This not only affects templates but also fragment |
|---|
| 44 | # creation using trac.util.html.tag and friends |
|---|
| 45 | pre_1_3 = parse_version(get_distribution("Trac").version) < parse_version('1.3') |
|---|
| 46 | group_tmpl = None |
|---|
| 47 | |
|---|
| 48 | def __init__(self): |
|---|
| 49 | self.smp_milestone = SmpMilestone(self.env) |
|---|
| 50 | self.smp_version = SmpVersion(self.env) |
|---|
| 51 | |
|---|
| 52 | def _load_template(self): |
|---|
| 53 | chrome = Chrome(self.env) |
|---|
| 54 | if self.pre_1_3: |
|---|
| 55 | self.group_tmpl = chrome.load_template("smp_roadmap.html", None) |
|---|
| 56 | else: |
|---|
| 57 | self.group_tmpl = chrome.load_template("smp_roadmap_jinja.html", False) |
|---|
| 58 | |
|---|
| 59 | # Unused preference item |
|---|
| 60 | # if get_filter_settings(req, 'roadmap', 'smp_hideprojdesc'): |
|---|
| 61 | # hide.append('projectdescription') |
|---|
| 62 | # prefs = """<div> |
|---|
| 63 | # <input type="checkbox" id="hideprojectdescription" name="smp_hideprojdesc" value="1" |
|---|
| 64 | # {hideprjdescchk} /> |
|---|
| 65 | # <label for="hideprojectdescription">{hideprjdesclabel}</label> |
|---|
| 66 | # </div>""" |
|---|
| 67 | |
|---|
| 68 | def create_hide_milestone_item(self, req): |
|---|
| 69 | prefs = """ |
|---|
| 70 | <div> |
|---|
| 71 | <input type="checkbox" id="hidemilestones" name="smp_hidemilestones" value="1" |
|---|
| 72 | {hidemschk} /> |
|---|
| 73 | <label for="hidemilestones">{hidemslabel}</label> |
|---|
| 74 | </div>""" |
|---|
| 75 | hidemschk = ' checked="checked"' if get_filter_settings(req, 'roadmap', 'smp_hidemilestones') else '', |
|---|
| 76 | return prefs.format(hidemslabel=_('Hide milestones'), hidemschk=hidemschk) |
|---|
| 77 | |
|---|
| 78 | # IRequestFilter methods |
|---|
| 79 | |
|---|
| 80 | def pre_process_request(self, req, handler): |
|---|
| 81 | return handler |
|---|
| 82 | |
|---|
| 83 | def post_process_request(self, req, template, data, content_type): |
|---|
| 84 | """Call extensions adding data or filtering data in the |
|---|
| 85 | appropriate order. |
|---|
| 86 | """ |
|---|
| 87 | if data: |
|---|
| 88 | path_elms = req.path_info.split('/') |
|---|
| 89 | if len(path_elms) > 1 and path_elms[1] == 'roadmap': |
|---|
| 90 | add_stylesheet(req, "simplemultiproject/css/simplemultiproject.css") |
|---|
| 91 | |
|---|
| 92 | for provider in self.data_provider: |
|---|
| 93 | data = provider.add_data(req, data) |
|---|
| 94 | |
|---|
| 95 | for provider in self.data_filters: |
|---|
| 96 | data = provider.filter_data(req, data) |
|---|
| 97 | |
|---|
| 98 | # Add project table to preferences on roadmap page |
|---|
| 99 | # xpath: //form[@id="prefs"] |
|---|
| 100 | xform = JTransformer('form#prefs') |
|---|
| 101 | filter_list = [xform.prepend(create_proj_table(self, req, 'roadmap'))] |
|---|
| 102 | |
|---|
| 103 | # Add 'group' check box |
|---|
| 104 | group_proj = get_filter_settings(req, 'roadmap', 'smp_group') |
|---|
| 105 | chked = ' checked="1"' if group_proj else '' |
|---|
| 106 | # xpath: //form[@id="prefs"] |
|---|
| 107 | xform = JTransformer('form#prefs') |
|---|
| 108 | filter_list.append(xform.prepend(u'<div>' |
|---|
| 109 | u'<input type="hidden" name="smp_update" value="group" />' |
|---|
| 110 | u'<input type="checkbox" id="groupbyproject" name="smp_group" ' |
|---|
| 111 | u'value="1"%s/>' |
|---|
| 112 | u'<label for="groupbyproject">Group by project</label></div><br />' % |
|---|
| 113 | chked)) |
|---|
| 114 | # Add preference checkbox |
|---|
| 115 | xform = JTransformer('#prefs div.buttons') |
|---|
| 116 | filter_list.append(xform.before(self.create_hide_milestone_item(req))) |
|---|
| 117 | |
|---|
| 118 | if chked: |
|---|
| 119 | if not self.group_tmpl: |
|---|
| 120 | self._load_template() |
|---|
| 121 | # Add new grouped content |
|---|
| 122 | # xpath: //div[@class="milestones"] |
|---|
| 123 | xform = JTransformer('div.milestones') |
|---|
| 124 | chrome = Chrome(self.env) |
|---|
| 125 | data = chrome.populate_data(req, data) |
|---|
| 126 | if self.pre_1_3: |
|---|
| 127 | filter_list.append(xform.before(self.group_tmpl.generate(**data).render('html'))) |
|---|
| 128 | else: |
|---|
| 129 | filter_list.append(xform.before(chrome.render_template_string(self.group_tmpl, data))) |
|---|
| 130 | filter_list.append(xform.remove()) # Remove default milestone entries |
|---|
| 131 | |
|---|
| 132 | add_script_data(req, {'smp_filter': filter_list}) |
|---|
| 133 | add_script(req, 'simplemultiproject/js/jtransform.js') |
|---|
| 134 | |
|---|
| 135 | return template, data, content_type |
|---|
| 136 | |
|---|
| 137 | # IRoadmapDataProvider |
|---|
| 138 | |
|---|
| 139 | def add_projects_to_dict(self, req, data): |
|---|
| 140 | """Add allowed projects to the data dict. |
|---|
| 141 | |
|---|
| 142 | :param req: a Request object |
|---|
| 143 | :param data: dictionary holding data for template |
|---|
| 144 | :return None |
|---|
| 145 | |
|---|
| 146 | This checks if the user has access to the projects. If not a project won't be added to the list of available |
|---|
| 147 | projects. Closed projects are ignored, too. |
|---|
| 148 | """ |
|---|
| 149 | usr_projects = SmpPermissionPolicy.active_projects_by_permission(req, Project.select(self.env)) |
|---|
| 150 | data.update({'projects': usr_projects, |
|---|
| 151 | 'project_ids': [project.id for project in usr_projects]}) |
|---|
| 152 | |
|---|
| 153 | def add_project_info_to_milestones(self, data): |
|---|
| 154 | # Do the milestone updates |
|---|
| 155 | data['ms_without_prj'] = False |
|---|
| 156 | if data.get('milestones'): |
|---|
| 157 | # Add info about linked projects |
|---|
| 158 | for item in data.get('milestones'): |
|---|
| 159 | # Used in smp_roadmap.html to check if there is a ms - proj link |
|---|
| 160 | ids_for_ms = self.smp_milestone.get_project_ids_for_resource_item('milestone', item.name) |
|---|
| 161 | if not ids_for_ms: |
|---|
| 162 | item.id_project = [] # Milestones without a project are for all |
|---|
| 163 | data['ms_without_prj'] = True |
|---|
| 164 | else: |
|---|
| 165 | item.id_project = ids_for_ms |
|---|
| 166 | |
|---|
| 167 | def add_data(self, req, data): |
|---|
| 168 | # Get all projects user has access to. |
|---|
| 169 | self.add_projects_to_dict(req, data) |
|---|
| 170 | self.add_project_info_to_milestones(data) |
|---|
| 171 | # self.add_project_info_to_versions(data) |
|---|
| 172 | |
|---|
| 173 | return data |
|---|
| 174 | |
|---|
| 175 | def filter_data(self, req, data): |
|---|
| 176 | |
|---|
| 177 | if data and get_filter_settings(req, 'roadmap', 'smp_hidemilestones'): |
|---|
| 178 | data['milestones'] = [] |
|---|
| 179 | data['milestone_stats'] = [] |
|---|
| 180 | |
|---|
| 181 | filter_proj = get_project_filter_settings(req, 'roadmap', 'smp_projects', 'All') |
|---|
| 182 | if 'All' in filter_proj: |
|---|
| 183 | return data |
|---|
| 184 | |
|---|
| 185 | # Remove projects from dict which are not selected. The template will loop over this data. |
|---|
| 186 | if 'projects' in data: |
|---|
| 187 | filtered = [] |
|---|
| 188 | for project in data['projects']: |
|---|
| 189 | if project.name in filter_proj: |
|---|
| 190 | filtered.append(project) |
|---|
| 191 | data['projects'] = filtered |
|---|
| 192 | |
|---|
| 193 | if 'milestones' in data: |
|---|
| 194 | item_stats = data.get('milestone_stats') |
|---|
| 195 | filtered_items = [] |
|---|
| 196 | filtered_item_stats = [] |
|---|
| 197 | for idx, ms in enumerate(data['milestones']): |
|---|
| 198 | ms_proj = self.smp_milestone.get_project_names_for_item(ms.name) |
|---|
| 199 | # Milestones without linked projects are good for every project |
|---|
| 200 | if not ms_proj: |
|---|
| 201 | filtered_items.append(ms) |
|---|
| 202 | filtered_item_stats.append(item_stats[idx]) |
|---|
| 203 | else: |
|---|
| 204 | # List of project names |
|---|
| 205 | for name in ms_proj: |
|---|
| 206 | if name in filter_proj: |
|---|
| 207 | filtered_items.append(ms) |
|---|
| 208 | filtered_item_stats.append(item_stats[idx]) |
|---|
| 209 | break # Only add a milestone once |
|---|
| 210 | data['milestones'] = filtered_items |
|---|
| 211 | data['milestone_stats'] = filtered_item_stats |
|---|
| 212 | |
|---|
| 213 | # TODO: Is this still needed? |
|---|
| 214 | if 'versions' in data: |
|---|
| 215 | item_stats = data.get('version_stats') |
|---|
| 216 | filtered_items = [] |
|---|
| 217 | filtered_item_stats = [] |
|---|
| 218 | for idx, ms in enumerate(data['versions']): |
|---|
| 219 | ms_proj = self.smp_version.get_project_names_for_item(ms.name) |
|---|
| 220 | # Versions without linked projects are good for every project |
|---|
| 221 | if not ms_proj: |
|---|
| 222 | filtered_items.append(ms) |
|---|
| 223 | filtered_item_stats.append(item_stats[idx]) |
|---|
| 224 | else: |
|---|
| 225 | # List of project names |
|---|
| 226 | for name in ms_proj: |
|---|
| 227 | if name in filter_proj: |
|---|
| 228 | filtered_items.append(ms) |
|---|
| 229 | filtered_item_stats.append(item_stats[idx]) |
|---|
| 230 | break # Only add a version once |
|---|
| 231 | |
|---|
| 232 | data['versions'] = filtered_items |
|---|
| 233 | data['version_stats'] = filtered_item_stats |
|---|
| 234 | |
|---|
| 235 | return data |
|---|
| 236 | |
|---|
| 237 | # ITemplateProvider methods |
|---|
| 238 | |
|---|
| 239 | def get_templates_dirs(self): |
|---|
| 240 | self.log.info(resource_filename(__name__, 'templates')) |
|---|
| 241 | return [resource_filename(__name__, 'templates')] |
|---|
| 242 | |
|---|
| 243 | def get_htdocs_dirs(self): |
|---|
| 244 | return [('simplemultiproject', resource_filename(__name__, 'htdocs'))] |
|---|
| 245 | |
|---|
| 246 | |
|---|
| 247 | def div_from_projects(all_projects, filter_prj, size): |
|---|
| 248 | """Create the project select div for the preference pane on Roadmap and timeline page.""" |
|---|
| 249 | # Don't change indentation here without fixing the test cases |
|---|
| 250 | div_templ = u"""<div style="overflow:hidden;"> |
|---|
| 251 | <div> |
|---|
| 252 | <label>Filter Project:</label> |
|---|
| 253 | </div> |
|---|
| 254 | <div> |
|---|
| 255 | <input type="hidden" name="smp_update" value="filter"> |
|---|
| 256 | <select id="Filter-Projects" name="smp_projects" multiple size="{size}" style="overflow:auto;"> |
|---|
| 257 | <option value="All"{all_selected}>All</option> |
|---|
| 258 | {options} |
|---|
| 259 | </select> |
|---|
| 260 | </div> |
|---|
| 261 | <br> |
|---|
| 262 | </div>""" |
|---|
| 263 | option_tmpl = u"""<option value="{p_name}"{sel}> |
|---|
| 264 | {p_name} |
|---|
| 265 | </option>""" |
|---|
| 266 | |
|---|
| 267 | options = u"" |
|---|
| 268 | for item in all_projects: |
|---|
| 269 | sel = u' selected' if item.name in filter_prj else u'' |
|---|
| 270 | options += option_tmpl.format(p_name=item.name, sel=sel) |
|---|
| 271 | |
|---|
| 272 | return div_templ.format(size=size, all_selected='' if filter_prj else u' selected', options=options) |
|---|
| 273 | |
|---|
| 274 | |
|---|
| 275 | def create_proj_table(self, req, session_name='roadmap'): |
|---|
| 276 | """Create a select tag holding valid projects (means not closed) for |
|---|
| 277 | the current user. |
|---|
| 278 | |
|---|
| 279 | @param self: Component instance holding the Environment object |
|---|
| 280 | @param req : Trac request object |
|---|
| 281 | |
|---|
| 282 | @return DIV tag holding a project select control with label |
|---|
| 283 | """ |
|---|
| 284 | projects = Project.select(self.env) |
|---|
| 285 | filtered_projects = SmpPermissionPolicy.active_projects_by_permission(req, projects) |
|---|
| 286 | |
|---|
| 287 | if filtered_projects: |
|---|
| 288 | size = len(filtered_projects) + 1 # Account for 'All' option |
|---|
| 289 | else: |
|---|
| 290 | return u'<div><p>No projects defined.</p><br></div>' |
|---|
| 291 | |
|---|
| 292 | if size > 5: |
|---|
| 293 | size = 5 |
|---|
| 294 | |
|---|
| 295 | # list of currently selected projects. The info is stored in the request or session data |
|---|
| 296 | filter_prj = get_project_filter_settings(req, session_name, 'smp_projects', 'All') |
|---|
| 297 | if 'All' in filter_prj: |
|---|
| 298 | filter_prj = [] |
|---|
| 299 | |
|---|
| 300 | return div_from_projects(filtered_projects, filter_prj, size) |
|---|