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

Side by Side Diff: appengine/monorail/tracker/tracker_bizobj.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
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 """Business objects for the Monorail issue tracker.
7
8 These are classes and functions that operate on the objects that
9 users care about in the issue tracker: e.g., issues, and the issue
10 tracker configuration.
11 """
12
13 import logging
14
15 from framework import framework_bizobj
16 from framework import framework_constants
17 from framework import framework_helpers
18 from framework import urls
19 from proto import tracker_pb2
20 from tracker import tracker_constants
21
22
23 def GetOwnerId(issue):
24 """Get the owner of an issue, whether it is explicit or derived."""
25 return (issue.owner_id or issue.derived_owner_id or
26 framework_constants.NO_USER_SPECIFIED)
27
28
29 def GetStatus(issue):
30 """Get the status of an issue, whether it is explicit or derived."""
31 return issue.status or issue.derived_status or ''
32
33
34 def GetCcIds(issue):
35 """Get the Cc's of an issue, whether they are explicit or derived."""
36 return issue.cc_ids + issue.derived_cc_ids
37
38
39 def GetLabels(issue):
40 """Get the labels of an issue, whether explicit or derived."""
41 return issue.labels + issue.derived_labels
42
43
44 def MakeProjectIssueConfig(
45 project_id, well_known_statuses, statuses_offer_merge, well_known_labels,
46 excl_label_prefixes, templates, col_spec):
47 """Return a ProjectIssueConfig with the given values."""
48 # pylint: disable=multiple-statements
49 if not well_known_statuses: well_known_statuses = []
50 if not statuses_offer_merge: statuses_offer_merge = []
51 if not well_known_labels: well_known_labels = []
52 if not excl_label_prefixes: excl_label_prefixes = []
53 if not templates: templates = []
54 if not col_spec: col_spec = ' '
55
56 project_config = tracker_pb2.ProjectIssueConfig()
57 if project_id: # There is no ID for harmonized configs.
58 project_config.project_id = project_id
59
60 SetConfigStatuses(project_config, well_known_statuses)
61 project_config.statuses_offer_merge = statuses_offer_merge
62 SetConfigLabels(project_config, well_known_labels)
63 SetConfigTemplates(project_config, templates)
64 project_config.exclusive_label_prefixes = excl_label_prefixes
65
66 # ID 0 means that nothing has been specified, so use hard-coded defaults.
67 project_config.default_template_for_developers = 0
68 project_config.default_template_for_users = 0
69
70 project_config.default_col_spec = col_spec
71
72 # Note: default project issue config has no filter rules.
73
74 return project_config
75
76
77 def UsersInvolvedInConfig(config):
78 """Return a set of all user IDs referenced in the ProjectIssueConfig."""
79 result = set()
80 for template in config.templates:
81 result.add(template.owner_id)
82 result.update(template.admin_ids)
83 for field in config.field_defs:
84 result.update(field.admin_ids)
85 return result
86
87
88 def FindFieldDef(field_name, config):
89 """Find the specified field, or return None."""
90 field_name_lower = field_name.lower()
91 for fd in config.field_defs:
92 if fd.field_name.lower() == field_name_lower:
93 return fd
94
95 return None
96
97
98 def FindFieldDefByID(field_id, config):
99 """Find the specified field, or return None."""
100 for fd in config.field_defs:
101 if fd.field_id == field_id:
102 return fd
103
104 return None
105
106
107 def GetGrantedPerms(issue, effective_ids, config):
108 """Return a set of permissions granted by user-valued fields in an issue."""
109 granted_perms = set()
110 for field_value in issue.field_values:
111 if field_value.user_id in effective_ids:
112 field_def = FindFieldDefByID(field_value.field_id, config)
113 if field_def and field_def.grants_perm:
114 # TODO(jrobbins): allow comma-separated list in grants_perm
115 granted_perms.add(field_def.grants_perm.lower())
116
117 return granted_perms
118
119
120 def LabelIsMaskedByField(label, field_names):
121 """If the label should be displayed as a field, return the field name.
122
123 Args:
124 label: string label to consider.
125 field_names: a list of field names in lowercase.
126
127 Returns:
128 If masked, return the lowercase name of the field, otherwise None. A label
129 is masked by a custom field if the field name "Foo" matches the key part of
130 a key-value label "Foo-Bar".
131 """
132 if '-' not in label:
133 return None
134
135 for field_name_lower in field_names:
136 if label.lower().startswith(field_name_lower + '-'):
137 return field_name_lower
138
139 return None
140
141
142 def NonMaskedLabels(labels, field_names):
143 """Return only those labels that are not masked by custom fields."""
144 return [lab for lab in labels
145 if not LabelIsMaskedByField(lab, field_names)]
146
147
148 def MakeFieldDef(
149 field_id, project_id, field_name, field_type_int, applic_type, applic_pred,
150 is_required, is_multivalued, min_value, max_value, regex, needs_member,
151 needs_perm, grants_perm, notify_on, docstring, is_deleted):
152 """Make a FieldDef PB for the given FieldDef table row tuple."""
153 fd = tracker_pb2.FieldDef(
154 field_id=field_id, project_id=project_id, field_name=field_name,
155 field_type=field_type_int, is_required=bool(is_required),
156 is_multivalued=bool(is_multivalued), docstring=docstring,
157 is_deleted=bool(is_deleted), applicable_type=applic_type or '',
158 applicable_predicate=applic_pred or '',
159 needs_member=bool(needs_member), grants_perm=grants_perm or '',
160 notify_on=tracker_pb2.NotifyTriggers(notify_on or 0))
161 if min_value is not None:
162 fd.min_value = min_value
163 if max_value is not None:
164 fd.max_value = max_value
165 if regex is not None:
166 fd.regex = regex
167 if needs_perm is not None:
168 fd.needs_perm = needs_perm
169 return fd
170
171
172 def MakeFieldValue(field_id, int_value, str_value, user_id, derived):
173 """Make a FieldValue based on the given information."""
174 fv = tracker_pb2.FieldValue(field_id=field_id, derived=derived)
175 if int_value is not None:
176 fv.int_value = int_value
177 elif str_value is not None:
178 fv.str_value = str_value
179 elif user_id is not None:
180 fv.user_id = user_id
181
182 return fv
183
184
185 def GetFieldValueWithRawValue(field_type, field_value, users_by_id, raw_value):
186 """Find and return the field value of the specified field type.
187
188 If the specified field_value is None or is empty then the raw_value is
189 returned. When the field type is USER_TYPE the raw_value is used as a key to
190 lookup users_by_id.
191
192 Args:
193 field_type: tracker_pb2.FieldTypes type.
194 field_value: tracker_pb2.FieldValue type.
195 users_by_id: Dict mapping user_ids to UserViews.
196 raw_value: String to use if field_value is not specified.
197
198 Returns:
199 Value of the specified field type.
200 """
201 ret_value = GetFieldValue(field_value, users_by_id)
202 if ret_value:
203 return ret_value
204 # Special case for user types.
205 if field_type == tracker_pb2.FieldTypes.USER_TYPE:
206 if raw_value in users_by_id:
207 return users_by_id[raw_value].email
208 return raw_value
209
210
211 def GetFieldValue(fv, users_by_id):
212 """Return the value of this field. Give emails for users in users_by_id."""
213 if fv is None:
214 return None
215 elif fv.int_value is not None:
216 return fv.int_value
217 elif fv.str_value is not None:
218 return fv.str_value
219 elif fv.user_id is not None:
220 if fv.user_id in users_by_id:
221 return users_by_id[fv.user_id].email
222 else:
223 logging.info('Failed to lookup user %d when getting field', fv.user_id)
224 return fv.user_id
225 else:
226 return None
227
228
229 def FindComponentDef(path, config):
230 """Find the specified component, or return None."""
231 path_lower = path.lower()
232 for cd in config.component_defs:
233 if cd.path.lower() == path_lower:
234 return cd
235
236 return None
237
238
239 def FindMatchingComponentIDs(path, config, exact=True):
240 """Return a list of components that match the given path."""
241 component_ids = []
242 path_lower = path.lower()
243
244 if exact:
245 for cd in config.component_defs:
246 if cd.path.lower() == path_lower:
247 component_ids.append(cd.component_id)
248 else:
249 path_lower_delim = path.lower() + '>'
250 for cd in config.component_defs:
251 target_delim = cd.path.lower() + '>'
252 if target_delim.startswith(path_lower_delim):
253 component_ids.append(cd.component_id)
254
255 return component_ids
256
257
258 def FindComponentDefByID(component_id, config):
259 """Find the specified component, or return None."""
260 for cd in config.component_defs:
261 if cd.component_id == component_id:
262 return cd
263
264 return None
265
266
267 def FindAncestorComponents(config, component_def):
268 """Return a list of all components the given component is under."""
269 path_lower = component_def.path.lower()
270 return [cd for cd in config.component_defs
271 if path_lower.startswith(cd.path.lower() + '>')]
272
273
274 def FindDescendantComponents(config, component_def):
275 """Return a list of all nested components under the given component."""
276 path_plus_delim = component_def.path.lower() + '>'
277 return [cd for cd in config.component_defs
278 if cd.path.lower().startswith(path_plus_delim)]
279
280
281 def MakeComponentDef(
282 component_id, project_id, path, docstring, deprecated, admin_ids, cc_ids,
283 created, creator_id, modified=None, modifier_id=None):
284 """Make a ComponentDef PB for the given FieldDef table row tuple."""
285 cd = tracker_pb2.ComponentDef(
286 component_id=component_id, project_id=project_id, path=path,
287 docstring=docstring, deprecated=bool(deprecated),
288 admin_ids=admin_ids, cc_ids=cc_ids, created=created,
289 creator_id=creator_id, modified=modified, modifier_id=modifier_id)
290 return cd
291
292
293 def MakeSavedQuery(
294 query_id, name, base_query_id, query, subscription_mode=None,
295 executes_in_project_ids=None):
296 """Make SavedQuery PB for the given info."""
297 saved_query = tracker_pb2.SavedQuery(
298 name=name, base_query_id=base_query_id, query=query)
299 if query_id is not None:
300 saved_query.query_id = query_id
301 if subscription_mode is not None:
302 saved_query.subscription_mode = subscription_mode
303 if executes_in_project_ids is not None:
304 saved_query.executes_in_project_ids = executes_in_project_ids
305 return saved_query
306
307
308 def SetConfigStatuses(project_config, well_known_statuses):
309 """Internal method to set the well-known statuses of ProjectIssueConfig."""
310 project_config.well_known_statuses = []
311 for status, docstring, means_open, deprecated in well_known_statuses:
312 canonical_status = framework_bizobj.CanonicalizeLabel(status)
313 project_config.well_known_statuses.append(tracker_pb2.StatusDef(
314 status_docstring=docstring, status=canonical_status,
315 means_open=means_open, deprecated=deprecated))
316
317
318 def SetConfigLabels(project_config, well_known_labels):
319 """Internal method to set the well-known labels of a ProjectIssueConfig."""
320 project_config.well_known_labels = []
321 for label, docstring, deprecated in well_known_labels:
322 canonical_label = framework_bizobj.CanonicalizeLabel(label)
323 project_config.well_known_labels.append(tracker_pb2.LabelDef(
324 label=canonical_label, label_docstring=docstring,
325 deprecated=deprecated))
326
327
328 def SetConfigTemplates(project_config, template_dict_list):
329 """Internal method to set the templates of a ProjectIssueConfig."""
330 templates = [ConvertDictToTemplate(template_dict)
331 for template_dict in template_dict_list]
332 project_config.templates = templates
333
334
335 def ConvertDictToTemplate(template_dict):
336 """Construct a Template PB with the values from template_dict.
337
338 Args:
339 template_dict: dictionary with fields corresponding to the Template
340 PB fields.
341
342 Returns:
343 A Template protocol buffer thatn can be stored in the
344 project's ProjectIssueConfig PB.
345 """
346 return MakeIssueTemplate(
347 template_dict.get('name'), template_dict.get('summary'),
348 template_dict.get('status'), template_dict.get('owner_id'),
349 template_dict.get('content'), template_dict.get('labels'), [], [],
350 template_dict.get('components'),
351 summary_must_be_edited=template_dict.get('summary_must_be_edited'),
352 owner_defaults_to_member=template_dict.get('owner_defaults_to_member'),
353 component_required=template_dict.get('component_required'),
354 members_only=template_dict.get('members_only'))
355
356
357 def MakeIssueTemplate(
358 name, summary, status, owner_id, content, labels, field_values, admin_ids,
359 component_ids, summary_must_be_edited=None, owner_defaults_to_member=None,
360 component_required=None, members_only=None):
361 """Make an issue template PB."""
362 template = tracker_pb2.TemplateDef()
363 template.name = name
364 if summary:
365 template.summary = summary
366 if status:
367 template.status = status
368 if owner_id:
369 template.owner_id = owner_id
370 template.content = content
371 template.field_values = field_values
372 template.labels = labels or []
373 template.admin_ids = admin_ids
374 template.component_ids = component_ids or []
375
376 if summary_must_be_edited is not None:
377 template.summary_must_be_edited = summary_must_be_edited
378 if owner_defaults_to_member is not None:
379 template.owner_defaults_to_member = owner_defaults_to_member
380 if component_required is not None:
381 template.component_required = component_required
382 if members_only is not None:
383 template.members_only = members_only
384
385 return template
386
387
388 def MakeDefaultProjectIssueConfig(project_id):
389 """Return a ProjectIssueConfig with use by projects that don't have one."""
390 return MakeProjectIssueConfig(
391 project_id,
392 tracker_constants.DEFAULT_WELL_KNOWN_STATUSES,
393 tracker_constants.DEFAULT_STATUSES_OFFER_MERGE,
394 tracker_constants.DEFAULT_WELL_KNOWN_LABELS,
395 tracker_constants.DEFAULT_EXCL_LABEL_PREFIXES,
396 tracker_constants.DEFAULT_TEMPLATES,
397 tracker_constants.DEFAULT_COL_SPEC)
398
399
400 def HarmonizeConfigs(config_list):
401 """Combine several ProjectIssueConfigs into one for cross-project sorting.
402
403 Args:
404 config_list: a list of ProjectIssueConfig PBs with labels and statuses
405 among other fields.
406
407 Returns:
408 A new ProjectIssueConfig with just the labels and status values filled
409 in to be a logical union of the given configs. Specifically, the order
410 of the combined status and label lists should be maintained.
411 """
412 if not config_list:
413 return MakeDefaultProjectIssueConfig(None)
414
415 harmonized_status_names = _CombineOrderedLists(
416 [[stat.status for stat in config.well_known_statuses]
417 for config in config_list])
418 harmonized_label_names = _CombineOrderedLists(
419 [[lab.label for lab in config.well_known_labels]
420 for config in config_list])
421 harmonized_default_sort_spec = ' '.join(
422 config.default_sort_spec for config in config_list)
423 # This col_spec is probably not what the user wants to view because it is
424 # too much information. We join all the col_specs here so that we are sure
425 # to lookup all users needed for sorting, even if it is more than needed.
426 # xxx we need to look up users based on colspec rather than sortspec?
427 harmonized_default_col_spec = ' '.join(
428 config.default_col_spec for config in config_list)
429
430 result_config = tracker_pb2.ProjectIssueConfig()
431 # The combined config is only used during sorting, never stored.
432 result_config.default_col_spec = harmonized_default_col_spec
433 result_config.default_sort_spec = harmonized_default_sort_spec
434
435 for status_name in harmonized_status_names:
436 result_config.well_known_statuses.append(tracker_pb2.StatusDef(
437 status=status_name, means_open=True))
438
439 for label_name in harmonized_label_names:
440 result_config.well_known_labels.append(tracker_pb2.LabelDef(
441 label=label_name))
442
443 for config in config_list:
444 result_config.field_defs.extend(config.field_defs)
445 result_config.component_defs.extend(config.component_defs)
446
447 return result_config
448
449
450 def HarmonizeLabelOrStatusRows(def_rows):
451 """Put the given label defs into a logical global order."""
452 ranked_defs_by_project = {}
453 oddball_defs = []
454 for row in def_rows:
455 def_id, project_id, rank, label = row[0], row[1], row[2], row[3]
456 if rank is not None:
457 ranked_defs_by_project.setdefault(project_id, []).append(
458 (def_id, rank, label))
459 else:
460 oddball_defs.append((def_id, rank, label))
461
462 oddball_defs.sort(reverse=True, key=lambda def_tuple: def_tuple[2].lower())
463 # Compose the list-of-lists in a consistent order by project_id.
464 list_of_lists = [ranked_defs_by_project[pid]
465 for pid in sorted(ranked_defs_by_project.keys())]
466 harmonized_ranked_defs = _CombineOrderedLists(
467 list_of_lists, include_duplicate_keys=True,
468 key=lambda def_tuple: def_tuple[2])
469
470 return oddball_defs + harmonized_ranked_defs
471
472
473 def _CombineOrderedLists(
474 list_of_lists, include_duplicate_keys=False, key=lambda x: x):
475 """Combine lists of items while maintaining their desired order.
476
477 Args:
478 list_of_lists: a list of lists of strings.
479 include_duplicate_keys: Pass True to make the combined list have the
480 same total number of elements as the sum of the input lists.
481 key: optional function to choose which part of the list items hold the
482 string used for comparison. The result will have the whole items.
483
484 Returns:
485 A single list of items containing one copy of each of the items
486 in any of the original list, and in an order that maintains the original
487 list ordering as much as possible.
488 """
489 combined_items = []
490 combined_keys = []
491 seen_keys_set = set()
492 for one_list in list_of_lists:
493 _AccumulateCombinedList(
494 one_list, combined_items, combined_keys, seen_keys_set, key=key,
495 include_duplicate_keys=include_duplicate_keys)
496
497 return combined_items
498
499
500 def _AccumulateCombinedList(
501 one_list, combined_items, combined_keys, seen_keys_set,
502 include_duplicate_keys=False, key=lambda x: x):
503 """Accumulate strings into a combined list while its maintaining ordering.
504
505 Args:
506 one_list: list of strings in a desired order.
507 combined_items: accumulated list of items in the desired order.
508 combined_keys: accumulated list of key strings in the desired order.
509 seen_keys_set: set of strings that are already in combined_list.
510 include_duplicate_keys: Pass True to make the combined list have the
511 same total number of elements as the sum of the input lists.
512 key: optional function to choose which part of the list items hold the
513 string used for comparison. The result will have the whole items.
514
515 Returns:
516 Nothing. But, combined_items is modified to mix in all the items of
517 one_list at appropriate points such that nothing in combined_items
518 is reordered, and the ordering of items from one_list is maintained
519 as much as possible. Also, seen_keys_set is modified to add any keys
520 for items that were added to combined_items.
521
522 Also, any strings that begin with "#" are compared regardless of the "#".
523 The purpose of such strings is to guide the final ordering.
524 """
525 insert_idx = 0
526 for item in one_list:
527 s = key(item).lower()
528 if s in seen_keys_set:
529 item_idx = combined_keys.index(s) # Need parallel list of keys
530 insert_idx = max(insert_idx, item_idx + 1)
531
532 if s not in seen_keys_set or include_duplicate_keys:
533 combined_items.insert(insert_idx, item)
534 combined_keys.insert(insert_idx, s)
535 insert_idx += 1
536
537 seen_keys_set.add(s)
538
539
540 def GetBuiltInQuery(query_id):
541 """If the given query ID is for a built-in query, return that string."""
542 return tracker_constants.DEFAULT_CANNED_QUERY_CONDS.get(query_id, '')
543
544
545 def UsersInvolvedInAmendments(amendments):
546 """Return a set of all user IDs mentioned in the given Amendments."""
547 user_id_set = set()
548 for amendment in amendments:
549 user_id_set.update(amendment.added_user_ids)
550 user_id_set.update(amendment.removed_user_ids)
551
552 return user_id_set
553
554
555 def _AccumulateUsersInvolvedInComment(comment, user_id_set):
556 """Build up a set of all users involved in an IssueComment.
557
558 Args:
559 comment: an IssueComment PB.
560 user_id_set: a set of user IDs to build up.
561
562 Returns:
563 The same set, but modified to have the user IDs of user who
564 entered the comment, and all the users mentioned in any amendments.
565 """
566 user_id_set.add(comment.user_id)
567 user_id_set.update(UsersInvolvedInAmendments(comment.amendments))
568
569 return user_id_set
570
571
572 def UsersInvolvedInComment(comment):
573 """Return a set of all users involved in an IssueComment.
574
575 Args:
576 comment: an IssueComment PB.
577
578 Returns:
579 A set with the user IDs of user who entered the comment, and all the
580 users mentioned in any amendments.
581 """
582 return _AccumulateUsersInvolvedInComment(comment, set())
583
584
585 def UsersInvolvedInCommentList(comments):
586 """Return a set of all users involved in a list of IssueComments.
587
588 Args:
589 comments: a list of IssueComment PBs.
590
591 Returns:
592 A set with the user IDs of user who entered the comment, and all the
593 users mentioned in any amendments.
594 """
595 result = set()
596 for c in comments:
597 _AccumulateUsersInvolvedInComment(c, result)
598
599 return result
600
601
602 def UsersInvolvedInIssues(issues):
603 """Return a set of all user IDs referenced in the issues' metadata."""
604 result = set()
605 for issue in issues:
606 result.update([issue.reporter_id, issue.owner_id, issue.derived_owner_id])
607 result.update(issue.cc_ids)
608 result.update(issue.derived_cc_ids)
609 result.update(fv.user_id for fv in issue.field_values if fv.user_id)
610
611 return result
612
613
614 def MakeAmendment(
615 field, new_value, added_ids, removed_ids, custom_field_name=None,
616 old_value=None):
617 """Utility function to populate an Amendment PB.
618
619 Args:
620 field: enum for the field being updated.
621 new_value: new string value of that field.
622 added_ids: list of user IDs being added.
623 removed_ids: list of user IDs being removed.
624 custom_field_name: optional name of a custom field.
625 old_value: old string value of that field.
626
627 Returns:
628 An instance of Amendment.
629 """
630 amendment = tracker_pb2.Amendment()
631 amendment.field = field
632 amendment.newvalue = new_value
633 amendment.added_user_ids.extend(added_ids)
634 amendment.removed_user_ids.extend(removed_ids)
635
636 if old_value is not None:
637 amendment.oldvalue = old_value
638
639 if custom_field_name is not None:
640 amendment.custom_field_name = custom_field_name
641
642 return amendment
643
644
645 def _PlusMinusString(added_items, removed_items):
646 """Return a concatenation of the items, with a minus on removed items.
647
648 Args:
649 added_items: list of string items added.
650 removed_items: list of string items removed.
651
652 Returns:
653 A unicode string with all the removed items first (preceeded by minus
654 signs) and then the added items.
655 """
656 assert all(isinstance(item, basestring)
657 for item in added_items + removed_items)
658 # TODO(jrobbins): this is not good when values can be negative ints.
659 return ' '.join(
660 ['-%s' % item.strip()
661 for item in removed_items if item] +
662 ['%s' % item for item in added_items if item])
663
664
665 def _PlusMinusAmendment(
666 field, added_items, removed_items, custom_field_name=None):
667 """Make an Amendment PB with the given added/removed items."""
668 return MakeAmendment(
669 field, _PlusMinusString(added_items, removed_items), [], [],
670 custom_field_name=custom_field_name)
671
672
673 def _PlusMinusRefsAmendment(
674 field, added_refs, removed_refs, default_project_name=None):
675 """Make an Amendment PB with the given added/removed refs."""
676 return _PlusMinusAmendment(
677 field,
678 [FormatIssueRef(r, default_project_name=default_project_name)
679 for r in added_refs if r],
680 [FormatIssueRef(r, default_project_name=default_project_name)
681 for r in removed_refs if r])
682
683
684 def MakeSummaryAmendment(new_summary, old_summary):
685 """Make an Amendment PB for a change to the summary."""
686 return MakeAmendment(
687 tracker_pb2.FieldID.SUMMARY, new_summary, [], [], old_value=old_summary)
688
689
690 def MakeStatusAmendment(new_status, old_status):
691 """Make an Amendment PB for a change to the status."""
692 return MakeAmendment(
693 tracker_pb2.FieldID.STATUS, new_status, [], [], old_value=old_status)
694
695
696 def MakeOwnerAmendment(new_owner_id, old_owner_id):
697 """Make an Amendment PB for a change to the owner."""
698 return MakeAmendment(
699 tracker_pb2.FieldID.OWNER, '', [new_owner_id], [old_owner_id])
700
701
702 def MakeCcAmendment(added_cc_ids, removed_cc_ids):
703 """Make an Amendment PB for a change to the Cc list."""
704 return MakeAmendment(
705 tracker_pb2.FieldID.CC, '', added_cc_ids, removed_cc_ids)
706
707
708 def MakeLabelsAmendment(added_labels, removed_labels):
709 """Make an Amendment PB for a change to the labels."""
710 return _PlusMinusAmendment(
711 tracker_pb2.FieldID.LABELS, added_labels, removed_labels)
712
713
714 def DiffValueLists(new_list, old_list):
715 """Give an old list and a new list, return the added and removed items."""
716 if not old_list:
717 return new_list, []
718 if not new_list:
719 return [], old_list
720
721 added = []
722 removed = old_list[:] # Assume everything was removed, then narrow that down
723 for val in new_list:
724 if val in removed:
725 removed.remove(val)
726 else:
727 added.append(val)
728
729 return added, removed
730
731
732 def MakeFieldAmendment(field_id, config, new_values, old_values=None):
733 """Return an amendment showing how an issue's field changed.
734
735 Args:
736 field_id: int field ID of a built-in or custom issue field.
737 config: config info for the current project, including field_defs.
738 new_values: list of strings representing new values of field.
739 old_values: list of strings representing old values of field.
740
741 Returns:
742 A new Amemdnent object.
743
744 Raises:
745 ValueError: if the specified field was not found.
746 """
747 fd = FindFieldDefByID(field_id, config)
748
749 if fd is None:
750 raise ValueError('field %r vanished mid-request', field_id)
751
752 if fd.is_multivalued:
753 old_values = old_values or []
754 added, removed = DiffValueLists(new_values, old_values)
755 if fd.field_type == tracker_pb2.FieldTypes.USER_TYPE:
756 return MakeAmendment(
757 tracker_pb2.FieldID.CUSTOM, '', added, removed,
758 custom_field_name=fd.field_name)
759 else:
760 return _PlusMinusAmendment(
761 tracker_pb2.FieldID.CUSTOM,
762 ['%s' % item for item in added],
763 ['%s' % item for item in removed],
764 custom_field_name=fd.field_name)
765
766 else:
767 if fd.field_type == tracker_pb2.FieldTypes.USER_TYPE:
768 return MakeAmendment(
769 tracker_pb2.FieldID.CUSTOM, '', new_values, [],
770 custom_field_name=fd.field_name)
771
772 if new_values:
773 new_str = ', '.join('%s' % item for item in new_values)
774 else:
775 new_str = '----'
776
777 return MakeAmendment(
778 tracker_pb2.FieldID.CUSTOM, new_str, [], [],
779 custom_field_name=fd.field_name)
780
781
782 def MakeFieldClearedAmendment(field_id, config):
783 fd = FindFieldDefByID(field_id, config)
784
785 if fd is None:
786 raise ValueError('field %r vanished mid-request', field_id)
787
788 return MakeAmendment(
789 tracker_pb2.FieldID.CUSTOM, '----', [], [],
790 custom_field_name=fd.field_name)
791
792
793 def MakeComponentsAmendment(added_comp_ids, removed_comp_ids, config):
794 """Make an Amendment PB for a change to the components."""
795 # TODO(jrobbins): record component IDs as ints and display them with
796 # lookups (and maybe permission checks in the future). But, what
797 # about history that references deleleted components?
798 added_comp_paths = []
799 for comp_id in added_comp_ids:
800 cd = FindComponentDefByID(comp_id, config)
801 if cd:
802 added_comp_paths.append(cd.path)
803
804 removed_comp_paths = []
805 for comp_id in removed_comp_ids:
806 cd = FindComponentDefByID(comp_id, config)
807 if cd:
808 removed_comp_paths.append(cd.path)
809
810 return _PlusMinusAmendment(
811 tracker_pb2.FieldID.COMPONENTS,
812 added_comp_paths, removed_comp_paths)
813
814
815 def MakeBlockedOnAmendment(
816 added_refs, removed_refs, default_project_name=None):
817 """Make an Amendment PB for a change to the blocked on issues."""
818 return _PlusMinusRefsAmendment(
819 tracker_pb2.FieldID.BLOCKEDON, added_refs, removed_refs,
820 default_project_name=default_project_name)
821
822
823 def MakeBlockingAmendment(added_refs, removed_refs, default_project_name=None):
824 """Make an Amendment PB for a change to the blocking issues."""
825 return _PlusMinusRefsAmendment(
826 tracker_pb2.FieldID.BLOCKING, added_refs, removed_refs,
827 default_project_name=default_project_name)
828
829
830 def MakeMergedIntoAmendment(added_ref, removed_ref, default_project_name=None):
831 """Make an Amendment PB for a change to the merged-into issue."""
832 return _PlusMinusRefsAmendment(
833 tracker_pb2.FieldID.MERGEDINTO, [added_ref], [removed_ref],
834 default_project_name=default_project_name)
835
836
837 def MakeProjectAmendment(new_project_name):
838 """Make an Amendment PB for a change to an issue's project."""
839 return MakeAmendment(
840 tracker_pb2.FieldID.PROJECT, new_project_name, [], [])
841
842
843 def AmendmentString(amendment, users_by_id):
844 """Produce a displayable string for an Amendment PB.
845
846 Args:
847 amendment: Amendment PB to display.
848 users_by_id: dict {user_id: user_view, ...} including all users
849 mentioned in amendment.
850
851 Returns:
852 A string that could be displayed on a web page or sent in email.
853 """
854 if amendment.newvalue:
855 return amendment.newvalue
856
857 # Display new owner only
858 if amendment.field == tracker_pb2.FieldID.OWNER:
859 if amendment.added_user_ids and amendment.added_user_ids[0] > 0:
860 uid = amendment.added_user_ids[0]
861 result = users_by_id[uid].display_name
862 else:
863 result = framework_constants.NO_USER_NAME
864 else:
865 result = _PlusMinusString(
866 [users_by_id[uid].display_name for uid in amendment.added_user_ids
867 if uid in users_by_id],
868 [users_by_id[uid].display_name for uid in amendment.removed_user_ids
869 if uid in users_by_id])
870
871 return result
872
873
874 def AmendmentLinks(amendment, users_by_id, project_name):
875 """Produce a list of value/url pairs for an Amendment PB.
876
877 Args:
878 amendment: Amendment PB to display.
879 users_by_id: dict {user_id: user_view, ...} including all users
880 mentioned in amendment.
881 project_nme: Name of project the issue/comment/amendment is in.
882
883 Returns:
884 A list of dicts with 'value' and 'url' keys. 'url' may be None.
885 """
886 # Display both old and new summary
887 if amendment.field == tracker_pb2.FieldID.SUMMARY:
888 result = amendment.newvalue
889 if amendment.oldvalue:
890 result += ' (was: %s)' % amendment.oldvalue
891 return [{'value': result, 'url': None}]
892 # Display new owner only
893 elif amendment.field == tracker_pb2.FieldID.OWNER:
894 if amendment.added_user_ids and amendment.added_user_ids[0] > 0:
895 uid = amendment.added_user_ids[0]
896 return [{'value': users_by_id[uid].display_name, 'url': None}]
897 else:
898 return [{'value': framework_constants.NO_USER_NAME, 'url': None}]
899 elif amendment.field in (tracker_pb2.FieldID.BLOCKEDON,
900 tracker_pb2.FieldID.BLOCKING,
901 tracker_pb2.FieldID.MERGEDINTO):
902 values = amendment.newvalue.split()
903 bug_refs = [_SafeParseIssueRef(v.strip()) for v in values]
904 issue_urls = [FormatIssueUrl(ref, default_project_name=project_name)
905 for ref in bug_refs]
906 # TODO(jrobbins): Permission checks on referenced issues to allow
907 # showing summary on hover.
908 return [{'value': v, 'url': u} for (v, u) in zip(values, issue_urls)]
909 elif amendment.newvalue:
910 # Catchall for everything except user-valued fields.
911 return [{'value': v, 'url': None} for v in amendment.newvalue.split()]
912 else:
913 # Applies to field==CC or CUSTOM with user type.
914 values = _PlusMinusString(
915 [users_by_id[uid].display_name for uid in amendment.added_user_ids
916 if uid in users_by_id],
917 [users_by_id[uid].display_name for uid in amendment.removed_user_ids
918 if uid in users_by_id])
919 return [{'value': v.strip(), 'url': None} for v in values.split()]
920
921
922 def GetAmendmentFieldName(amendment):
923 """Get user-visible name for an amendment to a built-in or custom field."""
924 if amendment.custom_field_name:
925 return amendment.custom_field_name
926 else:
927 field_name = str(amendment.field)
928 return field_name.capitalize()
929
930
931 def MakeDanglingIssueRef(project_name, issue_id):
932 """Create a DanglingIssueRef pb."""
933 ret = tracker_pb2.DanglingIssueRef()
934 ret.project = project_name
935 ret.issue_id = issue_id
936 return ret
937
938
939 def FormatIssueUrl(issue_ref_tuple, default_project_name=None):
940 """Format an issue url from an issue ref."""
941 if issue_ref_tuple is None:
942 return ''
943 project_name, local_id = issue_ref_tuple
944 project_name = project_name or default_project_name
945 url = framework_helpers.FormatURL(
946 None, '/p/%s%s' % (project_name, urls.ISSUE_DETAIL), id=local_id)
947 return url
948
949
950 def FormatIssueRef(issue_ref_tuple, default_project_name=None):
951 """Format an issue reference for users: e.g., 123, or projectname:123."""
952 if issue_ref_tuple is None:
953 return ''
954 project_name, local_id = issue_ref_tuple
955 if project_name and project_name != default_project_name:
956 return '%s:%d' % (project_name, local_id)
957 else:
958 return str(local_id)
959
960
961 def ParseIssueRef(ref_str):
962 """Parse an issue ref string: e.g., 123, or projectname:123 into a tuple.
963
964 Raises ValueError if the ref string exists but can't be parsed.
965 """
966 if not ref_str.strip():
967 return None
968
969 if ':' in ref_str:
970 project_name, id_str = ref_str.split(':', 1)
971 project_name = project_name.strip().lstrip('-')
972 else:
973 project_name = None
974 id_str = ref_str
975
976 id_str = id_str.lstrip('-')
977
978 return project_name, int(id_str)
979
980
981 def _SafeParseIssueRef(ref_str):
982 """Same as ParseIssueRef, but catches ValueError and returns None instead."""
983 try:
984 return ParseIssueRef(ref_str)
985 except ValueError:
986 return None
987
988
989 def MergeFields(field_values, fields_add, fields_remove, field_defs):
990 """Merge the fields to add/remove into the current field values.
991
992 Args:
993 field_values: list of current FieldValue PBs.
994 fields_add: list of FieldValue PBs to add to field_values. If any of these
995 is for a single-valued field, it replaces all previous values for the
996 same field_id in field_values.
997 fields_remove: list of FieldValues to remove from field_values, if found.
998 field_defs: list of FieldDef PBs from the issue's project's config.
999
1000 Returns:
1001 A 3-tuple with the merged field values, the specific values that added
1002 or removed. The actual added or removed might be fewer than the requested
1003 ones if the issue already had one of the values-to-add or lacked one of the
1004 values-to-remove.
1005 """
1006 is_multi = {fd.field_id: fd.is_multivalued for fd in field_defs}
1007 merged_fvs = list(field_values)
1008 fvs_added = []
1009 for fv_consider in fields_add:
1010 consider_value = GetFieldValue(fv_consider, {})
1011 for old_fv in field_values:
1012 if (fv_consider.field_id == old_fv.field_id and
1013 GetFieldValue(old_fv, {}) == consider_value):
1014 break
1015 else:
1016 # Drop any existing values for non-multi fields.
1017 if not is_multi.get(fv_consider.field_id):
1018 merged_fvs = [fv for fv in merged_fvs
1019 if fv.field_id != fv_consider.field_id]
1020 fvs_added.append(fv_consider)
1021 merged_fvs.append(fv_consider)
1022
1023 fvs_removed = []
1024 for fv_consider in fields_remove:
1025 consider_value = GetFieldValue(fv_consider, {})
1026 for old_fv in field_values:
1027 if (fv_consider.field_id == old_fv.field_id and
1028 GetFieldValue(old_fv, {}) == consider_value):
1029 fvs_removed.append(fv_consider)
1030 merged_fvs.remove(old_fv)
1031
1032 return merged_fvs, fvs_added, fvs_removed
OLDNEW
« no previous file with comments | « appengine/monorail/tracker/test/tracker_views_test.py ('k') | appengine/monorail/tracker/tracker_constants.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698