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