blob: c2687db09cfa49b27f06186e95ab50c2271bcd69 [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001# 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."""
7from __future__ import print_function
8from __future__ import division
9from __future__ import absolute_import
10
11import collections
12import logging
13import re
14import time
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +020015from six.moves import urllib
Copybara854996b2021-09-07 19:36:02 +000016
17from google.appengine.api import app_identity
18import ezt
19
20from features import federated
21from framework import exceptions
22from framework import filecontent
23from framework import framework_bizobj
24from framework import framework_constants
25from framework import framework_helpers
26from framework import framework_views
27from framework import gcs_helpers
28from framework import permissions
29from framework import template_helpers
30from framework import timestr
31from framework import urls
32from proto import tracker_pb2
33from tracker import attachment_helpers
34from tracker import tracker_bizobj
35from tracker import tracker_constants
36from tracker import tracker_helpers
37
38
39class 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
115class _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
148class 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
220def _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
227def _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
235class 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 = (
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200255 gcs_helpers.SignUrl(bucket_name, gcs_object) + '&' +
256 urllib.parse.urlencode(
257 {
258 'response-content-displacement':
259 ('attachment; filename=%s' % self.filename)
260 }))
Copybara854996b2021-09-07 19:36:02 +0000261
262
263class AttachmentView(template_helpers.PBProxy):
264 """Wrapper class to make it easier to display issue attachments via EZT."""
265
266 def __init__(self, attach_pb, project_name):
267 """Get IssueAttachmentContent PB and make its fields available as attrs.
268
269 Args:
270 attach_pb: Attachment part of IssueComment protocol buffer.
271 project_name: string Name of the current project.
272 """
273 super(AttachmentView, self).__init__(attach_pb)
274 self.filesizestr = template_helpers.BytesKbOrMb(attach_pb.filesize)
275 self.downloadurl = attachment_helpers.GetDownloadURL(
276 attach_pb.attachment_id)
277 self.url = attachment_helpers.GetViewURL(
278 attach_pb, self.downloadurl, project_name)
279 self.thumbnail_url = attachment_helpers.GetThumbnailURL(
280 attach_pb, self.downloadurl)
281 self.video_url = attachment_helpers.GetVideoURL(
282 attach_pb, self.downloadurl)
283
284 self.iconurl = '/images/paperclip.png'
285
286
287class AmendmentView(object):
288 """Wrapper class that makes it easier to display an Amendment via EZT."""
289
290 def __init__(self, amendment, users_by_id, project_name):
291 """Get the info from the PB and put it into easily accessible attrs.
292
293 Args:
294 amendment: Amendment part of an IssueComment protocol buffer.
295 users_by_id: dict mapping user_ids to UserViews.
296 project_name: Name of the project the issue/comment/amendment is in.
297 """
298 # TODO(jrobbins): take field-level restrictions into account.
299 # Including the case where user is not allowed to see any amendments.
300 self.field_name = tracker_bizobj.GetAmendmentFieldName(amendment)
301 self.newvalue = tracker_bizobj.AmendmentString(amendment, users_by_id)
302 self.values = tracker_bizobj.AmendmentLinks(
303 amendment, users_by_id, project_name)
304
305
306class ComponentDefView(template_helpers.PBProxy):
307 """Wrapper class to make it easier to display component definitions."""
308
309 def __init__(self, cnxn, services, component_def, users_by_id):
310 super(ComponentDefView, self).__init__(component_def)
311
312 c_path = component_def.path
313 if '>' in c_path:
314 self.parent_path = c_path[:c_path.rindex('>')]
315 self.leaf_name = c_path[c_path.rindex('>') + 1:]
316 else:
317 self.parent_path = ''
318 self.leaf_name = c_path
319
320 self.docstring_short = template_helpers.FitUnsafeText(
321 component_def.docstring, 200)
322
323 self.admins = [users_by_id.get(admin_id)
324 for admin_id in component_def.admin_ids]
325 self.cc = [users_by_id.get(cc_id) for cc_id in component_def.cc_ids]
326 self.labels = [
327 services.config.LookupLabel(cnxn, component_def.project_id, label_id)
328 for label_id in component_def.label_ids]
329 self.classes = 'all '
330 if self.parent_path == '':
331 self.classes += 'toplevel '
332 self.classes += 'deprecated ' if component_def.deprecated else 'active '
333
334
335class ComponentValueView(object):
336 """Wrapper class that makes it easier to display a component value."""
337
338 def __init__(self, component_id, config, derived):
339 """Make the component name and docstring available as attrs.
340
341 Args:
342 component_id: int component_id to look up in the config
343 config: ProjectIssueConfig PB for the issue's project.
344 derived: True if this component was derived.
345 """
346 cd = tracker_bizobj.FindComponentDefByID(component_id, config)
347 self.path = cd.path
348 self.docstring = cd.docstring
349 self.docstring_short = template_helpers.FitUnsafeText(cd.docstring, 60)
350 self.derived = ezt.boolean(derived)
351
352
353class FieldValueView(object):
354 """Wrapper class that makes it easier to display a custom field value."""
355
356 def __init__(
357 self, fd, config, values, derived_values, issue_types, applicable=None,
358 phase_name=None):
359 """Make several values related to this field available as attrs.
360
361 Args:
362 fd: field definition to be displayed (or not, if no value).
363 config: ProjectIssueConfig PB for the issue's project.
364 values: list of explicit field values.
365 derived_values: list of derived field values.
366 issue_types: set of lowered string values from issues' "Type-*" labels.
367 applicable: optional boolean that overrides the rule that determines
368 when a field is applicable.
369 phase_name: name of the phase this field value belongs to.
370 """
371 self.field_def = FieldDefView(fd, config)
372 self.field_id = fd.field_id
373 self.field_name = fd.field_name
374 self.field_docstring = fd.docstring
375 self.field_docstring_short = template_helpers.FitUnsafeText(
376 fd.docstring, 60)
377 self.phase_name = phase_name or ""
378
379 self.values = values
380 self.derived_values = derived_values
381
382 self.applicable_type = fd.applicable_type
383 if applicable is not None:
384 self.applicable = ezt.boolean(applicable)
385 else:
386 # Note: We don't show approval types, approval sub fields, or
387 # phase fields in ezt issue pages.
388 if (fd.field_type == tracker_pb2.FieldTypes.APPROVAL_TYPE or
389 fd.approval_id or fd.is_phase_field):
390 self.applicable = ezt.boolean(False)
391 else:
392 # A field is applicable to a given issue if it (a) applies to all,
393 # issues or (b) already has a value on this issue, or (c) says that
394 # it applies to issues with this type (or a prefix of it).
395 applicable_type_lower = self.applicable_type.lower()
396 self.applicable = ezt.boolean(
397 not self.applicable_type or values or
398 any(type_label.startswith(applicable_type_lower)
399 for type_label in issue_types))
400 # TODO(jrobbins): also evaluate applicable_predicate
401
402 self.display = ezt.boolean( # or fd.show_empty
403 self.values or self.derived_values or
404 (self.applicable and not fd.is_niche))
405
406 #FieldValueView does not handle determining if it's editable
407 #by the logged-in user. This can be determined by using
408 #permission.CanEditValueForFieldDef.
409 self.is_editable = ezt.boolean(True)
410
411
412def _PrecomputeInfoForValueViews(labels, derived_labels, field_values, config,
413 phases):
414 """Organize issue values into datastructures used to make FieldValueViews."""
415 field_values_by_id = collections.defaultdict(list)
416 for fv in field_values:
417 field_values_by_id[fv.field_id].append(fv)
418 lower_enum_field_names = [
419 fd.field_name.lower() for fd in config.field_defs
420 if fd.field_type == tracker_pb2.FieldTypes.ENUM_TYPE]
421 labels_by_prefix = tracker_bizobj.LabelsByPrefix(
422 labels, lower_enum_field_names)
423 der_labels_by_prefix = tracker_bizobj.LabelsByPrefix(
424 derived_labels, lower_enum_field_names)
425 label_docs = {wkl.label.lower(): wkl.label_docstring
426 for wkl in config.well_known_labels}
427 phases_by_name = collections.defaultdict(list)
428 # group issue phases by name
429 for phase in phases:
430 phases_by_name[phase.name.lower()].append(phase)
431 return (labels_by_prefix, der_labels_by_prefix, field_values_by_id,
432 label_docs, phases_by_name)
433
434
435def MakeAllFieldValueViews(
436 config, labels, derived_labels, field_values, users_by_id,
437 parent_approval_ids=None, phases=None):
438 """Return a list of FieldValues, each containing values from the issue.
439 A phase field value view will be created for each unique phase name found
440 in the given list a phases. Phase field value views will not be created
441 if the phases list is empty.
442 """
443 parent_approval_ids = parent_approval_ids or []
444 precomp_view_info = _PrecomputeInfoForValueViews(
445 labels, derived_labels, field_values, config, phases or [])
446 def GetApplicable(fd):
447 if fd.approval_id and fd.approval_id in parent_approval_ids:
448 return True
449 return None
450 field_value_views = [
451 _MakeFieldValueView(fd, config, precomp_view_info, users_by_id,
452 applicable=GetApplicable(fd))
453 # TODO(jrobbins): field-level view restrictions, display options
454 for fd in config.field_defs
455 if not fd.is_deleted and not fd.is_phase_field]
456
457 # Make a phase field's view for each unique phase_name found in phases.
458 (_, _, _, _, phases_by_name) = precomp_view_info
459 for phase_name in phases_by_name.keys():
460 field_value_views.extend([
461 _MakeFieldValueView(
462 fd, config, precomp_view_info, users_by_id, phase_name=phase_name)
463 for fd in config.field_defs if fd.is_phase_field])
464
465 field_value_views = sorted(
466 field_value_views, key=lambda f: (f.applicable_type, f.field_name))
467 return field_value_views
468
469
470def _MakeFieldValueView(
471 fd, config, precomp_view_info, users_by_id, applicable=None,
472 phase_name=None):
473 """Return a FieldValueView with all values from the issue for that field."""
474 (labels_by_prefix, der_labels_by_prefix, field_values_by_id,
475 label_docs, phases_by_name) = precomp_view_info
476
477 field_name_lower = fd.field_name.lower()
478 values = []
479 derived_values = []
480
481 if fd.field_type == tracker_pb2.FieldTypes.ENUM_TYPE:
482 values = _ConvertLabelsToFieldValues(
483 labels_by_prefix.get(field_name_lower, []),
484 field_name_lower, label_docs)
485 derived_values = _ConvertLabelsToFieldValues(
486 der_labels_by_prefix.get(field_name_lower, []),
487 field_name_lower, label_docs)
488 else:
489 # Phases with the same name may have different phase_ids. Phases
490 # are defined during template creation and updating a template structure
491 # may result in new phase rows to be created while existing issues
492 # are referencing older phase rows.
493 phase_ids_for_phase_name = [
494 phase.phase_id for phase in phases_by_name.get(phase_name, [])]
495 # If a phase_name is given, we must filter field_values_by_id fvs to those
496 # that belong to the given phase. This is not done for labels
497 # because monorail does not support phase enum_type field values.
498 values = _MakeFieldValueItems(
499 [fv for fv in field_values_by_id.get(fd.field_id, [])
500 if not fv.derived and
501 (not phase_name or (fv.phase_id in phase_ids_for_phase_name))],
502 users_by_id)
503 derived_values = _MakeFieldValueItems(
504 [fv for fv in field_values_by_id.get(fd.field_id, [])
505 if fv.derived and
506 (not phase_name or (fv.phase_id in phase_ids_for_phase_name))],
507 users_by_id)
508
509 issue_types = (labels_by_prefix.get('type', []) +
510 der_labels_by_prefix.get('type', []))
511 issue_types_lower = [it.lower() for it in issue_types]
512
513 return FieldValueView(fd, config, values, derived_values, issue_types_lower,
514 applicable=applicable, phase_name=phase_name)
515
516
517def _MakeFieldValueItems(field_values, users_by_id):
518 """Make appropriate int, string, or user values in the given fields."""
519 result = []
520 for fv in field_values:
521 val = tracker_bizobj.GetFieldValue(fv, users_by_id)
522 result.append(template_helpers.EZTItem(
523 val=val, docstring=val, idx=len(result)))
524
525 return result
526
527
528def MakeBounceFieldValueViews(
529 field_vals, phase_field_vals, config, applicable_fields=None):
530 # type: (Sequence[proto.tracker_pb2.FieldValue],
531 # Sequence[proto.tracker_pb2.FieldValue],
532 # proto.tracker_pb2.ProjectIssueConfig
533 # Sequence[proto.tracker_pb2.FieldDef]) -> Sequence[FieldValueView]
534 """Return a list of field values to display on a validation bounce page."""
535 applicable_set = set()
536 # Handle required fields
537 if applicable_fields:
538 for fd in applicable_fields:
539 applicable_set.add(fd.field_id)
540
541 field_value_views = []
542 for fd in config.field_defs:
543 if fd.field_id in field_vals:
544 # TODO(jrobbins): also bounce derived values.
545 val_items = [
546 template_helpers.EZTItem(val=v, docstring='', idx=idx)
547 for idx, v in enumerate(field_vals[fd.field_id])]
548 field_value_views.append(FieldValueView(
549 fd, config, val_items, [], None, applicable=True))
550 elif fd.field_id in phase_field_vals:
551 vals_by_phase_name = phase_field_vals.get(fd.field_id)
552 for phase_name, values in vals_by_phase_name.items():
553 val_items = [
554 template_helpers.EZTItem(val=v, docstring='', idx=idx)
555 for idx, v in enumerate(values)]
556 field_value_views.append(FieldValueView(
557 fd, config, val_items, [], None, applicable=False,
558 phase_name=phase_name))
559 elif fd.is_required and fd.field_id in applicable_set:
560 # Show required fields that have no value set.
561 field_value_views.append(
562 FieldValueView(fd, config, [], [], None, applicable=True))
563
564 return field_value_views
565
566
567def _ConvertLabelsToFieldValues(label_values, field_name_lower, label_docs):
568 """Iterate through the given labels and pull out values for the field.
569
570 Args:
571 label_values: a list of label strings for the given field.
572 field_name_lower: lowercase string name of the custom field.
573 label_docs: {lower_label: docstring} for well-known labels in the project.
574
575 Returns:
576 A list of EZT items with val and docstring fields. One item is included
577 for each label that matches the given field name.
578 """
579 values = []
580 for idx, lab_val in enumerate(label_values):
581 full_label_lower = '%s-%s' % (field_name_lower, lab_val.lower())
582 values.append(template_helpers.EZTItem(
583 val=lab_val, docstring=label_docs.get(full_label_lower, ''), idx=idx))
584
585 return values
586
587
588class FieldDefView(template_helpers.PBProxy):
589 """Wrapper class to make it easier to display field definitions via EZT."""
590
591 def __init__(self, field_def, config, user_views=None, approval_def=None):
592 super(FieldDefView, self).__init__(field_def)
593
594 self.type_name = str(field_def.field_type)
595 self.field_def = field_def
596
597 self.choices = []
598 if field_def.field_type == tracker_pb2.FieldTypes.ENUM_TYPE:
599 self.choices = tracker_helpers.LabelsMaskedByFields(
600 config, [field_def.field_name], trim_prefix=True)
601
602 self.approvers = []
603 self.survey = ''
604 self.survey_questions = []
605 if (approval_def and
606 field_def.field_type == tracker_pb2.FieldTypes.APPROVAL_TYPE):
607 self.approvers = [user_views.get(approver_id) for
608 approver_id in approval_def.approver_ids]
609 if approval_def.survey:
610 self.survey = approval_def.survey
611 self.survey_questions = self.survey.split('\n')
612
613
614 self.docstring_short = template_helpers.FitUnsafeText(
615 field_def.docstring, 200)
616 self.validate_help = None
617
618 if field_def.is_required:
619 self.importance = 'required'
620 elif field_def.is_niche:
621 self.importance = 'niche'
622 else:
623 self.importance = 'normal'
624
625 if field_def.min_value is not None:
626 self.min_value = field_def.min_value
627 self.validate_help = 'Value must be >= %d' % field_def.min_value
628 else:
629 self.min_value = None # Otherwise it would default to 0
630
631 if field_def.max_value is not None:
632 self.max_value = field_def.max_value
633 self.validate_help = 'Value must be <= %d' % field_def.max_value
634 else:
635 self.max_value = None # Otherwise it would default to 0
636
637 if field_def.min_value is not None and field_def.max_value is not None:
638 self.validate_help = 'Value must be between %d and %d' % (
639 field_def.min_value, field_def.max_value)
640
641 if field_def.regex:
642 self.validate_help = 'Value must match regex: %s' % field_def.regex
643
644 if field_def.needs_member:
645 self.validate_help = 'Value must be a project member'
646
647 if field_def.needs_perm:
648 self.validate_help = (
649 'Value must be a project member with permission %s' %
650 field_def.needs_perm)
651
652 self.date_action_str = str(field_def.date_action or 'no_action').lower()
653
654 self.admins = []
655 if user_views:
656 self.admins = [user_views.get(admin_id)
657 for admin_id in field_def.admin_ids]
658
659 self.editors = []
660 if user_views:
661 self.editors = [
662 user_views.get(editor_id) for editor_id in field_def.editor_ids
663 ]
664
665 if field_def.approval_id:
666 self.is_approval_subfield = ezt.boolean(True)
667 self.parent_approval_name = tracker_bizobj.FindFieldDefByID(
668 field_def.approval_id, config).field_name
669 else:
670 self.is_approval_subfield = ezt.boolean(False)
671
672 self.is_phase_field = ezt.boolean(field_def.is_phase_field)
673 self.is_restricted_field = ezt.boolean(field_def.is_restricted_field)
674
675
676class IssueTemplateView(template_helpers.PBProxy):
677 """Wrapper class to make it easier to display an issue template via EZT."""
678
679 def __init__(self, mr, template, user_service, config):
680 super(IssueTemplateView, self).__init__(template)
681
682 self.ownername = ''
683 try:
684 self.owner_view = framework_views.MakeUserView(
685 mr.cnxn, user_service, template.owner_id)
686 except exceptions.NoSuchUserException:
687 self.owner_view = None
688 if self.owner_view:
689 self.ownername = self.owner_view.email
690
691 self.admin_views = list(framework_views.MakeAllUserViews(
692 mr.cnxn, user_service, template.admin_ids).values())
693 self.admin_names = ', '.join(sorted([
694 admin_view.email for admin_view in self.admin_views]))
695
696 self.summary_must_be_edited = ezt.boolean(template.summary_must_be_edited)
697 self.members_only = ezt.boolean(template.members_only)
698 self.owner_defaults_to_member = ezt.boolean(
699 template.owner_defaults_to_member)
700 self.component_required = ezt.boolean(template.component_required)
701
702 component_paths = []
703 for component_id in template.component_ids:
704 component_paths.append(
705 tracker_bizobj.FindComponentDefByID(component_id, config).path)
706 self.components = ', '.join(component_paths)
707
708 self.can_view = ezt.boolean(permissions.CanViewTemplate(
709 mr.auth.effective_ids, mr.perms, mr.project, template))
710 self.can_edit = ezt.boolean(permissions.CanEditTemplate(
711 mr.auth.effective_ids, mr.perms, mr.project, template))
712
713 field_name_set = {fd.field_name.lower() for fd in config.field_defs
714 if fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE and
715 not fd.is_deleted} # TODO(jrobbins): restrictions
716 non_masked_labels = [
717 lab for lab in template.labels
718 if not tracker_bizobj.LabelIsMaskedByField(lab, field_name_set)]
719
720 for i, label in enumerate(non_masked_labels):
721 setattr(self, 'label%d' % i, label)
722 for i in range(len(non_masked_labels), framework_constants.MAX_LABELS):
723 setattr(self, 'label%d' % i, '')
724
725 field_user_views = MakeFieldUserViews(mr.cnxn, template, user_service)
726
727 self.field_values = []
728 for fv in template.field_values:
729 self.field_values.append(template_helpers.EZTItem(
730 field_id=fv.field_id,
731 val=tracker_bizobj.GetFieldValue(fv, field_user_views),
732 idx=len(self.field_values)))
733
734 self.complete_field_values = MakeAllFieldValueViews(
735 config, template.labels, [], template.field_values, field_user_views)
736
737 # Templates only display and edit the first value of multi-valued fields, so
738 # expose a single value, if any.
739 # TODO(jrobbins): Fully support multi-valued fields in templates.
740 for idx, field_value_view in enumerate(self.complete_field_values):
741 field_value_view.idx = idx
742 if field_value_view.values:
743 field_value_view.val = field_value_view.values[0].val
744 else:
745 field_value_view.val = None
746
747
748def MakeFieldUserViews(cnxn, template, user_service):
749 """Return {user_id: user_view} for users in template field values."""
750 field_user_ids = [
751 fv.user_id for fv in template.field_values
752 if fv.user_id]
753 field_user_views = framework_views.MakeAllUserViews(
754 cnxn, user_service, field_user_ids)
755 return field_user_views
756
757
758class ConfigView(template_helpers.PBProxy):
759 """Make it easy to display most fields of a ProjectIssueConfig in EZT."""
760
761 def __init__(self, mr, services, config, template=None,
762 load_all_templates=False):
763 """Gather data for the issue section of a project admin page.
764
765 Args:
766 mr: MonorailRequest, including a database connection, the current
767 project, and authenticated user IDs.
768 services: Persist services with ProjectService, ConfigService,
769 TemplateService and UserService included.
770 config: ProjectIssueConfig for the current project..
771 template (TemplateDef, optional): the current template.
772 load_all_templates (boolean): default False. If true loads self.templates.
773
774 Returns:
775 Project info in a dict suitable for EZT.
776 """
777 super(ConfigView, self).__init__(config)
778 self.open_statuses = []
779 self.closed_statuses = []
780 for wks in config.well_known_statuses:
781 item = template_helpers.EZTItem(
782 name=wks.status,
783 name_padded=wks.status.ljust(20),
784 commented='#' if wks.deprecated else '',
785 docstring=wks.status_docstring)
786 if tracker_helpers.MeansOpenInProject(wks.status, config):
787 self.open_statuses.append(item)
788 else:
789 self.closed_statuses.append(item)
790
791 is_member = framework_bizobj.UserIsInProject(
792 mr.project, mr.auth.effective_ids)
793 template_set = services.template.GetTemplateSetForProject(mr.cnxn,
794 config.project_id)
795
796 # Filter non-viewable templates
797 self.template_names = []
798 for _, template_name, members_only in template_set:
799 if members_only and not is_member:
800 continue
801 self.template_names.append(template_name)
802
803 if load_all_templates:
804 templates = services.template.GetProjectTemplates(mr.cnxn,
805 config.project_id)
806 self.templates = [
807 IssueTemplateView(mr, tmpl, services.user, config)
808 for tmpl in templates]
809 for index, template_view in enumerate(self.templates):
810 template_view.index = index
811
812 if template:
813 self.template_view = IssueTemplateView(mr, template, services.user,
814 config)
815
816 self.field_names = [ # TODO(jrobbins): field-level controls
817 fd.field_name for fd in config.field_defs if
818 fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE and
819 not fd.is_deleted]
820 self.issue_labels = tracker_helpers.LabelsNotMaskedByFields(
821 config, self.field_names)
822 self.excl_prefixes = [
823 prefix.lower() for prefix in config.exclusive_label_prefixes]
824 self.restrict_to_known = ezt.boolean(config.restrict_to_known)
825
826 self.default_col_spec = (
827 config.default_col_spec or tracker_constants.DEFAULT_COL_SPEC)
828
829
830def StatusDefsAsText(config):
831 """Return two strings for editing open and closed status definitions."""
832 open_lines = []
833 closed_lines = []
834 for wks in config.well_known_statuses:
835 line = '%s%s%s%s' % (
836 '#' if wks.deprecated else '',
837 wks.status.ljust(20),
838 '\t= ' if wks.status_docstring else '',
839 wks.status_docstring)
840
841 if tracker_helpers.MeansOpenInProject(wks.status, config):
842 open_lines.append(line)
843 else:
844 closed_lines.append(line)
845
846 open_text = '\n'.join(open_lines)
847 closed_text = '\n'.join(closed_lines)
848 logging.info('open_text is \n%s', open_text)
849 logging.info('closed_text is \n%s', closed_text)
850 return open_text, closed_text
851
852
853def LabelDefsAsText(config):
854 """Return a string for editing label definitions."""
855 field_names = [fd.field_name for fd in config.field_defs
856 if fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE
857 and not fd.is_deleted]
858 masked_labels = tracker_helpers.LabelsMaskedByFields(config, field_names)
859 masked_set = set(masked.name for masked in masked_labels)
860
861 label_def_lines = []
862 for wkl in config.well_known_labels:
863 if wkl.label in masked_set:
864 continue
865 line = '%s%s%s%s' % (
866 '#' if wkl.deprecated else '',
867 wkl.label.ljust(20),
868 '\t= ' if wkl.label_docstring else '',
869 wkl.label_docstring)
870 label_def_lines.append(line)
871
872 labels_text = '\n'.join(label_def_lines)
873 logging.info('labels_text is \n%s', labels_text)
874 return labels_text