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 """View objects to help display tracker business objects in templates.""" |
| 7 |
| 8 import collections |
| 9 import logging |
| 10 import re |
| 11 import time |
| 12 import urllib |
| 13 |
| 14 from google.appengine.api import app_identity |
| 15 from third_party import ezt |
| 16 |
| 17 from framework import filecontent |
| 18 from framework import framework_constants |
| 19 from framework import framework_helpers |
| 20 from framework import framework_views |
| 21 from framework import gcs_helpers |
| 22 from framework import permissions |
| 23 from framework import template_helpers |
| 24 from framework import timestr |
| 25 from framework import urls |
| 26 from proto import tracker_pb2 |
| 27 from services import user_svc |
| 28 from tracker import tracker_bizobj |
| 29 from tracker import tracker_constants |
| 30 from tracker import tracker_helpers |
| 31 |
| 32 |
| 33 class IssueView(template_helpers.PBProxy): |
| 34 """Wrapper class that makes it easier to display an Issue via EZT.""" |
| 35 |
| 36 def __init__( |
| 37 self, issue, users_by_id, config, open_related=None, |
| 38 closed_related=None, all_related=None): |
| 39 """Store relevant values for later display by EZT. |
| 40 |
| 41 Args: |
| 42 issue: An Issue protocol buffer. |
| 43 users_by_id: dict {user_id: UserViews} for all users mentioned in issue. |
| 44 config: ProjectIssueConfig for this issue. |
| 45 open_related: dict of visible open issues that are related to this issue. |
| 46 closed_related: dict {issue_id: issue} of visible closed issues that |
| 47 are related to this issue. |
| 48 all_related: dict {issue_id: issue} of all blocked-on, blocking, |
| 49 or merged-into issues referenced from this issue, regardless of |
| 50 perms. |
| 51 """ |
| 52 super(IssueView, self).__init__(issue) |
| 53 |
| 54 # The users involved in this issue must be present in users_by_id if |
| 55 # this IssueView is to be used on the issue detail or peek pages. But, |
| 56 # they can be absent from users_by_id if the IssueView is used as a |
| 57 # tile in the grid view. |
| 58 self.owner = users_by_id.get(issue.owner_id) |
| 59 self.derived_owner = users_by_id.get(issue.derived_owner_id) |
| 60 self.cc = [users_by_id.get(cc_id) for cc_id in issue.cc_ids |
| 61 if cc_id] |
| 62 self.derived_cc = [users_by_id.get(cc_id) |
| 63 for cc_id in issue.derived_cc_ids |
| 64 if cc_id] |
| 65 self.status = framework_views.StatusView(issue.status, config) |
| 66 self.derived_status = framework_views.StatusView( |
| 67 issue.derived_status, config) |
| 68 # If we don't have a config available, we don't need to access is_open, so |
| 69 # let it be True. |
| 70 self.is_open = ezt.boolean( |
| 71 not config or |
| 72 tracker_helpers.MeansOpenInProject( |
| 73 tracker_bizobj.GetStatus(issue), config)) |
| 74 |
| 75 self.components = sorted( |
| 76 [ComponentValueView(component_id, config, False) |
| 77 for component_id in issue.component_ids |
| 78 if tracker_bizobj.FindComponentDefByID(component_id, config)] + |
| 79 [ComponentValueView(component_id, config, True) |
| 80 for component_id in issue.derived_component_ids |
| 81 if tracker_bizobj.FindComponentDefByID(component_id, config)], |
| 82 key=lambda cvv: cvv.path) |
| 83 |
| 84 self.fields = [ |
| 85 MakeFieldValueView( |
| 86 fd, config, issue.labels, issue.derived_labels, issue.field_values, |
| 87 users_by_id) |
| 88 # TODO(jrobbins): field-level view restrictions, display options |
| 89 for fd in config.field_defs |
| 90 if not fd.is_deleted] |
| 91 self.fields = sorted( |
| 92 self.fields, key=lambda f: (f.applicable_type, f.field_name)) |
| 93 |
| 94 field_names = [fd.field_name.lower() for fd in config.field_defs |
| 95 if not fd.is_deleted] # TODO(jrobbins): restricts |
| 96 self.labels = [ |
| 97 framework_views.LabelView(label, config) |
| 98 for label in tracker_bizobj.NonMaskedLabels(issue.labels, field_names)] |
| 99 self.derived_labels = [ |
| 100 framework_views.LabelView(label, config) |
| 101 for label in issue.derived_labels |
| 102 if not tracker_bizobj.LabelIsMaskedByField(label, field_names)] |
| 103 self.restrictions = _RestrictionsView(issue) |
| 104 |
| 105 # TODO(jrobbins): sort by order of labels in project config |
| 106 |
| 107 self.short_summary = issue.summary[:tracker_constants.SHORT_SUMMARY_LENGTH] |
| 108 |
| 109 if issue.closed_timestamp: |
| 110 self.closed = timestr.FormatAbsoluteDate(issue.closed_timestamp) |
| 111 else: |
| 112 self.closed = '' |
| 113 |
| 114 blocked_on_iids = issue.blocked_on_iids |
| 115 blocking_iids = issue.blocking_iids |
| 116 |
| 117 # Note that merged_into_str and blocked_on_str includes all issue |
| 118 # references, even those referring to issues that the user can't view, |
| 119 # so open_related and closed_related cannot be used. |
| 120 if all_related is not None: |
| 121 all_blocked_on_refs = [ |
| 122 (all_related[ref_iid].project_name, all_related[ref_iid].local_id) |
| 123 for ref_iid in issue.blocked_on_iids] |
| 124 all_blocked_on_refs.extend([ |
| 125 (r.project, r.issue_id) for r in issue.dangling_blocked_on_refs]) |
| 126 self.blocked_on_str = ', '.join( |
| 127 tracker_bizobj.FormatIssueRef( |
| 128 ref, default_project_name=issue.project_name) |
| 129 for ref in all_blocked_on_refs) |
| 130 all_blocking_refs = [ |
| 131 (all_related[ref_iid].project_name, all_related[ref_iid].local_id) |
| 132 for ref_iid in issue.blocking_iids] |
| 133 all_blocking_refs.extend([ |
| 134 (r.project, r.issue_id) for r in issue.dangling_blocking_refs]) |
| 135 self.blocking_str = ', '.join( |
| 136 tracker_bizobj.FormatIssueRef( |
| 137 ref, default_project_name=issue.project_name) |
| 138 for ref in all_blocking_refs) |
| 139 if issue.merged_into: |
| 140 merged_issue = all_related[issue.merged_into] |
| 141 merged_into_ref = merged_issue.project_name, merged_issue.local_id |
| 142 else: |
| 143 merged_into_ref = None |
| 144 self.merged_into_str = tracker_bizobj.FormatIssueRef( |
| 145 merged_into_ref, default_project_name=issue.project_name) |
| 146 |
| 147 self.blocked_on = [] |
| 148 self.blocking = [] |
| 149 current_project_name = issue.project_name |
| 150 |
| 151 if open_related is not None and closed_related is not None: |
| 152 self.merged_into = IssueRefView( |
| 153 current_project_name, issue.merged_into, |
| 154 open_related, closed_related) |
| 155 |
| 156 self.blocked_on = [ |
| 157 IssueRefView(current_project_name, iid, open_related, closed_related) |
| 158 for iid in blocked_on_iids] |
| 159 self.blocked_on.extend( |
| 160 [DanglingIssueRefView(ref.project, ref.issue_id) |
| 161 for ref in issue.dangling_blocked_on_refs]) |
| 162 self.blocked_on = [irv for irv in self.blocked_on if irv.visible] |
| 163 # TODO(jrobbins): sort by irv project_name and local_id |
| 164 |
| 165 self.blocking = [ |
| 166 IssueRefView(current_project_name, iid, open_related, closed_related) |
| 167 for iid in blocking_iids] |
| 168 self.blocking.extend( |
| 169 [DanglingIssueRefView(ref.project, ref.issue_id) |
| 170 for ref in issue.dangling_blocking_refs]) |
| 171 self.blocking = [irv for irv in self.blocking if irv.visible] |
| 172 # TODO(jrobbins): sort by irv project_name and local_id |
| 173 |
| 174 self.detail_relative_url = tracker_helpers.FormatRelativeIssueURL( |
| 175 issue.project_name, urls.ISSUE_DETAIL, id=issue.local_id) |
| 176 |
| 177 |
| 178 class _RestrictionsView(object): |
| 179 """An EZT object for the restrictions associated with an issue.""" |
| 180 |
| 181 # Restrict label fragments that correspond to known permissions. |
| 182 _VIEW = permissions.VIEW.lower() |
| 183 _EDIT = permissions.EDIT_ISSUE.lower() |
| 184 _ADD_COMMENT = permissions.ADD_ISSUE_COMMENT.lower() |
| 185 _KNOWN_ACTION_KINDS = {_VIEW, _EDIT, _ADD_COMMENT} |
| 186 |
| 187 def __init__(self, issue): |
| 188 # List of restrictions that don't map to a known action kind. |
| 189 self.other = [] |
| 190 |
| 191 restrictions_by_action = collections.defaultdict(list) |
| 192 # We can't use GetRestrictions here, as we prefer to preserve |
| 193 # the case of the label when showing restrictions in the UI. |
| 194 for label in tracker_bizobj.GetLabels(issue): |
| 195 if permissions.IsRestrictLabel(label): |
| 196 _kw, action_kind, needed_perm = label.split('-', 2) |
| 197 action_kind = action_kind.lower() |
| 198 if action_kind in self._KNOWN_ACTION_KINDS: |
| 199 restrictions_by_action[action_kind].append(needed_perm) |
| 200 else: |
| 201 self.other.append(label) |
| 202 |
| 203 self.view = ' and '.join(restrictions_by_action[self._VIEW]) |
| 204 self.add_comment = ' and '.join(restrictions_by_action[self._ADD_COMMENT]) |
| 205 self.edit = ' and '.join(restrictions_by_action[self._EDIT]) |
| 206 |
| 207 self.has_restrictions = ezt.boolean( |
| 208 self.view or self.add_comment or self.edit or self.other) |
| 209 |
| 210 |
| 211 class IssueRefView(object): |
| 212 """A simple object to easily display links to issues in EZT.""" |
| 213 |
| 214 def __init__(self, current_project_name, issue_id, open_dict, closed_dict): |
| 215 """Make a simple object to display a link to a referenced issue. |
| 216 |
| 217 Args: |
| 218 current_project_name: string name of the current project. |
| 219 issue_id: int issue ID of the target issue. |
| 220 open_dict: dict {issue_id: issue} of pre-fetched open issues that the |
| 221 user is allowed to view. |
| 222 closed_dict: dict of pre-fetched closed issues that the user is |
| 223 allowed to view. |
| 224 |
| 225 Note, the target issue may be a member of either open_dict or |
| 226 closed_dict, or neither one. If neither, nothing is displayed. |
| 227 """ |
| 228 if (not issue_id or |
| 229 issue_id not in open_dict and issue_id not in closed_dict): |
| 230 # Issue not found or not visible to this user, so don't link to it. |
| 231 self.visible = ezt.boolean(False) |
| 232 return |
| 233 |
| 234 self.visible = ezt.boolean(True) |
| 235 |
| 236 if issue_id in open_dict: |
| 237 related_issue = open_dict[issue_id] |
| 238 self.is_open = ezt.boolean(True) |
| 239 else: |
| 240 related_issue = closed_dict[issue_id] |
| 241 self.is_open = ezt.boolean(False) |
| 242 |
| 243 if current_project_name == related_issue.project_name: |
| 244 self.url = 'detail?id=%s' % related_issue.local_id |
| 245 self.display_name = 'issue %s' % related_issue.local_id |
| 246 else: |
| 247 self.url = '/p/%s%s?id=%s' % ( |
| 248 related_issue.project_name, urls.ISSUE_DETAIL, |
| 249 related_issue.local_id) |
| 250 self.display_name = 'issue %s:%s' % ( |
| 251 related_issue.project_name, related_issue.local_id) |
| 252 |
| 253 self.summary = related_issue.summary |
| 254 |
| 255 def DebugString(self): |
| 256 if not self.visible: |
| 257 return 'IssueRefView(not visible)' |
| 258 |
| 259 return 'IssueRefView(%s)' % self.display_name |
| 260 |
| 261 |
| 262 class DanglingIssueRefView(object): |
| 263 |
| 264 def __init__(self, project_name, issue_id): |
| 265 """Makes a simple object to display a link to an issue still in Codesite. |
| 266 |
| 267 Satisfies the same API and internal data members as IssueRefView, |
| 268 excpet for the arguments to __init__. |
| 269 |
| 270 Args: |
| 271 project_name: The name of the project on Codesite |
| 272 issue_id: The local id of the issue in that project |
| 273 """ |
| 274 self.visible = True |
| 275 self.is_open = True # TODO(agable) Make a call to Codesite to set this? |
| 276 self.url = 'https://code.google.com/p/%s/issues/detail?id=%d' % ( |
| 277 project_name, issue_id) |
| 278 self.display_name = 'issue %s:%d' % (project_name, issue_id) |
| 279 self.short_name = 'issue %s:%d' % (project_name, issue_id) |
| 280 self.summary = 'Issue %d in %s.' % (issue_id, project_name) |
| 281 |
| 282 def DebugString(self): |
| 283 return 'DanglingIssueRefView(%s)' % self.display_name |
| 284 |
| 285 |
| 286 class IssueCommentView(template_helpers.PBProxy): |
| 287 """Wrapper class that makes it easier to display an IssueComment via EZT.""" |
| 288 |
| 289 def __init__( |
| 290 self, project_name, comment_pb, users_by_id, autolink, |
| 291 all_referenced_artifacts, mr, issue, effective_ids=None): |
| 292 """Get IssueComment PB and make its fields available as attrs. |
| 293 |
| 294 Args: |
| 295 project_name: Name of the project this issue belongs to. |
| 296 comment_pb: Comment protocol buffer. |
| 297 users_by_id: dict mapping user_ids to UserViews, including |
| 298 the user that entered the comment, and any changed participants. |
| 299 autolink: utility object for automatically linking to other |
| 300 issues, svn revisions, etc. |
| 301 all_referenced_artifacts: opaque object with details of referenced |
| 302 artifacts that is needed by autolink. |
| 303 mr: common information parsed from the HTTP request. |
| 304 issue: Issue PB for the issue that this comment is part of. |
| 305 effective_ids: optional set of int user IDs for the comment author. |
| 306 """ |
| 307 super(IssueCommentView, self).__init__(comment_pb) |
| 308 |
| 309 self.id = comment_pb.id |
| 310 self.creator = users_by_id[comment_pb.user_id] |
| 311 |
| 312 # TODO(jrobbins): this should be based on the issue project, not the |
| 313 # request project for non-project views and cross-project. |
| 314 if mr.project: |
| 315 self.creator_role = framework_helpers.GetRoleName( |
| 316 effective_ids or {self.creator.user_id}, mr.project) |
| 317 else: |
| 318 self.creator_role = None |
| 319 |
| 320 time_tuple = time.localtime(comment_pb.timestamp) |
| 321 self.date_string = timestr.FormatAbsoluteDate( |
| 322 comment_pb.timestamp, old_format=timestr.MONTH_DAY_FMT) |
| 323 self.date_relative = timestr.FormatRelativeDate(comment_pb.timestamp) |
| 324 self.date_tooltip = time.asctime(time_tuple) |
| 325 self.text_runs = _ParseTextRuns(comment_pb.content) |
| 326 if autolink: |
| 327 self.text_runs = autolink.MarkupAutolinks( |
| 328 mr, self.text_runs, all_referenced_artifacts) |
| 329 |
| 330 self.attachments = [AttachmentView(attachment, project_name) |
| 331 for attachment in comment_pb.attachments] |
| 332 self.amendments = [ |
| 333 AmendmentView(amendment, users_by_id, mr.project_name) |
| 334 for amendment in comment_pb.amendments] |
| 335 # Treat comments from banned users as being deleted. |
| 336 self.is_deleted = (comment_pb.deleted_by or |
| 337 (self.creator and self.creator.banned)) |
| 338 self.can_delete = False |
| 339 if mr.auth.user_id and mr.project: |
| 340 # TODO(jrobbins): pass through config, then I can do: |
| 341 # granted_perms = tracker_bizobj.GetGrantedPerms( |
| 342 # issue, mr.auth.effective_ids, config) |
| 343 self.can_delete = permissions.CanDelete( |
| 344 mr.auth.user_id, mr.auth.effective_ids, mr.perms, |
| 345 comment_pb.deleted_by, comment_pb.user_id, |
| 346 mr.project, permissions.GetRestrictions(issue)) |
| 347 |
| 348 # Prevent spammers from undeleting their own comments, but |
| 349 # allow people with permission to undelete their own comments. |
| 350 if comment_pb.is_spam and comment_pb.user_id == mr.auth.user_id: |
| 351 self.can_delete = mr.perms.HasPerm(permissions.MODERATE_SPAM, |
| 352 mr.auth.user_id, mr.project) |
| 353 |
| 354 self.visible = self.can_delete or not self.is_deleted |
| 355 |
| 356 |
| 357 _TEMPLATE_TEXT_RE = re.compile('^(<b>[^<]+</b>)', re.MULTILINE) |
| 358 |
| 359 |
| 360 def _ParseTextRuns(content): |
| 361 """Convert the user's comment to a list of TextRun objects.""" |
| 362 chunks = _TEMPLATE_TEXT_RE.split(content) |
| 363 runs = [_ChunkToRun(chunk) for chunk in chunks] |
| 364 return runs |
| 365 |
| 366 |
| 367 def _ChunkToRun(chunk): |
| 368 """Convert a substring of the user's comment to a TextRun object.""" |
| 369 if chunk.startswith('<b>') and chunk.endswith('</b>'): |
| 370 return template_helpers.TextRun(chunk[3:-4], tag='b') |
| 371 else: |
| 372 return template_helpers.TextRun(chunk) |
| 373 |
| 374 |
| 375 VIEWABLE_IMAGE_TYPES = ['image/jpeg', 'image/gif', 'image/png', 'image/x-png'] |
| 376 MAX_PREVIEW_FILESIZE = 4 * 1024 * 1024 # 4MB |
| 377 |
| 378 |
| 379 class LogoView(template_helpers.PBProxy): |
| 380 """Wrapper class to make it easier to display project logos via EZT.""" |
| 381 |
| 382 def __init__(self, project_pb): |
| 383 if (not project_pb or |
| 384 not project_pb.logo_gcs_id or |
| 385 not project_pb.logo_file_name): |
| 386 self.thumbnail_url = '' |
| 387 self.viewurl = '' |
| 388 return |
| 389 |
| 390 object_path = ('/' + app_identity.get_default_gcs_bucket_name() + |
| 391 project_pb.logo_gcs_id) |
| 392 self.filename = project_pb.logo_file_name |
| 393 self.mimetype = filecontent.GuessContentTypeFromFilename(self.filename) |
| 394 |
| 395 self.thumbnail_url = gcs_helpers.SignUrl(object_path + '-thumbnail') |
| 396 self.viewurl = ( |
| 397 gcs_helpers.SignUrl(object_path) + '&' + urllib.urlencode( |
| 398 {'response-content-displacement': |
| 399 ('attachment; filename=%s' % self.filename)})) |
| 400 |
| 401 |
| 402 class AttachmentView(template_helpers.PBProxy): |
| 403 """Wrapper class to make it easier to display issue attachments via EZT.""" |
| 404 |
| 405 def __init__(self, attach_pb, project_name): |
| 406 """Get IssueAttachmentContent PB and make its fields available as attrs. |
| 407 |
| 408 Args: |
| 409 attach_pb: Attachment part of IssueComment protocol buffer. |
| 410 project_name: string Name of the current project. |
| 411 """ |
| 412 super(AttachmentView, self).__init__(attach_pb) |
| 413 self.filesizestr = template_helpers.BytesKbOrMb(attach_pb.filesize) |
| 414 self.downloadurl = 'attachment?aid=%s' % attach_pb.attachment_id |
| 415 |
| 416 self.url = None |
| 417 self.thumbnail_url = None |
| 418 if IsViewableImage(attach_pb.mimetype, attach_pb.filesize): |
| 419 self.url = self.downloadurl + '&inline=1' |
| 420 self.thumbnail_url = self.url + '&thumb=1' |
| 421 elif IsViewableText(attach_pb.mimetype, attach_pb.filesize): |
| 422 self.url = tracker_helpers.FormatRelativeIssueURL( |
| 423 project_name, urls.ISSUE_ATTACHMENT_TEXT, |
| 424 aid=attach_pb.attachment_id) |
| 425 |
| 426 self.iconurl = '/images/paperclip.png' |
| 427 |
| 428 |
| 429 def IsViewableImage(mimetype_charset, filesize): |
| 430 """Return true if we can safely display such an image in the browser. |
| 431 |
| 432 Args: |
| 433 mimetype_charset: string with the mimetype string that we got back |
| 434 from the 'file' command. It may have just the mimetype, or it |
| 435 may have 'foo/bar; charset=baz'. |
| 436 filesize: int length of the file in bytes. |
| 437 |
| 438 Returns: |
| 439 True iff we should allow the user to view a thumbnail or safe version |
| 440 of the image in the browser. False if this might not be safe to view, |
| 441 in which case we only offer a download link. |
| 442 """ |
| 443 mimetype = mimetype_charset.split(';', 1)[0] |
| 444 return (mimetype in VIEWABLE_IMAGE_TYPES and |
| 445 filesize < MAX_PREVIEW_FILESIZE) |
| 446 |
| 447 |
| 448 def IsViewableText(mimetype, filesize): |
| 449 """Return true if we can safely display such a file as escaped text.""" |
| 450 return (mimetype.startswith('text/') and |
| 451 filesize < MAX_PREVIEW_FILESIZE) |
| 452 |
| 453 |
| 454 class AmendmentView(object): |
| 455 """Wrapper class that makes it easier to display an Amendment via EZT.""" |
| 456 |
| 457 def __init__(self, amendment, users_by_id, project_name): |
| 458 """Get the info from the PB and put it into easily accessible attrs. |
| 459 |
| 460 Args: |
| 461 amendment: Amendment part of an IssueComment protocol buffer. |
| 462 users_by_id: dict mapping user_ids to UserViews. |
| 463 project_name: Name of the project the issue/comment/amendment is in. |
| 464 """ |
| 465 # TODO(jrobbins): take field-level restrictions into account. |
| 466 # Including the case where user is not allowed to see any amendments. |
| 467 self.field_name = tracker_bizobj.GetAmendmentFieldName(amendment) |
| 468 self.newvalue = tracker_bizobj.AmendmentString(amendment, users_by_id) |
| 469 self.values = tracker_bizobj.AmendmentLinks( |
| 470 amendment, users_by_id, project_name) |
| 471 |
| 472 |
| 473 class ComponentDefView(template_helpers.PBProxy): |
| 474 """Wrapper class to make it easier to display component definitions.""" |
| 475 |
| 476 def __init__(self, component_def, users_by_id): |
| 477 super(ComponentDefView, self).__init__(component_def) |
| 478 |
| 479 c_path = component_def.path |
| 480 if '>' in c_path: |
| 481 self.parent_path = c_path[:c_path.rindex('>')] |
| 482 self.leaf_name = c_path[c_path.rindex('>') + 1:] |
| 483 else: |
| 484 self.parent_path = '' |
| 485 self.leaf_name = c_path |
| 486 |
| 487 self.docstring_short = template_helpers.FitUnsafeText( |
| 488 component_def.docstring, 200) |
| 489 |
| 490 self.admins = [users_by_id.get(admin_id) |
| 491 for admin_id in component_def.admin_ids] |
| 492 self.cc = [users_by_id.get(cc_id) for cc_id in component_def.cc_ids] |
| 493 self.classes = 'all ' |
| 494 if self.parent_path == '': |
| 495 self.classes += 'toplevel ' |
| 496 self.classes += 'deprecated ' if component_def.deprecated else 'active ' |
| 497 |
| 498 |
| 499 class ComponentValueView(object): |
| 500 """Wrapper class that makes it easier to display a component value.""" |
| 501 |
| 502 def __init__(self, component_id, config, derived): |
| 503 """Make the component name and docstring available as attrs. |
| 504 |
| 505 Args: |
| 506 component_id: int component_id to look up in the config |
| 507 config: ProjectIssueConfig PB for the issue's project. |
| 508 derived: True if this component was derived. |
| 509 """ |
| 510 cd = tracker_bizobj.FindComponentDefByID(component_id, config) |
| 511 self.path = cd.path |
| 512 self.docstring = cd.docstring |
| 513 self.docstring_short = template_helpers.FitUnsafeText(cd.docstring, 60) |
| 514 self.derived = ezt.boolean(derived) |
| 515 |
| 516 |
| 517 class FieldValueView(object): |
| 518 """Wrapper class that makes it easier to display a custom field value.""" |
| 519 |
| 520 def __init__( |
| 521 self, fd, config, values, derived_values, issue_types, applicable=None): |
| 522 """Make several values related to this field available as attrs. |
| 523 |
| 524 Args: |
| 525 fd: field definition to be displayed (or not, if no value). |
| 526 config: ProjectIssueConfig PB for the issue's project. |
| 527 values: list of explicit field values. |
| 528 derived_values: list of derived field values. |
| 529 issue_types: set of lowered string values from issues' "Type-*" labels. |
| 530 applicable: optional boolean that overrides the rule that determines |
| 531 when a field is applicable. |
| 532 """ |
| 533 self.field_def = FieldDefView(fd, config) |
| 534 self.field_id = fd.field_id |
| 535 self.field_name = fd.field_name |
| 536 self.field_docstring = fd.docstring |
| 537 self.field_docstring_short = template_helpers.FitUnsafeText( |
| 538 fd.docstring, 60) |
| 539 |
| 540 self.values = values |
| 541 self.derived_values = derived_values |
| 542 |
| 543 self.applicable_type = fd.applicable_type |
| 544 if applicable is not None: |
| 545 self.applicable = ezt.boolean(applicable) |
| 546 else: |
| 547 # A field is applicable to a given issue if it (a) applies to all issues, |
| 548 # or (b) already has a value on this issue, or (c) says that it applies to |
| 549 # issues with this type (or a prefix of it). |
| 550 self.applicable = ezt.boolean( |
| 551 not self.applicable_type or values or |
| 552 any(type_label.startswith(self.applicable_type.lower()) |
| 553 for type_label in issue_types)) |
| 554 # TODO(jrobbins): also evaluate applicable_predicate |
| 555 |
| 556 self.display = ezt.boolean( # or fd.show_empty |
| 557 self.values or self.derived_values or self.applicable) |
| 558 |
| 559 |
| 560 def MakeFieldValueView( |
| 561 fd, config, labels, derived_labels, field_values, users_by_id): |
| 562 """Return a view on the issue's field value.""" |
| 563 field_name_lower = fd.field_name.lower() |
| 564 values = [] |
| 565 derived_values = [] |
| 566 |
| 567 if fd.field_type == tracker_pb2.FieldTypes.ENUM_TYPE: |
| 568 label_docs = {wkl.label: wkl.label_docstring |
| 569 for wkl in config.well_known_labels} |
| 570 values = _ConvertLabelsToFieldValues( |
| 571 labels, field_name_lower, label_docs) |
| 572 derived_values = _ConvertLabelsToFieldValues( |
| 573 derived_labels, field_name_lower, label_docs) |
| 574 |
| 575 else: |
| 576 values = FindFieldValues( |
| 577 [fv for fv in field_values if not fv.derived], |
| 578 fd.field_id, fd.field_type, users_by_id) |
| 579 derived_values = FindFieldValues( |
| 580 [fv for fv in field_values if fv.derived], |
| 581 fd.field_id, fd.field_type, users_by_id) |
| 582 |
| 583 issue_types = set() |
| 584 for lab in list(derived_labels) + list(labels): |
| 585 if lab.lower().startswith('type-'): |
| 586 issue_types.add(lab.split('-', 1)[1].lower()) |
| 587 |
| 588 return FieldValueView(fd, config, values, derived_values, issue_types) |
| 589 |
| 590 |
| 591 def FindFieldValues(field_values, field_id, field_type, users_by_id): |
| 592 """Accumulate appropriate int, string, or user values in the given fields.""" |
| 593 result = [] |
| 594 for fv in field_values: |
| 595 if fv.field_id != field_id: |
| 596 continue |
| 597 |
| 598 if field_type == tracker_pb2.FieldTypes.INT_TYPE: |
| 599 val = fv.int_value |
| 600 elif field_type == tracker_pb2.FieldTypes.STR_TYPE: |
| 601 val = fv.str_value |
| 602 elif field_type == tracker_pb2.FieldTypes.USER_TYPE: |
| 603 if fv.user_id in users_by_id: |
| 604 val = users_by_id[fv.user_id].email |
| 605 else: |
| 606 val = 'USER_%d' % fv.user_id # Should never be visible |
| 607 else: |
| 608 logging.error('unexpected field type %r', field_type) |
| 609 val = '' |
| 610 |
| 611 # Use ellipsis in the display val if the val is too long. |
| 612 result.append(template_helpers.EZTItem( |
| 613 val=val, docstring=val, idx=len(result))) |
| 614 |
| 615 return result |
| 616 |
| 617 |
| 618 def MakeBounceFieldValueViews(field_vals, config): |
| 619 """Return a list of field values to display on a validation bounce page.""" |
| 620 field_value_views = [] |
| 621 for fd in config.field_defs: |
| 622 if fd.field_id in field_vals: |
| 623 # TODO(jrobbins): also bounce derived values. |
| 624 val_items = [ |
| 625 template_helpers.EZTItem(val=v, docstring='', idx=idx) |
| 626 for idx, v in enumerate(field_vals[fd.field_id])] |
| 627 field_value_views.append(FieldValueView( |
| 628 fd, config, val_items, [], None, applicable=True)) |
| 629 |
| 630 return field_value_views |
| 631 |
| 632 |
| 633 def _ConvertLabelsToFieldValues(labels, field_name_lower, label_docs): |
| 634 """Iterate through the given labels and pull out values for the field. |
| 635 |
| 636 Args: |
| 637 labels: a list of label strings. |
| 638 field_name_lower: lowercase string name of the custom field. |
| 639 label_docs: {label: docstring} for well-known labels in the project. |
| 640 |
| 641 Returns: |
| 642 A list of EZT items with val and docstring fields. One item is included |
| 643 for each label that matches the given field name. |
| 644 """ |
| 645 values = [] |
| 646 field_delim = field_name_lower + '-' |
| 647 for idx, lab in enumerate(labels): |
| 648 if lab.lower().startswith(field_delim): |
| 649 val = lab[len(field_delim):] |
| 650 # Use ellipsis in the display val if the val is too long. |
| 651 val_short = template_helpers.FitUnsafeText(str(val), 20) |
| 652 values.append(template_helpers.EZTItem( |
| 653 val=val, val_short=val_short, docstring=label_docs.get(lab, ''), |
| 654 idx=idx)) |
| 655 |
| 656 return values |
| 657 |
| 658 |
| 659 class FieldDefView(template_helpers.PBProxy): |
| 660 """Wrapper class to make it easier to display field definitions via EZT.""" |
| 661 |
| 662 def __init__(self, field_def, config, user_views=None): |
| 663 super(FieldDefView, self).__init__(field_def) |
| 664 |
| 665 self.type_name = str(field_def.field_type) |
| 666 |
| 667 self.choices = [] |
| 668 if field_def.field_type == tracker_pb2.FieldTypes.ENUM_TYPE: |
| 669 self.choices = tracker_helpers.LabelsMaskedByFields( |
| 670 config, [field_def.field_name], trim_prefix=True) |
| 671 |
| 672 self.docstring_short = template_helpers.FitUnsafeText( |
| 673 field_def.docstring, 200) |
| 674 self.validate_help = None |
| 675 |
| 676 if field_def.min_value is not None: |
| 677 self.min_value = field_def.min_value |
| 678 self.validate_help = 'Value must be >= %d' % field_def.min_value |
| 679 else: |
| 680 self.min_value = None # Otherwise it would default to 0 |
| 681 |
| 682 if field_def.max_value is not None: |
| 683 self.max_value = field_def.max_value |
| 684 self.validate_help = 'Value must be <= %d' % field_def.max_value |
| 685 else: |
| 686 self.max_value = None # Otherwise it would default to 0 |
| 687 |
| 688 if field_def.min_value is not None and field_def.max_value is not None: |
| 689 self.validate_help = 'Value must be between %d and %d' % ( |
| 690 field_def.min_value, field_def.max_value) |
| 691 |
| 692 if field_def.regex: |
| 693 self.validate_help = 'Value must match regex: %s' % field_def.regex |
| 694 |
| 695 if field_def.needs_member: |
| 696 self.validate_help = 'Value must be a project member' |
| 697 |
| 698 if field_def.needs_perm: |
| 699 self.validate_help = ( |
| 700 'Value must be a project member with permission %s' % |
| 701 field_def.needs_perm) |
| 702 |
| 703 self.admins = [] |
| 704 if user_views: |
| 705 self.admins = [user_views.get(admin_id) |
| 706 for admin_id in field_def.admin_ids] |
| 707 |
| 708 |
| 709 class IssueTemplateView(template_helpers.PBProxy): |
| 710 """Wrapper class to make it easier to display an issue template via EZT.""" |
| 711 |
| 712 def __init__(self, mr, template, user_service, config): |
| 713 super(IssueTemplateView, self).__init__(template) |
| 714 |
| 715 self.ownername = '' |
| 716 try: |
| 717 self.owner_view = framework_views.MakeUserView( |
| 718 mr.cnxn, user_service, template.owner_id) |
| 719 except user_svc.NoSuchUserException: |
| 720 self.owner_view = None |
| 721 if self.owner_view: |
| 722 self.ownername = self.owner_view.email |
| 723 |
| 724 self.admin_views = framework_views.MakeAllUserViews( |
| 725 mr.cnxn, user_service, template.admin_ids).values() |
| 726 self.admin_names = ', '.join(sorted([ |
| 727 admin_view.email for admin_view in self.admin_views])) |
| 728 |
| 729 self.summary_must_be_edited = ezt.boolean(template.summary_must_be_edited) |
| 730 self.members_only = ezt.boolean(template.members_only) |
| 731 self.owner_defaults_to_member = ezt.boolean( |
| 732 template.owner_defaults_to_member) |
| 733 self.component_required = ezt.boolean(template.component_required) |
| 734 |
| 735 component_paths = [] |
| 736 for component_id in template.component_ids: |
| 737 component_paths.append( |
| 738 tracker_bizobj.FindComponentDefByID(component_id, config).path) |
| 739 self.components = ', '.join(component_paths) |
| 740 |
| 741 self.can_view = ezt.boolean(permissions.CanViewTemplate( |
| 742 mr.auth.effective_ids, mr.perms, mr.project, template)) |
| 743 self.can_edit = ezt.boolean(permissions.CanEditTemplate( |
| 744 mr.auth.effective_ids, mr.perms, mr.project, template)) |
| 745 |
| 746 field_name_set = {fd.field_name.lower() for fd in config.field_defs |
| 747 if not fd.is_deleted} # TODO(jrobbins): restrictions |
| 748 non_masked_labels = [ |
| 749 lab for lab in template.labels |
| 750 if not tracker_bizobj.LabelIsMaskedByField(lab, field_name_set)] |
| 751 |
| 752 for i, label in enumerate(non_masked_labels): |
| 753 setattr(self, 'label%d' % i, label) |
| 754 for i in range(len(non_masked_labels), framework_constants.MAX_LABELS): |
| 755 setattr(self, 'label%d' % i, '') |
| 756 |
| 757 field_user_views = MakeFieldUserViews(mr.cnxn, template, user_service) |
| 758 self.field_values = [] |
| 759 for fv in template.field_values: |
| 760 self.field_values.append(template_helpers.EZTItem( |
| 761 field_id=fv.field_id, |
| 762 val=tracker_bizobj.GetFieldValue(fv, field_user_views), |
| 763 idx=len(self.field_values))) |
| 764 |
| 765 self.complete_field_values = [ |
| 766 MakeFieldValueView( |
| 767 fd, config, template.labels, [], template.field_values, |
| 768 field_user_views) |
| 769 # TODO(jrobbins): field-level view restrictions, display options |
| 770 for fd in config.field_defs |
| 771 if not fd.is_deleted] |
| 772 |
| 773 # Templates only display and edit the first value of multi-valued fields, so |
| 774 # expose a single value, if any. |
| 775 # TODO(jrobbins): Fully support multi-valued fields in templates. |
| 776 for idx, field_value_view in enumerate(self.complete_field_values): |
| 777 field_value_view.idx = idx |
| 778 if field_value_view.values: |
| 779 field_value_view.val = field_value_view.values[0].val |
| 780 else: |
| 781 field_value_view.val = None |
| 782 |
| 783 |
| 784 def MakeFieldUserViews(cnxn, template, user_service): |
| 785 """Return {user_id: user_view} for users in template field values.""" |
| 786 field_user_ids = [ |
| 787 fv.user_id for fv in template.field_values |
| 788 if fv.user_id] |
| 789 field_user_views = framework_views.MakeAllUserViews( |
| 790 cnxn, user_service, field_user_ids) |
| 791 return field_user_views |
| 792 |
| 793 |
| 794 class ConfigView(template_helpers.PBProxy): |
| 795 """Make it easy to display most fieds of a ProjectIssueConfig in EZT.""" |
| 796 |
| 797 def __init__(self, mr, services, config): |
| 798 """Gather data for the issue section of a project admin page. |
| 799 |
| 800 Args: |
| 801 mr: MonorailRequest, including a database connection, the current |
| 802 project, and authenticated user IDs. |
| 803 services: Persist services with ProjectService, ConfigService, and |
| 804 UserService included. |
| 805 config: ProjectIssueConfig for the current project.. |
| 806 |
| 807 Returns: |
| 808 Project info in a dict suitable for EZT. |
| 809 """ |
| 810 super(ConfigView, self).__init__(config) |
| 811 self.open_statuses = [] |
| 812 self.closed_statuses = [] |
| 813 for wks in config.well_known_statuses: |
| 814 item = template_helpers.EZTItem( |
| 815 name=wks.status, |
| 816 name_padded=wks.status.ljust(20), |
| 817 commented='#' if wks.deprecated else '', |
| 818 docstring=wks.status_docstring) |
| 819 if tracker_helpers.MeansOpenInProject(wks.status, config): |
| 820 self.open_statuses.append(item) |
| 821 else: |
| 822 self.closed_statuses.append(item) |
| 823 |
| 824 self.templates = [ |
| 825 IssueTemplateView(mr, tmpl, services.user, config) |
| 826 for tmpl in config.templates] |
| 827 for index, template in enumerate(self.templates): |
| 828 template.index = index |
| 829 |
| 830 self.field_names = [ # TODO(jrobbins): field-level controls |
| 831 fd.field_name for fd in config.field_defs if not fd.is_deleted] |
| 832 self.issue_labels = tracker_helpers.LabelsNotMaskedByFields( |
| 833 config, self.field_names) |
| 834 self.excl_prefixes = [ |
| 835 prefix.lower() for prefix in config.exclusive_label_prefixes] |
| 836 self.restrict_to_known = ezt.boolean(config.restrict_to_known) |
| 837 |
| 838 self.default_col_spec = ( |
| 839 config.default_col_spec or tracker_constants.DEFAULT_COL_SPEC) |
OLD | NEW |