766
718
def comments(self):
767
719
"""Return the bugtask's comments."""
768
return self._getComments()
770
def _getComments(self, slice_info=None):
771
bug = self.context.bug
772
show_spam_controls = bug.userCanSetCommentVisibility(self.user)
773
return get_comments_for_bugtask(
774
self.context, truncate=True, slice_info=slice_info,
775
for_display=True, show_spam_controls=show_spam_controls,
720
show_spam_controls = check_permission(
721
'launchpad.Admin', self.context.bug)
722
return get_comments_for_bugtask(self.context, truncate=True,
723
for_display=True, show_spam_controls=show_spam_controls)
779
726
def interesting_activity(self):
780
return self._getInterestingActivity()
782
def _getInterestingActivity(self, earliest_activity_date=None,
783
latest_activity_date=None):
784
727
"""A sequence of interesting bug activity."""
785
if (earliest_activity_date is not None and
786
latest_activity_date is not None):
787
# Only get the activity for the date range that we're
788
# interested in to save us from processing too much.
789
activity = self.context.bug.getActivityForDateRange(
790
start_date=earliest_activity_date,
791
end_date=latest_activity_date)
793
activity = self.context.bug.activity
794
728
bug_change_re = (
795
729
'affects|description|security vulnerability|'
796
'summary|tags|visibility|bug task deleted')
730
'summary|tags|visibility')
797
731
bugtask_change_re = (
798
732
'[a-z0-9][a-z0-9\+\.\-]+( \([A-Za-z0-9\s]+\))?: '
799
733
'(assignee|importance|milestone|status)')
800
734
interesting_match = re.compile(
801
735
"^(%s|%s)$" % (bug_change_re, bugtask_change_re)).match
802
interesting_activity = tuple(
803
737
BugActivityItem(activity)
804
for activity in activity
738
for activity in self.context.bug.activity
805
739
if interesting_match(activity.whatchanged) is not None)
806
# This is a bit kludgy but it means that interesting_activity is
807
# populated correctly for all subsequent calls.
808
self._interesting_activity_cached_value = interesting_activity
809
return interesting_activity
811
def _getEventGroups(self, batch_size=None, offset=None):
742
def activity_and_comments(self):
743
"""Build list of comments interleaved with activities
745
When activities occur on the same day a comment was posted,
746
encapsulate them with that comment. For the remainder, group
747
then as if owned by the person who posted the first action
750
If the number of comments exceeds the configured maximum limit, the
751
list will be truncated to just the first and last sets of comments.
753
The division between the most recent and oldest is marked by an entry
754
in the list with the key 'num_hidden' defined.
812
756
# Ensure truncation results in < max_length comments as expected
813
757
assert(config.malone.comments_list_truncate_oldest_to
814
758
+ config.malone.comments_list_truncate_newest_to
815
759
< config.malone.comments_list_max_length)
817
if (not self.visible_comments_truncated_for_display and
761
if not self.visible_comments_truncated_for_display:
819
762
comments = self.comments
820
elif batch_size is not None:
821
# If we're limiting to a given set of comments, we work on
822
# just that subset of comments from hereon in, which saves
823
# on processing time a bit.
825
offset = self.visible_initial_comments
826
comments = self._getComments([
827
slice(offset, offset + batch_size)])
829
764
# the comment function takes 0-offset counts where comment 0 is
830
765
# the initial description, so we need to add one to the limits
832
767
oldest_count = 1 + self.visible_initial_comments
833
768
new_count = 1 + self.total_comments - self.visible_recent_comments
835
slice(None, oldest_count),
836
slice(new_count, None),
838
comments = self._getComments(slice_info)
769
show_spam_controls = check_permission(
770
'launchpad.Admin', self.context.bug)
771
comments = get_comments_for_bugtask(
772
self.context, truncate=True, for_display=True,
774
slice(None, oldest_count), slice(new_count, None)],
775
show_spam_controls=show_spam_controls)
840
777
visible_comments = get_visible_comments(
841
778
comments, user=self.user)
842
if len(visible_comments) > 0 and batch_size is not None:
843
first_comment = visible_comments[0]
844
last_comment = visible_comments[-1]
845
interesting_activity = (
846
self._getInterestingActivity(
847
earliest_activity_date=first_comment.datecreated,
848
latest_activity_date=last_comment.datecreated))
850
interesting_activity = self.interesting_activity
852
780
event_groups = group_comments_with_activity(
853
781
comments=visible_comments,
854
activities=interesting_activity)
858
def _event_groups(self):
859
"""Return a sorted list of event groups for the current BugTask.
861
This is a @cachedproperty wrapper around _getEventGroups(). It's
862
here so that we can override it in descendant views, passing
863
batch size parameters and suchlike to _getEventGroups() as we
866
return self._getEventGroups()
869
def activity_and_comments(self):
870
"""Build list of comments interleaved with activities
872
When activities occur on the same day a comment was posted,
873
encapsulate them with that comment. For the remainder, group
874
then as if owned by the person who posted the first action
877
If the number of comments exceeds the configured maximum limit, the
878
list will be truncated to just the first and last sets of comments.
880
The division between the most recent and oldest is marked by an entry
881
in the list with the key 'num_hidden' defined.
883
event_groups = self._event_groups
782
activities=self.interesting_activity)
885
784
def group_activities_by_target(activities):
886
785
activities = sorted(
1122
1010
max_bug_heat = 5000
1123
1011
heat_ratio = calculate_heat_display(bugtask.bug.heat, max_bug_heat)
1125
'<span><a href="/+help-bugs/bug-heat.html" target="help" '
1126
'class="icon"><img src="/@@/bug-heat-%(ratio)i.png" '
1013
'<span><a href="/+help/bug-heat.html" target="help" class="icon"><img'
1014
' src="/@@/bug-heat-%(ratio)i.png" '
1127
1015
'alt="%(ratio)i out of 4 heat flames" title="Heat: %(heat)i" /></a>'
1129
1017
% {'ratio': heat_ratio, 'heat': bugtask.bug.heat})
1133
class BugTaskBatchedCommentsAndActivityView(BugTaskView):
1134
"""A view for displaying batches of bug comments and activity."""
1136
# We never truncate comments in this view; there would be no point.
1137
visible_comments_truncated_for_display = False
1142
return int(self.request.form_ng.getOne('offset'))
1144
# We return visible_initial_comments + 1, since otherwise we'd
1145
# end up repeating comments that are already visible on the
1146
# page. The +1 accounts for the fact that bug comments are
1147
# essentially indexed from 1 due to comment 0 being the
1148
# initial bug description.
1149
return self.visible_initial_comments + 1
1152
def batch_size(self):
1154
return int(self.request.form_ng.getOne('batch_size'))
1156
return config.malone.comments_list_default_batch_size
1159
def next_batch_url(self):
1160
return "%s?offset=%s&batch_size=%s" % (
1161
canonical_url(self.context, view_name='+batched-comments'),
1162
self.next_offset, self.batch_size)
1165
def next_offset(self):
1166
return self.offset + self.batch_size
1169
def _event_groups(self):
1170
"""See `BugTaskView`."""
1171
batch_size = self.batch_size
1172
if (batch_size > (self.total_comments) or
1173
not self.has_more_comments_and_activity):
1174
# If the batch size is big enough to encompass all the
1175
# remaining comments and activity, trim it so that we don't
1177
if self.offset == self.visible_initial_comments + 1:
1178
offset_to_remove = self.visible_initial_comments
1180
offset_to_remove = self.offset
1182
self.total_comments - self.visible_recent_comments -
1183
# This last bit is to make sure that _getEventGroups()
1184
# doesn't accidentally inflate the batch size later on.
1186
return self._getEventGroups(
1187
batch_size=batch_size, offset=self.offset)
1190
def has_more_comments_and_activity(self):
1191
"""Return True if there are more camments and activity to load."""
1193
self.next_offset < (self.total_comments + self.total_activity))
1021
class BugTaskPortletView:
1022
"""A portlet for displaying a bug's bugtasks."""
1024
def alsoReportedIn(self):
1025
"""Return a list of IUpstreamBugTasks in which this bug is reported.
1027
If self.context is an IUpstreamBugTasks, it will be excluded
1031
task for task in self.context.bug.bugtasks
1032
if task.id is not self.context.id]
1196
1035
def get_prefix(bugtask):
1360
1182
# XXX: Brad Bollenbach 2006-09-29 bug=63000: Permission checking
1361
1183
# doesn't belong here!
1362
if not self.user_has_privileges:
1363
if 'milestone' in editable_field_names:
1364
editable_field_names.remove("milestone")
1365
if 'importance' in editable_field_names:
1366
editable_field_names.remove("importance")
1184
if ('milestone' in editable_field_names and
1185
not self.userCanEditMilestone()):
1186
editable_field_names.remove("milestone")
1188
if ('importance' in editable_field_names and
1189
not self.userCanEditImportance()):
1190
editable_field_names.remove("importance")
1368
1192
editable_field_names = set(('bugwatch', ))
1369
1193
if self.context.bugwatch is None:
1370
1194
editable_field_names.update(('status', 'assignee'))
1371
1195
if ('importance' in self.default_field_names
1372
and self.user_has_privileges):
1196
and self.userCanEditImportance()):
1373
1197
editable_field_names.add('importance')
1375
1199
bugtracker = self.context.bugwatch.bugtracker
1376
1200
if bugtracker.bugtrackertype == BugTrackerType.EMAILADDRESS:
1377
1201
editable_field_names.add('status')
1378
1202
if ('importance' in self.default_field_names
1379
and self.user_has_privileges):
1203
and self.userCanEditImportance()):
1380
1204
editable_field_names.add('importance')
1382
1206
if self.show_target_widget:
1726
1562
bugtask.transitionToAssignee(None)
1565
# We only set the statusexplanation field to the value of the
1566
# change comment if the BugTask has actually been changed in some
1567
# way. Otherwise, we just leave it as a comment on the bug.
1568
if comment_on_change:
1569
bugtask.statusexplanation = comment_on_change
1571
bugtask.statusexplanation = ""
1730
1574
ObjectModifiedEvent(
1731
1575
object=bugtask,
1732
1576
object_before_modification=bugtask_before_modification,
1733
1577
edited_fields=field_names))
1735
# We clear the known views cache because the bug may not be
1736
# viewable anymore by the current user. If the bug is not
1737
# viewable, then we redirect to the current bugtask's pillar's
1738
# bug index page with a message.
1739
get_property_cache(bugtask.bug)._known_viewers = set()
1740
if not bugtask.bug.userCanView(self.user):
1741
self.request.response.addWarningNotification(
1742
"The bug you have just updated is now a private bug for "
1743
"%s. You do not have permission to view such bugs."
1744
% bugtask.pillar.displayname)
1745
self._next_url_override = canonical_url(
1746
new_target.pillar, rootsite='bugs')
1748
1579
if (bugtask.sourcepackagename and (
1749
1580
self.widgets.get('target') or
1750
1581
self.widgets.get('sourcepackagename'))):
1776
1607
self.updateContextFromData(data)
1779
class BugTaskDeletionView(ReturnToReferrerMixin, LaunchpadFormView):
1780
"""Used to delete a bugtask."""
1785
label = 'Remove bug task'
1790
"""Return the next URL to call when this call completes."""
1791
if not self.request.is_ajax:
1792
return self._next_url or self._return_url
1795
@action('Delete', name='delete_bugtask')
1796
def delete_bugtask_action(self, action, data):
1797
bugtask = self.context
1799
deleted_bugtask_url = canonical_url(self.context, rootsite='bugs')
1800
success_message = ("This bug no longer affects %s."
1801
% bugtask.bugtargetdisplayname)
1802
error_message = None
1803
# We set the next_url here before the bugtask is deleted since later
1804
# the bugtask will not be available if required to construct the url.
1805
self._next_url = self._return_url
1809
self.request.response.addNotification(success_message)
1810
except CannotDeleteBugtask as e:
1811
error_message = str(e)
1812
self.request.response.addErrorNotification(error_message)
1813
if self.request.is_ajax:
1815
self.request.response.setHeader('Content-type',
1818
launchbag = getUtility(ILaunchBag)
1819
launchbag.add(bug.default_bugtask)
1820
# If we are deleting the current highlighted bugtask via ajax,
1821
# we must force a redirect to the new default bugtask to ensure
1822
# all URLs and other client cache content is correctly refreshed.
1823
# We can't do the redirect here since the XHR caller won't see it
1824
# so we return the URL to go to and let the caller do it.
1825
if self._return_url == deleted_bugtask_url:
1826
next_url = canonical_url(
1827
bug.default_bugtask, rootsite='bugs')
1828
self.request.response.setHeader('Content-type',
1830
return dumps(dict(bugtask_url=next_url))
1831
# No redirect required so return the new bugtask table HTML.
1832
view = getMultiAdapter(
1833
(bug, self.request),
1834
name='+bugtasks-and-nominations-table')
1836
return view.render()
1610
class BugTaskStatusView(LaunchpadView):
1611
"""Viewing the status of a bug task."""
1613
page_title = 'View status'
1615
def initialize(self):
1616
"""Set up the appropriate widgets.
1618
Different widgets are shown depending on if it's a remote bug
1622
'status', 'importance', 'assignee', 'statusexplanation']
1623
if not self.context.target_uses_malone:
1624
field_names += ['bugwatch']
1625
self.milestone_widget = None
1627
field_names += ['milestone']
1628
self.bugwatch_widget = None
1630
if self.context.distroseries or self.context.distribution:
1631
field_names += ['sourcepackagename']
1633
self.assignee_widget = CustomWidgetFactory(AssigneeDisplayWidget)
1634
self.status_widget = CustomWidgetFactory(DBItemDisplayWidget)
1635
self.importance_widget = CustomWidgetFactory(DBItemDisplayWidget)
1637
setUpWidgets(self, IBugTask, IDisplayWidget, names=field_names)
1839
1640
class BugTaskListingView(LaunchpadView):
2182
1959
return search_filter_url
1962
def getInitialValuesFromSearchParams(search_params, form_schema):
1963
"""Build a dictionary that can be given as initial values to
1964
setUpWidgets, based on the given search params.
1966
>>> initial = getInitialValuesFromSearchParams(
1967
... {'status': any(*UNRESOLVED_BUGTASK_STATUSES)}, IBugTaskSearch)
1968
>>> for status in initial['status']:
1969
... print status.name
1977
>>> initial = getInitialValuesFromSearchParams(
1978
... {'status': BugTaskStatus.INVALID}, IBugTaskSearch)
1979
>>> [status.name for status in initial['status']]
1982
>>> initial = getInitialValuesFromSearchParams(
1983
... {'importance': [BugTaskImportance.CRITICAL,
1984
... BugTaskImportance.HIGH]}, IBugTaskSearch)
1985
>>> [importance.name for importance in initial['importance']]
1986
['CRITICAL', 'HIGH']
1988
>>> getInitialValuesFromSearchParams(
1989
... {'assignee': NULL}, IBugTaskSearch)
1993
for key, value in search_params.items():
1994
if IList.providedBy(form_schema[key]):
1995
if isinstance(value, any):
1996
value = value.query_values
1997
elif isinstance(value, (list, tuple)):
2004
# Should be safe to pass value as it is to setUpWidgets, no need
2008
initial[key] = value
2185
2013
class BugTaskListingItem:
2186
2014
"""A decorated bug task.
2214
2042
"""Returns the bug heat flames HTML."""
2215
2043
return bugtask_heat_html(self.bugtask, target=self.target_context)
2219
"""Provide flattened data about bugtask for simple templaters."""
2220
age = DateTimeFormatterAPI(self.bug.datecreated).durationsince()
2222
date_last_updated = self.bug.date_last_message
2223
if (date_last_updated is None or
2224
self.bug.date_last_updated > date_last_updated):
2225
date_last_updated = self.bug.date_last_updated
2226
last_updated_formatter = DateTimeFormatterAPI(date_last_updated)
2227
last_updated = last_updated_formatter.displaydate()
2228
badges = getAdapter(self.bugtask, IPathAdapter, 'image').badges()
2229
target_image = getAdapter(self.target, IPathAdapter, 'image')
2230
if self.bugtask.milestone is not None:
2231
milestone_name = self.bugtask.milestone.displayname
2233
milestone_name = None
2235
if self.assignee is not None:
2236
assignee = self.assignee.displayname
2238
base_tag_url = "%s/?field.tag=" % canonical_url(
2239
self.bugtask.target,
2244
'assignee': assignee,
2245
'bug_url': canonical_url(self.bugtask),
2246
'bugtarget': self.bugtargetdisplayname,
2247
'bugtarget_css': target_image.sprite_css(),
2248
'bug_heat_html': self.bug_heat_html,
2251
'importance': self.importance.title,
2252
'importance_class': 'importance' + self.importance.name,
2253
'last_updated': last_updated,
2254
'milestone_name': milestone_name,
2255
'reporter': self.bug.owner.displayname,
2256
'status': self.status.title,
2257
'status_class': 'status' + self.status.name,
2258
'tags': [{'url': base_tag_url + tag, 'tag': tag}
2259
for tag in self.bug.tags],
2260
'title': self.bug.title,
2263
# This is a total hack, but pystache will run both truth/false values
2264
# for an empty list for some reason, and it "works" if it's just a
2265
# flag like this. We need this value for the mustache template to be
2266
# able to tell that there are no tags without looking at the list.
2267
flattened['has_tags'] = True if len(flattened['tags']) else False
2271
2046
class BugListingBatchNavigator(TableBatchNavigator):
2272
2047
"""A specialised batch navigator to load smartly extra bug information."""
2301
2060
return getUtility(IBugTaskSet).getBugTaskBadgeProperties(
2302
2061
self.currentBatch())
2304
def getCookieName(self):
2305
"""Return the cookie name used in bug listings js code."""
2306
cookie_name_template = '%s-buglist-fields'
2308
if self.user is not None:
2309
cookie_name = cookie_name_template % self.user.name
2311
cookie_name = cookie_name_template % 'anon'
2314
def _setFieldVisibility(self):
2315
"""Set field_visibility for the page load.
2317
If a cookie of the form $USER-buglist-fields is found,
2318
we set field_visibility from this cookie; otherwise,
2319
field_visibility will match the defaults.
2321
cookie_name = self.getCookieName()
2322
cookie = self.request.cookies.get(cookie_name)
2323
self.field_visibility = dict(self.field_visibility_defaults)
2324
# "cookie" looks like a URL query string, so we split
2325
# on '&' to get items, and then split on '=' to get
2326
# field/value pairs.
2329
for field, value in urlparse.parse_qsl(cookie):
2330
# Skip unsupported fields (from old cookies).
2331
if field not in self.field_visibility:
2333
# We only record True or False for field values.
2334
self.field_visibility[field] = (value == 'true')
2336
2063
def _getListingItem(self, bugtask):
2337
2064
"""Return a decorated bugtask for the bug listing."""
2338
2065
badge_property = self.bug_badge_properties[bugtask]
2499
2202
return Link('+nominations', 'Review nominations', icon='bug')
2502
# All sort orders supported by BugTaskSet.search() and a title for
2505
('importance', 'Importance', 'desc'),
2506
('status', 'Status', 'asc'),
2507
('id', 'Number', 'desc'),
2508
('title', 'Title', 'asc'),
2509
('targetname', 'Package/Project/Series name', 'asc'),
2510
('milestone_name', 'Milestone', 'asc'),
2511
('date_last_updated', 'Date last updated', 'desc'),
2512
('assignee', 'Assignee', 'asc'),
2513
('reporter', 'Reporter', 'asc'),
2514
('datecreated', 'Age', 'desc'),
2515
('tag', 'Tags', 'asc'),
2516
('heat', 'Heat', 'desc'),
2517
('date_closed', 'Date closed', 'desc'),
2518
('dateassigned', 'Date when the bug task was assigned', 'desc'),
2519
('number_of_duplicates', 'Number of duplicates', 'desc'),
2520
('latest_patch_uploaded', 'Date latest patch uploaded', 'desc'),
2521
('message_count', 'Number of comments', 'desc'),
2522
('milestone', 'Milestone ID', 'desc'),
2523
('specification', 'Linked blueprint', 'asc'),
2524
('task', 'Bug task ID', 'desc'),
2525
('users_affected_count', 'Number of affected users', 'desc'),
2529
2205
class BugTaskSearchListingView(LaunchpadFormView, FeedsMixin, BugsInfoMixin):
2530
2206
"""View that renders a list of bugs for a given set of search criteria."""
2532
2208
implements(IBugTaskSearchListingMenu)
2534
related_features = (
2535
'bugs.dynamic_bug_listings.enabled',
2536
'bugs.dynamic_bug_listings.pre_fetch',
2539
2210
# Only include <link> tags for bug feeds when using this view.
2541
2212
BugTargetLatestBugsFeedLink,
2691
2342
expose_structural_subscription_data_to_js(
2692
2343
self.context, self.request, self.user)
2693
if getFeatureFlag('bugs.dynamic_bug_listings.enabled'):
2694
cache = IJSONRequestCache(self.request)
2695
view_names = set(reg.name for reg
2696
in iter_view_registrations(self.__class__))
2697
if len(view_names) != 1:
2698
raise AssertionError("Ambiguous view name.")
2699
cache.objects['view_name'] = view_names.pop()
2700
batch_navigator = self.search()
2701
cache.objects['mustache_model'] = batch_navigator.model
2702
cache.objects['field_visibility'] = (
2703
batch_navigator.field_visibility)
2704
cache.objects['field_visibility_defaults'] = (
2705
batch_navigator.field_visibility_defaults)
2706
cache.objects['cbl_cookie_name'] = batch_navigator.getCookieName()
2708
def _getBatchInfo(batch):
2711
return {'memo': batch.range_memo,
2712
'start': batch.startNumber() - 1}
2714
next_batch = batch_navigator.batch.nextBatch()
2715
cache.objects['next'] = _getBatchInfo(next_batch)
2716
prev_batch = batch_navigator.batch.prevBatch()
2717
cache.objects['prev'] = _getBatchInfo(prev_batch)
2718
cache.objects['total'] = batch_navigator.batch.total()
2719
cache.objects['order_by'] = ','.join(
2720
get_sortorder_from_request(self.request))
2721
cache.objects['forwards'] = batch_navigator.batch.range_forwards
2722
last_batch = batch_navigator.batch.lastBatch()
2723
cache.objects['last_start'] = last_batch.startNumber() - 1
2724
cache.objects.update(_getBatchInfo(batch_navigator.batch))
2725
cache.objects['sort_keys'] = SORT_KEYS
2728
def show_config_portlet(self):
2729
if (IDistribution.providedBy(self.context) or
2730
IProduct.providedBy(self.context)):
2736
2346
def columns_to_show(self):
3347
2918
def addquestion_url(self):
3348
2919
"""Return the URL for the +addquestion view for the context."""
3349
2920
if IQuestionTarget.providedBy(self.context):
3350
answers_usage = IServiceUsage(self.context).answers_usage
3351
if answers_usage == ServiceUsage.LAUNCHPAD:
3352
return canonical_url(
3353
self.context, rootsite='answers',
3354
view_name='+addquestion')
2921
return canonical_url(
2922
self.context, rootsite='answers', view_name='+addquestion')
3359
def dynamic_bug_listing_enabled(self):
3360
"""Feature flag: Can the bug listing be customized?"""
3361
return bool(getFeatureFlag('bugs.dynamic_bug_listings.enabled'))
3364
def search_macro_title(self):
3365
"""The search macro's title text."""
3366
return u"Search bugs %s" % self.context_description
3369
def context_description(self):
3370
"""A phrase describing the context of the bug.
3372
The phrase is intended to be used for headings like
3373
"Bugs in $context", "Search bugs in $context". This
3374
property should be overridden for person related views.
3376
return "in %s" % self.context.displayname
3379
2927
class BugNominationsView(BugTaskSearchListingView):
3380
2928
"""View for accepting/declining bug nominations."""
3781
3320
def other_users_affected_count(self):
3782
"""The number of other users affected by this bug.
3784
if getFeatureFlag('bugs.affected_count_includes_dupes.disabled'):
3785
if self.current_user_affected_status:
3786
return self.context.users_affected_count - 1
3788
return self.context.users_affected_count
3321
"""The number of other users affected by this bug."""
3322
if self.current_user_affected_status:
3323
return self.context.users_affected_count - 1
3790
return self.context.other_users_affected_count_with_dupes
3793
def total_users_affected_count(self):
3794
"""The number of affected users, typically across all users.
3796
Counting across duplicates may be disabled at run time.
3798
if getFeatureFlag('bugs.affected_count_includes_dupes.disabled'):
3799
3325
return self.context.users_affected_count
3801
return self.context.users_affected_count_with_dupes
3804
3328
def affected_statement(self):
3805
3329
"""The default "this bug affects" statement to show.
3807
3331
The outputs of this method should be mirrored in
3808
3332
MeTooChoiceSource._getSourceNames() (Javascript).
3810
me_affected = self.current_user_affected_status
3811
other_affected = self.other_users_affected_count
3812
if me_affected is None:
3813
if other_affected == 1:
3334
if self.other_users_affected_count == 1:
3335
if self.current_user_affected_status is None:
3814
3336
return "This bug affects 1 person. Does this bug affect you?"
3815
elif other_affected > 1:
3337
elif self.current_user_affected_status:
3338
return "This bug affects you and 1 other person"
3340
return "This bug affects 1 person, but not you"
3341
elif self.other_users_affected_count > 1:
3342
if self.current_user_affected_status is None:
3817
3344
"This bug affects %d people. Does this bug "
3818
"affect you?" % (other_affected))
3345
"affect you?" % (self.other_users_affected_count))
3346
elif self.current_user_affected_status:
3347
return "This bug affects you and %d other people" % (
3348
self.other_users_affected_count)
3350
return "This bug affects %d people, but not you" % (
3351
self.other_users_affected_count)
3353
if self.current_user_affected_status is None:
3820
3354
return "Does this bug affect you?"
3821
elif me_affected is True:
3822
if other_affected == 0:
3355
elif self.current_user_affected_status:
3823
3356
return "This bug affects you"
3824
elif other_affected == 1:
3825
return "This bug affects you and 1 other person"
3827
return "This bug affects you and %d other people" % (
3830
if other_affected == 0:
3831
3358
return "This bug doesn't affect you"
3832
elif other_affected == 1:
3833
return "This bug affects 1 person, but not you"
3834
elif other_affected > 1:
3835
return "This bug affects %d people, but not you" % (
3839
3361
def anon_affected_statement(self):
3840
3362
"""The "this bug affects" statement to show to anonymous users.
3842
3364
The outputs of this method should be mirrored in
3843
3365
MeTooChoiceSource._getSourceNames() (Javascript).
3845
affected = self.total_users_affected_count
3367
if self.context.users_affected_count == 1:
3847
3368
return "This bug affects 1 person"
3849
return "This bug affects %d people" % affected
3369
elif self.context.users_affected_count > 1:
3370
return "This bug affects %d people" % (
3371
self.context.users_affected_count)
3854
def _allow_multipillar_private_bugs(self):
3855
""" Some teams still need to have multi pillar private bugs."""
3856
return bool(getFeatureFlag(
3857
'disclosure.allow_multipillar_private_bugs.enabled'))
3859
def canAddProjectTask(self):
3860
"""Can a new bug task on a project be added to this bug?
3862
If a bug has any bug tasks already, were it to be private, it cannot
3863
be marked as also affecting any other project, so return False.
3865
Note: this check is currently only relevant if a bug is private.
3866
Eventually, even public bugs will have this restriction too. So what
3867
happens now is that this API is used by the tales to add a class
3868
called 'disallow-private' to the Also Affects Project link. A css rule
3869
is used to hide the link when body.private is True.
3873
if self._allow_multipillar_private_bugs:
3875
return len(bug.bugtasks) == 0
3877
def canAddPackageTask(self):
3878
"""Can a new bug task on a src pkg be added to this bug?
3880
If a bug has any existing bug tasks on a project, were it to
3881
be private, then it cannot be marked as affecting a package,
3884
A task on a given package may still be illegal to add, but
3885
this will be caught when bug.addTask() is attempted.
3887
Note: this check is currently only relevant if a bug is private.
3888
Eventually, even public bugs will have this restriction too. So what
3889
happens now is that this API is used by the tales to add a class
3890
called 'disallow-private' to the Also Affects Package link. A css rule
3891
is used to hide the link when body.private is True.
3894
if self._allow_multipillar_private_bugs:
3896
for pillar in bug.affected_pillars:
3897
if IProduct.providedBy(pillar):
3902
class BugTaskTableRowView(LaunchpadView, BugTaskBugWatchMixin,
3903
BugTaskPrivilegeMixin):
3376
class BugTaskTableRowView(LaunchpadView, BugTaskBugWatchMixin):
3904
3377
"""Browser class for rendering a bugtask row on the bug page."""
3906
3379
is_conjoined_slave = None
3908
3381
target_link_title = None
3909
3382
many_bugtasks = False
3911
template = ViewPageTemplateFile(
3912
'../templates/bugtask-tasks-and-nominations-table-row.pt')
3914
3384
def __init__(self, context, request):
3915
3385
super(BugTaskTableRowView, self).__init__(context, request)
3916
3386
self.milestone_source = MilestoneVocabulary
3919
def api_request(self):
3920
return IWebServiceClientRequest(self.request)
3922
def initialize(self):
3923
super(BugTaskTableRowView, self).initialize()
3924
link = canonical_url(self.context)
3925
task_link = edit_link = canonical_url(
3926
self.context, view_name='+editstatus')
3927
delete_link = canonical_url(self.context, view_name='+delete')
3928
can_edit = check_permission('launchpad.Edit', self.context)
3929
bugtask_id = self.context.id
3930
launchbag = getUtility(ILaunchBag)
3931
is_primary = self.context.id == launchbag.bugtask.id
3933
# Looking at many_bugtasks is an important optimization. With
3934
# 150+ bugtasks, it can save three or four seconds of rendering
3936
expandable=(not self.many_bugtasks and self.canSeeTaskDetails()),
3937
indent_task=ISeriesBugTarget.providedBy(self.context.target),
3938
is_conjoined_slave=self.is_conjoined_slave,
3939
task_link=task_link,
3940
edit_link=edit_link,
3944
row_id='tasksummary%d' % bugtask_id,
3945
form_row_id='task%d' % bugtask_id,
3946
row_css_class='highlight' if is_primary else None,
3947
target_link=canonical_url(self.context.target),
3948
target_link_title=self.target_link_title,
3949
user_can_delete=self.user_can_delete_bugtask,
3950
delete_link=delete_link,
3951
user_can_edit_importance=self.user_has_privileges,
3952
importance_css_class='importance' + self.context.importance.name,
3953
importance_title=self.context.importance.title,
3954
# We always look up all milestones, so there's no harm
3955
# using len on the list here and avoid the COUNT query.
3956
target_has_milestones=len(self._visible_milestones) > 0,
3957
user_can_edit_status=self.user_can_edit_status,
3960
if not self.many_bugtasks:
3961
cache = IJSONRequestCache(self.request)
3962
bugtask_data = cache.objects.get('bugtask_data', None)
3963
if bugtask_data is None:
3964
bugtask_data = dict()
3965
cache.objects['bugtask_data'] = bugtask_data
3966
bugtask_data[bugtask_id] = self.bugtask_config()
3968
3388
def canSeeTaskDetails(self):
3969
3389
"""Whether someone can see a task's status details.
3983
3403
self.context.bug.duplicateof is None and
3984
3404
not self.is_converted_to_question)
3406
def expandable(self):
3407
"""Can the task's details be expanded?
3409
They can if there are not too many bugtasks, and if the user can see
3410
the task details."""
3411
# Looking at many_bugtasks is an important optimization. With 150+
3412
# bugtasks, it can save three or four seconds of rendering time.
3413
return not self.many_bugtasks and self.canSeeTaskDetails()
3415
def getTaskRowCSSClass(self):
3416
"""The appropriate CSS class for the row in the Affects table.
3418
Currently this consists solely of highlighting the current context.
3420
bugtask = self.context
3421
if bugtask == getUtility(ILaunchBag).bugtask:
3426
def shouldIndentTask(self):
3427
"""Should this task be indented in the task listing on the bug page?
3429
Returns True or False.
3431
return ISeriesBugTarget.providedBy(self.context.target)
3434
"""Return the proper link to the bugtask whether it's editable."""
3435
user = getUtility(ILaunchBag).user
3436
bugtask = self.context
3437
if check_permission('launchpad.Edit', user):
3438
return canonical_url(bugtask) + "/+editstatus"
3440
return canonical_url(bugtask) + "/+viewstatus"
3986
3442
def _getSeriesTargetNameHelper(self, bugtask):
3987
3443
"""Return the short name of bugtask's targeted series."""
3988
3444
series = bugtask.distroseries or bugtask.productseries
3537
def target_has_milestones(self):
3538
"""Are there any milestones we can target?
3540
We always look up all milestones, so there's no harm
3541
using len on the list here and avoid the COUNT query.
3543
return len(self._visible_milestones) > 0
4080
3545
def bugtask_canonical_url(self):
4081
3546
"""Return the canonical url for the bugtask."""
4082
3547
return canonical_url(self.context)
4085
3550
def user_can_edit_importance(self):
4086
3551
"""Can the user edit the Importance field?
4088
3553
If yes, return True, otherwise return False.
4090
return self.user_can_edit_status and self.user_has_privileges
4093
def user_can_edit_status(self):
4094
"""Can the user edit the Status field?
4096
If yes, return True, otherwise return False.
4098
bugtask = self.context
4099
edit_allowed = bugtask.target_uses_malone or bugtask.bugwatch
4100
if bugtask.bugwatch:
4101
bugtracker = bugtask.bugwatch.bugtracker
4103
bugtracker.bugtrackertype == BugTrackerType.EMAILADDRESS)
3555
return self.context.userCanEditImportance(self.user)
4107
3558
def user_can_edit_assignee(self):
4108
"""Can the user edit the Assignee field?
3559
"""Can the user edit the Milestone field?
4110
3561
If yes, return True, otherwise return False.
4112
3563
return self.user is not None
4115
def user_can_delete_bugtask(self):
4116
"""Can the user delete the bug task?
3566
def user_can_edit_milestone(self):
3567
"""Can the user edit the Milestone field?
4118
3569
If yes, return True, otherwise return False.
4120
bugtask = self.context
4121
return (check_permission('launchpad.Delete', bugtask)
4122
and bugtask.canBeDeleted())
3571
return self.context.userCanEditMilestone(self.user)
4125
3574
def style_for_add_milestone(self):
4126
3575
if self.context.milestone is None:
3578
return 'display: none'
4132
3581
def style_for_edit_milestone(self):
4133
3582
if self.context.milestone is None:
3583
return 'display: none'
4138
def bugtask_config(self):
4139
"""Configuration for the bugtask JS widgets on the row."""
4140
assignee_vocabulary, assignee_vocabulary_filters = (
4141
get_assignee_vocabulary_info(self.context))
4142
# If we have no filters or just the ALL filter, then no filtering
4143
# support is required.
4145
if (len(assignee_vocabulary_filters) > 1 or
4146
(len(assignee_vocabulary_filters) == 1
4147
and assignee_vocabulary_filters[0].name != 'ALL')):
4148
for filter in assignee_vocabulary_filters:
4149
filter_details.append({
4150
'name': filter.name,
4151
'title': filter.title,
4152
'description': filter.description,
3587
def js_config(self):
3588
"""Configuration for the JS widgets on the row, JSON-serialized."""
3589
assignee_vocabulary = get_assignee_vocabulary(self.context)
4154
3590
# Display the search field only if the user can set any person
3592
user = getUtility(ILaunchBag).user
4157
3593
hide_assignee_team_selection = (
4158
3594
not self.context.userCanSetAnyAssignee(user) and
4159
3595
(user is None or user.teams_participated_in.count() == 0))
4163
row_id=self.data['row_id'],
4164
form_row_id=self.data['form_row_id'],
4165
bugtask_path='/'.join([''] + self.data['link'].split('/')[3:]),
4166
prefix=get_prefix(cx),
4167
targetname=cx.bugtargetdisplayname,
4168
bug_title=cx.bug.title,
4169
assignee_value=cx.assignee and cx.assignee.name,
4170
assignee_is_team=cx.assignee and cx.assignee.is_team,
4171
assignee_vocabulary=assignee_vocabulary,
4172
assignee_vocabulary_filters=filter_details,
4173
hide_assignee_team_selection=hide_assignee_team_selection,
4174
user_can_unassign=cx.userCanUnassign(user),
4175
user_can_delete=self.user_can_delete_bugtask,
4176
delete_link=self.data['delete_link'],
4177
target_is_product=IProduct.providedBy(cx.target),
4178
status_widget_items=self.status_widget_items,
4179
status_value=cx.status.title,
4180
importance_widget_items=self.importance_widget_items,
4181
importance_value=cx.importance.title,
4182
milestone_widget_items=self.milestone_widget_items,
4186
request=self.api_request)
4187
if cx.milestone else None),
4188
user_can_edit_assignee=self.user_can_edit_assignee,
4189
user_can_edit_milestone=self.user_has_privileges,
4190
user_can_edit_status=self.user_can_edit_status,
4191
user_can_edit_importance=self.user_has_privileges,
3597
'row_id': 'tasksummary%s' % self.context.id,
3598
'bugtask_path': '/'.join(
3599
[''] + canonical_url(self.context).split('/')[3:]),
3600
'prefix': get_prefix(self.context),
3601
'assignee_value': self.context.assignee
3602
and self.context.assignee.name,
3603
'assignee_is_team': self.context.assignee
3604
and self.context.assignee.is_team,
3605
'assignee_vocabulary': assignee_vocabulary,
3606
'hide_assignee_team_selection': hide_assignee_team_selection,
3607
'user_can_unassign': self.context.userCanUnassign(user),
3608
'target_is_product': IProduct.providedBy(self.context.target),
3609
'status_widget_items': self.status_widget_items,
3610
'status_value': self.context.status.title,
3611
'importance_widget_items': self.importance_widget_items,
3612
'importance_value': self.context.importance.title,
3613
'milestone_widget_items': self.milestone_widget_items,
3614
'milestone_value': (self.context.milestone and
3616
self.context.milestone,
3617
request=IWebServiceClientRequest(
3620
'user_can_edit_assignee': self.user_can_edit_assignee,
3621
'user_can_edit_milestone': self.user_can_edit_milestone,
3622
'user_can_edit_status': not self.context.bugwatch,
3623
'user_can_edit_importance': (
3624
self.user_can_edit_importance and
3625
not self.context.bugwatch)})
4195
3628
class BugsBugTaskSearchListingView(BugTaskSearchListingView):