692
677
cancel_url = canonical_url(self.context)
693
678
return cancel_url
696
def api_request(self):
697
return IWebServiceClientRequest(self.request)
700
def recommended_canonical_url(self):
701
return canonical_url(self.context.bug, rootsite='bugs')
703
680
def initialize(self):
704
681
"""Set up the needed widgets."""
705
682
bug = self.context.bug
706
cache = IJSONRequestCache(self.request)
707
cache.objects['bug'] = bug
708
subscribers_url_data = {
709
'web_link': canonical_url(bug, rootsite='bugs'),
710
'self_link': absoluteURL(bug, self.api_request),
712
cache.objects['subscribers_portlet_url_data'] = subscribers_url_data
713
cache.objects['total_comments_and_activity'] = (
714
self.total_comments + self.total_activity)
715
cache.objects['initial_comment_batch_offset'] = (
716
self.visible_initial_comments + 1)
717
cache.objects['first visible_recent_comment'] = (
718
self.total_comments - self.visible_recent_comments)
683
IJSONRequestCache(self.request).objects['bug'] = bug
720
685
# See render() for how this flag is used.
721
686
self._redirecting_to_bug_list = False
757
722
series.bugtargetdisplayname)
758
723
self.request.response.redirect(canonical_url(self.context))
725
def isSeriesTargetableContext(self):
726
"""Is the context something that supports Series targeting?
728
Returns True or False.
731
IDistroBugTask.providedBy(self.context) or
732
IDistroSeriesBugTask.providedBy(self.context))
761
735
def comments(self):
762
736
"""Return the bugtask's comments."""
763
return self._getComments()
765
def _getComments(self, slice_info=None):
766
bug = self.context.bug
767
show_spam_controls = bug.userCanSetCommentVisibility(self.user)
768
return get_comments_for_bugtask(
769
self.context, truncate=True, slice_info=slice_info,
770
for_display=True, show_spam_controls=show_spam_controls,
737
return get_comments_for_bugtask(self.context, truncate=True,
774
741
def interesting_activity(self):
775
return self._getInterestingActivity()
777
def _getInterestingActivity(self, earliest_activity_date=None,
778
latest_activity_date=None):
779
742
"""A sequence of interesting bug activity."""
780
if (earliest_activity_date is not None and
781
latest_activity_date is not None):
782
# Only get the activity for the date range that we're
783
# interested in to save us from processing too much.
784
activity = self.context.bug.getActivityForDateRange(
785
start_date=earliest_activity_date,
786
end_date=latest_activity_date)
788
activity = self.context.bug.activity
789
743
bug_change_re = (
790
744
'affects|description|security vulnerability|'
791
'summary|tags|visibility|bug task deleted')
745
'summary|tags|visibility')
792
746
bugtask_change_re = (
793
747
'[a-z0-9][a-z0-9\+\.\-]+( \([A-Za-z0-9\s]+\))?: '
794
748
'(assignee|importance|milestone|status)')
795
749
interesting_match = re.compile(
796
750
"^(%s|%s)$" % (bug_change_re, bugtask_change_re)).match
797
interesting_activity = tuple(
798
752
BugActivityItem(activity)
799
for activity in activity
753
for activity in self.context.bug.activity
800
754
if interesting_match(activity.whatchanged) is not None)
801
# This is a bit kludgy but it means that interesting_activity is
802
# populated correctly for all subsequent calls.
803
self._interesting_activity_cached_value = interesting_activity
804
return interesting_activity
806
def _getEventGroups(self, batch_size=None, offset=None):
757
def activity_and_comments(self):
758
"""Build list of comments interleaved with activities
760
When activities occur on the same day a comment was posted,
761
encapsulate them with that comment. For the remainder, group
762
then as if owned by the person who posted the first action
765
If the number of comments exceeds the configured maximum limit, the
766
list will be truncated to just the first and last sets of comments.
768
The division between the most recent and oldest is marked by an entry
769
in the list with the key 'num_hidden' defined.
807
771
# Ensure truncation results in < max_length comments as expected
808
772
assert(config.malone.comments_list_truncate_oldest_to
809
773
+ config.malone.comments_list_truncate_newest_to
810
774
< config.malone.comments_list_max_length)
812
if (not self.visible_comments_truncated_for_display and
814
comments = self.comments
815
elif batch_size is not None:
816
# If we're limiting to a given set of comments, we work on
817
# just that subset of comments from hereon in, which saves
818
# on processing time a bit.
820
offset = self.visible_initial_comments
821
comments = self._getComments([
822
slice(offset, offset + batch_size)])
776
if not self.visible_comments_truncated_for_display:
777
comments=self.comments
824
779
# the comment function takes 0-offset counts where comment 0 is
825
780
# the initial description, so we need to add one to the limits
827
782
oldest_count = 1 + self.visible_initial_comments
828
new_count = 1 + self.total_comments - self.visible_recent_comments
830
slice(None, oldest_count),
831
slice(new_count, None),
833
comments = self._getComments(slice_info)
783
new_count = 1 + self.total_comments-self.visible_recent_comments
784
comments = get_comments_for_bugtask(
785
self.context, truncate=True, for_display=True,
787
slice(None, oldest_count), slice(new_count, None)])
835
789
visible_comments = get_visible_comments(
836
790
comments, user=self.user)
837
if len(visible_comments) > 0 and batch_size is not None:
838
first_comment = visible_comments[0]
839
last_comment = visible_comments[-1]
840
interesting_activity = (
841
self._getInterestingActivity(
842
earliest_activity_date=first_comment.datecreated,
843
latest_activity_date=last_comment.datecreated))
845
interesting_activity = self.interesting_activity
847
792
event_groups = group_comments_with_activity(
848
793
comments=visible_comments,
849
activities=interesting_activity)
853
def _event_groups(self):
854
"""Return a sorted list of event groups for the current BugTask.
856
This is a @cachedproperty wrapper around _getEventGroups(). It's
857
here so that we can override it in descendant views, passing
858
batch size parameters and suchlike to _getEventGroups() as we
861
return self._getEventGroups()
864
def activity_and_comments(self):
865
"""Build list of comments interleaved with activities
867
When activities occur on the same day a comment was posted,
868
encapsulate them with that comment. For the remainder, group
869
then as if owned by the person who posted the first action
872
If the number of comments exceeds the configured maximum limit, the
873
list will be truncated to just the first and last sets of comments.
875
The division between the most recent and oldest is marked by an entry
876
in the list with the key 'num_hidden' defined.
878
event_groups = self._event_groups
794
activities=self.interesting_activity)
880
796
def group_activities_by_target(activities):
881
797
activities = sorted(
1117
1022
max_bug_heat = 5000
1118
1023
heat_ratio = calculate_heat_display(bugtask.bug.heat, max_bug_heat)
1120
'<span><a href="/+help-bugs/bug-heat.html" target="help" '
1121
'class="icon"><img src="/@@/bug-heat-%(ratio)i.png" '
1025
'<span><a href="/+help/bug-heat.html" target="help" class="icon"><img'
1026
' src="/@@/bug-heat-%(ratio)i.png" '
1122
1027
'alt="%(ratio)i out of 4 heat flames" title="Heat: %(heat)i" /></a>'
1124
1029
% {'ratio': heat_ratio, 'heat': bugtask.bug.heat})
1128
class BugTaskBatchedCommentsAndActivityView(BugTaskView):
1129
"""A view for displaying batches of bug comments and activity."""
1131
# We never truncate comments in this view; there would be no point.
1132
visible_comments_truncated_for_display = False
1137
return int(self.request.form_ng.getOne('offset'))
1139
# We return visible_initial_comments + 1, since otherwise we'd
1140
# end up repeating comments that are already visible on the
1141
# page. The +1 accounts for the fact that bug comments are
1142
# essentially indexed from 1 due to comment 0 being the
1143
# initial bug description.
1144
return self.visible_initial_comments + 1
1147
def batch_size(self):
1149
return int(self.request.form_ng.getOne('batch_size'))
1151
return config.malone.comments_list_default_batch_size
1154
def next_batch_url(self):
1155
return "%s?offset=%s&batch_size=%s" % (
1156
canonical_url(self.context, view_name='+batched-comments'),
1157
self.next_offset, self.batch_size)
1160
def next_offset(self):
1161
return self.offset + self.batch_size
1164
def _event_groups(self):
1165
"""See `BugTaskView`."""
1166
batch_size = self.batch_size
1167
if (batch_size > (self.total_comments) or
1168
not self.has_more_comments_and_activity):
1169
# If the batch size is big enough to encompass all the
1170
# remaining comments and activity, trim it so that we don't
1172
if self.offset == self.visible_initial_comments + 1:
1173
offset_to_remove = self.visible_initial_comments
1175
offset_to_remove = self.offset
1177
self.total_comments - self.visible_recent_comments -
1178
# This last bit is to make sure that _getEventGroups()
1179
# doesn't accidentally inflate the batch size later on.
1181
return self._getEventGroups(
1182
batch_size=batch_size, offset=self.offset)
1185
def has_more_comments_and_activity(self):
1186
"""Return True if there are more camments and activity to load."""
1188
self.next_offset < (self.total_comments + self.total_activity))
1033
class BugTaskPortletView:
1034
"""A portlet for displaying a bug's bugtasks."""
1036
def alsoReportedIn(self):
1037
"""Return a list of IUpstreamBugTasks in which this bug is reported.
1039
If self.context is an IUpstreamBugTasks, it will be excluded
1043
task for task in self.context.bug.bugtasks
1044
if task.id is not self.context.id]
1191
1047
def get_prefix(bugtask):
1197
1053
keeping the field ids unique.
1200
parts.append(bugtask.pillar.name)
1202
series = bugtask.productseries or bugtask.distroseries
1204
parts.append(series.name)
1206
if bugtask.sourcepackagename is not None:
1207
parts.append(bugtask.sourcepackagename.name)
1056
if IUpstreamBugTask.providedBy(bugtask):
1057
parts.append(bugtask.product.name)
1059
elif IProductSeriesBugTask.providedBy(bugtask):
1060
parts.append(bugtask.productseries.name)
1061
parts.append(bugtask.productseries.product.name)
1063
elif IDistroBugTask.providedBy(bugtask):
1064
parts.append(bugtask.distribution.name)
1065
if bugtask.sourcepackagename is not None:
1066
parts.append(bugtask.sourcepackagename.name)
1068
elif IDistroSeriesBugTask.providedBy(bugtask):
1069
parts.append(bugtask.distroseries.distribution.name)
1070
parts.append(bugtask.distroseries.name)
1072
if bugtask.sourcepackagename is not None:
1073
parts.append(bugtask.sourcepackagename.name)
1076
raise AssertionError("Unknown IBugTask: %r" % bugtask)
1209
1077
return '_'.join(parts)
1212
def get_assignee_vocabulary_info(context):
1080
def get_assignee_vocabulary(context):
1213
1081
"""The vocabulary of bug task assignees the current user can set."""
1214
1082
if context.userCanSetAnyAssignee(getUtility(ILaunchBag).user):
1215
vocab_name = 'ValidAssignee'
1083
return 'ValidAssignee'
1217
vocab_name = 'AllUserTeamsParticipation'
1218
vocab = vocabulary_registry.get(None, vocab_name)
1219
return vocab_name, vocab.supportedFilters()
1085
return 'AllUserTeamsParticipation'
1222
1088
class BugTaskBugWatchMixin:
1223
1089
"""A mixin to be used where a BugTask view displays BugWatch data."""
1226
1092
def bug_watch_error_message(self):
1227
1093
"""Return a browser-useable error message for a bug watch."""
1228
1094
if not self.context.bugwatch:
1355
1193
# XXX: Brad Bollenbach 2006-09-29 bug=63000: Permission checking
1356
1194
# doesn't belong here!
1357
if not self.user_has_privileges:
1358
if 'milestone' in editable_field_names:
1359
editable_field_names.remove("milestone")
1360
if 'importance' in editable_field_names:
1361
editable_field_names.remove("importance")
1195
if ('milestone' in editable_field_names and
1196
not self.userCanEditMilestone()):
1197
editable_field_names.remove("milestone")
1199
if ('importance' in editable_field_names and
1200
not self.userCanEditImportance()):
1201
editable_field_names.remove("importance")
1363
1203
editable_field_names = set(('bugwatch', ))
1204
if not IUpstreamBugTask.providedBy(self.context):
1205
#XXX: Bjorn Tillenius 2006-03-01:
1206
# Should be possible to edit the product as well,
1207
# but that's harder due to complications with bug
1208
# watches. The new product might use Launchpad
1209
# officially, thus we need to handle that case.
1210
# Let's deal with that later.
1211
editable_field_names.add('sourcepackagename')
1364
1212
if self.context.bugwatch is None:
1365
1213
editable_field_names.update(('status', 'assignee'))
1366
1214
if ('importance' in self.default_field_names
1367
and self.user_has_privileges):
1215
and self.userCanEditImportance()):
1368
1216
editable_field_names.add('importance')
1370
1218
bugtracker = self.context.bugwatch.bugtracker
1371
1219
if bugtracker.bugtrackertype == BugTrackerType.EMAILADDRESS:
1372
1220
editable_field_names.add('status')
1373
1221
if ('importance' in self.default_field_names
1374
and self.user_has_privileges):
1222
and self.userCanEditImportance()):
1375
1223
editable_field_names.add('importance')
1377
if self.show_target_widget:
1378
editable_field_names.add('target')
1379
elif self.show_sourcepackagename_widget:
1380
editable_field_names.add('sourcepackagename')
1382
1225
# To help with caching, return an immutable object.
1383
1226
return frozenset(editable_field_names)
1537
1374
return read_only_field_names
1376
def userCanEditMilestone(self):
1377
"""Can the user edit the Milestone field?
1379
If yes, return True, otherwise return False.
1381
return self.context.userCanEditMilestone(self.user)
1383
def userCanEditImportance(self):
1384
"""Can the user edit the Importance field?
1386
If yes, return True, otherwise return False.
1388
return self.context.userCanEditImportance(self.user)
1390
def _getProductOrDistro(self):
1391
"""Return the product or distribution relevant to the context."""
1392
bugtask = self.context
1393
if IUpstreamBugTask.providedBy(bugtask):
1394
return bugtask.product
1395
elif IProductSeriesBugTask.providedBy(bugtask):
1396
return bugtask.productseries.product
1397
elif IDistroBugTask.providedBy(bugtask):
1398
return bugtask.distribution
1400
return bugtask.distroseries.distribution
1539
1402
def validate(self, data):
1540
if self.show_sourcepackagename_widget and 'sourcepackagename' in data:
1541
data['target'] = self.context.distroseries
1542
spn = data.get('sourcepackagename')
1544
data['target'] = data['target'].getSourcePackage(spn)
1545
del data['sourcepackagename']
1546
error_field = 'sourcepackagename'
1548
error_field = 'target'
1550
new_target = data.get('target')
1551
if new_target and new_target != self.context.target:
1553
self.context.validateTransitionToTarget(new_target)
1554
except IllegalTarget as e:
1555
self.setFieldError(error_field, e[0])
1403
"""See `LaunchpadFormView`."""
1404
bugtask = self.context
1405
if bugtask.distroseries is not None:
1406
distro = bugtask.distroseries.distribution
1408
distro = bugtask.distribution
1409
sourcename = bugtask.sourcepackagename
1410
old_product = bugtask.product
1412
if distro is not None and sourcename != data.get('sourcepackagename'):
1414
validate_distrotask(
1415
bugtask.bug, distro, data.get('sourcepackagename'))
1416
except LaunchpadValidationError, error:
1417
self.setFieldError('sourcepackagename', str(error))
1419
new_product = data.get('product')
1420
if (old_product is None or old_product == new_product or
1421
bugtask.pillar.bug_tracking_usage != ServiceUsage.LAUNCHPAD):
1422
# Either the product wasn't changed, we're dealing with a #
1423
# distro task, or the bugtask's product doesn't use Launchpad,
1424
# which means the product can't be changed.
1427
if new_product is None:
1428
self.setFieldError('product', 'Enter a project name')
1431
valid_upstreamtask(bugtask.bug, new_product)
1432
except WidgetsError, errors:
1433
self.setFieldError('product', errors.args[0])
1557
1435
def updateContextFromData(self, data, context=None):
1558
1436
"""Updates the context object using the submitted form data.
1655
1520
subject=bugtask.bug.followup_subject(),
1656
1521
content=comment_on_change)
1523
# Set the "changed" flag properly, just in case status and/or assignee
1524
# happen to be the only values that changed. We explicitly verify that
1525
# we got a new status and/or assignee, because the form is not always
1526
# guaranteed to pass all the values. For example: bugtasks linked to a
1527
# bug watch don't allow editting the form, and the value is missing
1658
1530
new_status = new_values.pop("status", missing)
1659
1531
new_assignee = new_values.pop("assignee", missing)
1660
1532
if new_status is not missing and bugtask.status != new_status:
1663
bugtask.transitionToStatus(new_status, self.user)
1664
except UserCannotEditBugTaskStatus:
1665
# We need to roll back the transaction at this point,
1666
# since other changes may have been made.
1670
"Only the Bug Supervisor for %s can set the bug's "
1672
(bugtask.target.displayname, new_status.title))
1534
bugtask.transitionToStatus(new_status, self.user)
1675
1536
if new_assignee is not missing and bugtask.assignee != new_assignee:
1676
1537
if new_assignee is not None and new_assignee != self.user:
1721
1582
bugtask.transitionToAssignee(None)
1585
# We only set the statusexplanation field to the value of the
1586
# change comment if the BugTask has actually been changed in some
1587
# way. Otherwise, we just leave it as a comment on the bug.
1588
if comment_on_change:
1589
bugtask.statusexplanation = comment_on_change
1591
bugtask.statusexplanation = ""
1725
1594
ObjectModifiedEvent(
1726
1595
object=bugtask,
1727
1596
object_before_modification=bugtask_before_modification,
1728
1597
edited_fields=field_names))
1730
# We clear the known views cache because the bug may not be
1731
# viewable anymore by the current user. If the bug is not
1732
# viewable, then we redirect to the current bugtask's pillar's
1733
# bug index page with a message.
1734
get_property_cache(bugtask.bug)._known_viewers = set()
1735
if not bugtask.bug.userCanView(self.user):
1736
self.request.response.addWarningNotification(
1737
"The bug you have just updated is now a private bug for "
1738
"%s. You do not have permission to view such bugs."
1739
% bugtask.pillar.displayname)
1740
self._next_url_override = canonical_url(
1741
new_target.pillar, rootsite='bugs')
1743
if (bugtask.sourcepackagename and (
1744
self.widgets.get('target') or
1745
self.widgets.get('sourcepackagename'))):
1599
if bugtask.sourcepackagename is not None:
1746
1600
real_package_name = bugtask.sourcepackagename.name
1748
1602
# We get entered_package_name directly from the form here, since
1749
1603
# validating the sourcepackagename field mutates its value in to
1750
1604
# the one already in real_package_name, which makes our comparison
1751
1605
# of the two below useless.
1752
if self.widgets.get('sourcepackagename'):
1753
field_name = self.widgets['sourcepackagename'].name
1755
field_name = self.widgets['target'].package_widget.name
1756
entered_package_name = self.request.form.get(field_name)
1606
entered_package_name = self.request.form.get(
1607
self.widgets['sourcepackagename'].name)
1758
1609
if real_package_name != entered_package_name:
1759
1610
# The user entered a binary package name which got
1765
1616
{'entered_package': entered_package_name,
1766
1617
'real_package': real_package_name})
1619
if (bugtask_before_modification.sourcepackagename !=
1620
bugtask.sourcepackagename):
1621
# The source package was changed, so tell the user that we've
1622
# subscribed the new bug supervisors.
1623
self.request.response.addNotification(
1624
"The bug supervisor for %s has been subscribed to this bug."
1625
% (bugtask.bugtargetdisplayname))
1768
1627
@action('Save Changes', name='save')
1769
1628
def save_action(self, action, data):
1770
1629
"""Update the bugtask with the form data."""
1771
1630
self.updateContextFromData(data)
1774
class BugTaskDeletionView(ReturnToReferrerMixin, LaunchpadFormView):
1775
"""Used to delete a bugtask."""
1780
label = 'Remove bug task'
1785
"""Return the next URL to call when this call completes."""
1786
if not self.request.is_ajax:
1787
return super(BugTaskDeletionView, self).next_url
1790
@action('Delete', name='delete_bugtask')
1791
def delete_bugtask_action(self, action, data):
1792
bugtask = self.context
1794
deleted_bugtask_url = canonical_url(self.context, rootsite='bugs')
1795
success_message = ("This bug no longer affects %s."
1796
% bugtask.bugtargetdisplayname)
1797
error_message = None
1801
self.request.response.addNotification(success_message)
1802
except CannotDeleteBugtask as e:
1803
error_message = str(e)
1804
self.request.response.addErrorNotification(error_message)
1805
if self.request.is_ajax:
1807
self.request.response.setHeader('Content-type',
1810
launchbag = getUtility(ILaunchBag)
1811
launchbag.add(bug.default_bugtask)
1812
# If we are deleting the current highlighted bugtask via ajax,
1813
# we must force a redirect to the new default bugtask to ensure
1814
# all URLs and other client cache content is correctly refreshed.
1815
# We can't do the redirect here since the XHR caller won't see it
1816
# so we return the URL to go to and let the caller do it.
1817
if self._return_url == deleted_bugtask_url:
1818
next_url = canonical_url(
1819
bug.default_bugtask, rootsite='bugs')
1820
self.request.response.setHeader('Content-type',
1822
return dumps(dict(bugtask_url=next_url))
1823
# No redirect required so return the new bugtask table HTML.
1824
view = getMultiAdapter(
1825
(bug, self.request),
1826
name='+bugtasks-and-nominations-table')
1828
return view.render()
1633
class BugTaskStatusView(LaunchpadView):
1634
"""Viewing the status of a bug task."""
1636
page_title = 'View status'
1638
def initialize(self):
1639
"""Set up the appropriate widgets.
1641
Different widgets are shown depending on if it's a remote bug
1645
'status', 'importance', 'assignee', 'statusexplanation']
1646
if not self.context.target_uses_malone:
1647
field_names += ['bugwatch']
1648
self.milestone_widget = None
1650
field_names += ['milestone']
1651
self.bugwatch_widget = None
1653
if not IUpstreamBugTask.providedBy(self.context):
1654
field_names += ['sourcepackagename']
1656
self.assignee_widget = CustomWidgetFactory(AssigneeDisplayWidget)
1657
self.status_widget = CustomWidgetFactory(DBItemDisplayWidget)
1658
self.importance_widget = CustomWidgetFactory(DBItemDisplayWidget)
1660
setUpWidgets(self, IBugTask, IDisplayWidget, names=field_names)
1831
1663
class BugTaskListingView(LaunchpadView):
2174
1987
return search_filter_url
1990
def getInitialValuesFromSearchParams(search_params, form_schema):
1991
"""Build a dictionary that can be given as initial values to
1992
setUpWidgets, based on the given search params.
1994
>>> initial = getInitialValuesFromSearchParams(
1995
... {'status': any(*UNRESOLVED_BUGTASK_STATUSES)}, IBugTaskSearch)
1996
>>> for status in initial['status']:
1997
... print status.name
2005
>>> initial = getInitialValuesFromSearchParams(
2006
... {'status': BugTaskStatus.INVALID}, IBugTaskSearch)
2007
>>> [status.name for status in initial['status']]
2010
>>> initial = getInitialValuesFromSearchParams(
2011
... {'importance': [BugTaskImportance.CRITICAL,
2012
... BugTaskImportance.HIGH]}, IBugTaskSearch)
2013
>>> [importance.name for importance in initial['importance']]
2014
['CRITICAL', 'HIGH']
2016
>>> getInitialValuesFromSearchParams(
2017
... {'assignee': NULL}, IBugTaskSearch)
2021
for key, value in search_params.items():
2022
if IList.providedBy(form_schema[key]):
2023
if isinstance(value, any):
2024
value = value.query_values
2025
elif isinstance(value, (list, tuple)):
2032
# Should be safe to pass value as it is to setUpWidgets, no need
2036
initial[key] = value
2177
2041
class BugTaskListingItem:
2178
2042
"""A decorated bug task.
2206
2070
"""Returns the bug heat flames HTML."""
2207
2071
return bugtask_heat_html(self.bugtask, target=self.target_context)
2211
"""Provide flattened data about bugtask for simple templaters."""
2212
age = DateTimeFormatterAPI(self.bug.datecreated).durationsince()
2214
date_last_updated = self.bug.date_last_message
2215
if (date_last_updated is None or
2216
self.bug.date_last_updated > date_last_updated):
2217
date_last_updated = self.bug.date_last_updated
2218
last_updated_formatter = DateTimeFormatterAPI(date_last_updated)
2219
last_updated = last_updated_formatter.displaydate()
2220
badges = getAdapter(self.bugtask, IPathAdapter, 'image').badges()
2221
target_image = getAdapter(self.target, IPathAdapter, 'image')
2222
if self.bugtask.milestone is not None:
2223
milestone_name = self.bugtask.milestone.displayname
2225
milestone_name = None
2227
if self.assignee is not None:
2228
assignee = self.assignee.displayname
2231
'assignee': assignee,
2232
'bug_url': canonical_url(self.bugtask),
2233
'bugtarget': self.bugtargetdisplayname,
2234
'bugtarget_css': target_image.sprite_css(),
2235
'bug_heat_html': self.bug_heat_html,
2238
'importance': self.importance.title,
2239
'importance_class': 'importance' + self.importance.name,
2240
'last_updated': last_updated,
2241
'milestone_name': milestone_name,
2242
'reporter': self.bug.owner.displayname,
2243
'status': self.status.title,
2244
'status_class': 'status' + self.status.name,
2245
'tags': ' '.join(self.bug.tags),
2246
'title': self.bug.title,
2250
2074
class BugListingBatchNavigator(TableBatchNavigator):
2251
2075
"""A specialised batch navigator to load smartly extra bug information."""
2280
2088
return getUtility(IBugTaskSet).getBugTaskBadgeProperties(
2281
2089
self.currentBatch())
2283
def getCookieName(self):
2284
"""Return the cookie name used in bug listings js code."""
2285
cookie_name_template = '%s-buglist-fields'
2287
if self.user is not None:
2288
cookie_name = cookie_name_template % self.user.name
2290
cookie_name = cookie_name_template % 'anon'
2293
def _setFieldVisibility(self):
2294
"""Set field_visibility for the page load.
2296
If a cookie of the form $USER-buglist-fields is found,
2297
we set field_visibility from this cookie; otherwise,
2298
field_visibility will match the defaults.
2300
cookie_name = self.getCookieName()
2301
cookie = self.request.cookies.get(cookie_name)
2302
self.field_visibility = dict(self.field_visibility_defaults)
2303
# "cookie" looks like a URL query string, so we split
2304
# on '&' to get items, and then split on '=' to get
2305
# field/value pairs.
2308
for field, value in urlparse.parse_qsl(cookie):
2309
# Skip unsupported fields (from old cookies).
2310
if field not in self.field_visibility:
2312
# We only record True or False for field values.
2313
self.field_visibility[field] = (value == 'true')
2315
2091
def _getListingItem(self, bugtask):
2316
2092
"""Return a decorated bugtask for the bug listing."""
2317
2093
badge_property = self.bug_badge_properties[bugtask]
2335
2111
"""Return a decorated list of visible bug tasks."""
2336
2112
return [self._getListingItem(bugtask) for bugtask in self.batch]
2339
def mustache_template(self):
2340
template_path = os.path.join(
2341
config.root, 'lib/lp/bugs/templates/buglisting.mustache')
2342
with open(template_path) as template_file:
2343
return template_file.read()
2346
def mustache_listings(self):
2347
return 'LP.mustache_listings = %s;' % dumps(
2348
self.mustache_template, cls=JSONEncoderForHTML)
2352
"""The rendered mustache template."""
2353
objects = IJSONRequestCache(self.request).objects
2354
if IUnauthenticatedPrincipal.providedBy(self.request.principal):
2355
objects = obfuscate_structure(objects)
2356
return pystache.render(self.mustache_template,
2357
objects['mustache_model'])
2361
bugtasks = [bugtask.model for bugtask in self.getBugListingItems()]
2362
for bugtask in bugtasks:
2363
bugtask.update(self.field_visibility)
2364
return {'bugtasks': bugtasks}
2367
2115
class NominatedBugReviewAction(EnumeratedType):
2368
2116
"""Enumeration for nomination review actions"""
2641
2365
expose_structural_subscription_data_to_js(
2642
2366
self.context, self.request, self.user)
2643
if getFeatureFlag('bugs.dynamic_bug_listings.enabled'):
2644
cache = IJSONRequestCache(self.request)
2645
view_names = set(reg.name for reg
2646
in iter_view_registrations(self.__class__))
2647
if len(view_names) != 1:
2648
raise AssertionError("Ambiguous view name.")
2649
cache.objects['view_name'] = view_names.pop()
2650
batch_navigator = self.search()
2651
cache.objects['mustache_model'] = batch_navigator.model
2652
cache.objects['field_visibility'] = (
2653
batch_navigator.field_visibility)
2654
cache.objects['field_visibility_defaults'] = (
2655
batch_navigator.field_visibility_defaults)
2656
cache.objects['cbl_cookie_name'] = batch_navigator.getCookieName()
2658
def _getBatchInfo(batch):
2661
return {'memo': batch.range_memo,
2662
'start': batch.startNumber() - 1}
2664
next_batch = batch_navigator.batch.nextBatch()
2665
cache.objects['next'] = _getBatchInfo(next_batch)
2666
prev_batch = batch_navigator.batch.prevBatch()
2667
cache.objects['prev'] = _getBatchInfo(prev_batch)
2668
cache.objects['total'] = batch_navigator.batch.total()
2669
cache.objects['order_by'] = ','.join(
2670
get_sortorder_from_request(self.request))
2671
cache.objects['forwards'] = batch_navigator.batch.range_forwards
2672
last_batch = batch_navigator.batch.lastBatch()
2673
cache.objects['last_start'] = last_batch.startNumber() - 1
2674
cache.objects.update(_getBatchInfo(batch_navigator.batch))
2677
def show_config_portlet(self):
2678
if (IDistribution.providedBy(self.context) or
2679
IProduct.providedBy(self.context)):
2685
2369
def columns_to_show(self):
3294
2941
def addquestion_url(self):
3295
2942
"""Return the URL for the +addquestion view for the context."""
3296
2943
if IQuestionTarget.providedBy(self.context):
3297
answers_usage = IServiceUsage(self.context).answers_usage
3298
if answers_usage == ServiceUsage.LAUNCHPAD:
3299
return canonical_url(
3300
self.context, rootsite='answers',
3301
view_name='+addquestion')
2944
return canonical_url(
2945
self.context, rootsite='answers', view_name='+addquestion')
3306
def dynamic_bug_listing_enabled(self):
3307
"""Feature flag: Can the bug listing be customized?"""
3308
return bool(getFeatureFlag('bugs.dynamic_bug_listings.enabled'))
3311
def search_macro_title(self):
3312
"""The search macro's title text."""
3313
return u"Search bugs %s" % self.context_description
3316
def context_description(self):
3317
"""A phrase describing the context of the bug.
3319
The phrase is intended to be used for headings like
3320
"Bugs in $context", "Search bugs in $context". This
3321
property should be overridden for person related views.
3323
return "in %s" % self.context.displayname
3326
2950
class BugNominationsView(BugTaskSearchListingView):
3327
2951
"""View for accepting/declining bug nominations."""
3726
3324
def other_users_affected_count(self):
3727
"""The number of other users affected by this bug.
3729
if getFeatureFlag('bugs.affected_count_includes_dupes.disabled'):
3730
if self.current_user_affected_status:
3731
return self.context.users_affected_count - 1
3733
return self.context.users_affected_count
3325
"""The number of other users affected by this bug."""
3326
if self.current_user_affected_status:
3327
return self.context.users_affected_count - 1
3735
return self.context.other_users_affected_count_with_dupes
3738
def total_users_affected_count(self):
3739
"""The number of affected users, typically across all users.
3741
Counting across duplicates may be disabled at run time.
3743
if getFeatureFlag('bugs.affected_count_includes_dupes.disabled'):
3744
3329
return self.context.users_affected_count
3746
return self.context.users_affected_count_with_dupes
3749
3332
def affected_statement(self):
3750
3333
"""The default "this bug affects" statement to show.
3752
3335
The outputs of this method should be mirrored in
3753
3336
MeTooChoiceSource._getSourceNames() (Javascript).
3755
me_affected = self.current_user_affected_status
3756
other_affected = self.other_users_affected_count
3757
if me_affected is None:
3758
if other_affected == 1:
3338
if self.other_users_affected_count == 1:
3339
if self.current_user_affected_status is None:
3759
3340
return "This bug affects 1 person. Does this bug affect you?"
3760
elif other_affected > 1:
3341
elif self.current_user_affected_status:
3342
return "This bug affects you and 1 other person"
3344
return "This bug affects 1 person, but not you"
3345
elif self.other_users_affected_count > 1:
3346
if self.current_user_affected_status is None:
3762
3348
"This bug affects %d people. Does this bug "
3763
"affect you?" % (other_affected))
3349
"affect you?" % (self.other_users_affected_count))
3350
elif self.current_user_affected_status:
3351
return "This bug affects you and %d other people" % (
3352
self.other_users_affected_count)
3354
return "This bug affects %d people, but not you" % (
3355
self.other_users_affected_count)
3357
if self.current_user_affected_status is None:
3765
3358
return "Does this bug affect you?"
3766
elif me_affected is True:
3767
if other_affected == 0:
3359
elif self.current_user_affected_status:
3768
3360
return "This bug affects you"
3769
elif other_affected == 1:
3770
return "This bug affects you and 1 other person"
3772
return "This bug affects you and %d other people" % (
3775
if other_affected == 0:
3776
3362
return "This bug doesn't affect you"
3777
elif other_affected == 1:
3778
return "This bug affects 1 person, but not you"
3779
elif other_affected > 1:
3780
return "This bug affects %d people, but not you" % (
3784
3365
def anon_affected_statement(self):
3785
3366
"""The "this bug affects" statement to show to anonymous users.
3787
3368
The outputs of this method should be mirrored in
3788
3369
MeTooChoiceSource._getSourceNames() (Javascript).
3790
affected = self.total_users_affected_count
3371
if self.context.users_affected_count == 1:
3792
3372
return "This bug affects 1 person"
3794
return "This bug affects %d people" % affected
3373
elif self.context.users_affected_count > 1:
3374
return "This bug affects %d people" % (
3375
self.context.users_affected_count)
3799
def _allow_multipillar_private_bugs(self):
3800
""" Some teams still need to have multi pillar private bugs."""
3801
return bool(getFeatureFlag(
3802
'disclosure.allow_multipillar_private_bugs.enabled'))
3804
def canAddProjectTask(self):
3805
"""Can a new bug task on a project be added to this bug?
3807
If a bug has any bug tasks already, were it to be private, it cannot
3808
be marked as also affecting any other project, so return False.
3810
Note: this check is currently only relevant if a bug is private.
3811
Eventually, even public bugs will have this restriction too. So what
3812
happens now is that this API is used by the tales to add a class
3813
called 'disallow-private' to the Also Affects Project link. A css rule
3814
is used to hide the link when body.private is True.
3818
if self._allow_multipillar_private_bugs:
3820
return len(bug.bugtasks) == 0
3822
def canAddPackageTask(self):
3823
"""Can a new bug task on a src pkg be added to this bug?
3825
If a bug has any existing bug tasks on a project, were it to
3826
be private, then it cannot be marked as affecting a package,
3829
A task on a given package may still be illegal to add, but
3830
this will be caught when bug.addTask() is attempted.
3832
Note: this check is currently only relevant if a bug is private.
3833
Eventually, even public bugs will have this restriction too. So what
3834
happens now is that this API is used by the tales to add a class
3835
called 'disallow-private' to the Also Affects Package link. A css rule
3836
is used to hide the link when body.private is True.
3839
if self._allow_multipillar_private_bugs:
3841
for pillar in bug.affected_pillars:
3842
if IProduct.providedBy(pillar):
3847
class BugTaskTableRowView(LaunchpadView, BugTaskBugWatchMixin,
3848
BugTaskPrivilegeMixin):
3380
class BugTaskTableRowView(LaunchpadView, BugTaskBugWatchMixin):
3849
3381
"""Browser class for rendering a bugtask row on the bug page."""
3851
3383
is_conjoined_slave = None
3853
3385
target_link_title = None
3854
3386
many_bugtasks = False
3856
template = ViewPageTemplateFile(
3857
'../templates/bugtask-tasks-and-nominations-table-row.pt')
3859
3388
def __init__(self, context, request):
3860
3389
super(BugTaskTableRowView, self).__init__(context, request)
3861
3390
self.milestone_source = MilestoneVocabulary
3864
def api_request(self):
3865
return IWebServiceClientRequest(self.request)
3867
def initialize(self):
3868
super(BugTaskTableRowView, self).initialize()
3869
link = canonical_url(self.context)
3870
task_link = edit_link = canonical_url(
3871
self.context, view_name='+editstatus')
3872
delete_link = canonical_url(self.context, view_name='+delete')
3873
can_edit = check_permission('launchpad.Edit', self.context)
3874
bugtask_id = self.context.id
3875
launchbag = getUtility(ILaunchBag)
3876
is_primary = self.context.id == launchbag.bugtask.id
3878
# Looking at many_bugtasks is an important optimization. With
3879
# 150+ bugtasks, it can save three or four seconds of rendering
3881
expandable=(not self.many_bugtasks and self.canSeeTaskDetails()),
3882
indent_task=ISeriesBugTarget.providedBy(self.context.target),
3883
is_conjoined_slave=self.is_conjoined_slave,
3884
task_link=task_link,
3885
edit_link=edit_link,
3889
row_id='tasksummary%d' % bugtask_id,
3890
form_row_id='task%d' % bugtask_id,
3891
row_css_class='highlight' if is_primary else None,
3892
target_link=canonical_url(self.context.target),
3893
target_link_title=self.target_link_title,
3894
user_can_delete=self.user_can_delete_bugtask,
3895
delete_link=delete_link,
3896
user_can_edit_importance=self.user_has_privileges,
3897
importance_css_class='importance' + self.context.importance.name,
3898
importance_title=self.context.importance.title,
3899
# We always look up all milestones, so there's no harm
3900
# using len on the list here and avoid the COUNT query.
3901
target_has_milestones=len(self._visible_milestones) > 0,
3902
user_can_edit_status=self.user_can_edit_status,
3905
if not self.many_bugtasks:
3906
cache = IJSONRequestCache(self.request)
3907
bugtask_data = cache.objects.get('bugtask_data', None)
3908
if bugtask_data is None:
3909
bugtask_data = dict()
3910
cache.objects['bugtask_data'] = bugtask_data
3911
bugtask_data[bugtask_id] = self.bugtask_config()
3913
3392
def canSeeTaskDetails(self):
3914
3393
"""Whether someone can see a task's status details.
3928
3407
self.context.bug.duplicateof is None and
3929
3408
not self.is_converted_to_question)
3410
def getTaskRowCSSClass(self):
3411
"""The appropriate CSS class for the row in the Affects table.
3413
Currently this consists solely of highlighting the current context.
3415
bugtask = self.context
3416
if bugtask == getUtility(ILaunchBag).bugtask:
3421
def shouldIndentTask(self):
3422
"""Should this task be indented in the task listing on the bug page?
3424
Returns True or False.
3426
bugtask = self.context
3427
return (IDistroSeriesBugTask.providedBy(bugtask) or
3428
IProductSeriesBugTask.providedBy(bugtask))
3431
"""Return the proper link to the bugtask whether it's editable."""
3432
user = getUtility(ILaunchBag).user
3433
bugtask = self.context
3434
if check_permission('launchpad.Edit', user):
3435
return canonical_url(bugtask) + "/+editstatus"
3437
return canonical_url(bugtask) + "/+viewstatus"
3931
3439
def _getSeriesTargetNameHelper(self, bugtask):
3932
3440
"""Return the short name of bugtask's targeted series."""
3933
series = bugtask.distroseries or bugtask.productseries
3936
return series.name.capitalize()
3441
if IDistroSeriesBugTask.providedBy(bugtask):
3442
return bugtask.distroseries.name.capitalize()
3443
elif IProductSeriesBugTask.providedBy(bugtask):
3444
return bugtask.productseries.name.capitalize()
3447
"Expected IDistroSeriesBugTask or IProductSeriesBugTask. "
3448
"Got: %r" % bugtask)
3938
3450
def getSeriesTargetName(self):
3939
3451
"""Get the series to which this task is targeted."""
3538
def target_has_milestones(self):
3539
"""Are there any milestones we can target?
3541
We always look up all milestones, so there's no harm
3542
using len on the list here and avoid the COUNT query.
3544
return len(self._visible_milestones) > 0
4025
3546
def bugtask_canonical_url(self):
4026
3547
"""Return the canonical url for the bugtask."""
4027
3548
return canonical_url(self.context)
4030
3551
def user_can_edit_importance(self):
4031
3552
"""Can the user edit the Importance field?
4033
3554
If yes, return True, otherwise return False.
4035
return self.user_can_edit_status and self.user_has_privileges
4038
def user_can_edit_status(self):
4039
"""Can the user edit the Status field?
4041
If yes, return True, otherwise return False.
4043
bugtask = self.context
4044
edit_allowed = bugtask.target_uses_malone or bugtask.bugwatch
4045
if bugtask.bugwatch:
4046
bugtracker = bugtask.bugwatch.bugtracker
4048
bugtracker.bugtrackertype == BugTrackerType.EMAILADDRESS)
3556
return self.context.userCanEditImportance(self.user)
4052
def user_can_edit_assignee(self):
4053
"""Can the user edit the Assignee field?
4055
If yes, return True, otherwise return False.
4057
return self.user is not None
4060
def user_can_delete_bugtask(self):
4061
"""Can the user delete the bug task?
4063
If yes, return True, otherwise return False.
4065
bugtask = self.context
4066
return (check_permission('launchpad.Delete', bugtask)
4067
and bugtask.canBeDeleted())
3559
def user_can_edit_milestone(self):
3560
"""Can the user edit the Milestone field?
3562
If yes, return True, otherwise return False.
3564
return self.context.userCanEditMilestone(self.user)
4070
3567
def style_for_add_milestone(self):
4071
3568
if self.context.milestone is None:
3571
return 'display: none'
4077
3574
def style_for_edit_milestone(self):
4078
3575
if self.context.milestone is None:
3576
return 'display: none'
4083
def bugtask_config(self):
4084
"""Configuration for the bugtask JS widgets on the row."""
4085
assignee_vocabulary, assignee_vocabulary_filters = (
4086
get_assignee_vocabulary_info(self.context))
4087
# If we have no filters or just the ALL filter, then no filtering
4088
# support is required.
4090
if (len(assignee_vocabulary_filters) > 1 or
4091
(len(assignee_vocabulary_filters) == 1
4092
and assignee_vocabulary_filters[0].name != 'ALL')):
4093
for filter in assignee_vocabulary_filters:
4094
filter_details.append({
4095
'name': filter.name,
4096
'title': filter.title,
4097
'description': filter.description,
3580
def js_config(self):
3581
"""Configuration for the JS widgets on the row, JSON-serialized."""
3582
assignee_vocabulary = get_assignee_vocabulary(self.context)
4099
3583
# Display the search field only if the user can set any person
3585
user = getUtility(ILaunchBag).user
4102
3586
hide_assignee_team_selection = (
4103
3587
not self.context.userCanSetAnyAssignee(user) and
4104
3588
(user is None or user.teams_participated_in.count() == 0))
4107
row_id=self.data['row_id'],
4108
form_row_id=self.data['form_row_id'],
4109
bugtask_path='/'.join([''] + self.data['link'].split('/')[3:]),
4110
prefix=get_prefix(cx),
4111
targetname=cx.bugtargetdisplayname,
4112
bug_title=cx.bug.title,
4113
assignee_value=cx.assignee and cx.assignee.name,
4114
assignee_is_team=cx.assignee and cx.assignee.is_team,
4115
assignee_vocabulary=assignee_vocabulary,
4116
assignee_vocabulary_filters=filter_details,
4117
hide_assignee_team_selection=hide_assignee_team_selection,
4118
user_can_unassign=cx.userCanUnassign(user),
4119
user_can_delete=self.user_can_delete_bugtask,
4120
delete_link=self.data['delete_link'],
4121
target_is_product=IProduct.providedBy(cx.target),
4122
status_widget_items=self.status_widget_items,
4123
status_value=cx.status.title,
4124
importance_widget_items=self.importance_widget_items,
4125
importance_value=cx.importance.title,
4126
milestone_widget_items=self.milestone_widget_items,
4130
request=self.api_request)
4131
if cx.milestone else None),
4132
user_can_edit_assignee=self.user_can_edit_assignee,
4133
user_can_edit_milestone=self.user_has_privileges,
4134
user_can_edit_status=self.user_can_edit_status,
4135
user_can_edit_importance=self.user_has_privileges,
3590
'row_id': 'tasksummary%s' % self.context.id,
3591
'bugtask_path': '/'.join(
3592
[''] + canonical_url(self.context).split('/')[3:]),
3593
'prefix': get_prefix(self.context),
3594
'assignee_vocabulary': assignee_vocabulary,
3595
'hide_assignee_team_selection': hide_assignee_team_selection,
3596
'user_can_unassign': self.context.userCanUnassign(user),
3597
'target_is_product': IProduct.providedBy(self.context.target),
3598
'status_widget_items': self.status_widget_items,
3599
'status_value': self.context.status.title,
3600
'importance_widget_items': self.importance_widget_items,
3601
'importance_value': self.context.importance.title,
3602
'milestone_widget_items': self.milestone_widget_items,
3603
'milestone_value': (self.context.milestone and
3605
self.context.milestone,
3606
request=IWebServiceClientRequest(
3609
'user_can_edit_milestone': self.user_can_edit_milestone,
3610
'user_can_edit_status': not self.context.bugwatch,
3611
'user_can_edit_importance': (
3612
self.user_can_edit_importance and
3613
not self.context.bugwatch)})
4139
3616
class BugsBugTaskSearchListingView(BugTaskSearchListingView):