| # Copyright 2016 The Chromium Authors. All rights reserved. |
| # Use of this source code is govered by a BSD-style |
| # license that can be found in the LICENSE file or at |
| # https://developers.google.com/open-source/licenses/bsd |
| |
| """View objects to help display tracker business objects in templates.""" |
| |
| import collections |
| import logging |
| import re |
| import time |
| import urllib |
| |
| from google.appengine.api import app_identity |
| from third_party import ezt |
| |
| from framework import exceptions |
| from framework import filecontent |
| from framework import framework_constants |
| from framework import framework_helpers |
| from framework import framework_views |
| from framework import gcs_helpers |
| from framework import permissions |
| from framework import template_helpers |
| from framework import timestr |
| from framework import urls |
| from proto import tracker_pb2 |
| from tracker import tracker_bizobj |
| from tracker import tracker_constants |
| from tracker import tracker_helpers |
| |
| |
| class IssueView(template_helpers.PBProxy): |
| """Wrapper class that makes it easier to display an Issue via EZT.""" |
| |
| def __init__( |
| self, issue, users_by_id, config, open_related=None, |
| closed_related=None, all_related=None): |
| """Store relevant values for later display by EZT. |
| |
| Args: |
| issue: An Issue protocol buffer. |
| users_by_id: dict {user_id: UserViews} for all users mentioned in issue. |
| config: ProjectIssueConfig for this issue. |
| open_related: dict of visible open issues that are related to this issue. |
| closed_related: dict {issue_id: issue} of visible closed issues that |
| are related to this issue. |
| all_related: optional dict {issue_id: issue} of all blocked-on, blocking, |
| or merged-into issues referenced from this issue, regardless of |
| perms. |
| """ |
| super(IssueView, self).__init__(issue) |
| |
| # The users involved in this issue must be present in users_by_id if |
| # this IssueView is to be used on the issue detail or peek pages. But, |
| # they can be absent from users_by_id if the IssueView is used as a |
| # tile in the grid view. |
| self.owner = users_by_id.get(issue.owner_id) |
| self.derived_owner = users_by_id.get(issue.derived_owner_id) |
| self.cc = [users_by_id.get(cc_id) for cc_id in issue.cc_ids |
| if cc_id] |
| self.derived_cc = [users_by_id.get(cc_id) |
| for cc_id in issue.derived_cc_ids |
| if cc_id] |
| self.status = framework_views.StatusView(issue.status, config) |
| self.derived_status = framework_views.StatusView( |
| issue.derived_status, config) |
| # If we don't have a config available, we don't need to access is_open, so |
| # let it be True. |
| self.is_open = ezt.boolean( |
| not config or |
| tracker_helpers.MeansOpenInProject( |
| tracker_bizobj.GetStatus(issue), config)) |
| |
| self.components = sorted( |
| [ComponentValueView(component_id, config, False) |
| for component_id in issue.component_ids |
| if tracker_bizobj.FindComponentDefByID(component_id, config)] + |
| [ComponentValueView(component_id, config, True) |
| for component_id in issue.derived_component_ids |
| if tracker_bizobj.FindComponentDefByID(component_id, config)], |
| key=lambda cvv: cvv.path) |
| |
| self.fields = [ |
| MakeFieldValueView( |
| fd, config, issue.labels, issue.derived_labels, issue.field_values, |
| users_by_id) |
| # TODO(jrobbins): field-level view restrictions, display options |
| for fd in config.field_defs |
| if not fd.is_deleted] |
| self.fields = sorted( |
| self.fields, key=lambda f: (f.applicable_type, f.field_name)) |
| |
| field_names = [fd.field_name.lower() for fd in config.field_defs |
| if not fd.is_deleted] # TODO(jrobbins): restricts |
| self.labels = [ |
| framework_views.LabelView(label, config) |
| for label in tracker_bizobj.NonMaskedLabels(issue.labels, field_names)] |
| self.derived_labels = [ |
| framework_views.LabelView(label, config) |
| for label in issue.derived_labels |
| if not tracker_bizobj.LabelIsMaskedByField(label, field_names)] |
| self.restrictions = _RestrictionsView(issue) |
| |
| # TODO(jrobbins): sort by order of labels in project config |
| |
| self.short_summary = issue.summary[:tracker_constants.SHORT_SUMMARY_LENGTH] |
| |
| if issue.closed_timestamp: |
| self.closed = timestr.FormatAbsoluteDate(issue.closed_timestamp) |
| else: |
| self.closed = '' |
| |
| blocked_on_iids = issue.blocked_on_iids |
| blocking_iids = issue.blocking_iids |
| |
| # Note that merged_into_str and blocked_on_str includes all issue |
| # references, even those referring to issues that the user can't view, |
| # so open_related and closed_related cannot be used. |
| if all_related is not None: |
| all_blocked_on_refs = [ |
| (all_related[ref_iid].project_name, all_related[ref_iid].local_id) |
| for ref_iid in issue.blocked_on_iids] |
| all_blocked_on_refs.extend([ |
| (r.project, r.issue_id) for r in issue.dangling_blocked_on_refs]) |
| self.blocked_on_str = ', '.join( |
| tracker_bizobj.FormatIssueRef( |
| ref, default_project_name=issue.project_name) |
| for ref in all_blocked_on_refs) |
| all_blocking_refs = [ |
| (all_related[ref_iid].project_name, all_related[ref_iid].local_id) |
| for ref_iid in issue.blocking_iids] |
| all_blocking_refs.extend([ |
| (r.project, r.issue_id) for r in issue.dangling_blocking_refs]) |
| self.blocking_str = ', '.join( |
| tracker_bizobj.FormatIssueRef( |
| ref, default_project_name=issue.project_name) |
| for ref in all_blocking_refs) |
| if issue.merged_into: |
| merged_issue = all_related[issue.merged_into] |
| merged_into_ref = merged_issue.project_name, merged_issue.local_id |
| else: |
| merged_into_ref = None |
| self.merged_into_str = tracker_bizobj.FormatIssueRef( |
| merged_into_ref, default_project_name=issue.project_name) |
| |
| self.blocked_on = [] |
| self.has_dangling = ezt.boolean(self.dangling_blocked_on_refs) |
| self.blocking = [] |
| current_project_name = issue.project_name |
| |
| if (open_related is not None and closed_related is not None |
| and all_related is not None): |
| self.merged_into = IssueRefView( |
| current_project_name, all_related.get(issue.merged_into), |
| open_related, closed_related) |
| |
| self.blocked_on = [ |
| IssueRefView( |
| current_project_name, all_related.get(iid), |
| open_related, closed_related) |
| for iid in blocked_on_iids] |
| self.blocked_on.extend( |
| [DanglingIssueRefView(ref.project, ref.issue_id) |
| for ref in issue.dangling_blocked_on_refs]) |
| # TODO(jrobbins): sort by irv project_name and local_id |
| |
| self.blocking = [ |
| IssueRefView( |
| current_project_name, all_related.get(iid), |
| open_related, closed_related) |
| for iid in blocking_iids] |
| self.blocking.extend( |
| [DanglingIssueRefView(ref.project, ref.issue_id) |
| for ref in issue.dangling_blocking_refs]) |
| # TODO(jrobbins): sort by irv project_name and local_id |
| |
| visible_open_blocked_on = [ |
| irv for irv in self.blocked_on |
| if (not irv.is_dangling and |
| open_related and irv.issue_id in open_related)] |
| self.multiple_blocked_on = ezt.boolean(len(visible_open_blocked_on) >= 2) |
| self.detail_relative_url = tracker_helpers.FormatRelativeIssueURL( |
| issue.project_name, urls.ISSUE_DETAIL, id=issue.local_id) |
| |
| |
| class _RestrictionsView(object): |
| """An EZT object for the restrictions associated with an issue.""" |
| |
| # Restrict label fragments that correspond to known permissions. |
| _VIEW = permissions.VIEW.lower() |
| _EDIT = permissions.EDIT_ISSUE.lower() |
| _ADD_COMMENT = permissions.ADD_ISSUE_COMMENT.lower() |
| _KNOWN_ACTION_KINDS = {_VIEW, _EDIT, _ADD_COMMENT} |
| |
| def __init__(self, issue): |
| # List of restrictions that don't map to a known action kind. |
| self.other = [] |
| |
| restrictions_by_action = collections.defaultdict(list) |
| # We can't use GetRestrictions here, as we prefer to preserve |
| # the case of the label when showing restrictions in the UI. |
| for label in tracker_bizobj.GetLabels(issue): |
| if permissions.IsRestrictLabel(label): |
| _kw, action_kind, needed_perm = label.split('-', 2) |
| action_kind = action_kind.lower() |
| if action_kind in self._KNOWN_ACTION_KINDS: |
| restrictions_by_action[action_kind].append(needed_perm) |
| else: |
| self.other.append(label) |
| |
| self.view = ' and '.join(restrictions_by_action[self._VIEW]) |
| self.add_comment = ' and '.join(restrictions_by_action[self._ADD_COMMENT]) |
| self.edit = ' and '.join(restrictions_by_action[self._EDIT]) |
| |
| self.has_restrictions = ezt.boolean( |
| self.view or self.add_comment or self.edit or self.other) |
| |
| |
| class IssueRefView(object): |
| """A simple object to easily display links to issues in EZT.""" |
| |
| def __init__( |
| self, current_project_name, related_issue, open_dict, closed_dict): |
| """Make a simple object to display a link to a referenced issue. |
| |
| Args: |
| current_project_name: string name of the current project. |
| related_issue: issue PB of the target issue. |
| open_dict: dict {issue_id: issue} of pre-fetched open issues that the |
| user is allowed to view. |
| closed_dict: dict of pre-fetched closed issues that the user is |
| allowed to view. |
| |
| Note, the target issue may be a member of either open_dict or |
| closed_dict, or neither one. If neither, nothing is displayed. |
| """ |
| if not related_issue: |
| # Issue not found, so don't link to it. |
| self.visible = ezt.boolean(False) |
| self.url = None |
| self.display_name = 'missing issue' |
| self.issue_ref = None |
| return |
| |
| self.issue_id = related_issue.issue_id |
| self.visible = ezt.boolean( |
| self.issue_id in open_dict or self.issue_id in closed_dict) |
| self.is_open = ezt.boolean(self.issue_id in open_dict) |
| |
| if current_project_name == related_issue.project_name: |
| self.url = 'detail?id=%s' % related_issue.local_id |
| self.display_name = 'issue %s' % related_issue.local_id |
| self.issue_ref = related_issue.local_id |
| else: |
| self.url = '/p/%s%s?id=%s' % ( |
| related_issue.project_name, urls.ISSUE_DETAIL, |
| related_issue.local_id) |
| self.display_name = 'issue %s:%s' % ( |
| related_issue.project_name, related_issue.local_id) |
| self.issue_ref = self.display_name[6:] |
| |
| if self.visible: |
| self.summary = related_issue.summary |
| else: |
| self.summary = None |
| self.url = None |
| |
| self.is_dangling = ezt.boolean(False) |
| |
| def DebugString(self): |
| if not self.visible: |
| return 'IssueRefView(not visible)' |
| |
| return 'IssueRefView(%s)' % self.display_name |
| |
| |
| class DanglingIssueRefView(object): |
| |
| def __init__(self, project_name, issue_id): |
| """Makes a simple object to display a link to an issue still in Codesite. |
| |
| Satisfies the same API and internal data members as IssueRefView, |
| except for the arguments to __init__. |
| |
| Args: |
| project_name: The name of the project on Codesite |
| issue_id: The local id of the issue in that project |
| """ |
| self.visible = True |
| self.is_open = True # TODO(agable) Make a call to Codesite to set this? |
| self.url = 'https://code.google.com/p/%s/issues/detail?id=%d' % ( |
| project_name, issue_id) |
| self.display_name = 'issue %s:%d' % (project_name, issue_id) |
| self.short_name = 'issue %s:%d' % (project_name, issue_id) |
| self.summary = 'Issue %d in %s.' % (issue_id, project_name) |
| self.issue_ref = self.display_name[6:] |
| self.is_dangling = ezt.boolean(True) |
| |
| def DebugString(self): |
| return 'DanglingIssueRefView(%s)' % self.display_name |
| |
| |
| class IssueCommentView(template_helpers.PBProxy): |
| """Wrapper class that makes it easier to display an IssueComment via EZT.""" |
| |
| def __init__( |
| self, project_name, comment_pb, users_by_id, autolink, |
| all_referenced_artifacts, mr, issue, effective_ids=None): |
| """Get IssueComment PB and make its fields available as attrs. |
| |
| Args: |
| project_name: Name of the project this issue belongs to. |
| comment_pb: Comment protocol buffer. |
| users_by_id: dict mapping user_ids to UserViews, including |
| the user that entered the comment, and any changed participants. |
| autolink: utility object for automatically linking to other |
| issues, git revisions, etc. |
| all_referenced_artifacts: opaque object with details of referenced |
| artifacts that is needed by autolink. |
| mr: common information parsed from the HTTP request. |
| issue: Issue PB for the issue that this comment is part of. |
| effective_ids: optional set of int user IDs for the comment author. |
| """ |
| super(IssueCommentView, self).__init__(comment_pb) |
| |
| self.id = comment_pb.id |
| self.creator = users_by_id[comment_pb.user_id] |
| |
| # TODO(jrobbins): this should be based on the issue project, not the |
| # request project for non-project views and cross-project. |
| if mr.project: |
| self.creator_role = framework_helpers.GetRoleName( |
| effective_ids or {self.creator.user_id}, mr.project) |
| else: |
| self.creator_role = None |
| |
| time_tuple = time.localtime(comment_pb.timestamp) |
| self.date_string = timestr.FormatAbsoluteDate( |
| comment_pb.timestamp, old_format=timestr.MONTH_DAY_YEAR_FMT) |
| self.date_relative = timestr.FormatRelativeDate(comment_pb.timestamp) |
| self.date_tooltip = time.asctime(time_tuple) |
| self.date_yyyymmdd = timestr.FormatAbsoluteDate( |
| comment_pb.timestamp, recent_format=timestr.MONTH_DAY_YEAR_FMT, |
| old_format=timestr.MONTH_DAY_YEAR_FMT) |
| self.text_runs = _ParseTextRuns(comment_pb.content) |
| if autolink and not comment_pb.deleted_by: |
| self.text_runs = autolink.MarkupAutolinks( |
| mr, self.text_runs, all_referenced_artifacts) |
| |
| self.attachments = [AttachmentView(attachment, project_name) |
| for attachment in comment_pb.attachments] |
| self.amendments = sorted([ |
| AmendmentView(amendment, users_by_id, mr.project_name) |
| for amendment in comment_pb.amendments], |
| key=lambda amendment: amendment.field_name.lower()) |
| # Treat comments from banned users as being deleted. |
| self.is_deleted = (comment_pb.deleted_by or |
| (self.creator and self.creator.banned)) |
| self.can_delete = False |
| if mr.auth.user_id and mr.project: |
| # TODO(jrobbins): pass through config, then I can do: |
| # granted_perms = tracker_bizobj.GetGrantedPerms( |
| # issue, mr.auth.effective_ids, config) |
| self.can_delete = permissions.CanDelete( |
| mr.auth.user_id, mr.auth.effective_ids, mr.perms, |
| comment_pb.deleted_by, comment_pb.user_id, |
| mr.project, permissions.GetRestrictions(issue)) |
| |
| # Prevent spammers from undeleting their own comments, but |
| # allow people with permission to undelete their own comments. |
| if comment_pb.is_spam and comment_pb.user_id == mr.auth.user_id: |
| self.can_delete = mr.perms.HasPerm(permissions.MODERATE_SPAM, |
| mr.auth.user_id, mr.project) |
| |
| self.visible = self.can_delete or not self.is_deleted |
| |
| |
| _TEMPLATE_TEXT_RE = re.compile('^(<b>[^<]+</b>)', re.MULTILINE) |
| |
| |
| def _ParseTextRuns(content): |
| """Convert the user's comment to a list of TextRun objects.""" |
| chunks = _TEMPLATE_TEXT_RE.split(content) |
| runs = [_ChunkToRun(chunk) for chunk in chunks] |
| return runs |
| |
| |
| def _ChunkToRun(chunk): |
| """Convert a substring of the user's comment to a TextRun object.""" |
| if chunk.startswith('<b>') and chunk.endswith('</b>'): |
| return template_helpers.TextRun(chunk[3:-4], tag='b') |
| else: |
| return template_helpers.TextRun(chunk) |
| |
| |
| VIEWABLE_IMAGE_TYPES = [ |
| 'image/jpeg', 'image/gif', 'image/png', 'image/x-png', 'image/webp', |
| ] |
| VIEWABLE_VIDEO_TYPES = [ |
| 'video/ogg', 'video/mp4', 'video/mpg', 'video/mpeg', 'video/webm', |
| 'video/quicktime', |
| ] |
| MAX_PREVIEW_FILESIZE = 15 * 1024 * 1024 # 15 MB |
| |
| |
| class LogoView(template_helpers.PBProxy): |
| """Wrapper class to make it easier to display project logos via EZT.""" |
| |
| def __init__(self, project_pb): |
| if (not project_pb or |
| not project_pb.logo_gcs_id or |
| not project_pb.logo_file_name): |
| self.thumbnail_url = '' |
| self.viewurl = '' |
| return |
| |
| bucket_name = app_identity.get_default_gcs_bucket_name() |
| gcs_object = project_pb.logo_gcs_id |
| self.filename = project_pb.logo_file_name |
| self.mimetype = filecontent.GuessContentTypeFromFilename(self.filename) |
| |
| self.thumbnail_url = gcs_helpers.SignUrl(bucket_name, |
| gcs_object + '-thumbnail') |
| self.viewurl = ( |
| gcs_helpers.SignUrl(bucket_name, gcs_object) + '&' + urllib.urlencode( |
| {'response-content-displacement': |
| ('attachment; filename=%s' % self.filename)})) |
| |
| |
| class AttachmentView(template_helpers.PBProxy): |
| """Wrapper class to make it easier to display issue attachments via EZT.""" |
| |
| def __init__(self, attach_pb, project_name): |
| """Get IssueAttachmentContent PB and make its fields available as attrs. |
| |
| Args: |
| attach_pb: Attachment part of IssueComment protocol buffer. |
| project_name: string Name of the current project. |
| """ |
| super(AttachmentView, self).__init__(attach_pb) |
| self.filesizestr = template_helpers.BytesKbOrMb(attach_pb.filesize) |
| self.downloadurl = 'attachment?aid=%s&signed_aid=%s' % ( |
| attach_pb.attachment_id, |
| tracker_helpers.SignAttachmentID(attach_pb.attachment_id)) |
| |
| self.url = None |
| self.thumbnail_url = None |
| self.video_url = None |
| if IsViewableImage(attach_pb.mimetype, attach_pb.filesize): |
| self.url = self.downloadurl + '&inline=1' |
| self.thumbnail_url = self.url + '&thumb=1' |
| elif IsViewableVideo(attach_pb.mimetype, attach_pb.filesize): |
| self.url = self.downloadurl + '&inline=1' |
| self.video_url = self.url |
| elif IsViewableText(attach_pb.mimetype, attach_pb.filesize): |
| self.url = tracker_helpers.FormatRelativeIssueURL( |
| project_name, urls.ISSUE_ATTACHMENT_TEXT, |
| aid=attach_pb.attachment_id) |
| |
| self.iconurl = '/images/paperclip.png' |
| |
| |
| def IsViewableImage(mimetype_charset, filesize): |
| """Return true if we can safely display such an image in the browser. |
| |
| Args: |
| mimetype_charset: string with the mimetype string that we got back |
| from the 'file' command. It may have just the mimetype, or it |
| may have 'foo/bar; charset=baz'. |
| filesize: int length of the file in bytes. |
| |
| Returns: |
| True iff we should allow the user to view a thumbnail or safe version |
| of the image in the browser. False if this might not be safe to view, |
| in which case we only offer a download link. |
| """ |
| mimetype = mimetype_charset.split(';', 1)[0] |
| return (mimetype in VIEWABLE_IMAGE_TYPES and |
| filesize < MAX_PREVIEW_FILESIZE) |
| |
| |
| def IsViewableVideo(mimetype_charset, filesize): |
| """Return true if we can safely display such a video in the browser. |
| |
| Args: |
| mimetype_charset: string with the mimetype string that we got back |
| from the 'file' command. It may have just the mimetype, or it |
| may have 'foo/bar; charset=baz'. |
| filesize: int length of the file in bytes. |
| |
| Returns: |
| True iff we should allow the user to watch the video in the page. |
| """ |
| mimetype = mimetype_charset.split(';', 1)[0] |
| return (mimetype in VIEWABLE_VIDEO_TYPES and |
| filesize < MAX_PREVIEW_FILESIZE) |
| |
| |
| def IsViewableText(mimetype, filesize): |
| """Return true if we can safely display such a file as escaped text.""" |
| return (mimetype.startswith('text/') and |
| filesize < MAX_PREVIEW_FILESIZE) |
| |
| |
| class AmendmentView(object): |
| """Wrapper class that makes it easier to display an Amendment via EZT.""" |
| |
| def __init__(self, amendment, users_by_id, project_name): |
| """Get the info from the PB and put it into easily accessible attrs. |
| |
| Args: |
| amendment: Amendment part of an IssueComment protocol buffer. |
| users_by_id: dict mapping user_ids to UserViews. |
| project_name: Name of the project the issue/comment/amendment is in. |
| """ |
| # TODO(jrobbins): take field-level restrictions into account. |
| # Including the case where user is not allowed to see any amendments. |
| self.field_name = tracker_bizobj.GetAmendmentFieldName(amendment) |
| self.newvalue = tracker_bizobj.AmendmentString(amendment, users_by_id) |
| self.values = tracker_bizobj.AmendmentLinks( |
| amendment, users_by_id, project_name) |
| |
| |
| class ComponentDefView(template_helpers.PBProxy): |
| """Wrapper class to make it easier to display component definitions.""" |
| |
| def __init__(self, cnxn, services, component_def, users_by_id): |
| super(ComponentDefView, self).__init__(component_def) |
| |
| c_path = component_def.path |
| if '>' in c_path: |
| self.parent_path = c_path[:c_path.rindex('>')] |
| self.leaf_name = c_path[c_path.rindex('>') + 1:] |
| else: |
| self.parent_path = '' |
| self.leaf_name = c_path |
| |
| self.docstring_short = template_helpers.FitUnsafeText( |
| component_def.docstring, 200) |
| |
| self.admins = [users_by_id.get(admin_id) |
| for admin_id in component_def.admin_ids] |
| self.cc = [users_by_id.get(cc_id) for cc_id in component_def.cc_ids] |
| self.labels = [ |
| services.config.LookupLabel(cnxn, component_def.project_id, label_id) |
| for label_id in component_def.label_ids] |
| self.classes = 'all ' |
| if self.parent_path == '': |
| self.classes += 'toplevel ' |
| self.classes += 'deprecated ' if component_def.deprecated else 'active ' |
| |
| |
| class ComponentValueView(object): |
| """Wrapper class that makes it easier to display a component value.""" |
| |
| def __init__(self, component_id, config, derived): |
| """Make the component name and docstring available as attrs. |
| |
| Args: |
| component_id: int component_id to look up in the config |
| config: ProjectIssueConfig PB for the issue's project. |
| derived: True if this component was derived. |
| """ |
| cd = tracker_bizobj.FindComponentDefByID(component_id, config) |
| self.path = cd.path |
| self.docstring = cd.docstring |
| self.docstring_short = template_helpers.FitUnsafeText(cd.docstring, 60) |
| self.derived = ezt.boolean(derived) |
| |
| |
| class FieldValueView(object): |
| """Wrapper class that makes it easier to display a custom field value.""" |
| |
| def __init__( |
| self, fd, config, values, derived_values, issue_types, applicable=None): |
| """Make several values related to this field available as attrs. |
| |
| Args: |
| fd: field definition to be displayed (or not, if no value). |
| config: ProjectIssueConfig PB for the issue's project. |
| values: list of explicit field values. |
| derived_values: list of derived field values. |
| issue_types: set of lowered string values from issues' "Type-*" labels. |
| applicable: optional boolean that overrides the rule that determines |
| when a field is applicable. |
| """ |
| self.field_def = FieldDefView(fd, config) |
| self.field_id = fd.field_id |
| self.field_name = fd.field_name |
| self.field_docstring = fd.docstring |
| self.field_docstring_short = template_helpers.FitUnsafeText( |
| fd.docstring, 60) |
| |
| self.values = values |
| self.derived_values = derived_values |
| |
| self.applicable_type = fd.applicable_type |
| if applicable is not None: |
| self.applicable = ezt.boolean(applicable) |
| else: |
| # Note: We don't show approval types or approval sub fields |
| # in ezt issue pages |
| if (fd.field_type == tracker_pb2.FieldTypes.APPROVAL_TYPE or |
| fd.approval_id): |
| self.applicable = ezt.boolean(False) |
| else: |
| # A field is applicable to a given issue if it (a) applies to all, |
| # issues or (b) already has a value on this issue, or (c) says that |
| # it applies to issues with this type (or a prefix of it). |
| self.applicable = ezt.boolean( |
| not self.applicable_type or values or |
| any(type_label.startswith(self.applicable_type.lower()) |
| for type_label in issue_types)) |
| # TODO(jrobbins): also evaluate applicable_predicate |
| |
| self.display = ezt.boolean( # or fd.show_empty |
| self.values or self.derived_values or |
| (self.applicable and not fd.is_niche)) |
| |
| |
| def MakeFieldValueView( |
| fd, config, labels, derived_labels, field_values, users_by_id): |
| """Return a view on the issue's field value.""" |
| field_name_lower = fd.field_name.lower() |
| values = [] |
| derived_values = [] |
| |
| if fd.field_type == tracker_pb2.FieldTypes.ENUM_TYPE: |
| label_docs = {wkl.label: wkl.label_docstring |
| for wkl in config.well_known_labels} |
| values = _ConvertLabelsToFieldValues( |
| labels, field_name_lower, label_docs) |
| derived_values = _ConvertLabelsToFieldValues( |
| derived_labels, field_name_lower, label_docs) |
| else: |
| values = FindFieldValues( |
| [fv for fv in field_values if not fv.derived], |
| fd.field_id, users_by_id) |
| derived_values = FindFieldValues( |
| [fv for fv in field_values if fv.derived], |
| fd.field_id, users_by_id) |
| |
| issue_types = set() |
| for lab in list(derived_labels) + list(labels): |
| if lab.lower().startswith('type-'): |
| issue_types.add(lab.split('-', 1)[1].lower()) |
| |
| return FieldValueView(fd, config, values, derived_values, issue_types) |
| |
| |
| def FindFieldValues(field_values, field_id, users_by_id): |
| """Accumulate appropriate int, string, or user values in the given fields.""" |
| result = [] |
| for fv in field_values: |
| if fv.field_id != field_id: |
| continue |
| |
| val = tracker_bizobj.GetFieldValue(fv, users_by_id) |
| result.append(template_helpers.EZTItem( |
| val=val, docstring=val, idx=len(result))) |
| |
| return result |
| |
| |
| def MakeBounceFieldValueViews(field_vals, config): |
| """Return a list of field values to display on a validation bounce page.""" |
| field_value_views = [] |
| for fd in config.field_defs: |
| if fd.field_id in field_vals: |
| # TODO(jrobbins): also bounce derived values. |
| val_items = [ |
| template_helpers.EZTItem(val=v, docstring='', idx=idx) |
| for idx, v in enumerate(field_vals[fd.field_id])] |
| field_value_views.append(FieldValueView( |
| fd, config, val_items, [], None, applicable=True)) |
| |
| return field_value_views |
| |
| |
| def _ConvertLabelsToFieldValues(labels, field_name_lower, label_docs): |
| """Iterate through the given labels and pull out values for the field. |
| |
| Args: |
| labels: a list of label strings. |
| field_name_lower: lowercase string name of the custom field. |
| label_docs: {label: docstring} for well-known labels in the project. |
| |
| Returns: |
| A list of EZT items with val and docstring fields. One item is included |
| for each label that matches the given field name. |
| """ |
| values = [] |
| field_delim = field_name_lower + '-' |
| for idx, lab in enumerate(labels): |
| if lab.lower().startswith(field_delim): |
| val = lab[len(field_delim):] |
| values.append(template_helpers.EZTItem( |
| val=val, docstring=label_docs.get(lab, ''), idx=idx)) |
| |
| return values |
| |
| |
| class FieldDefView(template_helpers.PBProxy): |
| """Wrapper class to make it easier to display field definitions via EZT.""" |
| |
| def __init__(self, field_def, config, user_views=None, approval_def=None): |
| super(FieldDefView, self).__init__(field_def) |
| |
| self.type_name = str(field_def.field_type) |
| |
| self.choices = [] |
| if field_def.field_type == tracker_pb2.FieldTypes.ENUM_TYPE: |
| self.choices = tracker_helpers.LabelsMaskedByFields( |
| config, [field_def.field_name], trim_prefix=True) |
| |
| if (approval_def and |
| field_def.field_type == tracker_pb2.FieldTypes.APPROVAL_TYPE): |
| self.approvers = [user_views.get(approver_id) for |
| approver_id in approval_def.approver_ids] |
| self.survey = approval_def.survey |
| else: |
| self.approvers = [] |
| self.survey = None |
| |
| self.docstring_short = template_helpers.FitUnsafeText( |
| field_def.docstring, 200) |
| self.validate_help = None |
| |
| if field_def.is_required: |
| self.importance = 'required' |
| elif field_def.is_niche: |
| self.importance = 'niche' |
| else: |
| self.importance = 'normal' |
| |
| if field_def.min_value is not None: |
| self.min_value = field_def.min_value |
| self.validate_help = 'Value must be >= %d' % field_def.min_value |
| else: |
| self.min_value = None # Otherwise it would default to 0 |
| |
| if field_def.max_value is not None: |
| self.max_value = field_def.max_value |
| self.validate_help = 'Value must be <= %d' % field_def.max_value |
| else: |
| self.max_value = None # Otherwise it would default to 0 |
| |
| if field_def.min_value is not None and field_def.max_value is not None: |
| self.validate_help = 'Value must be between %d and %d' % ( |
| field_def.min_value, field_def.max_value) |
| |
| if field_def.regex: |
| self.validate_help = 'Value must match regex: %s' % field_def.regex |
| |
| if field_def.needs_member: |
| self.validate_help = 'Value must be a project member' |
| |
| if field_def.needs_perm: |
| self.validate_help = ( |
| 'Value must be a project member with permission %s' % |
| field_def.needs_perm) |
| |
| self.date_action_str = str(field_def.date_action or 'no_action').lower() |
| |
| self.admins = [] |
| if user_views: |
| self.admins = [user_views.get(admin_id) |
| for admin_id in field_def.admin_ids] |
| |
| if field_def.approval_id: |
| self.is_approval_sub_field = ezt.boolean(True) |
| self.parent_approval_name = tracker_bizobj.FindFieldDefByID( |
| field_def.approval_id, config).field_name |
| else: |
| self.is_approval_sub_field = ezt.boolean(False) |
| |
| |
| class IssueTemplateView(template_helpers.PBProxy): |
| """Wrapper class to make it easier to display an issue template via EZT.""" |
| |
| def __init__(self, mr, template, user_service, config): |
| super(IssueTemplateView, self).__init__(template) |
| |
| self.ownername = '' |
| try: |
| self.owner_view = framework_views.MakeUserView( |
| mr.cnxn, user_service, template.owner_id) |
| except exceptions.NoSuchUserException: |
| self.owner_view = None |
| if self.owner_view: |
| self.ownername = self.owner_view.email |
| |
| self.admin_views = framework_views.MakeAllUserViews( |
| mr.cnxn, user_service, template.admin_ids).values() |
| self.admin_names = ', '.join(sorted([ |
| admin_view.email for admin_view in self.admin_views])) |
| |
| self.summary_must_be_edited = ezt.boolean(template.summary_must_be_edited) |
| self.members_only = ezt.boolean(template.members_only) |
| self.owner_defaults_to_member = ezt.boolean( |
| template.owner_defaults_to_member) |
| self.component_required = ezt.boolean(template.component_required) |
| |
| component_paths = [] |
| for component_id in template.component_ids: |
| component_paths.append( |
| tracker_bizobj.FindComponentDefByID(component_id, config).path) |
| self.components = ', '.join(component_paths) |
| |
| self.can_view = ezt.boolean(permissions.CanViewTemplate( |
| mr.auth.effective_ids, mr.perms, mr.project, template)) |
| self.can_edit = ezt.boolean(permissions.CanEditTemplate( |
| mr.auth.effective_ids, mr.perms, mr.project, template)) |
| |
| field_name_set = {fd.field_name.lower() for fd in config.field_defs |
| if not fd.is_deleted} # TODO(jrobbins): restrictions |
| non_masked_labels = [ |
| lab for lab in template.labels |
| if not tracker_bizobj.LabelIsMaskedByField(lab, field_name_set)] |
| |
| for i, label in enumerate(non_masked_labels): |
| setattr(self, 'label%d' % i, label) |
| for i in range(len(non_masked_labels), framework_constants.MAX_LABELS): |
| setattr(self, 'label%d' % i, '') |
| |
| field_user_views = MakeFieldUserViews(mr.cnxn, template, user_service) |
| self.field_values = [] |
| for fv in template.field_values: |
| self.field_values.append(template_helpers.EZTItem( |
| field_id=fv.field_id, |
| val=tracker_bizobj.GetFieldValue(fv, field_user_views), |
| idx=len(self.field_values))) |
| |
| self.complete_field_values = [ |
| MakeFieldValueView( |
| fd, config, template.labels, [], template.field_values, |
| field_user_views) |
| # TODO(jrobbins): field-level view restrictions, display options |
| for fd in config.field_defs |
| if not fd.is_deleted] |
| |
| # Templates only display and edit the first value of multi-valued fields, so |
| # expose a single value, if any. |
| # TODO(jrobbins): Fully support multi-valued fields in templates. |
| for idx, field_value_view in enumerate(self.complete_field_values): |
| field_value_view.idx = idx |
| if field_value_view.values: |
| field_value_view.val = field_value_view.values[0].val |
| else: |
| field_value_view.val = None |
| |
| |
| def MakeFieldUserViews(cnxn, template, user_service): |
| """Return {user_id: user_view} for users in template field values.""" |
| field_user_ids = [ |
| fv.user_id for fv in template.field_values |
| if fv.user_id] |
| field_user_views = framework_views.MakeAllUserViews( |
| cnxn, user_service, field_user_ids) |
| return field_user_views |
| |
| |
| class ConfigView(template_helpers.PBProxy): |
| """Make it easy to display most fieds of a ProjectIssueConfig in EZT.""" |
| |
| def __init__(self, mr, services, config): |
| """Gather data for the issue section of a project admin page. |
| |
| Args: |
| mr: MonorailRequest, including a database connection, the current |
| project, and authenticated user IDs. |
| services: Persist services with ProjectService, ConfigService, and |
| UserService included. |
| config: ProjectIssueConfig for the current project.. |
| |
| Returns: |
| Project info in a dict suitable for EZT. |
| """ |
| super(ConfigView, self).__init__(config) |
| self.open_statuses = [] |
| self.closed_statuses = [] |
| for wks in config.well_known_statuses: |
| item = template_helpers.EZTItem( |
| name=wks.status, |
| name_padded=wks.status.ljust(20), |
| commented='#' if wks.deprecated else '', |
| docstring=wks.status_docstring) |
| if tracker_helpers.MeansOpenInProject(wks.status, config): |
| self.open_statuses.append(item) |
| else: |
| self.closed_statuses.append(item) |
| |
| self.templates = [ |
| IssueTemplateView(mr, tmpl, services.user, config) |
| for tmpl in config.templates] |
| for index, template in enumerate(self.templates): |
| template.index = index |
| |
| self.field_names = [ # TODO(jrobbins): field-level controls |
| fd.field_name for fd in config.field_defs if not fd.is_deleted] |
| self.issue_labels = tracker_helpers.LabelsNotMaskedByFields( |
| config, self.field_names) |
| self.excl_prefixes = [ |
| prefix.lower() for prefix in config.exclusive_label_prefixes] |
| self.restrict_to_known = ezt.boolean(config.restrict_to_known) |
| |
| self.default_col_spec = ( |
| config.default_col_spec or tracker_constants.DEFAULT_COL_SPEC) |