| 1 | # -*- coding: utf-8 -*- |
|---|
| 2 | # |
|---|
| 3 | # Copyright (C) 2016 Cinc |
|---|
| 4 | # All rights reserved. |
|---|
| 5 | # |
|---|
| 6 | # This software is licensed as described in the file COPYING.txt, which |
|---|
| 7 | # you should have received as part of this distribution. |
|---|
| 8 | # |
|---|
| 9 | # Author: Cinc |
|---|
| 10 | # |
|---|
| 11 | |
|---|
| 12 | from collections import namedtuple |
|---|
| 13 | from trac.admin import IAdminPanelProvider |
|---|
| 14 | from trac.config import ConfigSection |
|---|
| 15 | from trac.core import Component, implements |
|---|
| 16 | from trac.mimeview.api import Mimeview |
|---|
| 17 | from trac.util.translation import _ |
|---|
| 18 | from trac.versioncontrol.api import RepositoryManager |
|---|
| 19 | from trac.web.chrome import add_link, add_notice, add_script, add_script_data, add_stylesheet, add_warning, Chrome |
|---|
| 20 | from .model import ReviewDataModel, ReviewFileModel |
|---|
| 21 | from .repo import insert_project_files, repo_path_exists |
|---|
| 22 | |
|---|
| 23 | __author__ = 'Cinc' |
|---|
| 24 | __license__ = "BSD" |
|---|
| 25 | |
|---|
| 26 | |
|---|
| 27 | def get_prj_file_list(self, prj_name): |
|---|
| 28 | with self.env.db_query as db: |
|---|
| 29 | FileData = namedtuple('FileData', ['file_id', 'path', 'repo', 'hash', 'rev', 'changerev']) |
|---|
| 30 | files = [[FileData(*item), ''] for item in db("""SELECT f.file_id, f.path, |
|---|
| 31 | f.repo, f.hash, f.revision, f.changerevision |
|---|
| 32 | FROM peerreviewfile f |
|---|
| 33 | WHERE f.project = %s ORDER BY f.path |
|---|
| 34 | """, (prj_name,))] |
|---|
| 35 | approved_hashes = [item[0] for item in db("SELECT a.hash FROM peerreviewfile AS a WHERE status = 'approved'")] |
|---|
| 36 | for item in files: |
|---|
| 37 | if item[0].hash in approved_hashes: |
|---|
| 38 | item[1] = 'Approved' |
|---|
| 39 | return files |
|---|
| 40 | |
|---|
| 41 | |
|---|
| 42 | class PeerReviewFileAdmin(Component): |
|---|
| 43 | """Admin panel to specify files belonging to a project. |
|---|
| 44 | |
|---|
| 45 | [[BR]] |
|---|
| 46 | You may define a project identifier and a root folder from the repository holding all the files |
|---|
| 47 | of a project using this admin panel. When saving the information all the files in the folder hierarchy are hashed |
|---|
| 48 | and file name, revision, hash and project name are inserted in the database. |
|---|
| 49 | |
|---|
| 50 | Using the file information it is possible to create reports (see TracReports for more information) like which |
|---|
| 51 | files may need a review and more. |
|---|
| 52 | """ |
|---|
| 53 | implements(IAdminPanelProvider) |
|---|
| 54 | |
|---|
| 55 | external_map_section = ConfigSection('peerreview:externals', |
|---|
| 56 | """It is possible to create a list of project files to check against files approved during a code review. |
|---|
| 57 | A root directory may be selected which contains the source files. While traversing the tree {{{svn:esternal}}} |
|---|
| 58 | information is taken into account and all linked directories are also traversed. |
|---|
| 59 | |
|---|
| 60 | It is possible to have virtual repositories in Trac pointing to different parts of a bigger Subversion |
|---|
| 61 | repository. To make sure externals are properly mapped path information may be provided in the section |
|---|
| 62 | {{{[peerreview:externals]}}} of the TracIni. Note that Subversion 1.5 server root relative URLs supported |
|---|
| 63 | (like {{{/foo/bar/src}}}) but not other relative URLs. |
|---|
| 64 | |
|---|
| 65 | Example: |
|---|
| 66 | {{{ |
|---|
| 67 | #!ini |
|---|
| 68 | [peerreview:externals] |
|---|
| 69 | 1 = /svn/repos1/src_branch1/foo /src_branch1/foo Repo-Src1 |
|---|
| 70 | 2 = /svn/repos1/src_branch2/bar /src_branch2/bar Repo-Src2 |
|---|
| 71 | 3 = /svn/repos1/src_branch3/baz /src_branch3/baz Repo-Src3 |
|---|
| 72 | 4 = http://server/svn/repos1/src_branch3 /src_branch3 Repo-Src3 |
|---|
| 73 | }}} |
|---|
| 74 | |
|---|
| 75 | With the above, the |
|---|
| 76 | `/svn/repos1/src_branch1/foo` external will |
|---|
| 77 | be mapped to `/src_branch1/foo` in the repository {{{Repo-Src1}}}. |
|---|
| 78 | |
|---|
| 79 | You only have to provide the common path prefix here. The remainder of the external path will automatically |
|---|
| 80 | appended thus {{{/svn/repos1/src_branch1/foo/dir1/dir2/dir3}}} becomes {{{/src_branch1/foo/dir1/dir2/dir3}}}. |
|---|
| 81 | """) |
|---|
| 82 | |
|---|
| 83 | # IAdminPanelProvider methods |
|---|
| 84 | |
|---|
| 85 | def __init__(self): |
|---|
| 86 | self._externals_map = {} |
|---|
| 87 | |
|---|
| 88 | def get_admin_panels(self, req): |
|---|
| 89 | if 'CODE_REVIEW_DEV' in req.perm: |
|---|
| 90 | yield ('codereview', 'Code review', 'projectfiles', 'Project Files') |
|---|
| 91 | |
|---|
| 92 | def render_admin_panel(self, req, cat, page, path_info): |
|---|
| 93 | |
|---|
| 94 | def remove_project_info(rem_name): |
|---|
| 95 | # Remove project name info |
|---|
| 96 | rev_data = ReviewDataModel(self.env) |
|---|
| 97 | rev_data.clear_props() |
|---|
| 98 | rev_data['data'] = rem_name |
|---|
| 99 | rev_data['data_key'] = 'name' |
|---|
| 100 | for item in rev_data.list_matching_objects(): |
|---|
| 101 | item.delete() |
|---|
| 102 | # Remove info about project like rootfolder, extensions, revision, repo |
|---|
| 103 | rev_data = ReviewDataModel(self.env) |
|---|
| 104 | rev_data.clear_props() |
|---|
| 105 | rev_data['data_key'] = rem_name |
|---|
| 106 | for item in rev_data.list_matching_objects(): |
|---|
| 107 | item.delete() |
|---|
| 108 | ReviewFileModel.delete_files_by_project_name(self.env, rem_name) |
|---|
| 109 | |
|---|
| 110 | def add_project_info(): |
|---|
| 111 | def _insert_project_info(type_, key_, val): |
|---|
| 112 | rev_data = ReviewDataModel(self.env) |
|---|
| 113 | rev_data['type'] = type_ |
|---|
| 114 | rev_data['data_key'] = key_ |
|---|
| 115 | rev_data['data'] = val |
|---|
| 116 | rev_data.insert() |
|---|
| 117 | _insert_project_info('fileproject', 'name', name) |
|---|
| 118 | _insert_project_info('rootfolder', name, rootfolder) |
|---|
| 119 | _insert_project_info('excludeext', name, exts) |
|---|
| 120 | _insert_project_info('excludepath', name, exclpath) |
|---|
| 121 | _insert_project_info('includeext', name, incl) |
|---|
| 122 | _insert_project_info('repo', name, reponame) |
|---|
| 123 | _insert_project_info('revision', name, rev) |
|---|
| 124 | _insert_project_info('follow_externals', name, follow_externals) |
|---|
| 125 | # Create option for DynamicVariables plugin |
|---|
| 126 | # Note: we need to get the updated project data here |
|---|
| 127 | prj_lst = sorted([key for key in ReviewDataModel.all_file_project_data(self.env)]) |
|---|
| 128 | if prj_lst: |
|---|
| 129 | self.config.set('dynvars', 'project_files.options', '|'.join(prj_lst)) |
|---|
| 130 | self.config.save() |
|---|
| 131 | |
|---|
| 132 | def create_ext_list(ext_str): |
|---|
| 133 | """Create a list of extensions from a string. |
|---|
| 134 | |
|---|
| 135 | Double ',', trailing ',' and empty extensions are filtered out. Extensions not starting with '.' |
|---|
| 136 | are ignored. |
|---|
| 137 | |
|---|
| 138 | @return: unfiltered extension list, filtered extension list |
|---|
| 139 | """ |
|---|
| 140 | if not ext_str: |
|---|
| 141 | return [], [] |
|---|
| 142 | # filter trailing ',', double ','and empty exts |
|---|
| 143 | ext_list = [ext.strip() for ext in ext_str.split(',') if ext.strip()] |
|---|
| 144 | return ext_list, [ext.lower() for ext in ext_list if ext[0] == '.'] |
|---|
| 145 | |
|---|
| 146 | def create_path_list(path_str): |
|---|
| 147 | """Create a list of paths from a string. |
|---|
| 148 | |
|---|
| 149 | Double ',', trailing ',' and empty extensions are filtered out. Paths not starting with '/' |
|---|
| 150 | are ignored. |
|---|
| 151 | |
|---|
| 152 | @return: unfiltered list, filtered list |
|---|
| 153 | """ |
|---|
| 154 | if not path_str: |
|---|
| 155 | return [], [] |
|---|
| 156 | # filter trailing ',', double ','and empty exts |
|---|
| 157 | ext_list = [ext.strip() for ext in path_str.split(',') if ext.strip()] |
|---|
| 158 | return ext_list, [ext for ext in ext_list if ext[0] == '/'] |
|---|
| 159 | |
|---|
| 160 | req.perm.require('CODE_REVIEW_DEV') |
|---|
| 161 | |
|---|
| 162 | name = req.args.get('projectname') or path_info |
|---|
| 163 | rootfolder = req.args.get('rootfolder') |
|---|
| 164 | reponame = req.args.get('reponame', '') |
|---|
| 165 | rev = req.args.get('rev', None) |
|---|
| 166 | exts = req.args.get('excludeext', '') |
|---|
| 167 | incl = req.args.get('includeext', '') |
|---|
| 168 | exclpath = req.args.get('excludepath', '') |
|---|
| 169 | follow_externals = req.args.get('follow_ext', False) |
|---|
| 170 | sel = req.args.getlist('sel') # For removal |
|---|
| 171 | |
|---|
| 172 | all_proj = ReviewDataModel.all_file_project_data(self.env) |
|---|
| 173 | |
|---|
| 174 | if req.method=='POST': |
|---|
| 175 | def _do_redirect(action): |
|---|
| 176 | parms = {'projectname': name, |
|---|
| 177 | 'rootfolder': rootfolder, |
|---|
| 178 | 'excludeext': exts, |
|---|
| 179 | 'includeext': incl, |
|---|
| 180 | 'excludepath': exclpath, |
|---|
| 181 | 'repo': reponame, |
|---|
| 182 | 'rev': rev, |
|---|
| 183 | 'error': 1 |
|---|
| 184 | } |
|---|
| 185 | if action == 'add': |
|---|
| 186 | req.redirect(req.href.admin(cat, page, parms)) |
|---|
| 187 | elif action == 'save': |
|---|
| 188 | req.redirect(req.href.admin(cat, page, path_info, parms)) |
|---|
| 189 | |
|---|
| 190 | def check_parameters(action): |
|---|
| 191 | if not repo_path_exists(self.env, rootfolder, reponame): |
|---|
| 192 | add_warning(req, _("The given root folder %s can't be found in the repository or it is a file."), |
|---|
| 193 | rootfolder) |
|---|
| 194 | _do_redirect(action) |
|---|
| 195 | if len(ext_list) != len(ext_filtered): |
|---|
| 196 | add_warning(req, _("Some extensions in exclude list are not valid: %s"), exts) |
|---|
| 197 | _do_redirect(action) |
|---|
| 198 | if len(incl_list) != len(incl_filtered): |
|---|
| 199 | add_warning(req, _("Some extensions in include list are not valid: %s"), incl) |
|---|
| 200 | _do_redirect(action) |
|---|
| 201 | if len(path_lst) != len(path_lst_filtered): |
|---|
| 202 | add_warning(req, _("Some entries in the exclude path list are not valid.")) |
|---|
| 203 | _do_redirect(action) |
|---|
| 204 | |
|---|
| 205 | ext_list, ext_filtered = create_ext_list(exts) |
|---|
| 206 | incl_list, incl_filtered = create_ext_list(incl) |
|---|
| 207 | path_lst, path_lst_filtered = create_path_list(exclpath) |
|---|
| 208 | |
|---|
| 209 | if req.args.get('add'): |
|---|
| 210 | action = 'add' |
|---|
| 211 | if not name: |
|---|
| 212 | add_warning(req, _("You need to specify a project name.")) |
|---|
| 213 | _do_redirect(action) |
|---|
| 214 | if name in all_proj: |
|---|
| 215 | add_warning(req, _("The project identifier already exists.")) |
|---|
| 216 | _do_redirect(action) |
|---|
| 217 | check_parameters(action) # This redirects to the page on parameter error |
|---|
| 218 | add_project_info() |
|---|
| 219 | errors, num_files = insert_project_files(self, rootfolder, name, ext_filtered, incl_filtered, |
|---|
| 220 | path_lst_filtered, |
|---|
| 221 | follow_externals, rev=rev, repo_name=reponame) |
|---|
| 222 | add_notice(req, _("The project has been added. %s files belonging to the project %s have been added " |
|---|
| 223 | "to the database"), num_files, name) |
|---|
| 224 | for err in errors: |
|---|
| 225 | add_warning(req, err) |
|---|
| 226 | elif req.args.get('save'): |
|---|
| 227 | action = 'save' |
|---|
| 228 | if not req.args.get('projectname'): |
|---|
| 229 | add_warning(req, _("No project name given. The old name was inserted again.")) |
|---|
| 230 | add_warning(req, _("No changes have been saved.")) |
|---|
| 231 | _do_redirect(action) |
|---|
| 232 | if name != path_info: |
|---|
| 233 | if name in all_proj: |
|---|
| 234 | add_warning(req, _("The project identifier already exists.")) |
|---|
| 235 | _do_redirect(action) |
|---|
| 236 | check_parameters(action) # This redirects to the page on parameter error |
|---|
| 237 | |
|---|
| 238 | # Handle change. We remove all data for old name and recreate it using the new one |
|---|
| 239 | remove_project_info(path_info) |
|---|
| 240 | add_project_info() |
|---|
| 241 | errors, num_files = insert_project_files(self, rootfolder, name, ext_filtered, incl_filtered, |
|---|
| 242 | path_lst_filtered, |
|---|
| 243 | follow_externals, rev=rev, repo_name=reponame) |
|---|
| 244 | add_notice(req, _("Your changes have been saved. %s files belonging to the project %s have been added " |
|---|
| 245 | "to the database"), num_files, name) |
|---|
| 246 | for err in errors: |
|---|
| 247 | add_warning(req, err) |
|---|
| 248 | elif req.args.get('remove'): |
|---|
| 249 | for rem_name in sel: |
|---|
| 250 | remove_project_info(rem_name) |
|---|
| 251 | |
|---|
| 252 | req.redirect(req.href.admin(cat, page)) |
|---|
| 253 | |
|---|
| 254 | all_proj_lst = [[key, value] for key, value in all_proj.items()] |
|---|
| 255 | for prj in all_proj_lst: |
|---|
| 256 | repos = RepositoryManager(self.env).get_repository(prj[1]['repo']) # This should be 'reponame' but... |
|---|
| 257 | prj[1]['display_rev'] = repos.display_rev |
|---|
| 258 | data = {'view': 'detail' if path_info else 'list', |
|---|
| 259 | 'projects': sorted(all_proj_lst, key=lambda item: item[0]), |
|---|
| 260 | 'projectname': name, |
|---|
| 261 | } |
|---|
| 262 | if(path_info): |
|---|
| 263 | # Details page or file list |
|---|
| 264 | data['view_project'] = path_info |
|---|
| 265 | view_proj = all_proj[path_info] |
|---|
| 266 | # With V3.1 the following was added to the saved information for multi repo support. |
|---|
| 267 | # It isn't available for old projects. |
|---|
| 268 | view_proj.setdefault('repo', '') |
|---|
| 269 | view_proj.setdefault('revision', '') |
|---|
| 270 | # With V3.2 includeext and excludepath were added. |
|---|
| 271 | # These aren't available for old projects. |
|---|
| 272 | view_proj.setdefault('includeext', '') |
|---|
| 273 | view_proj.setdefault('excludepath', '') |
|---|
| 274 | # Legacy support. The name changed in V3.2 |
|---|
| 275 | try: |
|---|
| 276 | excl_ext = view_proj['excludeext'] |
|---|
| 277 | except KeyError: |
|---|
| 278 | excl_ext = view_proj['extensions'] |
|---|
| 279 | view_proj.setdefault('follow_externals', False) |
|---|
| 280 | data.update({ |
|---|
| 281 | 'rootfolder': rootfolder or view_proj['rootfolder'], |
|---|
| 282 | 'excludeext': exts or excl_ext, |
|---|
| 283 | 'excludepath': exclpath or view_proj['excludepath'], #exclpath or excl_path, |
|---|
| 284 | 'includeext': incl or view_proj['includeext'], #incl or incl_ext, |
|---|
| 285 | 'reponame': reponame or view_proj['repo'], |
|---|
| 286 | 'revision': rev or view_proj['revision'], |
|---|
| 287 | 'follow_externals': follow_externals or view_proj['follow_externals'] |
|---|
| 288 | }) |
|---|
| 289 | repos = RepositoryManager(self.env).get_repository(data['reponame']) |
|---|
| 290 | data['display_rev'] = repos.display_rev |
|---|
| 291 | if req.args.get('filelist'): |
|---|
| 292 | data['view'] = 'filelist' |
|---|
| 293 | data['files'] = get_prj_file_list(self, path_info) |
|---|
| 294 | |
|---|
| 295 | # For downloading in docx format |
|---|
| 296 | conversions = Mimeview(self.env).get_supported_conversions('text/x-trac-reviewfilelist') |
|---|
| 297 | for key, name, ext, mime_in, mime_out, q, c in conversions: |
|---|
| 298 | conversion_href = req.href("peerreview", format=key, filelist=path_info) |
|---|
| 299 | add_link(req, 'alternate', conversion_href, name, mime_out) |
|---|
| 300 | |
|---|
| 301 | else: |
|---|
| 302 | data.update({ |
|---|
| 303 | 'rootfolder': rootfolder, |
|---|
| 304 | 'excludeext': exts, |
|---|
| 305 | 'excludepath': exclpath, |
|---|
| 306 | 'includeext': incl, |
|---|
| 307 | 'reponame': reponame, |
|---|
| 308 | 'revision': rev |
|---|
| 309 | }) |
|---|
| 310 | |
|---|
| 311 | add_stylesheet(req, 'common/css/browser.css') |
|---|
| 312 | add_stylesheet(req, 'hw/css/admin_file.css') |
|---|
| 313 | add_script_data(req, {'repo_browser': req.href.adminrepobrowser(data['rootfolder'], |
|---|
| 314 | repo=data['reponame'], |
|---|
| 315 | rev=data['revision']), |
|---|
| 316 | 'show_repo_idx': path_info == None if 'error' not in req.args else False} |
|---|
| 317 | ) |
|---|
| 318 | add_script(req, 'hw/js/admin_files.js') |
|---|
| 319 | |
|---|
| 320 | if hasattr(Chrome, 'jenv'): |
|---|
| 321 | return 'peeradmin_files_jinja.html', data |
|---|
| 322 | else: |
|---|
| 323 | return 'admin_files.html', data |
|---|