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