blob: 44af6b74c2577cba62ea18b67a0e8c3f366e6f5d [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"""Classes and functions for displaying grids of project artifacts.
7
8A grid is a two-dimensional display of items where the user can choose
9the X and Y axes.
10"""
11from __future__ import print_function
12from __future__ import division
13from __future__ import absolute_import
14
15import ezt
16
17import collections
18import logging
19import settings
20
21from features import features_constants
22from framework import framework_constants
23from framework import sorting
24from framework import table_view_helpers
25from framework import template_helpers
26from framework import urls
27from proto import tracker_pb2
28from tracker import tracker_bizobj
29from tracker import tracker_constants
30from tracker import tracker_helpers
31
32
33# We shorten long attribute values to fit into the table cells.
34_MAX_CELL_DISPLAY_CHARS = 70
35
36
37def SortGridHeadings(col_name, heading_value_list, users_by_id, config,
38 asc_accessors):
39 """Sort the grid headings according to well-known status and label order.
40
41 Args:
42 col_name: String column name that is used on that grid axis.
43 heading_value_list: List of grid row or column heading values.
44 users_by_id: Dict mapping user_ids to UserViews.
45 config: ProjectIssueConfig PB for the current project.
46 asc_accessors: Dict (col_name -> function()) for special columns.
47
48 Returns:
49 The same heading values, but sorted in a logical order.
50 """
51 decorated_list = []
52 fd = tracker_bizobj.FindFieldDef(col_name, config)
53 if fd and fd.field_type != tracker_pb2.FieldTypes.ENUM_TYPE: # Handle fields.
54 for value in heading_value_list:
55 field_value = tracker_bizobj.GetFieldValueWithRawValue(
56 fd.field_type, None, users_by_id, value)
57 decorated_list.append([field_value, field_value])
58 elif col_name == 'status':
59 wk_statuses = [wks.status.lower()
60 for wks in config.well_known_statuses]
61 decorated_list = [(_WKSortingValue(value.lower(), wk_statuses), value)
62 for value in heading_value_list]
63
64 elif col_name in asc_accessors: # Special cols still sort alphabetically.
65 decorated_list = [(value, value)
66 for value in heading_value_list]
67
68 else: # Anything else is assumed to be a label prefix
69 col_name_dash = col_name + '-'
70 wk_labels = []
71 for wkl in config.well_known_labels:
72 lab_lower = wkl.label.lower()
73 if lab_lower.startswith(col_name_dash):
74 wk_labels.append(lab_lower.split('-', 1)[-1])
75 decorated_list = [(_WKSortingValue(value.lower(), wk_labels), value)
76 for value in heading_value_list]
77
78 decorated_list.sort()
79 result = [decorated_tuple[1] for decorated_tuple in decorated_list]
80 logging.info('Headers for %s are: %r', col_name, result)
81 return result
82
83
84def _WKSortingValue(value, well_known_list):
85 """Return a value used to sort headings so that well-known ones are first."""
86 if not value:
87 return sorting.MAX_STRING # Undefined values sort last.
88 try:
89 # well-known values sort by index
90 return well_known_list.index(value)
91 except ValueError:
92 return value # odd-ball values lexicographically after all well-known ones
93
94
95def MakeGridData(
96 artifacts, x_attr, x_headings, y_attr, y_headings, users_by_id,
97 artifact_view_factory, all_label_values, config, related_issues,
98 hotlist_context_dict=None):
99 """Return a list of grid row items for display by EZT.
100
101 Args:
102 artifacts: a list of issues to consider showing.
103 x_attr: lowercase name of the attribute that defines the x-axis.
104 x_headings: list of values for column headings.
105 y_attr: lowercase name of the attribute that defines the y-axis.
106 y_headings: list of values for row headings.
107 users_by_id: dict {user_id: user_view, ...} for referenced users.
108 artifact_view_factory: constructor for grid tiles.
109 all_label_values: pre-parsed dictionary of values from the key-value
110 labels on each issue: {issue_id: {key: [val,...], ...}, ...}
111 config: ProjectIssueConfig PB for the current project.
112 related_issues: dict {issue_id: issue} of pre-fetched related issues.
113 hotlist_context_dict: dict{issue_id: {hotlist_item_field: field_value, ..}}
114
115 Returns:
116 A list of EZTItems, each representing one grid row, and each having
117 a nested list of grid cells.
118
119 Each grid row has a row name, and a list of cells. Each cell has a
120 list of tiles. Each tile represents one artifact. Artifacts are
121 represented once in each cell that they match, so one artifact that
122 has multiple values for a certain attribute can occur in multiple cells.
123 """
124 x_attr = x_attr.lower()
125 y_attr = y_attr.lower()
126
127 # A flat dictionary {(x, y): [cell, ...], ...] for the whole grid.
128 x_y_data = collections.defaultdict(list)
129
130 # Put each issue into the grid cell(s) where it belongs.
131 for art in artifacts:
132 if hotlist_context_dict:
133 hotlist_issues_context = hotlist_context_dict[art.issue_id]
134 else:
135 hotlist_issues_context = None
136 label_value_dict = all_label_values[art.local_id]
137 x_vals = GetArtifactAttr(
138 art, x_attr, users_by_id, label_value_dict, config, related_issues,
139 hotlist_issue_context=hotlist_issues_context)
140 y_vals = GetArtifactAttr(
141 art, y_attr, users_by_id, label_value_dict, config, related_issues,
142 hotlist_issue_context=hotlist_issues_context)
143 tile = artifact_view_factory(art)
144
145 # Put the current issue into each cell where it belongs, which will usually
146 # be exactly 1 cell, but it could be a few.
147 if x_attr != '--' and y_attr != '--': # User specified both axes.
148 for x in x_vals:
149 for y in y_vals:
150 x_y_data[x, y].append(tile)
151 elif y_attr != '--': # User only specified Y axis.
152 for y in y_vals:
153 x_y_data['All', y].append(tile)
154 elif x_attr != '--': # User only specified X axis.
155 for x in x_vals:
156 x_y_data[x, 'All'].append(tile)
157 else: # User specified neither axis.
158 x_y_data['All', 'All'].append(tile)
159
160 # Convert the dictionary to a list-of-lists so that EZT can iterate over it.
161 grid_data = []
162 i = 0
163 for y in y_headings:
164 cells_in_row = []
165 for x in x_headings:
166 tiles = x_y_data[x, y]
167 for tile in tiles:
168 tile.data_idx = i
169 i += 1
170
171 drill_down = ''
172 if x_attr != '--':
173 drill_down = MakeDrillDownSearch(x_attr, x)
174 if y_attr != '--':
175 drill_down += MakeDrillDownSearch(y_attr, y)
176
177 cells_in_row.append(template_helpers.EZTItem(
178 tiles=tiles, count=len(tiles), drill_down=drill_down))
179 grid_data.append(template_helpers.EZTItem(
180 grid_y_heading=y, cells_in_row=cells_in_row))
181
182 return grid_data
183
184
185def MakeDrillDownSearch(attr, value):
186 """Constructs search term for drill-down.
187
188 Args:
189 attr: lowercase name of the attribute to narrow the search on.
190 value: value to narrow the search to.
191
192 Returns:
193 String with user-query term to narrow a search to the given attr value.
194 """
195 if value == framework_constants.NO_VALUES:
196 return '-has:%s ' % attr
197 else:
198 return '%s=%s ' % (attr, value)
199
200
201def MakeLabelValuesDict(art):
202 """Return a dict of label values and a list of one-word labels.
203
204 Args:
205 art: artifact object, e.g., an issue PB.
206
207 Returns:
208 A dict {prefix: [suffix,...], ...} for each key-value label.
209 """
210 label_values = collections.defaultdict(list)
211 for label_name in tracker_bizobj.GetLabels(art):
212 if '-' in label_name:
213 key, value = label_name.split('-', 1)
214 label_values[key.lower()].append(value)
215
216 return label_values
217
218
219def GetArtifactAttr(
220 art, attribute_name, users_by_id, label_attr_values_dict,
221 config, related_issues, hotlist_issue_context=None):
222 """Return the requested attribute values of the given artifact.
223
224 Args:
225 art: a tracked artifact with labels, local_id, summary, stars, and owner.
226 attribute_name: lowercase string name of attribute to get.
227 users_by_id: dictionary of UserViews already created.
228 label_attr_values_dict: dictionary {'key': [value, ...], }.
229 config: ProjectIssueConfig PB for the current project.
230 related_issues: dict {issue_id: issue} of pre-fetched related issues.
231 hotlist_issue_context: dict of {hotlist_issue_field: field_value,..}
232
233 Returns:
234 A list of string attribute values, or [framework_constants.NO_VALUES]
235 if the artifact has no value for that attribute.
236 """
237 if attribute_name == '--':
238 return []
239 if attribute_name == 'id':
240 return [art.local_id]
241 if attribute_name == 'summary':
242 return [art.summary]
243 if attribute_name == 'status':
244 return [tracker_bizobj.GetStatus(art)]
245 if attribute_name == 'stars':
246 return [art.star_count]
247 if attribute_name == 'attachments':
248 return [art.attachment_count]
249 # TODO(jrobbins): support blocking
250 if attribute_name == 'project':
251 return [art.project_name]
252 if attribute_name == 'mergedinto':
253 if art.merged_into and art.merged_into != 0:
254 return [tracker_bizobj.FormatIssueRef((
255 related_issues[art.merged_into].project_name,
256 related_issues[art.merged_into].local_id))]
257 else:
258 return [framework_constants.NO_VALUES]
259 if attribute_name == 'blocked':
260 return ['Yes' if art.blocked_on_iids else 'No']
261 if attribute_name == 'blockedon':
262 if not art.blocked_on_iids:
263 return [framework_constants.NO_VALUES]
264 else:
265 return [tracker_bizobj.FormatIssueRef((
266 related_issues[blocked_on_iid].project_name,
267 related_issues[blocked_on_iid].local_id)) for
268 blocked_on_iid in art.blocked_on_iids]
269 if attribute_name == 'blocking':
270 if not art.blocking_iids:
271 return [framework_constants.NO_VALUES]
272 return [tracker_bizobj.FormatIssueRef((
273 related_issues[blocking_iid].project_name,
274 related_issues[blocking_iid].local_id)) for
275 blocking_iid in art.blocking_iids]
276 if attribute_name == 'adder':
277 if hotlist_issue_context:
278 adder_id = hotlist_issue_context['adder_id']
279 return [users_by_id[adder_id].display_name]
280 else:
281 return [framework_constants.NO_VALUES]
282 if attribute_name == 'added':
283 if hotlist_issue_context:
284 return [hotlist_issue_context['date_added']]
285 else:
286 return [framework_constants.NO_VALUES]
287 if attribute_name == 'reporter':
288 return [users_by_id[art.reporter_id].display_name]
289 if attribute_name == 'owner':
290 owner_id = tracker_bizobj.GetOwnerId(art)
291 if not owner_id:
292 return [framework_constants.NO_VALUES]
293 else:
294 return [users_by_id[owner_id].display_name]
295 if attribute_name == 'cc':
296 cc_ids = tracker_bizobj.GetCcIds(art)
297 if not cc_ids:
298 return [framework_constants.NO_VALUES]
299 else:
300 return [users_by_id[cc_id].display_name for cc_id in cc_ids]
301 if attribute_name == 'component':
302 comp_ids = list(art.component_ids) + list(art.derived_component_ids)
303 if not comp_ids:
304 return [framework_constants.NO_VALUES]
305 else:
306 paths = []
307 for comp_id in comp_ids:
308 cd = tracker_bizobj.FindComponentDefByID(comp_id, config)
309 if cd:
310 paths.append(cd.path)
311 return paths
312
313 # Check to see if it is a field. Process as field only if it is not an enum
314 # type because enum types are stored as key-value labels.
315 fd = tracker_bizobj.FindFieldDef(attribute_name, config)
316 if fd and fd.field_type != tracker_pb2.FieldTypes.ENUM_TYPE:
317 values = []
318 for fv in art.field_values:
319 if fv.field_id == fd.field_id:
320 value = tracker_bizobj.GetFieldValueWithRawValue(
321 fd.field_type, fv, users_by_id, None)
322 values.append(value)
323 return values
324
325 # Since it is not a built-in attribute or a field, it must be a key-value
326 # label.
327 return label_attr_values_dict.get(
328 attribute_name, [framework_constants.NO_VALUES])
329
330
331def AnyArtifactHasNoAttr(
332 artifacts, attr_name, users_by_id, all_label_values, config,
333 related_issues, hotlist_context_dict=None):
334 """Return true if any artifact does not have a value for attr_name."""
335 # TODO(jrobbins): all_label_values needs to be keyed by issue_id to allow
336 # cross-project grid views.
337 for art in artifacts:
338 if hotlist_context_dict:
339 hotlist_issue_context = hotlist_context_dict[art.issue_id]
340 else:
341 hotlist_issue_context = None
342 vals = GetArtifactAttr(
343 art, attr_name.lower(), users_by_id, all_label_values[art.local_id],
344 config, related_issues, hotlist_issue_context=hotlist_issue_context)
345 if framework_constants.NO_VALUES in vals:
346 return True
347
348 return False
349
350
351def GetGridViewData(
352 mr, results, config, users_by_id, starred_iid_set,
353 grid_limited, related_issues, hotlist_context_dict=None):
354 """EZT template values to render a Grid View of issues.
355 Args:
356 mr: commonly used info parsed from the request.
357 results: The Issue PBs that are the search results to be displayed.
358 config: The ProjectConfig PB for the project this view is in.
359 users_by_id: A dictionary {user_id: user_view,...} for all the users
360 involved in results.
361 starred_iid_set: Set of issues that the user has starred.
362 grid_limited: True if the results were limited to fit within the grid.
363 related_issues: dict {issue_id: issue} of pre-fetched related issues.
364 hotlist_context_dict: dict for building a hotlist grid table
365
366 Returns:
367 Dictionary for EZT template rendering of the Grid View.
368 """
369 # We need ordered_columns because EZT loops have no loop-counter available.
370 # And, we use column number in the Javascript to hide/show columns.
371 columns = mr.col_spec.split()
372 ordered_columns = [template_helpers.EZTItem(col_index=i, name=col)
373 for i, col in enumerate(columns)]
374 other_built_in_cols = (features_constants.OTHER_BUILT_IN_COLS if
375 hotlist_context_dict else
376 tracker_constants.OTHER_BUILT_IN_COLS)
377 unshown_columns = table_view_helpers.ComputeUnshownColumns(
378 results, columns, config, other_built_in_cols)
379
380 grid_x_attr = (mr.x or config.default_x_attr or '--').lower()
381 grid_y_attr = (mr.y or config.default_y_attr or '--').lower()
382
383 # Prevent the user from using an axis that we don't support.
384 for bad_axis in tracker_constants.NOT_USED_IN_GRID_AXES:
385 lower_bad_axis = bad_axis.lower()
386 if grid_x_attr == lower_bad_axis:
387 grid_x_attr = '--'
388 if grid_y_attr == lower_bad_axis:
389 grid_y_attr = '--'
390 # Using the same attribute on both X and Y is not useful.
391 if grid_x_attr == grid_y_attr:
392 grid_x_attr = '--'
393
394 all_label_values = {}
395 for art in results:
396 all_label_values[art.local_id] = (
397 MakeLabelValuesDict(art))
398
399 if grid_x_attr == '--':
400 grid_x_headings = ['All']
401 else:
402 grid_x_items = table_view_helpers.ExtractUniqueValues(
403 [grid_x_attr], results, users_by_id, config, related_issues,
404 hotlist_context_dict=hotlist_context_dict)
405 grid_x_headings = grid_x_items[0].filter_values
406 if AnyArtifactHasNoAttr(
407 results, grid_x_attr, users_by_id, all_label_values,
408 config, related_issues, hotlist_context_dict= hotlist_context_dict):
409 grid_x_headings.append(framework_constants.NO_VALUES)
410 grid_x_headings = SortGridHeadings(
411 grid_x_attr, grid_x_headings, users_by_id, config,
412 tracker_helpers.SORTABLE_FIELDS)
413
414 if grid_y_attr == '--':
415 grid_y_headings = ['All']
416 else:
417 grid_y_items = table_view_helpers.ExtractUniqueValues(
418 [grid_y_attr], results, users_by_id, config, related_issues,
419 hotlist_context_dict=hotlist_context_dict)
420 grid_y_headings = grid_y_items[0].filter_values
421 if AnyArtifactHasNoAttr(
422 results, grid_y_attr, users_by_id, all_label_values,
423 config, related_issues, hotlist_context_dict= hotlist_context_dict):
424 grid_y_headings.append(framework_constants.NO_VALUES)
425 grid_y_headings = SortGridHeadings(
426 grid_y_attr, grid_y_headings, users_by_id, config,
427 tracker_helpers.SORTABLE_FIELDS)
428
429 logging.info('grid_x_headings = %s', grid_x_headings)
430 logging.info('grid_y_headings = %s', grid_y_headings)
431 grid_data = PrepareForMakeGridData(
432 results, starred_iid_set, grid_x_attr, grid_x_headings,
433 grid_y_attr, grid_y_headings, users_by_id, all_label_values,
434 config, related_issues, hotlist_context_dict=hotlist_context_dict)
435
436 grid_axis_choice_dict = {}
437 for oc in ordered_columns:
438 grid_axis_choice_dict[oc.name] = True
439 for uc in unshown_columns:
440 grid_axis_choice_dict[uc] = True
441 for bad_axis in tracker_constants.NOT_USED_IN_GRID_AXES:
442 if bad_axis in grid_axis_choice_dict:
443 del grid_axis_choice_dict[bad_axis]
444 grid_axis_choices = list(grid_axis_choice_dict.keys())
445 grid_axis_choices.sort()
446
447 grid_cell_mode = mr.cells
448 if len(results) > settings.max_tiles_in_grid and mr.cells == 'tiles':
449 grid_cell_mode = 'ids'
450
451 grid_view_data = {
452 'grid_limited': ezt.boolean(grid_limited),
453 'grid_shown': len(results),
454 'grid_x_headings': grid_x_headings,
455 'grid_y_headings': grid_y_headings,
456 'grid_data': grid_data,
457 'grid_axis_choices': grid_axis_choices,
458 'grid_cell_mode': grid_cell_mode,
459 'results': results, # Really only useful in if-any.
460 }
461 return grid_view_data
462
463
464def PrepareForMakeGridData(
465 allowed_results, starred_iid_set, x_attr,
466 grid_col_values, y_attr, grid_row_values, users_by_id, all_label_values,
467 config, related_issues, hotlist_context_dict=None):
468 """Return all data needed for EZT to render the body of the grid view."""
469
470 def IssueViewFactory(issue):
471 return template_helpers.EZTItem(
472 summary=issue.summary, local_id=issue.local_id, issue_id=issue.issue_id,
473 status=issue.status or issue.derived_status, starred=None, data_idx=0,
474 project_name=issue.project_name)
475
476 grid_data = MakeGridData(
477 allowed_results, x_attr, grid_col_values, y_attr, grid_row_values,
478 users_by_id, IssueViewFactory, all_label_values, config, related_issues,
479 hotlist_context_dict=hotlist_context_dict)
480 issue_dict = {issue.issue_id: issue for issue in allowed_results}
481 for grid_row in grid_data:
482 for grid_cell in grid_row.cells_in_row:
483 for tile in grid_cell.tiles:
484 if tile.issue_id in starred_iid_set:
485 tile.starred = ezt.boolean(True)
486 issue = issue_dict[tile.issue_id]
487 tile.issue_url = tracker_helpers.FormatRelativeIssueURL(
488 issue.project_name, urls.ISSUE_DETAIL, id=tile.local_id)
489 tile.issue_ref = issue.project_name + ':' + str(tile.local_id)
490
491 return grid_data