blob: f81ba40144c1995d46eae691242d547ca2a74b50 [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 lists of project artifacts.
6
7This file exports classes TableRow and TableCell that help
8represent HTML table rows and cells. These classes make rendering
9HTML tables that list project artifacts much easier to do with EZT.
10"""
11from __future__ import print_function
12from __future__ import division
13from __future__ import absolute_import
14
15import collections
16import itertools
17import logging
18
19from functools import total_ordering
20
21import ezt
22
23from framework import framework_constants
24from framework import template_helpers
25from framework import timestr
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
29
30
31def ComputeUnshownColumns(results, shown_columns, config, built_in_cols):
32 """Return a list of unshown columns that the user could add.
33
34 Args:
35 results: list of search result PBs. Each must have labels.
36 shown_columns: list of column names to be used in results table.
37 config: harmonized config for the issue search, including all
38 well known labels and custom fields.
39 built_in_cols: list of other column names that are built into the tool.
40 E.g., star count, or creation date.
41
42 Returns:
43 List of column names to append to the "..." menu.
44 """
45 unshown_set = set() # lowercases column names
46 unshown_list = [] # original-case column names
47 shown_set = {col.lower() for col in shown_columns}
48 labels_already_seen = set() # whole labels, original case
49
50 def _MaybeAddLabel(label_name):
51 """Add the key part of the given label if needed."""
52 if label_name.lower() in labels_already_seen:
53 return
54 labels_already_seen.add(label_name.lower())
55 if '-' in label_name:
56 col, _value = label_name.split('-', 1)
57 _MaybeAddCol(col)
58
59 def _MaybeAddCol(col):
60 if col.lower() not in shown_set and col.lower() not in unshown_set:
61 unshown_list.append(col)
62 unshown_set.add(col.lower())
63
64 # The user can always add any of the default columns.
65 for col in config.default_col_spec.split():
66 _MaybeAddCol(col)
67
68 # The user can always add any of the built-in columns.
69 for col in built_in_cols:
70 _MaybeAddCol(col)
71
72 # The user can add a column for any well-known labels
73 for wkl in config.well_known_labels:
74 _MaybeAddLabel(wkl.label)
75
76 phase_names = set(itertools.chain.from_iterable(
77 (phase.name.lower() for phase in result.phases) for result in results))
78 # The user can add a column for any custom field
79 field_ids_alread_seen = set()
80 for fd in config.field_defs:
81 field_lower = fd.field_name.lower()
82 field_ids_alread_seen.add(fd.field_id)
83 if fd.is_phase_field:
84 for name in phase_names:
85 phase_field_col = name + '.' + field_lower
86 if (phase_field_col not in shown_set and
87 phase_field_col not in unshown_set):
88 unshown_list.append(phase_field_col)
89 unshown_set.add(phase_field_col)
90 elif field_lower not in shown_set and field_lower not in unshown_set:
91 unshown_list.append(fd.field_name)
92 unshown_set.add(field_lower)
93
94 if fd.field_type == tracker_pb2.FieldTypes.APPROVAL_TYPE:
95 approval_lower_approver = (
96 field_lower + tracker_constants.APPROVER_COL_SUFFIX)
97 if (approval_lower_approver not in shown_set and
98 approval_lower_approver not in unshown_set):
99 unshown_list.append(
100 fd.field_name + tracker_constants.APPROVER_COL_SUFFIX)
101 unshown_set.add(approval_lower_approver)
102
103 # The user can add a column for any key-value label or field in the results.
104 for r in results:
105 for label_name in tracker_bizobj.GetLabels(r):
106 _MaybeAddLabel(label_name)
107 for field_value in r.field_values:
108 if field_value.field_id not in field_ids_alread_seen:
109 field_ids_alread_seen.add(field_value.field_id)
110 fd = tracker_bizobj.FindFieldDefByID(field_value.field_id, config)
111 if fd: # could be None for a foreign field, which we don't display.
112 field_lower = fd.field_name.lower()
113 if field_lower not in shown_set and field_lower not in unshown_set:
114 unshown_list.append(fd.field_name)
115 unshown_set.add(field_lower)
116
117 return sorted(unshown_list)
118
119
120def ExtractUniqueValues(columns, artifact_list, users_by_id,
121 config, related_issues, hotlist_context_dict=None):
122 """Build a nested list of unique values so the user can auto-filter.
123
124 Args:
125 columns: a list of lowercase column name strings, which may contain
126 combined columns like "priority/pri".
127 artifact_list: a list of artifacts in the complete set of search results.
128 users_by_id: dict mapping user_ids to UserViews.
129 config: ProjectIssueConfig PB for the current project.
130 related_issues: dict {issue_id: issue} of pre-fetched related issues.
131 hotlist_context_dict: dict for building a hotlist grid table
132
133 Returns:
134 [EZTItem(col1, colname1, [val11, val12,...]), ...]
135 A list of EZTItems, each of which has a col_index, column_name,
136 and a list of unique values that appear in that column.
137 """
138 column_values = {col_name: {} for col_name in columns}
139
140 # For each combined column "a/b/c", add entries that point from "a" back
141 # to "a/b/c", from "b" back to "a/b/c", and from "c" back to "a/b/c".
142 combined_column_parts = collections.defaultdict(list)
143 for col in columns:
144 if '/' in col:
145 for col_part in col.split('/'):
146 combined_column_parts[col_part].append(col)
147
148 unique_labels = set()
149 for art in artifact_list:
150 unique_labels.update(tracker_bizobj.GetLabels(art))
151
152 for label in unique_labels:
153 if '-' in label:
154 col, val = label.split('-', 1)
155 col = col.lower()
156 if col in column_values:
157 column_values[col][val.lower()] = val
158 if col in combined_column_parts:
159 for combined_column in combined_column_parts[col]:
160 column_values[combined_column][val.lower()] = val
161 else:
162 if 'summary' in column_values:
163 column_values['summary'][label.lower()] = label
164
165 # TODO(jrobbins): Consider refacting some of this to tracker_bizobj
166 # or a new builtins.py to reduce duplication.
167 if 'reporter' in column_values:
168 for art in artifact_list:
169 reporter_id = art.reporter_id
170 if reporter_id and reporter_id in users_by_id:
171 reporter_username = users_by_id[reporter_id].display_name
172 column_values['reporter'][reporter_username] = reporter_username
173
174 if 'owner' in column_values:
175 for art in artifact_list:
176 owner_id = tracker_bizobj.GetOwnerId(art)
177 if owner_id and owner_id in users_by_id:
178 owner_username = users_by_id[owner_id].display_name
179 column_values['owner'][owner_username] = owner_username
180
181 if 'cc' in column_values:
182 for art in artifact_list:
183 cc_ids = tracker_bizobj.GetCcIds(art)
184 for cc_id in cc_ids:
185 if cc_id and cc_id in users_by_id:
186 cc_username = users_by_id[cc_id].display_name
187 column_values['cc'][cc_username] = cc_username
188
189 if 'component' in column_values:
190 for art in artifact_list:
191 all_comp_ids = list(art.component_ids) + list(art.derived_component_ids)
192 for component_id in all_comp_ids:
193 cd = tracker_bizobj.FindComponentDefByID(component_id, config)
194 if cd:
195 column_values['component'][cd.path] = cd.path
196
197 if 'stars' in column_values:
198 for art in artifact_list:
199 star_count = art.star_count
200 column_values['stars'][star_count] = star_count
201
202 if 'status' in column_values:
203 for art in artifact_list:
204 status = tracker_bizobj.GetStatus(art)
205 if status:
206 column_values['status'][status.lower()] = status
207
208 if 'project' in column_values:
209 for art in artifact_list:
210 project_name = art.project_name
211 column_values['project'][project_name] = project_name
212
213 if 'mergedinto' in column_values:
214 for art in artifact_list:
215 if art.merged_into and art.merged_into != 0:
216 merged_issue = related_issues[art.merged_into]
217 merged_issue_ref = tracker_bizobj.FormatIssueRef((
218 merged_issue.project_name, merged_issue.local_id))
219 column_values['mergedinto'][merged_issue_ref] = merged_issue_ref
220
221 if 'blocked' in column_values:
222 for art in artifact_list:
223 if art.blocked_on_iids:
224 column_values['blocked']['is_blocked'] = 'Yes'
225 else:
226 column_values['blocked']['is_not_blocked'] = 'No'
227
228 if 'blockedon' in column_values:
229 for art in artifact_list:
230 if art.blocked_on_iids:
231 for blocked_on_iid in art.blocked_on_iids:
232 blocked_on_issue = related_issues[blocked_on_iid]
233 blocked_on_ref = tracker_bizobj.FormatIssueRef((
234 blocked_on_issue.project_name, blocked_on_issue.local_id))
235 column_values['blockedon'][blocked_on_ref] = blocked_on_ref
236
237 if 'blocking' in column_values:
238 for art in artifact_list:
239 if art.blocking_iids:
240 for blocking_iid in art.blocking_iids:
241 blocking_issue = related_issues[blocking_iid]
242 blocking_ref = tracker_bizobj.FormatIssueRef((
243 blocking_issue.project_name, blocking_issue.local_id))
244 column_values['blocking'][blocking_ref] = blocking_ref
245
246 if 'added' in column_values:
247 for art in artifact_list:
248 if hotlist_context_dict and hotlist_context_dict[art.issue_id]:
249 issue_dict = hotlist_context_dict[art.issue_id]
250 date_added = issue_dict['date_added']
251 column_values['added'][date_added] = date_added
252
253 if 'adder' in column_values:
254 for art in artifact_list:
255 if hotlist_context_dict and hotlist_context_dict[art.issue_id]:
256 issue_dict = hotlist_context_dict[art.issue_id]
257 adder_id = issue_dict['adder_id']
258 adder = users_by_id[adder_id].display_name
259 column_values['adder'][adder] = adder
260
261 if 'note' in column_values:
262 for art in artifact_list:
263 if hotlist_context_dict and hotlist_context_dict[art.issue_id]:
264 issue_dict = hotlist_context_dict[art.issue_id]
265 note = issue_dict['note']
266 if issue_dict['note']:
267 column_values['note'][note] = note
268
269 if 'attachments' in column_values:
270 for art in artifact_list:
271 attachment_count = art.attachment_count
272 column_values['attachments'][attachment_count] = attachment_count
273
274 # Add all custom field values if the custom field name is a shown column.
275 field_id_to_col = {}
276 for art in artifact_list:
277 for fv in art.field_values:
278 field_col, field_type = field_id_to_col.get(fv.field_id, (None, None))
279 if field_col == 'NOT_SHOWN':
280 continue
281 if field_col is None:
282 fd = tracker_bizobj.FindFieldDefByID(fv.field_id, config)
283 if not fd:
284 field_id_to_col[fv.field_id] = 'NOT_SHOWN', None
285 continue
286 field_col = fd.field_name.lower()
287 field_type = fd.field_type
288 if field_col not in column_values:
289 field_id_to_col[fv.field_id] = 'NOT_SHOWN', None
290 continue
291 field_id_to_col[fv.field_id] = field_col, field_type
292
293 if field_type == tracker_pb2.FieldTypes.ENUM_TYPE:
294 continue # Already handled by label parsing
295 elif field_type == tracker_pb2.FieldTypes.INT_TYPE:
296 val = fv.int_value
297 elif field_type == tracker_pb2.FieldTypes.STR_TYPE:
298 val = fv.str_value
299 elif field_type == tracker_pb2.FieldTypes.USER_TYPE:
300 user = users_by_id.get(fv.user_id)
301 val = user.email if user else framework_constants.NO_USER_NAME
302 elif field_type == tracker_pb2.FieldTypes.DATE_TYPE:
303 val = fv.int_value # TODO(jrobbins): convert to date
304 elif field_type == tracker_pb2.FieldTypes.BOOL_TYPE:
305 val = 'Yes' if fv.int_value else 'No'
306
307 column_values[field_col][val] = val
308
309 # TODO(jrobbins): make the capitalization of well-known unique label and
310 # status values match the way it is written in the issue config.
311
312 # Return EZTItems for each column in left-to-right display order.
313 result = []
314 for i, col_name in enumerate(columns):
315 # TODO(jrobbins): sort each set of column values top-to-bottom, by the
316 # order specified in the project artifact config. For now, just sort
317 # lexicographically to make expected output defined.
318 sorted_col_values = sorted(column_values[col_name].values())
319 result.append(template_helpers.EZTItem(
320 col_index=i, column_name=col_name, filter_values=sorted_col_values))
321
322 return result
323
324
325def MakeTableData(
326 visible_results, starred_items, lower_columns, lower_group_by,
327 users_by_id, cell_factories, id_accessor, related_issues,
328 viewable_iids_set, config, context_for_all_issues=None):
329 """Return a list of list row objects for display by EZT.
330
331 Args:
332 visible_results: list of artifacts to display on one pagination page.
333 starred_items: list of IDs/names of items in the current project
334 that the signed in user has starred.
335 lower_columns: list of column names to display, all lowercase. These can
336 be combined column names, e.g., 'priority/pri'.
337 lower_group_by: list of column names that define row groups, all lowercase.
338 users_by_id: dict mapping user IDs to UserViews.
339 cell_factories: dict of functions that each create TableCell objects.
340 id_accessor: function that maps from an artifact to the ID/name that might
341 be in the starred items list.
342 related_issues: dict {issue_id: issue} of pre-fetched related issues.
343 viewable_iids_set: set of issue ids that can be viewed by the user.
344 config: ProjectIssueConfig PB for the current project.
345 context_for_all_issues: A dictionary of dictionaries containing values
346 passed in to cell factory functions to create TableCells. Dictionary
347 form: {issue_id: {'rank': issue_rank, 'issue_info': info_value, ..},
348 issue_id: {'rank': issue_rank}, ..}
349
350 Returns:
351 A list of TableRow objects, one for each visible result.
352 """
353 table_data = []
354
355 group_cell_factories = [
356 ChooseCellFactory(group.strip('-'), cell_factories, config)
357 for group in lower_group_by]
358
359 # Make a list of cell factories, one for each column.
360 factories_to_use = [
361 ChooseCellFactory(col, cell_factories, config) for col in lower_columns]
362
363 current_group = None
364 for idx, art in enumerate(visible_results):
365 row = MakeRowData(
366 art, lower_columns, users_by_id, factories_to_use, related_issues,
367 viewable_iids_set, config, context_for_all_issues)
368 row.starred = ezt.boolean(id_accessor(art) in starred_items)
369 row.idx = idx # EZT does not have loop counters, so add idx.
370 table_data.append(row)
371 row.group = None
372
373 # Also include group information for the first row in each group.
374 # TODO(jrobbins): This seems like more overhead than we need for the
375 # common case where no new group heading row is to be inserted.
376 group = MakeRowData(
377 art, [group_name.strip('-') for group_name in lower_group_by],
378 users_by_id, group_cell_factories, related_issues, viewable_iids_set,
379 config, context_for_all_issues)
380 for cell, group_name in zip(group.cells, lower_group_by):
381 cell.group_name = group_name
382 if group == current_group:
383 current_group.rows_in_group += 1
384 else:
385 row.group = group
386 current_group = group
387 current_group.rows_in_group = 1
388
389 return table_data
390
391
392def MakeRowData(
393 art, columns, users_by_id, cell_factory_list, related_issues,
394 viewable_iids_set, config, context_for_all_issues):
395 """Make a TableRow for use by EZT when rendering HTML table of results.
396
397 Args:
398 art: a project artifact PB
399 columns: list of lower-case column names
400 users_by_id: dictionary {user_id: UserView} with each UserView having
401 a "display_name" member.
402 cell_factory_list: list of functions that each create TableCell
403 objects for a given column.
404 related_issues: dict {issue_id: issue} of pre-fetched related issues.
405 viewable_iids_set: set of issue ids that can be viewed by the user.
406 config: ProjectIssueConfig PB for the current project.
407 context_for_all_issues: A dictionary of dictionaries containing values
408 passed in to cell factory functions to create TableCells. Dictionary
409 form: {issue_id: {'rank': issue_rank, 'issue_info': info_value, ..},
410 issue_id: {'rank': issue_rank}, ..}
411
412 Returns:
413 A TableRow object for use by EZT to render a table of results.
414 """
415 if context_for_all_issues is None:
416 context_for_all_issues = {}
417 ordered_row_data = []
418 non_col_labels = []
419 label_values = collections.defaultdict(list)
420
421 flattened_columns = set()
422 for col in columns:
423 if '/' in col:
424 flattened_columns.update(col.split('/'))
425 else:
426 flattened_columns.add(col)
427
428 # Group all "Key-Value" labels by key, and separate the "OneWord" labels.
429 _AccumulateLabelValues(
430 art.labels, flattened_columns, label_values, non_col_labels)
431
432 _AccumulateLabelValues(
433 art.derived_labels, flattened_columns, label_values,
434 non_col_labels, is_derived=True)
435
436 # Build up a list of TableCell objects for this row.
437 for i, col in enumerate(columns):
438 factory = cell_factory_list[i]
439 kw = {
440 'col': col,
441 'users_by_id': users_by_id,
442 'non_col_labels': non_col_labels,
443 'label_values': label_values,
444 'related_issues': related_issues,
445 'viewable_iids_set': viewable_iids_set,
446 'config': config,
447 }
448 kw.update(context_for_all_issues.get(art.issue_id, {}))
449 new_cell = factory(art, **kw)
450 new_cell.col_index = i
451 ordered_row_data.append(new_cell)
452
453 return TableRow(ordered_row_data)
454
455
456def _AccumulateLabelValues(
457 labels, columns, label_values, non_col_labels, is_derived=False):
458 """Parse OneWord and Key-Value labels for display in a list page.
459
460 Args:
461 labels: a list of label strings.
462 columns: a list of column names.
463 label_values: mutable dictionary {key: [value, ...]} of label values
464 seen so far.
465 non_col_labels: mutable list of OneWord labels seen so far.
466 is_derived: true if these labels were derived via rules.
467
468 Returns:
469 Nothing. But, the given label_values dictionary will grow to hold
470 the values of the key-value labels passed in, and the non_col_labels
471 list will grow to hold the OneWord labels passed in. These are shown
472 in label columns, and in the summary column, respectively
473 """
474 for label_name in labels:
475 if '-' in label_name:
476 parts = label_name.split('-')
477 for pivot in range(1, len(parts)):
478 column_name = '-'.join(parts[:pivot])
479 value = '-'.join(parts[pivot:])
480 column_name = column_name.lower()
481 if column_name in columns:
482 label_values[column_name].append((value, is_derived))
483 else:
484 non_col_labels.append((label_name, is_derived))
485
486
487@total_ordering
488class TableRow(object):
489 """A tiny auxiliary class to represent a row in an HTML table."""
490
491 def __init__(self, cells):
492 """Initialize the table row with the given data."""
493 self.cells = cells
494 # Used by MakeTableData for layout.
495 self.idx = None
496 self.group = None
497 self.rows_in_group = None
498 self.starred = None
499
500 def __eq__(self, other):
501 """A row is == if each cell is == to the cells in the other row."""
502 return other and self.cells == other.cells
503
504 def __ne__(self, other):
505 return not other and self.cells != other.cells
506
507 def __lt__(self, other):
508 return other and self.cells < other.cells
509
510 def DebugString(self):
511 """Return a string that is useful for on-page debugging."""
512 return 'TR(%s)' % self.cells
513
514
515# TODO(jrobbins): also add unsortable... or change this to a list of operations
516# that can be done.
517CELL_TYPE_ID = 'ID'
518CELL_TYPE_SUMMARY = 'summary'
519CELL_TYPE_ATTR = 'attr'
520CELL_TYPE_UNFILTERABLE = 'unfilterable'
521CELL_TYPE_NOTE = 'note'
522CELL_TYPE_PROJECT = 'project'
523CELL_TYPE_URL = 'url'
524CELL_TYPE_ISSUES = 'issues'
525
526
527@total_ordering
528class TableCell(object):
529 """Helper class to represent a table cell when rendering using EZT."""
530
531 # Should instances of this class be rendered with whitespace:nowrap?
532 # Subclasses can override this constant.
533 NOWRAP = ezt.boolean(True)
534
535 def __init__(self, cell_type, explicit_values,
536 derived_values=None, non_column_labels=None, align='',
537 sort_values=True):
538 """Store all the given data for later access by EZT."""
539 self.type = cell_type
540 self.align = align
541 self.col_index = 0 # Is set afterward
542 self.values = []
543 if non_column_labels:
544 self.non_column_labels = [
545 template_helpers.EZTItem(value=v, is_derived=ezt.boolean(d))
546 for v, d in non_column_labels]
547 else:
548 self.non_column_labels = []
549
550 for v in (sorted(explicit_values) if sort_values else explicit_values):
551 self.values.append(CellItem(v))
552
553 if derived_values:
554 for v in (sorted(derived_values) if sort_values else derived_values):
555 self.values.append(CellItem(v, is_derived=True))
556
557 def __eq__(self, other):
558 """A row is == if each cell is == to the cells in the other row."""
559 return other and self.values == other.values
560
561 def __ne__(self, other):
562 return not other and self.values != other.values
563
564 def __lt__(self, other):
565 return other and self.values < other.values
566
567 def DebugString(self):
568 return 'TC(%r, %r, %r)' % (
569 self.type,
570 [v.DebugString() for v in self.values],
571 self.non_column_labels)
572
573
574def CompositeFactoryTableCell(factory_col_list_arg):
575 """Cell factory that combines multiple cells in a combined column."""
576
577 class FactoryClass(TableCell):
578 factory_col_list = factory_col_list_arg
579
580 def __init__(self, art, **kw):
581 TableCell.__init__(self, CELL_TYPE_UNFILTERABLE, [])
582
583 for sub_factory, sub_col in self.factory_col_list:
584 kw['col'] = sub_col
585 sub_cell = sub_factory(art, **kw)
586 self.non_column_labels.extend(sub_cell.non_column_labels)
587 self.values.extend(sub_cell.values)
588 return FactoryClass
589
590
591def CompositeColTableCell(columns_to_combine, cell_factories, config):
592 """Cell factory that combines multiple cells in a combined column."""
593 factory_col_list = []
594 for sub_col in columns_to_combine:
595 sub_factory = ChooseCellFactory(sub_col, cell_factories, config)
596 factory_col_list.append((sub_factory, sub_col))
597 return CompositeFactoryTableCell(factory_col_list)
598
599
600@total_ordering
601class CellItem(object):
602 """Simple class to display one part of a table cell's value, with style."""
603
604 def __init__(self, item, is_derived=False):
605 self.item = item
606 self.is_derived = ezt.boolean(is_derived)
607
608 def __eq__(self, other):
609 """A row is == if each cell is == to the item in the other row."""
610 return other and self.item == other.item
611
612 def __ne__(self, other):
613 return not other and self.item != other.item
614
615 def __lt__(self, other):
616 return other and self.item < other.item
617
618 def DebugString(self):
619 if self.is_derived:
620 return 'CI(derived: %r)' % self.item
621 else:
622 return 'CI(%r)' % self.item
623
624
625class TableCellKeyLabels(TableCell):
626 """TableCell subclass specifically for showing user-defined label values."""
627
628 def __init__(self, _art, col=None, label_values=None, **_kw):
629 label_value_pairs = label_values.get(col, [])
630 explicit_values = [value for value, is_derived in label_value_pairs
631 if not is_derived]
632 derived_values = [value for value, is_derived in label_value_pairs
633 if is_derived]
634 TableCell.__init__(self, CELL_TYPE_ATTR, explicit_values,
635 derived_values=derived_values)
636
637
638class TableCellProject(TableCell):
639 """TableCell subclass for showing an artifact's project name."""
640
641 def __init__(self, art, **_kw):
642 TableCell.__init__(
643 self, CELL_TYPE_PROJECT, [art.project_name])
644
645
646class TableCellStars(TableCell):
647 """TableCell subclass for showing an artifact's star count."""
648
649 def __init__(self, art, **_kw):
650 TableCell.__init__(
651 self, CELL_TYPE_ATTR, [art.star_count], align='right')
652
653
654class TableCellSummary(TableCell):
655 """TableCell subclass for showing an artifact's summary."""
656
657 def __init__(self, art, non_col_labels=None, **_kw):
658 TableCell.__init__(
659 self, CELL_TYPE_SUMMARY, [art.summary],
660 non_column_labels=non_col_labels)
661
662
663class TableCellDate(TableCell):
664 """TableCell subclass for showing any kind of date timestamp."""
665
666 # Make instances of this class render with whitespace:nowrap.
667 NOWRAP = ezt.boolean(True)
668
669 def __init__(self, timestamp, days_only=False):
670 values = []
671 if timestamp:
672 date_str = timestr.FormatRelativeDate(timestamp, days_only=days_only)
673 if not date_str:
674 date_str = timestr.FormatAbsoluteDate(timestamp)
675 values = [date_str]
676
677 TableCell.__init__(self, CELL_TYPE_UNFILTERABLE, values)
678
679
680class TableCellCustom(TableCell):
681 """Abstract TableCell subclass specifically for showing custom fields."""
682
683 def __init__(self, art, col=None, users_by_id=None, config=None, **_kw):
684 explicit_values = []
685 derived_values = []
686 cell_type = CELL_TYPE_ATTR
687 phase_names_by_id = {
688 phase.phase_id: phase.name.lower() for phase in art.phases}
689 phase_name = None
690 # Check if col represents a phase field value in the form <phase>.<field>
691 if '.' in col:
692 phase_name, col = col.split('.', 1)
693 for fv in art.field_values:
694 # TODO(jrobbins): for cross-project search this could be a list.
695 fd = tracker_bizobj.FindFieldDefByID(fv.field_id, config)
696 if not fd:
697 # TODO(jrobbins): This can happen if an issue with a custom
698 # field value is moved to a different project.
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100699 logging.warning(
700 'Issue ID %r has undefined field value %r', art.issue_id, fv)
Copybara854996b2021-09-07 19:36:02 +0000701 elif fd.field_name.lower() == col and (
702 phase_names_by_id.get(fv.phase_id) == phase_name):
703 if fd.field_type == tracker_pb2.FieldTypes.URL_TYPE:
704 cell_type = CELL_TYPE_URL
705 if fd.field_type == tracker_pb2.FieldTypes.STR_TYPE:
706 self.NOWRAP = ezt.boolean(False)
707 val = tracker_bizobj.GetFieldValue(fv, users_by_id)
708 if fv.derived:
709 derived_values.append(val)
710 else:
711 explicit_values.append(val)
712
713 TableCell.__init__(self, cell_type, explicit_values,
714 derived_values=derived_values)
715
716 def ExtractValue(self, fv, _users_by_id):
717 return 'field-id-%d-not-implemented-yet' % fv.field_id
718
719class TableCellApprovalStatus(TableCell):
720 """Abstract TableCell subclass specifically for showing approval fields."""
721
722 def __init__(self, art, col=None, config=None, **_kw):
723 explicit_values = []
724 for av in art.approval_values:
725 fd = tracker_bizobj.FindFieldDef(col, config)
726 ad = tracker_bizobj.FindApprovalDef(col, config)
727 if not (ad and fd):
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100728 logging.warning(
729 'Issue ID %r has undefined field value %r', art.issue_id, av)
Copybara854996b2021-09-07 19:36:02 +0000730 elif av.approval_id == fd.field_id:
731 explicit_values.append(av.status.name)
732 break
733
734 TableCell.__init__(self, CELL_TYPE_ATTR, explicit_values)
735
736
737class TableCellApprovalApprover(TableCell):
738 """TableCell subclass specifically for showing approval approvers."""
739
740 def __init__(self, art, col=None, config=None, users_by_id=None, **_kw):
741 explicit_values = []
742 approval_name = col[:-len(tracker_constants.APPROVER_COL_SUFFIX)]
743 for av in art.approval_values:
744 fd = tracker_bizobj.FindFieldDef(approval_name, config)
745 ad = tracker_bizobj.FindApprovalDef(approval_name, config)
746 if not (ad and fd):
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100747 logging.warning(
748 'Issue ID %r has undefined field value %r', art.issue_id, av)
Copybara854996b2021-09-07 19:36:02 +0000749 elif av.approval_id == fd.field_id:
750 explicit_values = [users_by_id.get(approver_id).display_name
751 for approver_id in av.approver_ids
752 if users_by_id.get(approver_id)]
753 break
754
755 TableCell.__init__(self, CELL_TYPE_ATTR, explicit_values)
756
757def ChooseCellFactory(col, cell_factories, config):
758 """Return the CellFactory to use for the given column."""
759 if col in cell_factories:
760 return cell_factories[col]
761
762 if '/' in col:
763 return CompositeColTableCell(col.split('/'), cell_factories, config)
764
765 is_approver_col = False
766 possible_field_name = col
767 if col.endswith(tracker_constants.APPROVER_COL_SUFFIX):
768 possible_field_name = col[:-len(tracker_constants.APPROVER_COL_SUFFIX)]
769 is_approver_col = True
770 # Check if col represents a phase field value in the form <phase>.<field>
771 elif '.' in possible_field_name:
772 possible_field_name = possible_field_name.split('.')[-1]
773
774 fd = tracker_bizobj.FindFieldDef(possible_field_name, config)
775 if fd:
776 # We cannot assume that non-enum_type field defs do not share their
777 # names with label prefixes. So we need to group them with
778 # TableCellKeyLabels to make sure we catch appropriate labels values.
779 if fd.field_type == tracker_pb2.FieldTypes.APPROVAL_TYPE:
780 if is_approver_col:
781 # Combined cell for 'FieldName-approver' to hold approvers
782 # belonging to FieldName and values belonging to labels with
783 # 'FieldName-approver' as the key.
784 return CompositeFactoryTableCell(
785 [(TableCellApprovalApprover, col), (TableCellKeyLabels, col)])
786 return CompositeFactoryTableCell(
787 [(TableCellApprovalStatus, col), (TableCellKeyLabels, col)])
788 elif fd.field_type != tracker_pb2.FieldTypes.ENUM_TYPE:
789 return CompositeFactoryTableCell(
790 [(TableCellCustom, col), (TableCellKeyLabels, col)])
791
792 return TableCellKeyLabels