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