Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 1 | # Copyright 2016 The Chromium Authors. All rights reserved. |
| 2 | # Use of this source code is governed 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 detail page and related forms. |
| 7 | |
| 8 | Summary of classes: |
| 9 | IssueDetailEzt: Show one issue in detail w/ all metadata and comments, and |
| 10 | process additional comments or metadata changes on it. |
| 11 | FlagSpamForm: Record the user's desire to report the issue as spam. |
| 12 | """ |
| 13 | from __future__ import print_function |
| 14 | from __future__ import division |
| 15 | from __future__ import absolute_import |
| 16 | |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 17 | import json |
| 18 | import logging |
| 19 | import time |
| 20 | import ezt |
| 21 | |
| 22 | import settings |
| 23 | from api import converters |
| 24 | from businesslogic import work_env |
| 25 | from features import features_bizobj |
| 26 | from features import send_notifications |
| 27 | from features import hotlist_helpers |
| 28 | from features import hotlist_views |
| 29 | from framework import exceptions |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame^] | 30 | from framework import flaskservlet |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 31 | from framework import framework_bizobj |
| 32 | from framework import framework_constants |
| 33 | from framework import framework_helpers |
| 34 | from framework import framework_views |
| 35 | from framework import jsonfeed |
| 36 | from framework import paginate |
| 37 | from framework import permissions |
| 38 | from framework import servlet |
| 39 | from framework import servlet_helpers |
| 40 | from framework import sorting |
| 41 | from framework import sql |
| 42 | from framework import template_helpers |
| 43 | from framework import urls |
| 44 | from framework import xsrf |
| 45 | from proto import user_pb2 |
| 46 | from proto import tracker_pb2 |
| 47 | from services import features_svc |
| 48 | from services import tracker_fulltext |
| 49 | from tracker import field_helpers |
| 50 | from tracker import tracker_bizobj |
| 51 | from tracker import tracker_constants |
| 52 | from tracker import tracker_helpers |
| 53 | from tracker import tracker_views |
| 54 | |
| 55 | from google.protobuf import json_format |
| 56 | |
| 57 | |
| 58 | def CheckMoveIssueRequest( |
| 59 | services, mr, issue, move_selected, move_to, errors): |
| 60 | """Process the move issue portions of the issue update form. |
| 61 | |
| 62 | Args: |
| 63 | services: A Services object |
| 64 | mr: commonly used info parsed from the request. |
| 65 | issue: Issue protobuf for the issue being moved. |
| 66 | move_selected: True if the user selected the Move action. |
| 67 | move_to: A project_name or url to move this issue to or None |
| 68 | if the project name wasn't sent in the form. |
| 69 | errors: The errors object for this request. |
| 70 | |
| 71 | Returns: |
| 72 | The project pb for the project the issue will be moved to |
| 73 | or None if the move cannot be performed. Perhaps because |
| 74 | the project does not exist, in which case move_to and |
| 75 | move_to_project will be set on the errors object. Perhaps |
| 76 | the user does not have permission to move the issue to the |
| 77 | destination project, in which case the move_to field will be |
| 78 | set on the errors object. |
| 79 | """ |
| 80 | if not move_selected: |
| 81 | return None |
| 82 | |
| 83 | if not move_to: |
| 84 | errors.move_to = 'No destination project specified' |
| 85 | errors.move_to_project = move_to |
| 86 | return None |
| 87 | |
| 88 | if issue.project_name == move_to: |
| 89 | errors.move_to = 'This issue is already in project ' + move_to |
| 90 | errors.move_to_project = move_to |
| 91 | return None |
| 92 | |
| 93 | move_to_project = services.project.GetProjectByName(mr.cnxn, move_to) |
| 94 | if not move_to_project: |
| 95 | errors.move_to = 'No such project: ' + move_to |
| 96 | errors.move_to_project = move_to |
| 97 | return None |
| 98 | |
| 99 | # permissions enforcement |
| 100 | if not servlet_helpers.CheckPermForProject( |
| 101 | mr, permissions.EDIT_ISSUE, move_to_project): |
| 102 | errors.move_to = 'You do not have permission to move issues to project' |
| 103 | errors.move_to_project = move_to |
| 104 | return None |
| 105 | |
| 106 | elif permissions.GetRestrictions(issue): |
| 107 | errors.move_to = ( |
| 108 | 'Issues with Restrict labels are not allowed to be moved.') |
| 109 | errors.move_to_project = '' |
| 110 | return None |
| 111 | |
| 112 | return move_to_project |
| 113 | |
| 114 | |
| 115 | def _ComputeBackToListURL(mr, issue, config, hotlist, services): |
| 116 | """Construct a URL to return the user to the place that they came from.""" |
| 117 | if hotlist: |
| 118 | back_to_list_url = hotlist_helpers.GetURLOfHotlist( |
| 119 | mr.cnxn, hotlist, services.user) |
| 120 | else: |
| 121 | back_to_list_url = tracker_helpers.FormatIssueListURL( |
| 122 | mr, config, cursor='%s:%d' % (issue.project_name, issue.local_id)) |
| 123 | |
| 124 | return back_to_list_url |
| 125 | |
| 126 | |
| 127 | class FlipperRedirectBase(servlet.Servlet): |
| 128 | |
| 129 | # pylint: disable=arguments-differ |
| 130 | # pylint: disable=unused-argument |
| 131 | def get(self, project_name=None, viewed_username=None, hotlist_id=None): |
| 132 | with work_env.WorkEnv(self.mr, self.services) as we: |
| 133 | hotlist_id = self.mr.GetIntParam('hotlist_id') |
| 134 | current_issue = we.GetIssueByLocalID(self.mr.project_id, self.mr.local_id, |
| 135 | use_cache=False) |
| 136 | hotlist = None |
| 137 | if hotlist_id: |
| 138 | try: |
| 139 | hotlist = self.services.features.GetHotlist(self.mr.cnxn, hotlist_id) |
| 140 | except features_svc.NoSuchHotlistException: |
| 141 | pass |
| 142 | |
| 143 | try: |
| 144 | adj_issue = GetAdjacentIssue( |
| 145 | self.mr, we, current_issue, hotlist=hotlist, |
| 146 | next_issue=self.next_handler) |
| 147 | path = '/p/%s%s' % (adj_issue.project_name, urls.ISSUE_DETAIL) |
| 148 | url = framework_helpers.FormatURL( |
| 149 | [(name, self.mr.GetParam(name)) for |
| 150 | name in framework_helpers.RECOGNIZED_PARAMS], |
| 151 | path, id=adj_issue.local_id) |
| 152 | except exceptions.NoSuchIssueException: |
| 153 | config = we.GetProjectConfig(self.mr.project_id) |
| 154 | url = _ComputeBackToListURL(self.mr, current_issue, config, |
| 155 | hotlist, self.services) |
| 156 | self.redirect(url) |
| 157 | |
| 158 | |
| 159 | class FlipperNext(FlipperRedirectBase): |
| 160 | next_handler = True |
| 161 | |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame^] | 162 | # def GetFlipperNextRedirectPage(self, **kwargs): |
| 163 | # self.next_handler = True |
| 164 | # return self.handler(**kwargs) |
| 165 | |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 166 | |
| 167 | class FlipperPrev(FlipperRedirectBase): |
| 168 | next_handler = False |
| 169 | |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame^] | 170 | # def GetFlipperPrevRedirectPage(self, **kwargs): |
| 171 | # self.next_handler = False |
| 172 | # return self.handler(**kwargs) |
| 173 | |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 174 | |
| 175 | class FlipperList(servlet.Servlet): |
| 176 | # pylint: disable=arguments-differ |
| 177 | # pylint: disable=unused-argument |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame^] | 178 | # TODO: (monorail:6511)change to get(self) when convert to flask |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 179 | def get(self, project_name=None, viewed_username=None, hotlist_id=None): |
| 180 | with work_env.WorkEnv(self.mr, self.services) as we: |
| 181 | hotlist_id = self.mr.GetIntParam('hotlist_id') |
| 182 | current_issue = we.GetIssueByLocalID(self.mr.project_id, self.mr.local_id, |
| 183 | use_cache=False) |
| 184 | hotlist = None |
| 185 | if hotlist_id: |
| 186 | try: |
| 187 | hotlist = self.services.features.GetHotlist(self.mr.cnxn, hotlist_id) |
| 188 | except features_svc.NoSuchHotlistException: |
| 189 | pass |
| 190 | |
| 191 | config = we.GetProjectConfig(self.mr.project_id) |
| 192 | |
| 193 | if hotlist: |
| 194 | self.mr.ComputeColSpec(hotlist) |
| 195 | else: |
| 196 | self.mr.ComputeColSpec(config) |
| 197 | |
| 198 | url = _ComputeBackToListURL(self.mr, current_issue, config, |
| 199 | hotlist, self.services) |
| 200 | self.redirect(url) |
| 201 | |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame^] | 202 | # def GetFlipperList(self, **kwargs): |
| 203 | # return self.handler(**kwargs) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 204 | |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame^] | 205 | |
| 206 | # TODO: (monorail:6511) change to flaskJsonFeed when convert to flask |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 207 | class FlipperIndex(jsonfeed.JsonFeed): |
| 208 | """Return a JSON object of an issue's index in search. |
| 209 | |
| 210 | This is a distinct JSON endpoint because it can be expensive to compute. |
| 211 | """ |
| 212 | CHECK_SECURITY_TOKEN = False |
| 213 | |
| 214 | def HandleRequest(self, mr): |
| 215 | hotlist_id = mr.GetIntParam('hotlist_id') |
| 216 | list_url = None |
| 217 | with work_env.WorkEnv(mr, self.services) as we: |
| 218 | if not _ShouldShowFlipper(mr, self.services): |
| 219 | return {} |
| 220 | issue = we.GetIssueByLocalID(mr.project_id, mr.local_id, use_cache=False) |
| 221 | hotlist = None |
| 222 | |
| 223 | if hotlist_id: |
| 224 | hotlist = self.services.features.GetHotlist(mr.cnxn, hotlist_id) |
| 225 | |
| 226 | if not features_bizobj.IssueIsInHotlist(hotlist, issue.issue_id): |
| 227 | raise exceptions.InvalidHotlistException() |
| 228 | |
| 229 | if not permissions.CanViewHotlist( |
| 230 | mr.auth.effective_ids, mr.perms, hotlist): |
| 231 | raise permissions.PermissionException() |
| 232 | |
| 233 | (prev_iid, cur_index, next_iid, total_count |
| 234 | ) = we.GetIssuePositionInHotlist( |
| 235 | issue, hotlist, mr.can, mr.sort_spec, mr.group_by_spec) |
| 236 | else: |
| 237 | (prev_iid, cur_index, next_iid, total_count |
| 238 | ) = we.FindIssuePositionInSearch(issue) |
| 239 | |
| 240 | config = we.GetProjectConfig(self.mr.project_id) |
| 241 | |
| 242 | if hotlist: |
| 243 | mr.ComputeColSpec(hotlist) |
| 244 | else: |
| 245 | mr.ComputeColSpec(config) |
| 246 | |
| 247 | list_url = _ComputeBackToListURL(mr, issue, config, hotlist, |
| 248 | self.services) |
| 249 | |
| 250 | prev_url = None |
| 251 | next_url = None |
| 252 | |
| 253 | recognized_params = [(name, mr.GetParam(name)) for name in |
| 254 | framework_helpers.RECOGNIZED_PARAMS] |
| 255 | if prev_iid: |
| 256 | prev_issue = we.services.issue.GetIssue(mr.cnxn, prev_iid) |
| 257 | path = '/p/%s%s' % (prev_issue.project_name, urls.ISSUE_DETAIL) |
| 258 | prev_url = framework_helpers.FormatURL( |
| 259 | recognized_params, path, id=prev_issue.local_id) |
| 260 | |
| 261 | if next_iid: |
| 262 | next_issue = we.services.issue.GetIssue(mr.cnxn, next_iid) |
| 263 | path = '/p/%s%s' % (next_issue.project_name, urls.ISSUE_DETAIL) |
| 264 | next_url = framework_helpers.FormatURL( |
| 265 | recognized_params, path, id=next_issue.local_id) |
| 266 | |
| 267 | return { |
| 268 | 'prev_iid': prev_iid, |
| 269 | 'prev_url': prev_url, |
| 270 | 'cur_index': cur_index, |
| 271 | 'next_iid': next_iid, |
| 272 | 'next_url': next_url, |
| 273 | 'list_url': list_url, |
| 274 | 'total_count': total_count, |
| 275 | } |
| 276 | |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame^] | 277 | # def GetFlipperIndex(self, **kwargs): |
| 278 | # return self.handler(**kwargs) |
| 279 | |
| 280 | # def PostFlipperIndex(self, **kwargs): |
| 281 | # return self.handler(**kwargs) |
| 282 | |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 283 | |
| 284 | def _ShouldShowFlipper(mr, services): |
| 285 | """Return True if we should show the flipper.""" |
| 286 | |
| 287 | # Check if the user entered a specific issue ID of an existing issue. |
| 288 | if tracker_constants.JUMP_RE.match(mr.query): |
| 289 | return False |
| 290 | |
| 291 | # Check if the user came directly to an issue without specifying any |
| 292 | # query or sort. E.g., through crbug.com. Generating the issue ref |
| 293 | # list can be too expensive in projects that have a large number of |
| 294 | # issues. The all and open issues cans are broad queries, other |
| 295 | # canned queries should be narrow enough to not need this special |
| 296 | # treatment. |
| 297 | if (not mr.query and not mr.sort_spec and |
| 298 | mr.can in [tracker_constants.ALL_ISSUES_CAN, |
| 299 | tracker_constants.OPEN_ISSUES_CAN]): |
| 300 | num_issues_in_project = services.issue.GetHighestLocalID( |
| 301 | mr.cnxn, mr.project_id) |
| 302 | if num_issues_in_project > settings.threshold_to_suppress_prev_next: |
| 303 | return False |
| 304 | |
| 305 | return True |
| 306 | |
| 307 | |
| 308 | def GetAdjacentIssue( |
| 309 | mr, we, issue, hotlist=None, next_issue=False): |
| 310 | """Compute next or previous issue given params of current issue. |
| 311 | |
| 312 | Args: |
| 313 | mr: MonorailRequest, including can and sorting/grouping order. |
| 314 | we: A WorkEnv instance. |
| 315 | issue: The current issue (from which to compute prev/next). |
| 316 | hotlist (optional): The current hotlist. |
| 317 | next_issue (bool): If True, return next, issue, else return previous issue. |
| 318 | |
| 319 | Returns: |
| 320 | The adjacent issue. |
| 321 | |
| 322 | Raises: |
| 323 | NoSuchIssueException when there is no adjacent issue in the list. |
| 324 | """ |
| 325 | if hotlist: |
| 326 | (prev_iid, _cur_index, next_iid, _total_count |
| 327 | ) = we.GetIssuePositionInHotlist( |
| 328 | issue, hotlist, mr.can, mr.sort_spec, mr.group_by_spec) |
| 329 | else: |
| 330 | (prev_iid, _cur_index, next_iid, _total_count |
| 331 | ) = we.FindIssuePositionInSearch(issue) |
| 332 | iid = next_iid if next_issue else prev_iid |
| 333 | if iid is None: |
| 334 | raise exceptions.NoSuchIssueException() |
| 335 | return we.GetIssue(iid) |