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