| OLD | NEW | 
| (Empty) |  | 
 |    1 # Copyright 2016 The Chromium Authors. All rights reserved. | 
 |    2 # Use of this source code is govered by a BSD-style | 
 |    3 # license that can be found in the LICENSE file or at | 
 |    4 # https://developers.google.com/open-source/licenses/bsd | 
 |    5  | 
 |    6 """Classes that implement the issue bulk edit page and related forms. | 
 |    7  | 
 |    8 Summary of classes: | 
 |    9   IssueBulkEdit: Show a form for editing multiple issues and allow the | 
 |   10      user to update them all at once. | 
 |   11 """ | 
 |   12  | 
 |   13 import httplib | 
 |   14 import logging | 
 |   15 import time | 
 |   16  | 
 |   17 from third_party import ezt | 
 |   18  | 
 |   19 from features import filterrules_helpers | 
 |   20 from features import notify | 
 |   21 from framework import actionlimit | 
 |   22 from framework import framework_constants | 
 |   23 from framework import framework_views | 
 |   24 from framework import monorailrequest | 
 |   25 from framework import permissions | 
 |   26 from framework import servlet | 
 |   27 from framework import template_helpers | 
 |   28 from services import tracker_fulltext | 
 |   29 from tracker import field_helpers | 
 |   30 from tracker import tracker_bizobj | 
 |   31 from tracker import tracker_helpers | 
 |   32 from tracker import tracker_views | 
 |   33  | 
 |   34  | 
 |   35 class IssueBulkEdit(servlet.Servlet): | 
 |   36   """IssueBulkEdit lists multiple issues and allows an edit to all of them.""" | 
 |   37  | 
 |   38   _PAGE_TEMPLATE = 'tracker/issue-bulk-edit-page.ezt' | 
 |   39   _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_ISSUES | 
 |   40   _CAPTCHA_ACTION_TYPES = [actionlimit.ISSUE_BULK_EDIT] | 
 |   41  | 
 |   42   _SECONDS_OVERHEAD = 4 | 
 |   43   _SECONDS_PER_UPDATE = 0.12 | 
 |   44   _SLOWNESS_THRESHOLD = 10 | 
 |   45  | 
 |   46   def AssertBasePermission(self, mr): | 
 |   47     """Check whether the user has any permission to visit this page. | 
 |   48  | 
 |   49     Args: | 
 |   50       mr: commonly used info parsed from the request. | 
 |   51  | 
 |   52     Raises: | 
 |   53       PermissionException: if the user is not allowed to enter an issue. | 
 |   54     """ | 
 |   55     super(IssueBulkEdit, self).AssertBasePermission(mr) | 
 |   56     can_edit = self.CheckPerm(mr, permissions.EDIT_ISSUE) | 
 |   57     can_comment = self.CheckPerm(mr, permissions.ADD_ISSUE_COMMENT) | 
 |   58     if not (can_edit and can_comment): | 
 |   59       raise permissions.PermissionException('bulk edit forbidden') | 
 |   60  | 
 |   61   def GatherPageData(self, mr): | 
 |   62     """Build up a dictionary of data values to use when rendering the page. | 
 |   63  | 
 |   64     Args: | 
 |   65       mr: commonly used info parsed from the request. | 
 |   66  | 
 |   67     Returns: | 
 |   68       Dict of values used by EZT for rendering the page. | 
 |   69     """ | 
 |   70     with self.profiler.Phase('getting issues'): | 
 |   71       if not mr.local_id_list: | 
 |   72         raise monorailrequest.InputException() | 
 |   73       requested_issues = self.services.issue.GetIssuesByLocalIDs( | 
 |   74           mr.cnxn, mr.project_id, sorted(mr.local_id_list)) | 
 |   75  | 
 |   76     with self.profiler.Phase('filtering issues'): | 
 |   77       # TODO(jrobbins): filter out issues that the user cannot edit and | 
 |   78       # provide that as feedback rather than just siliently ignoring them. | 
 |   79       open_issues, closed_issues = ( | 
 |   80           tracker_helpers.GetAllowedOpenedAndClosedIssues( | 
 |   81               mr, [issue.issue_id for issue in requested_issues], | 
 |   82               self.services)) | 
 |   83       issues = open_issues + closed_issues | 
 |   84  | 
 |   85     if not issues: | 
 |   86       self.abort(404, 'no issues found') | 
 |   87  | 
 |   88     config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id) | 
 |   89     type_label_set = { | 
 |   90         lab.lower() for lab in issues[0].labels | 
 |   91         if lab.lower().startswith('type-')} | 
 |   92     for issue in issues[1:]: | 
 |   93       new_type_set = { | 
 |   94           lab.lower() for lab in issue.labels | 
 |   95           if lab.lower().startswith('type-')} | 
 |   96       type_label_set &= new_type_set | 
 |   97  | 
 |   98     field_views = [ | 
 |   99         tracker_views.MakeFieldValueView( | 
 |  100             fd, config, type_label_set, [], [], {}) | 
 |  101         # TODO(jrobbins): field-level view restrictions, display options | 
 |  102         # TODO(jrobbins): custom fields in templates supply values to view. | 
 |  103         for fd in config.field_defs | 
 |  104         if not fd.is_deleted] | 
 |  105     # Explicitly set all field views to not required. We do not want to force | 
 |  106     # users to have to set it for issues missing required fields. | 
 |  107     # See https://bugs.chromium.org/p/monorail/issues/detail?id=500 for more | 
 |  108     # details. | 
 |  109     for fv in field_views: | 
 |  110       fv.field_def.is_required_bool = None | 
 |  111  | 
 |  112     with self.profiler.Phase('making issue proxies'): | 
 |  113       issue_views = [ | 
 |  114           template_helpers.EZTItem( | 
 |  115               local_id=issue.local_id, summary=issue.summary, | 
 |  116               closed=ezt.boolean(issue in closed_issues)) | 
 |  117           for issue in issues] | 
 |  118  | 
 |  119     num_seconds = (int(len(issue_views) * self._SECONDS_PER_UPDATE) + | 
 |  120                    self._SECONDS_OVERHEAD) | 
 |  121  | 
 |  122     page_perms = self.MakePagePerms( | 
 |  123         mr, None, | 
 |  124         permissions.CREATE_ISSUE, | 
 |  125         permissions.DELETE_ISSUE) | 
 |  126  | 
 |  127     return { | 
 |  128         'issue_tab_mode': 'issueBulkEdit', | 
 |  129         'issues': issue_views, | 
 |  130         'num_issues': len(issue_views), | 
 |  131         'show_progress': ezt.boolean(num_seconds > self._SLOWNESS_THRESHOLD), | 
 |  132         'num_seconds': num_seconds, | 
 |  133  | 
 |  134         'initial_comment': '', | 
 |  135         'initial_status': '', | 
 |  136         'initial_owner': '', | 
 |  137         'initial_merge_into': '', | 
 |  138         'initial_cc': '', | 
 |  139         'initial_components': '', | 
 |  140         'labels': [], | 
 |  141         'fields': field_views, | 
 |  142  | 
 |  143         'restrict_to_known': ezt.boolean(config.restrict_to_known), | 
 |  144         'page_perms': page_perms, | 
 |  145         'statuses_offer_merge': config.statuses_offer_merge, | 
 |  146         } | 
 |  147  | 
 |  148   def ProcessFormData(self, mr, post_data): | 
 |  149     """Process the posted issue update form. | 
 |  150  | 
 |  151     Args: | 
 |  152       mr: commonly used info parsed from the request. | 
 |  153       post_data: HTML form data from the request. | 
 |  154  | 
 |  155     Returns: | 
 |  156       String URL to redirect the user to after processing. | 
 |  157     """ | 
 |  158     if not mr.local_id_list: | 
 |  159       logging.info('missing issue local IDs, probably tampered') | 
 |  160       self.response.status = httplib.BAD_REQUEST | 
 |  161       return | 
 |  162  | 
 |  163     # Check that the user is logged in; anon users cannot update issues. | 
 |  164     if not mr.auth.user_id: | 
 |  165       logging.info('user was not logged in, cannot update issue') | 
 |  166       self.response.status = httplib.BAD_REQUEST  # xxx should raise except | 
 |  167       return | 
 |  168  | 
 |  169     self.CountRateLimitedActions( | 
 |  170         mr, {actionlimit.ISSUE_BULK_EDIT: len(mr.local_id_list)}) | 
 |  171  | 
 |  172     # Check that the user has permission to add a comment, and to enter | 
 |  173     # metadata if they are trying to do that. | 
 |  174     if not self.CheckPerm(mr, permissions.ADD_ISSUE_COMMENT): | 
 |  175       logging.info('user has no permission to add issue comment') | 
 |  176       self.response.status = httplib.BAD_REQUEST | 
 |  177       return | 
 |  178  | 
 |  179     if not self.CheckPerm(mr, permissions.EDIT_ISSUE): | 
 |  180       logging.info('user has no permission to edit issue metadata') | 
 |  181       self.response.status = httplib.BAD_REQUEST | 
 |  182       return | 
 |  183  | 
 |  184     move_to = post_data.get('move_to', '').lower() | 
 |  185     if move_to and not self.CheckPerm(mr, permissions.DELETE_ISSUE): | 
 |  186       logging.info('user has no permission to move issue') | 
 |  187       self.response.status = httplib.BAD_REQUEST | 
 |  188       return | 
 |  189  | 
 |  190     config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id) | 
 |  191  | 
 |  192     parsed = tracker_helpers.ParseIssueRequest( | 
 |  193         mr.cnxn, post_data, self.services, mr.errors, mr.project_name) | 
 |  194     field_helpers.ShiftEnumFieldsIntoLabels( | 
 |  195         parsed.labels, parsed.labels_remove, | 
 |  196         parsed.fields.vals, parsed.fields.vals_remove, | 
 |  197         config) | 
 |  198     field_vals = field_helpers.ParseFieldValues( | 
 |  199         mr.cnxn, self.services.user, parsed.fields.vals, config) | 
 |  200     field_vals_remove = field_helpers.ParseFieldValues( | 
 |  201         mr.cnxn, self.services.user, parsed.fields.vals_remove, config) | 
 |  202  | 
 |  203     # Treat status '' as no change and explicit 'clear' as clearing the status. | 
 |  204     status = parsed.status | 
 |  205     if status == '': | 
 |  206       status = None | 
 |  207     if post_data.get('op_statusenter') == 'clear': | 
 |  208       status = '' | 
 |  209  | 
 |  210     reporter_id = mr.auth.user_id | 
 |  211     logging.info('bulk edit request by %s', reporter_id) | 
 |  212     self.CheckCaptcha(mr, post_data) | 
 |  213  | 
 |  214     if parsed.users.owner_id is None: | 
 |  215       mr.errors.owner = 'Invalid owner username' | 
 |  216     else: | 
 |  217       valid, msg = tracker_helpers.IsValidIssueOwner( | 
 |  218           mr.cnxn, mr.project, parsed.users.owner_id, self.services) | 
 |  219       if not valid: | 
 |  220         mr.errors.owner = msg | 
 |  221  | 
 |  222     if (status in config.statuses_offer_merge and | 
 |  223         not post_data.get('merge_into')): | 
 |  224       mr.errors.merge_into_id = 'Please enter a valid issue ID' | 
 |  225  | 
 |  226     move_to_project = None | 
 |  227     if move_to: | 
 |  228       if mr.project_name == move_to: | 
 |  229         mr.errors.move_to = 'The issues are already in project ' + move_to | 
 |  230       else: | 
 |  231         move_to_project = self.services.project.GetProjectByName( | 
 |  232             mr.cnxn, move_to) | 
 |  233         if not move_to_project: | 
 |  234           mr.errors.move_to = 'No such project: ' + move_to | 
 |  235  | 
 |  236     # Treat owner '' as no change, and explicit 'clear' as NO_USER_SPECIFIED | 
 |  237     owner_id = parsed.users.owner_id | 
 |  238     if parsed.users.owner_username == '': | 
 |  239       owner_id = None | 
 |  240     if post_data.get('op_ownerenter') == 'clear': | 
 |  241       owner_id = framework_constants.NO_USER_SPECIFIED | 
 |  242  | 
 |  243     comp_ids = tracker_helpers.LookupComponentIDs( | 
 |  244         parsed.components.paths, config, mr.errors) | 
 |  245     comp_ids_remove = tracker_helpers.LookupComponentIDs( | 
 |  246         parsed.components.paths_remove, config, mr.errors) | 
 |  247     if post_data.get('op_componententer') == 'remove': | 
 |  248       comp_ids, comp_ids_remove = comp_ids_remove, comp_ids | 
 |  249  | 
 |  250     cc_ids, cc_ids_remove = parsed.users.cc_ids, parsed.users.cc_ids_remove | 
 |  251     if post_data.get('op_memberenter') == 'remove': | 
 |  252       cc_ids, cc_ids_remove = parsed.users.cc_ids_remove, parsed.users.cc_ids | 
 |  253  | 
 |  254     local_ids_actually_changed = [] | 
 |  255     old_owner_ids = [] | 
 |  256     combined_amendments = [] | 
 |  257     merge_into_issue = None | 
 |  258     new_starrers = set() | 
 |  259  | 
 |  260     if not mr.errors.AnyErrors(): | 
 |  261       issue_list = self.services.issue.GetIssuesByLocalIDs( | 
 |  262           mr.cnxn, mr.project_id, mr.local_id_list) | 
 |  263  | 
 |  264       # Skip any individual issues that the user is not allowed to edit. | 
 |  265       editable_issues = [ | 
 |  266           issue for issue in issue_list | 
 |  267           if permissions.CanEditIssue( | 
 |  268               mr.auth.effective_ids, mr.perms, mr.project, issue)] | 
 |  269  | 
 |  270       # Skip any restrict issues that cannot be moved | 
 |  271       if move_to: | 
 |  272         editable_issues = [ | 
 |  273             issue for issue in editable_issues | 
 |  274             if not permissions.GetRestrictions(issue)] | 
 |  275  | 
 |  276       # If 'Duplicate' status is specified ensure there are no permission issues | 
 |  277       # with the issue we want to merge with. | 
 |  278       if post_data.get('merge_into'): | 
 |  279         for issue in editable_issues: | 
 |  280           _, merge_into_issue = tracker_helpers.ParseMergeFields( | 
 |  281               mr.cnxn, self.services, mr.project_name, post_data, parsed.status, | 
 |  282               config, issue, mr.errors) | 
 |  283           if merge_into_issue: | 
 |  284             merge_allowed = tracker_helpers.IsMergeAllowed( | 
 |  285                 merge_into_issue, mr, self.services) | 
 |  286             if not merge_allowed: | 
 |  287               mr.errors.merge_into_id = 'Target issue %s cannot be modified' % ( | 
 |  288                                             merge_into_issue.local_id) | 
 |  289               break | 
 |  290  | 
 |  291             # Update the new_starrers set. | 
 |  292             new_starrers.update(tracker_helpers.GetNewIssueStarrers( | 
 |  293                 mr.cnxn, self.services, issue.issue_id, | 
 |  294                 merge_into_issue.issue_id)) | 
 |  295  | 
 |  296       # Proceed with amendments only if there are no reported errors. | 
 |  297       if not mr.errors.AnyErrors(): | 
 |  298         # Sort the issues: we want them in this order so that the | 
 |  299         # corresponding old_owner_id are found in the same order. | 
 |  300         editable_issues.sort(lambda i1, i2: cmp(i1.local_id, i2.local_id)) | 
 |  301  | 
 |  302         iids_to_invalidate = set() | 
 |  303         rules = self.services.features.GetFilterRules( | 
 |  304             mr.cnxn, config.project_id) | 
 |  305         predicate_asts = filterrules_helpers.ParsePredicateASTs( | 
 |  306             rules, config, None) | 
 |  307         for issue in editable_issues: | 
 |  308           old_owner_id = tracker_bizobj.GetOwnerId(issue) | 
 |  309           merge_into_iid = ( | 
 |  310               merge_into_issue.issue_id if merge_into_issue else None) | 
 |  311  | 
 |  312           amendments, _ = self.services.issue.DeltaUpdateIssue( | 
 |  313               mr.cnxn, self.services, mr.auth.user_id, mr.project_id, config, | 
 |  314               issue, status, owner_id, cc_ids, cc_ids_remove, comp_ids, | 
 |  315               comp_ids_remove, parsed.labels, parsed.labels_remove, field_vals, | 
 |  316               field_vals_remove, parsed.fields.fields_clear, | 
 |  317               merged_into=merge_into_iid, comment=parsed.comment, | 
 |  318               iids_to_invalidate=iids_to_invalidate, rules=rules, | 
 |  319               predicate_asts=predicate_asts) | 
 |  320  | 
 |  321           if amendments or parsed.comment:  # Avoid empty comments. | 
 |  322             local_ids_actually_changed.append(issue.local_id) | 
 |  323             old_owner_ids.append(old_owner_id) | 
 |  324             combined_amendments.extend(amendments) | 
 |  325  | 
 |  326         self.services.issue.InvalidateIIDs(mr.cnxn, iids_to_invalidate) | 
 |  327         self.services.project.UpdateRecentActivity( | 
 |  328             mr.cnxn, mr.project.project_id) | 
 |  329  | 
 |  330         # Add new_starrers and new CCs to merge_into_issue. | 
 |  331         if merge_into_issue: | 
 |  332           merge_into_project = self.services.project.GetProjectByName( | 
 |  333               mr.cnxn, merge_into_issue.project_name) | 
 |  334           tracker_helpers.AddIssueStarrers( | 
 |  335               mr.cnxn, self.services, mr, merge_into_issue.issue_id, | 
 |  336               merge_into_project, new_starrers) | 
 |  337           tracker_helpers.MergeCCsAndAddCommentMultipleIssues( | 
 |  338               self.services, mr, editable_issues, merge_into_project, | 
 |  339               merge_into_issue) | 
 |  340  | 
 |  341         if move_to and editable_issues: | 
 |  342           tracker_fulltext.UnindexIssues( | 
 |  343               [issue.issue_id for issue in editable_issues]) | 
 |  344           for issue in editable_issues: | 
 |  345             old_text_ref = 'issue %s:%s' % (issue.project_name, issue.local_id) | 
 |  346             moved_back_iids = self.services.issue.MoveIssues( | 
 |  347                 mr.cnxn, move_to_project, [issue], self.services.user) | 
 |  348             new_text_ref = 'issue %s:%s' % (issue.project_name, issue.local_id) | 
 |  349             if issue.issue_id in moved_back_iids: | 
 |  350               content = 'Moved %s back to %s again.' % ( | 
 |  351                   old_text_ref, new_text_ref) | 
 |  352             else: | 
 |  353               content = 'Moved %s to now be %s.' % (old_text_ref, new_text_ref) | 
 |  354             self.services.issue.CreateIssueComment( | 
 |  355               mr.cnxn, move_to_project.project_id, issue.local_id, | 
 |  356               mr.auth.user_id, content, amendments=[ | 
 |  357                   tracker_bizobj.MakeProjectAmendment( | 
 |  358                       move_to_project.project_name)]) | 
 |  359  | 
 |  360         send_email = 'send_email' in post_data | 
 |  361  | 
 |  362         users_by_id = framework_views.MakeAllUserViews( | 
 |  363             mr.cnxn, self.services.user, | 
 |  364             [owner_id], cc_ids, cc_ids_remove, old_owner_ids, | 
 |  365             tracker_bizobj.UsersInvolvedInAmendments(combined_amendments)) | 
 |  366         if move_to and editable_issues: | 
 |  367           project_id = move_to_project.project_id | 
 |  368           local_ids_actually_changed = [ | 
 |  369               issue.local_id for issue in editable_issues] | 
 |  370         else: | 
 |  371           project_id = mr.project_id | 
 |  372  | 
 |  373         notify.SendIssueBulkChangeNotification( | 
 |  374             mr.request.host, project_id, | 
 |  375             local_ids_actually_changed, old_owner_ids, parsed.comment, | 
 |  376             reporter_id, combined_amendments, send_email, users_by_id) | 
 |  377  | 
 |  378     if mr.errors.AnyErrors(): | 
 |  379       bounce_cc_parts = ( | 
 |  380           parsed.users.cc_usernames + | 
 |  381           ['-%s' % ccur for ccur in parsed.users.cc_usernames_remove]) | 
 |  382       bounce_labels = ( | 
 |  383           parsed.labels + | 
 |  384           ['-%s' % lr for lr in parsed.labels_remove]) | 
 |  385       self.PleaseCorrect( | 
 |  386           mr, initial_status=parsed.status, | 
 |  387           initial_owner=parsed.users.owner_username, | 
 |  388           initial_merge_into=post_data.get('merge_into', 0), | 
 |  389           initial_cc=', '.join(bounce_cc_parts), | 
 |  390           initial_comment=parsed.comment, | 
 |  391           initial_components=parsed.components.entered_str, | 
 |  392           labels=bounce_labels) | 
 |  393       return | 
 |  394  | 
 |  395     with self.profiler.Phase('reindexing issues'): | 
 |  396       logging.info('starting reindexing') | 
 |  397       start = time.time() | 
 |  398       # Get the updated issues and index them | 
 |  399       issue_list = self.services.issue.GetIssuesByLocalIDs( | 
 |  400           mr.cnxn, mr.project_id, mr.local_id_list) | 
 |  401       tracker_fulltext.IndexIssues( | 
 |  402           mr.cnxn, issue_list, self.services.user, self.services.issue, | 
 |  403           self.services.config) | 
 |  404       logging.info('reindexing %d issues took %s sec', | 
 |  405                    len(issue_list), time.time() - start) | 
 |  406  | 
 |  407     # TODO(jrobbins): These could be put into the form action attribute. | 
 |  408     mr.can = int(post_data['can']) | 
 |  409     mr.query = post_data['q'] | 
 |  410     mr.col_spec = post_data['colspec'] | 
 |  411     mr.sort_spec = post_data['sort'] | 
 |  412     mr.group_by_spec = post_data['groupby'] | 
 |  413     mr.start = int(post_data['start']) | 
 |  414     mr.num = int(post_data['num']) | 
 |  415  | 
 |  416     # TODO(jrobbins): implement bulk=N param for a better confirmation alert. | 
 |  417     return tracker_helpers.FormatIssueListURL( | 
 |  418         mr, config, saved=len(mr.local_id_list), ts=int(time.time())) | 
| OLD | NEW |