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 """Helper functions and classes used by the Monorail Issue Tracker pages. |
| 7 |
| 8 This module has functions that are reused in multiple servlets or |
| 9 other modules. |
| 10 """ |
| 11 |
| 12 import collections |
| 13 import logging |
| 14 import re |
| 15 import urllib |
| 16 |
| 17 import settings |
| 18 |
| 19 from framework import filecontent |
| 20 from framework import framework_bizobj |
| 21 from framework import framework_constants |
| 22 from framework import framework_helpers |
| 23 from framework import framework_views |
| 24 from framework import monorailrequest |
| 25 from framework import permissions |
| 26 from framework import sorting |
| 27 from framework import template_helpers |
| 28 from framework import urls |
| 29 from tracker import tracker_bizobj |
| 30 from tracker import tracker_constants |
| 31 |
| 32 |
| 33 # HTML input field names for blocked on and blocking issue refs. |
| 34 BLOCKED_ON = 'blocked_on' |
| 35 BLOCKING = 'blocking' |
| 36 |
| 37 # This string is used in HTML form element names to identify custom fields. |
| 38 # E.g., a value for a custom field with field_id 12 would be specified in |
| 39 # an HTML form element with name="custom_12". |
| 40 _CUSTOM_FIELD_NAME_PREFIX = 'custom_' |
| 41 |
| 42 # When the attachment quota gets within 1MB of the limit, stop offering |
| 43 # users the option to attach files. |
| 44 _SOFT_QUOTA_LEEWAY = 1024 * 1024 |
| 45 |
| 46 # Accessors for sorting built-in fields. |
| 47 SORTABLE_FIELDS = { |
| 48 'project': lambda issue: issue.project_name, |
| 49 'id': lambda issue: issue.local_id, |
| 50 'owner': tracker_bizobj.GetOwnerId, |
| 51 'reporter': lambda issue: issue.reporter_id, |
| 52 'component': lambda issue: issue.component_ids, |
| 53 'cc': tracker_bizobj.GetCcIds, |
| 54 'summary': lambda issue: issue.summary.lower(), |
| 55 'stars': lambda issue: issue.star_count, |
| 56 'attachments': lambda issue: issue.attachment_count, |
| 57 'opened': lambda issue: issue.opened_timestamp, |
| 58 'closed': lambda issue: issue.closed_timestamp, |
| 59 'modified': lambda issue: issue.modified_timestamp, |
| 60 'status': tracker_bizobj.GetStatus, |
| 61 'blocked': lambda issue: bool(issue.blocked_on_iids), |
| 62 'blockedon': lambda issue: issue.blocked_on_iids or sorting.MAX_STRING, |
| 63 'blocking': lambda issue: issue.blocking_iids or sorting.MAX_STRING, |
| 64 } |
| 65 |
| 66 |
| 67 # Namedtuples that hold data parsed from post_data. |
| 68 ParsedComponents = collections.namedtuple( |
| 69 'ParsedComponents', 'entered_str, paths, paths_remove') |
| 70 ParsedFields = collections.namedtuple( |
| 71 'ParsedFields', 'vals, vals_remove, fields_clear') |
| 72 ParsedUsers = collections.namedtuple( |
| 73 'ParsedUsers', 'owner_username, owner_id, cc_usernames, ' |
| 74 'cc_usernames_remove, cc_ids, cc_ids_remove') |
| 75 ParsedBlockers = collections.namedtuple( |
| 76 'ParsedBlockers', 'entered_str, iids, dangling_refs') |
| 77 ParsedIssue = collections.namedtuple( |
| 78 'ParsedIssue', 'summary, comment, status, users, labels, ' |
| 79 'labels_remove, components, fields, template_name, attachments, ' |
| 80 'blocked_on, blocking') |
| 81 |
| 82 |
| 83 def ParseIssueRequest(cnxn, post_data, services, errors, default_project_name): |
| 84 """Parse all the possible arguments out of the request. |
| 85 |
| 86 Args: |
| 87 cnxn: connection to SQL database. |
| 88 post_data: HTML form information. |
| 89 services: Connections to persistence layer. |
| 90 errors: object to accumulate validation error info. |
| 91 default_project_name: name of the project that contains the issue. |
| 92 |
| 93 Returns: |
| 94 A namedtuple with all parsed information. User IDs are looked up, but |
| 95 also the strings are returned to allow bouncing the user back to correct |
| 96 any errors. |
| 97 """ |
| 98 summary = post_data.get('summary', '') |
| 99 comment = post_data.get('comment', '') |
| 100 status = post_data.get('status', '') |
| 101 template_name = post_data.get('template_name', '') |
| 102 component_str = post_data.get('components', '') |
| 103 label_strs = post_data.getall('label') |
| 104 |
| 105 comp_paths, comp_paths_remove = _ClassifyPlusMinusItems( |
| 106 re.split('[,;\s]+', component_str)) |
| 107 parsed_components = ParsedComponents( |
| 108 component_str, comp_paths, comp_paths_remove) |
| 109 labels, labels_remove = _ClassifyPlusMinusItems(label_strs) |
| 110 parsed_fields = _ParseIssueRequestFields(post_data) |
| 111 # TODO(jrobbins): change from numbered fields to a multi-valued field. |
| 112 attachments = _ParseIssueRequestAttachments(post_data) |
| 113 parsed_users = _ParseIssueRequestUsers(cnxn, post_data, services) |
| 114 parsed_blocked_on = _ParseBlockers( |
| 115 cnxn, post_data, services, errors, default_project_name, BLOCKED_ON) |
| 116 parsed_blocking = _ParseBlockers( |
| 117 cnxn, post_data, services, errors, default_project_name, BLOCKING) |
| 118 |
| 119 parsed_issue = ParsedIssue( |
| 120 summary, comment, status, parsed_users, labels, labels_remove, |
| 121 parsed_components, parsed_fields, template_name, attachments, |
| 122 parsed_blocked_on, parsed_blocking) |
| 123 return parsed_issue |
| 124 |
| 125 |
| 126 def _ClassifyPlusMinusItems(add_remove_list): |
| 127 """Classify the given plus-or-minus items into add and remove lists.""" |
| 128 add_remove_set = {s.strip() for s in add_remove_list} |
| 129 add_strs = [s for s in add_remove_set if s and not s.startswith('-')] |
| 130 remove_strs = [s[1:] for s in add_remove_set if s[1:] and s.startswith('-')] |
| 131 return add_strs, remove_strs |
| 132 |
| 133 |
| 134 def _ParseIssueRequestFields(post_data): |
| 135 """Iterate over post_data and return custom field values found in it.""" |
| 136 field_val_strs = {} |
| 137 field_val_strs_remove = {} |
| 138 for key in post_data.keys(): |
| 139 if key.startswith(_CUSTOM_FIELD_NAME_PREFIX): |
| 140 val_strs = [v for v in post_data.getall(key) if v] |
| 141 if val_strs: |
| 142 field_id = int(key[len(_CUSTOM_FIELD_NAME_PREFIX):]) |
| 143 if post_data.get('op_' + key) == 'remove': |
| 144 field_val_strs_remove[field_id] = val_strs |
| 145 else: |
| 146 field_val_strs[field_id] = val_strs |
| 147 |
| 148 fields_clear = [] |
| 149 op_prefix = 'op_' + _CUSTOM_FIELD_NAME_PREFIX |
| 150 for op_key in post_data.keys(): |
| 151 if op_key.startswith(op_prefix): |
| 152 if post_data.get(op_key) == 'clear': |
| 153 field_id = int(op_key[len(op_prefix):]) |
| 154 fields_clear.append(field_id) |
| 155 |
| 156 return ParsedFields(field_val_strs, field_val_strs_remove, fields_clear) |
| 157 |
| 158 |
| 159 def _ParseIssueRequestAttachments(post_data): |
| 160 """Extract and clean-up any attached files from the post data. |
| 161 |
| 162 Args: |
| 163 post_data: dict w/ values from the user's HTTP POST form data. |
| 164 |
| 165 Returns: |
| 166 [(filename, filecontents, mimetype), ...] with items for each attachment. |
| 167 """ |
| 168 # TODO(jrobbins): change from numbered fields to a multi-valued field. |
| 169 attachments = [] |
| 170 for i in xrange(1, 16): |
| 171 if 'file%s' % i in post_data: |
| 172 item = post_data['file%s' % i] |
| 173 if isinstance(item, basestring): |
| 174 continue |
| 175 if '\\' in item.filename: # IE insists on giving us the whole path. |
| 176 item.filename = item.filename[item.filename.rindex('\\') + 1:] |
| 177 if not item.filename: |
| 178 continue # Skip any FILE fields that were not filled in. |
| 179 attachments.append(( |
| 180 item.filename, item.value, |
| 181 filecontent.GuessContentTypeFromFilename(item.filename))) |
| 182 |
| 183 return attachments |
| 184 |
| 185 |
| 186 def _ParseIssueRequestUsers(cnxn, post_data, services): |
| 187 """Extract usernames from the POST data, categorize them, and look up IDs. |
| 188 |
| 189 Args: |
| 190 cnxn: connection to SQL database. |
| 191 post_data: dict w/ data from the HTTP POST. |
| 192 services: Services. |
| 193 |
| 194 Returns: |
| 195 A namedtuple (owner_username, owner_id, cc_usernames, cc_usernames_remove, |
| 196 cc_ids, cc_ids_remove), containing: |
| 197 - issue owner's name and user ID, if any |
| 198 - the list of all cc'd usernames |
| 199 - the user IDs to add or remove from the issue CC list. |
| 200 Any of these user IDs may be None if the corresponding username |
| 201 or email address is invalid. |
| 202 """ |
| 203 # Get the user-entered values from post_data. |
| 204 cc_username_str = post_data.get('cc', '') |
| 205 owner_email = post_data.get('owner', '').strip() |
| 206 |
| 207 cc_usernames, cc_usernames_remove = _ClassifyPlusMinusItems( |
| 208 re.split('[,;\s]+', cc_username_str)) |
| 209 |
| 210 # Figure out the email addresses to lookup and do the lookup. |
| 211 emails_to_lookup = cc_usernames + cc_usernames_remove |
| 212 if owner_email: |
| 213 emails_to_lookup.append(owner_email) |
| 214 all_user_ids = services.user.LookupUserIDs( |
| 215 cnxn, emails_to_lookup, autocreate=True) |
| 216 if owner_email: |
| 217 owner_id = all_user_ids.get(owner_email) |
| 218 else: |
| 219 owner_id = framework_constants.NO_USER_SPECIFIED |
| 220 |
| 221 # Lookup the user IDs of the Cc addresses to add or remove. |
| 222 cc_ids = [all_user_ids.get(cc) for cc in cc_usernames] |
| 223 cc_ids_remove = [all_user_ids.get(cc) for cc in cc_usernames_remove] |
| 224 |
| 225 return ParsedUsers(owner_email, owner_id, cc_usernames, cc_usernames_remove, |
| 226 cc_ids, cc_ids_remove) |
| 227 |
| 228 |
| 229 def _ParseBlockers(cnxn, post_data, services, errors, default_project_name, |
| 230 field_name): |
| 231 """Parse input for issues that the current issue is blocking/blocked on. |
| 232 |
| 233 Args: |
| 234 cnxn: connection to SQL database. |
| 235 post_data: dict w/ values from the user's HTTP POST. |
| 236 services: connections to backend services. |
| 237 errors: object to accumulate validation error info. |
| 238 default_project_name: name of the project that contains the issue. |
| 239 field_name: string HTML input field name, e.g., BLOCKED_ON or BLOCKING. |
| 240 |
| 241 Returns: |
| 242 A namedtuple with the user input string, and a list of issue IDs. |
| 243 """ |
| 244 entered_str = post_data.get(field_name, '').strip() |
| 245 blocker_iids = [] |
| 246 dangling_ref_tuples = [] |
| 247 |
| 248 issue_ref = None |
| 249 for ref_str in re.split('[,;\s]+', entered_str): |
| 250 try: |
| 251 issue_ref = tracker_bizobj.ParseIssueRef(ref_str) |
| 252 except ValueError: |
| 253 setattr(errors, field_name, 'Invalid issue ID %s' % ref_str.strip()) |
| 254 break |
| 255 |
| 256 if not issue_ref: |
| 257 continue |
| 258 |
| 259 blocker_project_name, blocker_issue_id = issue_ref |
| 260 if not blocker_project_name: |
| 261 blocker_project_name = default_project_name |
| 262 |
| 263 # Detect and report if the same issue was specified. |
| 264 current_issue_id = int(post_data.get('id')) if post_data.get('id') else -1 |
| 265 if (blocker_issue_id == current_issue_id and |
| 266 blocker_project_name == default_project_name): |
| 267 setattr(errors, field_name, 'Cannot be %s the same issue' % field_name) |
| 268 break |
| 269 |
| 270 ref_projects = services.project.GetProjectsByName( |
| 271 cnxn, set([blocker_project_name])) |
| 272 blocker_iid = services.issue.ResolveIssueRefs( |
| 273 cnxn, ref_projects, default_project_name, [issue_ref]) |
| 274 if not blocker_iid: |
| 275 if blocker_project_name in settings.recognized_codesite_projects: |
| 276 # We didn't find the issue, but it had a explicitly-specified project |
| 277 # which we know is on Codesite. Allow it as a dangling reference. |
| 278 dangling_ref_tuples.append(issue_ref) |
| 279 continue |
| 280 else: |
| 281 # Otherwise, it doesn't exist, so report it. |
| 282 setattr(errors, field_name, 'Invalid issue ID %s' % ref_str.strip()) |
| 283 break |
| 284 if blocker_iid[0] not in blocker_iids: |
| 285 blocker_iids.extend(blocker_iid) |
| 286 |
| 287 blocker_iids.sort() |
| 288 dangling_ref_tuples.sort() |
| 289 return ParsedBlockers(entered_str, blocker_iids, dangling_ref_tuples) |
| 290 |
| 291 |
| 292 def IsValidIssueOwner(cnxn, project, owner_id, services): |
| 293 """Return True if the given user ID can be an issue owner. |
| 294 |
| 295 Args: |
| 296 cnxn: connection to SQL database. |
| 297 project: the current Project PB. |
| 298 owner_id: the user ID of the proposed issue owner. |
| 299 services: connections to backends. |
| 300 |
| 301 It is OK to have 0 for the owner_id, that simply means that the issue is |
| 302 unassigned. |
| 303 |
| 304 Returns: |
| 305 A pair (valid, err_msg). valid is True if the given user ID can be an |
| 306 issue owner. err_msg is an error message string to display to the user |
| 307 if valid == False, and is None if valid == True. |
| 308 """ |
| 309 # An issue is always allowed to have no owner specified. |
| 310 if owner_id == framework_constants.NO_USER_SPECIFIED: |
| 311 return True, None |
| 312 |
| 313 auth = monorailrequest.AuthData.FromUserID(cnxn, owner_id, services) |
| 314 if not framework_bizobj.UserIsInProject(project, auth.effective_ids): |
| 315 return False, 'Issue owner must be a project member' |
| 316 |
| 317 group_ids = services.usergroup.DetermineWhichUserIDsAreGroups( |
| 318 cnxn, [owner_id]) |
| 319 if owner_id in group_ids: |
| 320 return False, 'Issue owner cannot be a user group' |
| 321 |
| 322 return True, None |
| 323 |
| 324 |
| 325 def GetAllowedOpenedAndClosedIssues(mr, issue_ids, services): |
| 326 """Get filtered lists of open and closed issues identified by issue_ids. |
| 327 |
| 328 The function then filters the results to only the issues that the user |
| 329 is allowed to view. E.g., we only auto-link to issues that the user |
| 330 would be able to view if he/she clicked the link. |
| 331 |
| 332 Args: |
| 333 mr: commonly used info parsed from the request. |
| 334 issue_ids: list of int issue IDs for the target issues. |
| 335 services: connection to issue, config, and project persistence layers. |
| 336 |
| 337 Returns: |
| 338 Two lists of issues that the user is allowed to view: one for open |
| 339 issues and one for closed issues. |
| 340 """ |
| 341 open_issues, closed_issues = services.issue.GetOpenAndClosedIssues( |
| 342 mr.cnxn, issue_ids) |
| 343 project_dict = GetAllIssueProjects( |
| 344 mr.cnxn, open_issues + closed_issues, services.project) |
| 345 config_dict = services.config.GetProjectConfigs(mr.cnxn, project_dict.keys()) |
| 346 allowed_open_issues = FilterOutNonViewableIssues( |
| 347 mr.auth.effective_ids, mr.auth.user_pb, project_dict, config_dict, |
| 348 open_issues) |
| 349 allowed_closed_issues = FilterOutNonViewableIssues( |
| 350 mr.auth.effective_ids, mr.auth.user_pb, project_dict, config_dict, |
| 351 closed_issues) |
| 352 |
| 353 return allowed_open_issues, allowed_closed_issues |
| 354 |
| 355 |
| 356 def GetAllowedOpenAndClosedRelatedIssues(services, mr, issue): |
| 357 """Retrieve the issues that the given issue references. |
| 358 |
| 359 Related issues are the blocked on, blocking, and merged-into issues. |
| 360 This function also filters the results to only the issues that the |
| 361 user is allowed to view. |
| 362 |
| 363 Args: |
| 364 services: connection to issue, config, and project persistence layers. |
| 365 mr: commonly used info parsed from the request. |
| 366 issue: the Issue PB being viewed. |
| 367 |
| 368 Returns: |
| 369 Two dictionaries of issues that the user is allowed to view: one for open |
| 370 issues and one for closed issues. |
| 371 """ |
| 372 related_issue_iids = list(issue.blocked_on_iids) + list(issue.blocking_iids) |
| 373 if issue.merged_into: |
| 374 related_issue_iids.append(issue.merged_into) |
| 375 open_issues, closed_issues = GetAllowedOpenedAndClosedIssues( |
| 376 mr, related_issue_iids, services) |
| 377 open_dict = {issue.issue_id: issue for issue in open_issues} |
| 378 closed_dict = {issue.issue_id: issue for issue in closed_issues} |
| 379 return open_dict, closed_dict |
| 380 |
| 381 |
| 382 def MakeViewsForUsersInIssues(cnxn, issue_list, user_service, omit_ids=None): |
| 383 """Lookup all the users involved in any of the given issues. |
| 384 |
| 385 Args: |
| 386 cnxn: connection to SQL database. |
| 387 issue_list: list of Issue PBs from a result query. |
| 388 user_service: Connection to User backend storage. |
| 389 omit_ids: a list of user_ids to omit, e.g., because we already have them. |
| 390 |
| 391 Returns: |
| 392 A dictionary {user_id: user_view,...} for all the users involved |
| 393 in the given issues. |
| 394 """ |
| 395 issue_participant_id_set = tracker_bizobj.UsersInvolvedInIssues(issue_list) |
| 396 if omit_ids: |
| 397 issue_participant_id_set.difference_update(omit_ids) |
| 398 |
| 399 # TODO(jrobbins): consider caching View objects as well. |
| 400 users_by_id = framework_views.MakeAllUserViews( |
| 401 cnxn, user_service, issue_participant_id_set) |
| 402 |
| 403 return users_by_id |
| 404 |
| 405 |
| 406 def FormatIssueListURL( |
| 407 mr, config, absolute=True, project_names=None, **kwargs): |
| 408 """Format a link back to list view as configured by user.""" |
| 409 if project_names is None: |
| 410 project_names = [mr.project_name] |
| 411 if not tracker_constants.JUMP_RE.match(mr.query): |
| 412 if mr.query: |
| 413 kwargs['q'] = mr.query |
| 414 if mr.can and mr.can != 2: |
| 415 kwargs['can'] = mr.can |
| 416 def_col_spec = config.default_col_spec |
| 417 if mr.col_spec and mr.col_spec != def_col_spec: |
| 418 kwargs['colspec'] = mr.col_spec |
| 419 if mr.sort_spec: |
| 420 kwargs['sort'] = mr.sort_spec |
| 421 if mr.group_by_spec: |
| 422 kwargs['groupby'] = mr.group_by_spec |
| 423 if mr.start: |
| 424 kwargs['start'] = mr.start |
| 425 if mr.num != tracker_constants.DEFAULT_RESULTS_PER_PAGE: |
| 426 kwargs['num'] = mr.num |
| 427 |
| 428 if len(project_names) == 1: |
| 429 url = '/p/%s%s' % (project_names[0], urls.ISSUE_LIST) |
| 430 else: |
| 431 url = urls.ISSUE_LIST |
| 432 kwargs['projects'] = ','.join(sorted(project_names)) |
| 433 |
| 434 param_strings = ['%s=%s' % (k, urllib.quote((u'%s' % v).encode('utf-8'))) |
| 435 for k, v in kwargs.iteritems()] |
| 436 if param_strings: |
| 437 url += '?' + '&'.join(sorted(param_strings)) |
| 438 if absolute: |
| 439 url = '%s://%s%s' % (mr.request.scheme, mr.request.host, url) |
| 440 |
| 441 return url |
| 442 |
| 443 |
| 444 def FormatRelativeIssueURL(project_name, path, **kwargs): |
| 445 """Format a URL to get to an issue in the named project. |
| 446 |
| 447 Args: |
| 448 project_name: string name of the project containing the issue. |
| 449 path: string servlet path, e.g., from framework/urls.py. |
| 450 **kwargs: additional query-string parameters to include in the URL. |
| 451 |
| 452 Returns: |
| 453 A URL string. |
| 454 """ |
| 455 return framework_helpers.FormatURL( |
| 456 None, '/p/%s%s' % (project_name, path), **kwargs) |
| 457 |
| 458 |
| 459 def ComputeNewQuotaBytesUsed(project, attachments): |
| 460 """Add the given attachments to the project's attachment quota usage. |
| 461 |
| 462 Args: |
| 463 project: Project PB for the project being updated. |
| 464 attachments: a list of attachments being added to an issue. |
| 465 |
| 466 Returns: |
| 467 The new number of bytes used. |
| 468 |
| 469 Raises: |
| 470 OverAttachmentQuota: If project would go over quota. |
| 471 """ |
| 472 total_attach_size = 0L |
| 473 for _filename, content, _mimetype in attachments: |
| 474 total_attach_size += len(content) |
| 475 |
| 476 new_bytes_used = project.attachment_bytes_used + total_attach_size |
| 477 quota = (project.attachment_quota or |
| 478 tracker_constants.ISSUE_ATTACHMENTS_QUOTA_HARD) |
| 479 if new_bytes_used > quota: |
| 480 raise OverAttachmentQuota(new_bytes_used - quota) |
| 481 return new_bytes_used |
| 482 |
| 483 |
| 484 def IsUnderSoftAttachmentQuota(project): |
| 485 """Check the project's attachment quota against the soft quota limit. |
| 486 |
| 487 If there is a custom quota on the project, this will check against |
| 488 that instead of the system-wide default quota. |
| 489 |
| 490 Args: |
| 491 project: Project PB for the project to examine |
| 492 |
| 493 Returns: |
| 494 True if the project is under quota, false otherwise. |
| 495 """ |
| 496 quota = tracker_constants.ISSUE_ATTACHMENTS_QUOTA_SOFT |
| 497 if project.attachment_quota: |
| 498 quota = project.attachment_quota - _SOFT_QUOTA_LEEWAY |
| 499 |
| 500 return project.attachment_bytes_used < quota |
| 501 |
| 502 |
| 503 def GetAllIssueProjects(cnxn, issues, project_service): |
| 504 """Get all the projects that the given issues belong to. |
| 505 |
| 506 Args: |
| 507 cnxn: connection to SQL database. |
| 508 issues: list of issues, which may come from different projects. |
| 509 project_service: connection to project persistence layer. |
| 510 |
| 511 Returns: |
| 512 A dictionary {project_id: project} of all the projects that |
| 513 any of the given issues belongs to. |
| 514 """ |
| 515 needed_project_ids = {issue.project_id for issue in issues} |
| 516 project_dict = project_service.GetProjects(cnxn, needed_project_ids) |
| 517 return project_dict |
| 518 |
| 519 |
| 520 def GetPermissionsInAllProjects(user, effective_ids, projects): |
| 521 """Look up the permissions for the given user in each project.""" |
| 522 return { |
| 523 project.project_id: |
| 524 permissions.GetPermissions(user, effective_ids, project) |
| 525 for project in projects} |
| 526 |
| 527 |
| 528 def FilterOutNonViewableIssues( |
| 529 effective_ids, user, project_dict, config_dict, issues): |
| 530 """Return a filtered list of issues that the user can view.""" |
| 531 perms_dict = GetPermissionsInAllProjects( |
| 532 user, effective_ids, project_dict.values()) |
| 533 |
| 534 denied_project_ids = { |
| 535 pid for pid, p in project_dict.iteritems() |
| 536 if not permissions.CanView(effective_ids, perms_dict[pid], p, [])} |
| 537 |
| 538 results = [] |
| 539 for issue in issues: |
| 540 if issue.deleted or issue.project_id in denied_project_ids: |
| 541 continue |
| 542 |
| 543 if not permissions.HasRestrictions(issue): |
| 544 may_view = True |
| 545 else: |
| 546 perms = perms_dict[issue.project_id] |
| 547 project = project_dict[issue.project_id] |
| 548 config = config_dict.get(issue.project_id, config_dict.get('harmonized')) |
| 549 granted_perms = tracker_bizobj.GetGrantedPerms( |
| 550 issue, effective_ids, config) |
| 551 may_view = permissions.CanViewRestrictedIssueInVisibleProject( |
| 552 effective_ids, perms, project, issue, granted_perms=granted_perms) |
| 553 |
| 554 if may_view: |
| 555 results.append(issue) |
| 556 |
| 557 return results |
| 558 |
| 559 |
| 560 def MeansOpenInProject(status, config): |
| 561 """Return true if this status means that the issue is still open. |
| 562 |
| 563 Args: |
| 564 status: issue status string. E.g., 'New'. |
| 565 config: the config of the current project. |
| 566 |
| 567 Returns: |
| 568 Boolean True if the status means that the issue is open. |
| 569 """ |
| 570 status_lower = status.lower() |
| 571 |
| 572 # iterate over the list of known statuses for this project |
| 573 # return true if we find a match that declares itself to be open |
| 574 for wks in config.well_known_statuses: |
| 575 if wks.status.lower() == status_lower: |
| 576 return wks.means_open |
| 577 |
| 578 # if we didn't find a matching status we consider the status open |
| 579 return True |
| 580 |
| 581 |
| 582 def IsNoisy(num_comments, num_starrers): |
| 583 """Return True if this is a "noisy" issue that would send a ton of emails. |
| 584 |
| 585 The rule is that a very active issue with a large number of comments |
| 586 and starrers will only send notification when a comment (or change) |
| 587 is made by a project member. |
| 588 |
| 589 Args: |
| 590 num_comments: int number of comments on issue so far. |
| 591 num_starrers: int number of users who starred the issue. |
| 592 |
| 593 Returns: |
| 594 True if we will not bother starrers with an email notification for |
| 595 changes made by non-members. |
| 596 """ |
| 597 return (num_comments >= tracker_constants.NOISY_ISSUE_COMMENT_COUNT and |
| 598 num_starrers >= tracker_constants.NOISY_ISSUE_STARRER_COUNT) |
| 599 |
| 600 |
| 601 def MergeCCsAndAddComment( |
| 602 services, mr, issue, merge_into_project, merge_into_issue): |
| 603 """Modify the CC field of the target issue and add a comment to it.""" |
| 604 return MergeCCsAndAddCommentMultipleIssues( |
| 605 services, mr, [issue], merge_into_project, merge_into_issue) |
| 606 |
| 607 |
| 608 def MergeCCsAndAddCommentMultipleIssues( |
| 609 services, mr, issues, merge_into_project, merge_into_issue): |
| 610 """Modify the CC field of the target issue and add a comment to it.""" |
| 611 merge_into_restricts = permissions.GetRestrictions(merge_into_issue) |
| 612 merge_comment = '' |
| 613 source_cc = set() |
| 614 for issue in issues: |
| 615 if issue.project_name == merge_into_issue.project_name: |
| 616 issue_ref_str = '%d' % issue.local_id |
| 617 else: |
| 618 issue_ref_str = '%s:%d' % (issue.project_name, issue.local_id) |
| 619 if merge_comment: |
| 620 merge_comment += '\n' |
| 621 merge_comment += 'Issue %s has been merged into this issue.' % issue_ref_str |
| 622 |
| 623 if permissions.HasRestrictions(issue, perm='View'): |
| 624 restricts = permissions.GetRestrictions(issue) |
| 625 # Don't leak metadata from a restricted issue. |
| 626 if (issue.project_id != merge_into_issue.project_id or |
| 627 set(restricts) != set(merge_into_restricts)): |
| 628 # TODO(jrobbins): user option to choose to merge CC or not. |
| 629 # TODO(jrobbins): add a private comment rather than nothing |
| 630 continue |
| 631 |
| 632 source_cc.update(issue.cc_ids) |
| 633 if issue.owner_id: # owner_id == 0 means no owner |
| 634 source_cc.update([issue.owner_id]) |
| 635 |
| 636 target_cc = merge_into_issue.cc_ids |
| 637 add_cc = [user_id for user_id in source_cc if user_id not in target_cc] |
| 638 |
| 639 services.issue.ApplyIssueComment( |
| 640 mr.cnxn, services, mr.auth.user_id, |
| 641 merge_into_project.project_id, merge_into_issue.local_id, |
| 642 merge_into_issue.summary, merge_into_issue.status, |
| 643 merge_into_issue.owner_id, list(target_cc) + list(add_cc), |
| 644 merge_into_issue.labels, merge_into_issue.field_values, |
| 645 merge_into_issue.component_ids, merge_into_issue.blocked_on_iids, |
| 646 merge_into_issue.blocking_iids, merge_into_issue.dangling_blocked_on_refs, |
| 647 merge_into_issue.dangling_blocking_refs, merge_into_issue.merged_into, |
| 648 index_now=False, comment=merge_comment) |
| 649 |
| 650 return merge_comment |
| 651 |
| 652 |
| 653 def GetAttachmentIfAllowed(mr, services): |
| 654 """Retrieve the requested attachment, or raise an appropriate exception. |
| 655 |
| 656 Args: |
| 657 mr: commonly used info parsed from the request. |
| 658 services: connections to backend services. |
| 659 |
| 660 Returns: |
| 661 The requested Attachment PB, and the Issue that it belongs to. |
| 662 |
| 663 Raises: |
| 664 NoSuchAttachmentException: attachment was not found or was marked deleted. |
| 665 NoSuchIssueException: issue that contains attachment was not found. |
| 666 PermissionException: the user is not allowed to view the attachment. |
| 667 """ |
| 668 attachment = None |
| 669 |
| 670 attachment, cid, issue_id = services.issue.GetAttachmentAndContext( |
| 671 mr.cnxn, mr.aid) |
| 672 |
| 673 issue = services.issue.GetIssue(mr.cnxn, issue_id) |
| 674 config = services.config.GetProjectConfig(mr.cnxn, issue.project_id) |
| 675 granted_perms = tracker_bizobj.GetGrantedPerms( |
| 676 issue, mr.auth.effective_ids, config) |
| 677 permit_view = permissions.CanViewIssue( |
| 678 mr.auth.effective_ids, mr.perms, mr.project, issue, |
| 679 granted_perms=granted_perms) |
| 680 if not permit_view: |
| 681 raise permissions.PermissionException('Cannot view attachment\'s issue') |
| 682 |
| 683 comment = services.issue.GetComment(mr.cnxn, cid) |
| 684 can_delete = False |
| 685 if mr.auth.user_id and mr.project: |
| 686 can_delete = permissions.CanDelete( |
| 687 mr.auth.user_id, mr.auth.effective_ids, mr.perms, |
| 688 comment.deleted_by, comment.user_id, mr.project, |
| 689 permissions.GetRestrictions(issue), granted_perms=granted_perms) |
| 690 if comment.deleted_by and not can_delete: |
| 691 raise permissions.PermissionException('Cannot view attachment\'s comment') |
| 692 |
| 693 return attachment, issue |
| 694 |
| 695 |
| 696 def LabelsMaskedByFields(config, field_names, trim_prefix=False): |
| 697 """Return a list of EZTItems for labels that would be masked by fields.""" |
| 698 return _LabelsMaskedOrNot(config, field_names, trim_prefix=trim_prefix) |
| 699 |
| 700 |
| 701 def LabelsNotMaskedByFields(config, field_names, trim_prefix=False): |
| 702 """Return a list of EZTItems for labels that would not be masked.""" |
| 703 return _LabelsMaskedOrNot( |
| 704 config, field_names, invert=True, trim_prefix=trim_prefix) |
| 705 |
| 706 |
| 707 def _LabelsMaskedOrNot(config, field_names, invert=False, trim_prefix=False): |
| 708 """Return EZTItems for labels that'd be masked. Or not, when invert=True.""" |
| 709 field_names = [fn.lower() for fn in field_names] |
| 710 result = [] |
| 711 for wkl in config.well_known_labels: |
| 712 masked_by = tracker_bizobj.LabelIsMaskedByField(wkl.label, field_names) |
| 713 if (masked_by and not invert) or (not masked_by and invert): |
| 714 display_name = wkl.label |
| 715 if trim_prefix: |
| 716 display_name = display_name[len(masked_by) + 1:] |
| 717 result.append(template_helpers.EZTItem( |
| 718 name=display_name, |
| 719 name_padded=display_name.ljust(20), |
| 720 commented='#' if wkl.deprecated else '', |
| 721 docstring=wkl.label_docstring, |
| 722 docstring_short=template_helpers.FitUnsafeText( |
| 723 wkl.label_docstring, 40), |
| 724 idx=len(result))) |
| 725 |
| 726 return result |
| 727 |
| 728 |
| 729 def LookupComponentIDs(component_paths, config, errors): |
| 730 """Look up the IDs of the specified components in the given config.""" |
| 731 component_ids = [] |
| 732 for path in component_paths: |
| 733 if not path: |
| 734 continue |
| 735 cd = tracker_bizobj.FindComponentDef(path, config) |
| 736 if cd: |
| 737 component_ids.append(cd.component_id) |
| 738 else: |
| 739 errors.components = 'Unknown component %s' % path |
| 740 |
| 741 return component_ids |
| 742 |
| 743 |
| 744 def ParseAdminUsers(cnxn, admins_str, user_service): |
| 745 """Parse all the usernames of component, field, or template admins.""" |
| 746 admins, _remove = _ClassifyPlusMinusItems( |
| 747 re.split('[,;\s]+', admins_str)) |
| 748 all_user_ids = user_service.LookupUserIDs(cnxn, admins, autocreate=True) |
| 749 admin_ids = [all_user_ids[username] for username in admins] |
| 750 return admin_ids, admins_str |
| 751 |
| 752 |
| 753 def FilterIssueTypes(config): |
| 754 """Return a list of well-known issue types.""" |
| 755 well_known_issue_types = [] |
| 756 for wk_label in config.well_known_labels: |
| 757 if wk_label.label.lower().startswith('type-'): |
| 758 _, type_name = wk_label.label.split('-', 1) |
| 759 well_known_issue_types.append(type_name) |
| 760 |
| 761 return well_known_issue_types |
| 762 |
| 763 |
| 764 def ParseMergeFields( |
| 765 cnxn, services, project_name, post_data, status, config, issue, errors): |
| 766 """Parse info that identifies the issue to merge into, if any.""" |
| 767 merge_into_text = '' |
| 768 merge_into_ref = None |
| 769 merge_into_issue = None |
| 770 |
| 771 if status not in config.statuses_offer_merge: |
| 772 return '', None |
| 773 |
| 774 merge_into_text = post_data.get('merge_into', '') |
| 775 if merge_into_text: |
| 776 try: |
| 777 merge_into_ref = tracker_bizobj.ParseIssueRef(merge_into_text) |
| 778 except ValueError: |
| 779 logging.info('merge_into not an int: %r', merge_into_text) |
| 780 errors.merge_into_id = 'Please enter a valid issue ID' |
| 781 |
| 782 if not merge_into_ref: |
| 783 errors.merge_into_id = 'Please enter an issue ID' |
| 784 return merge_into_text, None |
| 785 |
| 786 merge_into_project_name, merge_into_id = merge_into_ref |
| 787 if (merge_into_id == issue.local_id and |
| 788 (merge_into_project_name == project_name or |
| 789 not merge_into_project_name)): |
| 790 logging.info('user tried to merge issue into itself: %r', merge_into_ref) |
| 791 errors.merge_into_id = 'Cannot merge issue into itself' |
| 792 return merge_into_text, None |
| 793 |
| 794 project = services.project.GetProjectByName( |
| 795 cnxn, merge_into_project_name or project_name) |
| 796 try: |
| 797 merge_into_issue = services.issue.GetIssueByLocalID( |
| 798 cnxn, project.project_id, merge_into_id) |
| 799 except Exception: |
| 800 logging.info('merge_into issue not found: %r', merge_into_ref) |
| 801 errors.merge_into_id = 'No such issue' |
| 802 return merge_into_text, None |
| 803 |
| 804 return merge_into_text, merge_into_issue |
| 805 |
| 806 |
| 807 def GetNewIssueStarrers(cnxn, services, issue_id, merge_into_iid): |
| 808 """Get starrers of current issue who have not starred the target issue.""" |
| 809 source_starrers = services.issue_star.LookupItemStarrers(cnxn, issue_id) |
| 810 target_starrers = services.issue_star.LookupItemStarrers( |
| 811 cnxn, merge_into_iid) |
| 812 return set(source_starrers) - set(target_starrers) |
| 813 |
| 814 |
| 815 def AddIssueStarrers( |
| 816 cnxn, services, mr, merge_into_iid, merge_into_project, new_starrers): |
| 817 """Merge all the starrers for the current issue into the target issue.""" |
| 818 project = merge_into_project or mr.project |
| 819 config = services.config.GetProjectConfig(mr.cnxn, project.project_id) |
| 820 for starrer_id in new_starrers: |
| 821 services.issue_star.SetStar( |
| 822 cnxn, services, config, merge_into_iid, starrer_id, True) |
| 823 |
| 824 |
| 825 def IsMergeAllowed(merge_into_issue, mr, services): |
| 826 """Check to see if user has permission to merge with specified issue.""" |
| 827 merge_into_project = services.project.GetProjectByName( |
| 828 mr.cnxn, merge_into_issue.project_name) |
| 829 merge_into_config = services.config.GetProjectConfig( |
| 830 mr.cnxn, merge_into_project.project_id) |
| 831 merge_granted_perms = tracker_bizobj.GetGrantedPerms( |
| 832 merge_into_issue, mr.auth.effective_ids, merge_into_config) |
| 833 |
| 834 merge_view_allowed = mr.perms.CanUsePerm( |
| 835 permissions.VIEW, mr.auth.effective_ids, |
| 836 merge_into_project, permissions.GetRestrictions(merge_into_issue), |
| 837 granted_perms=merge_granted_perms) |
| 838 merge_edit_allowed = mr.perms.CanUsePerm( |
| 839 permissions.EDIT_ISSUE, mr.auth.effective_ids, |
| 840 merge_into_project, permissions.GetRestrictions(merge_into_issue), |
| 841 granted_perms=merge_granted_perms) |
| 842 |
| 843 return merge_view_allowed and merge_edit_allowed |
| 844 |
| 845 |
| 846 class Error(Exception): |
| 847 """Base class for errors from this module.""" |
| 848 |
| 849 |
| 850 class OverAttachmentQuota(Error): |
| 851 """Project will exceed quota if the current operation is allowed.""" |
OLD | NEW |