Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(175)

Side by Side Diff: appengine/monorail/framework/table_view_helpers.py

Issue 1868553004: Open Source Monorail (Closed) Base URL: https://chromium.googlesource.com/infra/infra.git@master
Patch Set: Rebase Created 4 years, 8 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
« no previous file with comments | « appengine/monorail/framework/sql.py ('k') | appengine/monorail/framework/template_helpers.py » ('j') | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
(Empty)
1 # Copyright 2016 The Chromium Authors. All rights reserved.
2 # Use of this source code is govered 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 lists of project artifacts.
7
8 This file exports classes TableRow and TableCell that help
9 represent HTML table rows and cells. These classes make rendering
10 HTML tables that list project artifacts much easier to do with EZT.
11 """
12
13 import collections
14 import logging
15
16 from third_party import ezt
17
18 from framework import framework_constants
19 from framework import template_helpers
20 from proto import tracker_pb2
21 from tracker import tracker_bizobj
22
23
24 def ComputeUnshownColumns(results, shown_columns, config, built_in_cols):
25 """Return a list of unshown columns that the user could add.
26
27 Args:
28 results: list of search result PBs. Each must have labels.
29 shown_columns: list of column names to be used in results table.
30 config: harmonized config for the issue search, including all
31 well known labels and custom fields.
32 built_in_cols: list of other column names that are built into the tool.
33 E.g., star count, or creation date.
34
35 Returns:
36 List of column names to append to the "..." menu.
37 """
38 unshown_set = set() # lowercases column names
39 unshown_list = [] # original-case column names
40 shown_set = {col.lower() for col in shown_columns}
41 labels_already_seen = set() # whole labels, original case
42
43 def _MaybeAddLabel(label_name):
44 """Add the key part of the given label if needed."""
45 if label_name.lower() in labels_already_seen:
46 return
47 labels_already_seen.add(label_name.lower())
48 if '-' in label_name:
49 col, _value = label_name.split('-', 1)
50 _MaybeAddCol(col)
51
52 def _MaybeAddCol(col):
53 if col.lower() not in shown_set and col.lower() not in unshown_set:
54 unshown_list.append(col)
55 unshown_set.add(col.lower())
56
57 # The user can always add any of the default columns.
58 for col in config.default_col_spec.split():
59 _MaybeAddCol(col)
60
61 # The user can always add any of the built-in columns.
62 for col in built_in_cols:
63 _MaybeAddCol(col)
64
65 # The user can add a column for any well-known labels
66 for wkl in config.well_known_labels:
67 _MaybeAddLabel(wkl.label)
68
69 # The user can add a column for any custom field
70 field_ids_alread_seen = set()
71 for fd in config.field_defs:
72 field_lower = fd.field_name.lower()
73 field_ids_alread_seen.add(fd.field_id)
74 if field_lower not in shown_set and field_lower not in unshown_set:
75 unshown_list.append(fd.field_name)
76 unshown_set.add(field_lower)
77
78 # The user can add a column for any key-value label or field in the results.
79 for r in results:
80 for label_name in tracker_bizobj.GetLabels(r):
81 _MaybeAddLabel(label_name)
82 for field_value in r.field_values:
83 if field_value.field_id not in field_ids_alread_seen:
84 field_ids_alread_seen.add(field_value.field_id)
85 fd = tracker_bizobj.FindFieldDefByID(field_value.field_id, config)
86 if fd: # could be None for a foreign field, which we don't display.
87 field_lower = fd.field_name.lower()
88 if field_lower not in shown_set and field_lower not in unshown_set:
89 unshown_list.append(fd.field_name)
90 unshown_set.add(field_lower)
91
92 return sorted(unshown_list)
93
94
95 def ExtractUniqueValues(columns, artifact_list, users_by_id, config):
96 """Build a nested list of unique values so the user can auto-filter.
97
98 Args:
99 columns: a list of lowercase column name strings, which may contain
100 combined columns like "priority/pri".
101 artifact_list: a list of artifacts in the complete set of search results.
102 users_by_id: dict mapping user_ids to UserViews.
103 config: ProjectIssueConfig PB for the current project.
104
105 Returns:
106 [EZTItem(col1, colname1, [val11, val12,...]), ...]
107 A list of EZTItems, each of which has a col_index, column_name,
108 and a list of unique values that appear in that column.
109 """
110 column_values = {col_name: {} for col_name in columns}
111
112 # For each combined column "a/b/c", add entries that point from "a" back
113 # to "a/b/c", from "b" back to "a/b/c", and from "c" back to "a/b/c".
114 combined_column_parts = collections.defaultdict(list)
115 for col in columns:
116 if '/' in col:
117 for col_part in col.split('/'):
118 combined_column_parts[col_part].append(col)
119
120 unique_labels = set()
121 for art in artifact_list:
122 unique_labels.update(tracker_bizobj.GetLabels(art))
123
124 for label in unique_labels:
125 if '-' in label:
126 col, val = label.split('-', 1)
127 col = col.lower()
128 if col in column_values:
129 column_values[col][val.lower()] = val
130 if col in combined_column_parts:
131 for combined_column in combined_column_parts[col]:
132 column_values[combined_column][val.lower()] = val
133 else:
134 if 'summary' in column_values:
135 column_values['summary'][label.lower()] = label
136
137 # TODO(jrobbins): Consider refacting some of this to tracker_bizobj
138 # or a new builtins.py to reduce duplication.
139 if 'reporter' in column_values:
140 for art in artifact_list:
141 reporter_id = art.reporter_id
142 if reporter_id and reporter_id in users_by_id:
143 reporter_username = users_by_id[reporter_id].display_name
144 column_values['reporter'][reporter_username] = reporter_username
145
146 if 'owner' in column_values:
147 for art in artifact_list:
148 owner_id = tracker_bizobj.GetOwnerId(art)
149 if owner_id and owner_id in users_by_id:
150 owner_username = users_by_id[owner_id].display_name
151 column_values['owner'][owner_username] = owner_username
152
153 if 'cc' in column_values:
154 for art in artifact_list:
155 cc_ids = tracker_bizobj.GetCcIds(art)
156 for cc_id in cc_ids:
157 if cc_id and cc_id in users_by_id:
158 cc_username = users_by_id[cc_id].display_name
159 column_values['cc'][cc_username] = cc_username
160
161 if 'component' in column_values:
162 for art in artifact_list:
163 all_comp_ids = list(art.component_ids) + list(art.derived_component_ids)
164 for component_id in all_comp_ids:
165 cd = tracker_bizobj.FindComponentDefByID(component_id, config)
166 if cd:
167 column_values['component'][cd.path] = cd.path
168
169 if 'stars' in column_values:
170 for art in artifact_list:
171 star_count = art.star_count
172 column_values['stars'][star_count] = star_count
173
174 if 'status' in column_values:
175 for art in artifact_list:
176 status = tracker_bizobj.GetStatus(art)
177 if status:
178 column_values['status'][status.lower()] = status
179
180 # TODO(jrobbins): merged into, blocked on, and blocking. And, the ability
181 # to parse a user query on those fields and do a SQL search.
182
183 if 'attachments' in column_values:
184 for art in artifact_list:
185 attachment_count = art.attachment_count
186 column_values['attachments'][attachment_count] = attachment_count
187
188 # Add all custom field values if the custom field name is a shown column.
189 field_id_to_col = {}
190 for art in artifact_list:
191 for fv in art.field_values:
192 field_col, field_type = field_id_to_col.get(fv.field_id, (None, None))
193 if field_col == 'NOT_SHOWN':
194 continue
195 if field_col is None:
196 fd = tracker_bizobj.FindFieldDefByID(fv.field_id, config)
197 if not fd:
198 field_id_to_col[fv.field_id] = 'NOT_SHOWN', None
199 continue
200 field_col = fd.field_name.lower()
201 field_type = fd.field_type
202 if field_col not in column_values:
203 field_id_to_col[fv.field_id] = 'NOT_SHOWN', None
204 continue
205 field_id_to_col[fv.field_id] = field_col, field_type
206
207 if field_type == tracker_pb2.FieldTypes.ENUM_TYPE:
208 continue # Already handled by label parsing
209 elif field_type == tracker_pb2.FieldTypes.INT_TYPE:
210 val = fv.int_value
211 elif field_type == tracker_pb2.FieldTypes.STR_TYPE:
212 val = fv.str_value
213 elif field_type == tracker_pb2.FieldTypes.USER_TYPE:
214 user = users_by_id.get(fv.user_id)
215 val = user.email if user else framework_constants.NO_USER_NAME
216 elif field_type == tracker_pb2.FieldTypes.DATE_TYPE:
217 val = fv.int_value # TODO(jrobbins): convert to date
218 elif field_type == tracker_pb2.FieldTypes.BOOL_TYPE:
219 val = 'Yes' if fv.int_value else 'No'
220
221 column_values[field_col][val] = val
222
223 # TODO(jrobbins): make the capitalization of well-known unique label and
224 # status values match the way it is written in the issue config.
225
226 # Return EZTItems for each column in left-to-right display order.
227 result = []
228 for i, col_name in enumerate(columns):
229 # TODO(jrobbins): sort each set of column values top-to-bottom, by the
230 # order specified in the project artifact config. For now, just sort
231 # lexicographically to make expected output defined.
232 sorted_col_values = sorted(column_values[col_name].values())
233 result.append(template_helpers.EZTItem(
234 col_index=i, column_name=col_name, filter_values=sorted_col_values))
235
236 return result
237
238
239 def MakeTableData(
240 visible_results, logged_in_user_id, starred_items,
241 lower_columns, lower_group_by, users_by_id, cell_factories,
242 id_accessor, related_issues, config):
243 """Return a list of list row objects for display by EZT.
244
245 Args:
246 visible_results: list of artifacts to display on one pagination page.
247 logged_in_user_id: user ID of the signed in user, or None.
248 starred_items: list of IDs/names of items in the current project
249 that the signed in user has starred.
250 lower_columns: list of column names to display, all lowercase. These can
251 be combined column names, e.g., 'priority/pri'.
252 lower_group_by: list of column names that define row groups, all lowercase.
253 users_by_id: dict mapping user IDs to UserViews.
254 cell_factories: dict of functions that each create TableCell objects.
255 id_accessor: function that maps from an artifact to the ID/name that might
256 be in the starred items list.
257 related_issues: dict {issue_id: issue} of pre-fetched related issues.
258 config: ProjectIssueConfig PB for the current project.
259
260 Returns:
261 A list of TableRow objects, one for each visible result.
262 """
263 table_data = []
264
265 group_cell_factories = [
266 ChooseCellFactory(group.strip('-'), cell_factories, config)
267 for group in lower_group_by]
268
269 # Make a list of cell factories, one for each column.
270 factories_to_use = [
271 ChooseCellFactory(col, cell_factories, config) for col in lower_columns]
272
273 current_group = None
274 for idx, art in enumerate(visible_results):
275 owner_is_me = ezt.boolean(
276 logged_in_user_id and
277 tracker_bizobj.GetOwnerId(art) == logged_in_user_id)
278 row = MakeRowData(
279 art, lower_columns, owner_is_me, users_by_id, factories_to_use,
280 related_issues, config)
281 row.starred = ezt.boolean(id_accessor(art) in starred_items)
282 row.idx = idx # EZT does not have loop counters, so add idx.
283 table_data.append(row)
284 row.group = None
285
286 # Also include group information for the first row in each group.
287 # TODO(jrobbins): This seems like more overhead than we need for the
288 # common case where no new group heading row is to be inserted.
289 group = MakeRowData(
290 art, [group_name.strip('-') for group_name in lower_group_by],
291 owner_is_me, users_by_id, group_cell_factories, related_issues,
292 config)
293 for cell, group_name in zip(group.cells, lower_group_by):
294 cell.group_name = group_name
295 if group == current_group:
296 current_group.rows_in_group += 1
297 else:
298 row.group = group
299 current_group = group
300 current_group.rows_in_group = 1
301
302 return table_data
303
304
305 def MakeRowData(
306 art, columns, owner_is_me, users_by_id, cell_factory_list,
307 related_issues, config):
308 """Make a TableRow for use by EZT when rendering HTML table of results.
309
310 Args:
311 art: a project artifact PB
312 columns: list of lower-case column names
313 owner_is_me: boolean indicating that the logged in user is the owner
314 of the current artifact
315 users_by_id: dictionary {user_id: UserView} with each UserView having
316 a "display_name" member.
317 cell_factory_list: list of functions that each create TableCell
318 objects for a given column.
319 related_issues: dict {issue_id: issue} of pre-fetched related issues.
320 config: ProjectIssueConfig PB for the current project.
321
322 Returns:
323 A TableRow object for use by EZT to render a table of results.
324 """
325 ordered_row_data = []
326 non_col_labels = []
327 label_values = collections.defaultdict(list)
328
329 flattened_columns = set()
330 for col in columns:
331 if '/' in col:
332 flattened_columns.update(col.split('/'))
333 else:
334 flattened_columns.add(col)
335
336 # Group all "Key-Value" labels by key, and separate the "OneWord" labels.
337 _AccumulateLabelValues(
338 art.labels, flattened_columns, label_values, non_col_labels)
339
340 _AccumulateLabelValues(
341 art.derived_labels, flattened_columns, label_values,
342 non_col_labels, is_derived=True)
343
344 # Build up a list of TableCell objects for this row.
345 for i, col in enumerate(columns):
346 factory = cell_factory_list[i]
347 new_cell = factory(
348 art, col, users_by_id, non_col_labels, label_values, related_issues,
349 config)
350 new_cell.col_index = i
351 ordered_row_data.append(new_cell)
352
353 return TableRow(ordered_row_data, owner_is_me)
354
355
356 def _AccumulateLabelValues(
357 labels, columns, label_values, non_col_labels, is_derived=False):
358 """Parse OneWord and Key-Value labels for display in a list page.
359
360 Args:
361 labels: a list of label strings.
362 columns: a list of column names.
363 label_values: mutable dictionary {key: [value, ...]} of label values
364 seen so far.
365 non_col_labels: mutable list of OneWord labels seen so far.
366 is_derived: true if these labels were derived via rules.
367
368 Returns:
369 Nothing. But, the given label_values dictionary will grow to hold
370 the values of the key-value labels passed in, and the non_col_labels
371 list will grow to hold the OneWord labels passed in. These are shown
372 in label columns, and in the summary column, respectively
373 """
374 for label_name in labels:
375 if '-' in label_name:
376 parts = label_name.split('-')
377 for pivot in range(1, len(parts)):
378 column_name = '-'.join(parts[:pivot])
379 value = '-'.join(parts[pivot:])
380 column_name = column_name.lower()
381 if column_name in columns:
382 label_values[column_name].append((value, is_derived))
383 else:
384 non_col_labels.append((label_name, is_derived))
385
386
387 class TableRow(object):
388 """A tiny auxiliary class to represent a row in an HTML table."""
389
390 def __init__(self, cells, owner_is_me):
391 """Initialize the table row with the given data."""
392 self.cells = cells
393 self.owner_is_me = ezt.boolean(owner_is_me) # Shows tiny ">" on my issues.
394 # Used by MakeTableData for layout.
395 self.idx = None
396 self.group = None
397 self.rows_in_group = None
398 self.starred = None
399
400 def __cmp__(self, other):
401 """A row is == if each cell is == to the cells in the other row."""
402 return cmp(self.cells, other.cells) if other else -1
403
404 def DebugString(self):
405 """Return a string that is useful for on-page debugging."""
406 return 'TR(%s)' % self.cells
407
408
409 # TODO(jrobbins): also add unsortable... or change this to a list of operations
410 # that can be done.
411 CELL_TYPE_ID = 'ID'
412 CELL_TYPE_SUMMARY = 'summary'
413 CELL_TYPE_ATTR = 'attr'
414 CELL_TYPE_UNFILTERABLE = 'unfilterable'
415
416
417 class TableCell(object):
418 """Helper class to represent a table cell when rendering using EZT."""
419
420 # Should instances of this class be rendered with whitespace:nowrap?
421 # Subclasses can override this constant, e.g., issuelist TableCellOwner.
422 NOWRAP = ezt.boolean(False)
423
424 def __init__(self, cell_type, explicit_values,
425 derived_values=None, non_column_labels=None, align='',
426 sort_values=True):
427 """Store all the given data for later access by EZT."""
428 self.type = cell_type
429 self.align = align
430 self.col_index = 0 # Is set afterward
431 self.values = []
432 if non_column_labels:
433 self.non_column_labels = [
434 template_helpers.EZTItem(value=v, is_derived=ezt.boolean(d))
435 for v, d in non_column_labels]
436 else:
437 self.non_column_labels = []
438
439 for v in (sorted(explicit_values) if sort_values else explicit_values):
440 self.values.append(CellItem(v))
441
442 if derived_values:
443 for v in (sorted(derived_values) if sort_values else derived_values):
444 self.values.append(CellItem(v, is_derived=True))
445
446 def __cmp__(self, other):
447 """A cell is == if each value is == to the values in the other cells."""
448 return cmp(self.values, other.values) if other else -1
449
450 def DebugString(self):
451 return 'TC(%r, %r, %r)' % (
452 self.type,
453 [v.DebugString() for v in self.values],
454 self.non_column_labels)
455
456
457 def CompositeTableCell(columns_to_combine, cell_factories):
458 """Cell factory that combines multiple cells in a combined column."""
459
460 class FactoryClass(TableCell):
461 def __init__(self, art, _col, users_by_id,
462 non_col_labels, label_values, related_issues, config):
463 TableCell.__init__(self, CELL_TYPE_UNFILTERABLE, [])
464
465 for sub_col in columns_to_combine:
466 sub_factory = ChooseCellFactory(sub_col, cell_factories, config)
467 sub_cell = sub_factory(
468 art, sub_col, users_by_id, non_col_labels, label_values,
469 related_issues, config)
470 self.non_column_labels.extend(sub_cell.non_column_labels)
471 self.values.extend(sub_cell.values)
472
473 return FactoryClass
474
475
476 class CellItem(object):
477 """Simple class to display one part of a table cell's value, with style."""
478
479 def __init__(self, item, is_derived=False):
480 self.item = item
481 self.is_derived = ezt.boolean(is_derived)
482
483 def __cmp__(self, other):
484 return cmp(self.item, other.item) if other else -1
485
486 def DebugString(self):
487 if self.is_derived:
488 return 'CI(derived: %r)' % self.item
489 else:
490 return 'CI(%r)' % self.item
491
492
493 class TableCellKeyLabels(TableCell):
494 """TableCell subclass specifically for showing user-defined label values."""
495
496 def __init__(
497 self, _art, col, _users_by_id, _non_col_labels,
498 label_values, _related_issues, _config):
499 label_value_pairs = label_values.get(col, [])
500 explicit_values = [value for value, is_derived in label_value_pairs
501 if not is_derived]
502 derived_values = [value for value, is_derived in label_value_pairs
503 if is_derived]
504 TableCell.__init__(self, CELL_TYPE_ATTR, explicit_values,
505 derived_values=derived_values)
506
507
508 class TableCellProject(TableCell):
509 """TableCell subclass for showing an artifact's project name."""
510
511 # pylint: disable=unused-argument
512 def __init__(
513 self, art, col, users_by_id, non_col_labels, label_values,
514 _related_issues, _config):
515 TableCell.__init__(
516 self, CELL_TYPE_ATTR, [art.project_name])
517
518
519 class TableCellStars(TableCell):
520 """TableCell subclass for showing an artifact's star count."""
521
522 # pylint: disable=unused-argument
523 def __init__(
524 self, art, col, users_by_id, non_col_labels, label_values,
525 _related_issues, _config):
526 TableCell.__init__(
527 self, CELL_TYPE_ATTR, [art.star_count], align='right')
528
529
530 class TableCellSummary(TableCell):
531 """TableCell subclass for showing an artifact's summary."""
532
533 # pylint: disable=unused-argument
534 def __init__(
535 self, art, col, users_by_id, non_col_labels, label_values,
536 _related_issues, _config):
537 TableCell.__init__(
538 self, CELL_TYPE_SUMMARY, [art.summary],
539 non_column_labels=non_col_labels)
540
541
542 class TableCellCustom(TableCell):
543 """Abstract TableCell subclass specifically for showing custom fields."""
544
545 def __init__(
546 self, art, col, users_by_id, _non_col_labels,
547 _label_values, _related_issues, config):
548 explicit_values = []
549 derived_values = []
550 for fv in art.field_values:
551 # TODO(jrobbins): for cross-project search this could be a list.
552 fd = tracker_bizobj.FindFieldDefByID(fv.field_id, config)
553 if fd.field_name.lower() == col:
554 val = self.ExtractValue(fv, users_by_id)
555 if fv.derived:
556 derived_values.append(val)
557 else:
558 explicit_values.append(val)
559
560 TableCell.__init__(self, CELL_TYPE_ATTR, explicit_values,
561 derived_values=derived_values)
562
563 def ExtractValue(self, fv, _users_by_id):
564 return 'field-id-%d-not-implemented-yet' % fv.field_id
565
566
567 class TableCellCustomInt(TableCellCustom):
568 """TableCell subclass specifically for showing custom int fields."""
569
570 def ExtractValue(self, fv, _users_by_id):
571 return fv.int_value
572
573
574 class TableCellCustomStr(TableCellCustom):
575 """TableCell subclass specifically for showing custom str fields."""
576
577 def ExtractValue(self, fv, _users_by_id):
578 return fv.str_value
579
580
581 class TableCellCustomUser(TableCellCustom):
582 """TableCell subclass specifically for showing custom user fields."""
583
584 def ExtractValue(self, fv, users_by_id):
585 if fv.user_id in users_by_id:
586 return users_by_id[fv.user_id].email
587 return 'USER_%d' % fv.user_id
588
589
590 class TableCellCustomDate(TableCellCustom):
591 """TableCell subclass specifically for showing custom date fields."""
592
593 def ExtractValue(self, fv, _users_by_id):
594 # TODO(jrobbins): convert timestamp to formatted date and time
595 return fv.int_value
596
597
598 class TableCellCustomBool(TableCellCustom):
599 """TableCell subclass specifically for showing custom int fields."""
600
601 def ExtractValue(self, fv, _users_by_id):
602 return 'Yes' if fv.int_value else 'No'
603
604
605 _CUSTOM_FIELD_CELL_FACTORIES = {
606 tracker_pb2.FieldTypes.ENUM_TYPE: TableCellKeyLabels,
607 tracker_pb2.FieldTypes.INT_TYPE: TableCellCustomInt,
608 tracker_pb2.FieldTypes.STR_TYPE: TableCellCustomStr,
609 tracker_pb2.FieldTypes.USER_TYPE: TableCellCustomUser,
610 tracker_pb2.FieldTypes.DATE_TYPE: TableCellCustomDate,
611 tracker_pb2.FieldTypes.BOOL_TYPE: TableCellCustomBool,
612 }
613
614
615 def ChooseCellFactory(col, cell_factories, config):
616 """Return the CellFactory to use for the given column."""
617 if col in cell_factories:
618 return cell_factories[col]
619
620 if '/' in col:
621 return CompositeTableCell(col.split('/'), cell_factories)
622
623 fd = tracker_bizobj.FindFieldDef(col, config)
624 if fd:
625 return _CUSTOM_FIELD_CELL_FACTORIES[fd.field_type]
626
627 return TableCellKeyLabels
OLDNEW
« no previous file with comments | « appengine/monorail/framework/sql.py ('k') | appengine/monorail/framework/template_helpers.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698