~launchpad-pqm/launchpad/devel

« back to all changes in this revision

Viewing changes to lib/lp/bugs/browser/bugtask.py

  • Committer: Curtis Hovey
  • Date: 2011-05-12 18:25:06 UTC
  • mto: This revision was merged to the branch mainline in revision 13038.
  • Revision ID: curtis.hovey@canonical.com-20110512182506-098n1wovp9m1av59
Renamed licence_reviewed to project_reviewed.

Show diffs side-by-side

added added

removed removed

Lines of Context:
18
18
    'BugTaskBreadcrumb',
19
19
    'BugTaskContextMenu',
20
20
    'BugTaskCreateQuestionView',
21
 
    'BugTaskDeletionView',
22
21
    'BugTaskEditView',
23
22
    'BugTaskExpirableListingView',
24
23
    'BugTaskListingItem',
25
24
    'BugTaskListingView',
26
25
    'BugTaskNavigation',
 
26
    'BugTaskPortletView',
27
27
    'BugTaskPrivacyAdapter',
28
28
    'BugTaskRemoveQuestionView',
29
29
    'BugTasksAndNominationsView',
30
30
    'BugTaskSearchListingView',
31
31
    'BugTaskSetNavigation',
 
32
    'BugTaskStatusView',
32
33
    'BugTaskTableRowView',
33
34
    'BugTaskTextView',
34
35
    'BugTaskView',
47
48
    datetime,
48
49
    timedelta,
49
50
    )
50
 
from itertools import groupby
 
51
from itertools import (
 
52
    groupby,
 
53
    )
51
54
from math import (
52
55
    floor,
53
56
    log,
54
57
    )
55
58
from operator import attrgetter
56
 
import os.path
57
59
import re
58
 
import transaction
59
60
import urllib
60
 
import urlparse
61
61
 
62
62
from lazr.delegates import delegates
63
63
from lazr.enum import (
73
73
    IReference,
74
74
    IWebServiceClientRequest,
75
75
    )
76
 
from lazr.restful.utils import smartquote
77
76
from lazr.uri import URI
78
 
import pystache
79
77
from pytz import utc
80
78
from simplejson import dumps
81
 
from simplejson.encoder import JSONEncoderForHTML
82
 
from z3c.pt.pagetemplate import ViewPageTemplateFile
 
79
from storm.expr import SQL
 
80
from z3c.ptcompat import ViewPageTemplateFile
83
81
from zope import (
84
82
    component,
85
83
    formlib,
87
85
from zope.app.form import CustomWidgetFactory
88
86
from zope.app.form.browser.itemswidgets import RadioWidget
89
87
from zope.app.form.interfaces import (
 
88
    IDisplayWidget,
90
89
    IInputWidget,
91
90
    InputErrors,
92
 
    )
93
 
from zope.app.form.utility import setUpWidget
94
 
from zope.app.security.interfaces import IUnauthenticatedPrincipal
 
91
    WidgetsError,
 
92
    )
 
93
from zope.app.form.utility import (
 
94
    setUpWidget,
 
95
    setUpWidgets,
 
96
    )
95
97
from zope.component import (
96
98
    ComponentLookupError,
97
99
    getAdapter,
109
111
from zope.schema import Choice
110
112
from zope.schema.interfaces import (
111
113
    IContextSourceBinder,
 
114
    IList,
112
115
    )
113
116
from zope.schema.vocabulary import (
114
117
    getVocabularyRegistry,
119
122
    isinstance as zope_isinstance,
120
123
    removeSecurityProxy,
121
124
    )
122
 
from zope.traversing.browser import absoluteURL
123
125
from zope.traversing.interfaces import IPathAdapter
124
126
 
125
127
from canonical.config import config
131
133
    BugTargetLatestBugsFeedLink,
132
134
    FeedsMixin,
133
135
    )
134
 
from canonical.launchpad.interfaces.launchpad import IHasExternalBugTracker
 
136
from canonical.launchpad.interfaces.launchpad import (
 
137
    IHasExternalBugTracker,
 
138
    ILaunchpadCelebrities,
 
139
    )
 
140
from canonical.launchpad.interfaces.validation import (
 
141
    valid_upstreamtask,
 
142
    validate_distrotask,
 
143
    )
135
144
from canonical.launchpad.mailnotification import get_unified_diff
136
145
from canonical.launchpad.searchbuilder import (
137
146
    all,
138
147
    any,
139
148
    NULL,
140
149
    )
 
150
from canonical.launchpad.utilities.personroles import PersonRoles
141
151
from canonical.launchpad.webapp import (
142
152
    canonical_url,
143
153
    enabled_with_permission,
149
159
    redirection,
150
160
    stepthrough,
151
161
    )
152
 
from canonical.launchpad.webapp.authorization import (
153
 
    check_permission,
154
 
    precache_permission_for_objects,
155
 
    )
 
162
from canonical.launchpad.webapp.authorization import check_permission
156
163
from canonical.launchpad.webapp.batching import TableBatchNavigator
157
164
from canonical.launchpad.webapp.breadcrumb import Breadcrumb
158
165
from canonical.launchpad.webapp.interfaces import ILaunchBag
159
166
from canonical.launchpad.webapp.menu import structured
160
167
from canonical.lazr.interfaces import IObjectPrivacy
 
168
from canonical.lazr.utils import smartquote
161
169
from lp.answers.interfaces.questiontarget import IQuestionTarget
162
 
from lp.app.browser.launchpad import iter_view_registrations
163
170
from lp.app.browser.launchpadform import (
164
171
    action,
165
172
    custom_widget,
166
173
    LaunchpadEditFormView,
167
174
    LaunchpadFormView,
168
 
    ReturnToReferrerMixin,
169
175
    )
170
176
from lp.app.browser.lazrjs import (
171
177
    TextAreaEditorWidget,
172
178
    TextLineEditorWidget,
173
179
    vocabulary_to_choice_edit_items,
174
180
    )
175
 
from lp.app.browser.stringformatter import FormattersAPI
176
181
from lp.app.browser.tales import (
177
 
    BugTrackerFormatterAPI,
178
 
    DateTimeFormatterAPI,
179
182
    ObjectImageDisplayAPI,
180
183
    PersonFormatterAPI,
181
184
    )
184
187
    NotFoundError,
185
188
    UnexpectedFormData,
186
189
    )
187
 
from lp.app.interfaces.launchpad import (
188
 
    ILaunchpadCelebrities,
189
 
    IServiceUsage,
190
 
    )
 
190
from lp.app.interfaces.launchpad import IServiceUsage
 
191
from lp.app.validators import LaunchpadValidationError
191
192
from lp.app.widgets.itemswidgets import LabeledMultiCheckBoxWidget
192
 
from lp.app.widgets.popup import PersonPickerWidget
193
193
from lp.app.widgets.project import ProjectScopeWidget
194
194
from lp.bugs.browser.bug import (
195
195
    BugContextMenu,
196
196
    BugTextView,
197
197
    BugViewMixin,
198
198
    )
 
199
from lp.bugs.browser.structuralsubscription import (
 
200
    expose_structural_subscription_data_to_js,
 
201
    )
199
202
from lp.bugs.browser.bugcomment import (
200
203
    build_comments_from_chunks,
201
204
    group_comments_with_activity,
202
205
    )
203
 
from lp.bugs.browser.structuralsubscription import (
204
 
    expose_structural_subscription_data_to_js,
205
 
    )
206
206
from lp.bugs.browser.widgets.bug import BugTagsWidget
207
207
from lp.bugs.browser.widgets.bugtask import (
208
208
    AssigneeDisplayWidget,
209
209
    BugTaskAssigneeWidget,
210
210
    BugTaskBugWatchWidget,
211
211
    BugTaskSourcePackageNameWidget,
212
 
    BugTaskTargetWidget,
213
212
    DBItemDisplayWidget,
214
213
    NewLineToSpacesWidget,
215
214
    NominationReviewActionWidget,
227
226
    BugNominationStatus,
228
227
    IBugNominationSet,
229
228
    )
230
 
from lp.bugs.interfaces.bugtarget import ISeriesBugTarget
231
229
from lp.bugs.interfaces.bugtask import (
232
230
    BugBlueprintSearch,
233
231
    BugBranchSearch,
235
233
    BugTaskImportance,
236
234
    BugTaskSearchParams,
237
235
    BugTaskStatus,
238
 
    BugTaskStatusSearch,
239
236
    BugTaskStatusSearchDisplay,
240
 
    CannotDeleteBugtask,
241
237
    DEFAULT_SEARCH_BUGTASK_STATUSES_FOR_DISPLAY,
242
238
    IBugTask,
243
239
    IBugTaskSearch,
244
240
    IBugTaskSet,
245
241
    ICreateQuestionFromBugTaskForm,
 
242
    IDistroBugTask,
 
243
    IDistroSeriesBugTask,
246
244
    IFrontPageBugTaskSearch,
247
 
    IllegalTarget,
248
245
    INominationsReviewTableBatchNavigator,
249
246
    IPersonBugTaskSearch,
 
247
    IProductSeriesBugTask,
250
248
    IRemoveQuestionFromBugTaskForm,
 
249
    IUpstreamBugTask,
251
250
    IUpstreamProductBugTaskSearch,
252
251
    UNRESOLVED_BUGTASK_STATUSES,
253
 
    UserCannotEditBugTaskStatus,
254
252
    )
255
253
from lp.bugs.interfaces.bugtracker import BugTrackerType
256
254
from lp.bugs.interfaces.bugwatch import BugWatchActivityStatus
257
255
from lp.bugs.interfaces.cve import ICveSet
258
256
from lp.bugs.interfaces.malone import IMaloneApplication
259
 
from lp.code.interfaces.branchcollection import IAllBranches
 
257
from lp.bugs.model.bug import Bug
 
258
from lp.bugs.model.bugtask import BugTask
260
259
from lp.registry.interfaces.distribution import (
261
260
    IDistribution,
262
261
    IDistributionSet,
276
275
from lp.registry.interfaces.productseries import IProductSeries
277
276
from lp.registry.interfaces.projectgroup import IProjectGroup
278
277
from lp.registry.interfaces.sourcepackage import ISourcePackage
279
 
from lp.registry.model.personroles import PersonRoles
280
278
from lp.registry.vocabularies import MilestoneVocabulary
281
 
from lp.services.features import getFeatureFlag
282
279
from lp.services.fields import PersonChoice
283
280
from lp.services.propertycache import (
284
281
    cachedproperty,
285
 
    get_property_cache,
286
282
    )
287
 
from lp.services.utils import obfuscate_structure
288
283
 
289
 
vocabulary_registry = getVocabularyRegistry()
290
284
 
291
285
DISPLAY_BUG_STATUS_FOR_PATCHES = {
292
286
    BugTaskStatus.NEW: True,
300
294
    BugTaskStatus.FIXRELEASED: False,
301
295
    BugTaskStatus.UNKNOWN: False,
302
296
    BugTaskStatus.EXPIRED: False,
303
 
    BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE: True,
304
 
    BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE: True,
305
297
    }
306
298
 
307
299
 
334
326
 
335
327
 
336
328
def get_comments_for_bugtask(bugtask, truncate=False, for_display=False,
337
 
    slice_info=None, show_spam_controls=False, user=None):
 
329
    slice_info=None):
338
330
    """Return BugComments related to a bugtask.
339
331
 
340
332
    This code builds a sorted list of BugComments in one shot,
347
339
        to retrieve.
348
340
    """
349
341
    comments = build_comments_from_chunks(bugtask, truncate=truncate,
350
 
        slice_info=slice_info, show_spam_controls=show_spam_controls,
351
 
        user=user)
 
342
        slice_info=slice_info)
352
343
    # TODO: further fat can be shaved off here by limiting the attachments we
353
344
    # query to those that slice_info would include.
354
345
    for attachment in bugtask.bug.attachments_unpopulated:
372
363
            # this comment has a new title, so make that the rolling focus
373
364
            current_title = comment.title
374
365
            comment.display_title = True
375
 
    if for_display and comments and comments[0].index == 0:
 
366
    if for_display and comments and comments[0].index==0:
376
367
        # We show the text of the first comment as the bug description,
377
368
        # or via the special link "View original description", but we want
378
369
        # to display attachments filed together with the bug in the
574
565
        if name.isdigit():
575
566
            attachment = getUtility(IBugAttachmentSet)[name]
576
567
            if attachment is not None and attachment.bug == self.context.bug:
577
 
                return self.redirectSubTree(
578
 
                    canonical_url(attachment), status=301)
 
568
                return redirection(canonical_url(attachment), status=301)
579
569
 
580
570
    @stepthrough('+attachment')
581
571
    def traverse_attachment(self, name):
659
649
    def page_title(self):
660
650
        heading = 'Bug #%s in %s' % (
661
651
            self.context.bug.id, self.context.bugtargetdisplayname)
662
 
        title = FormattersAPI(self.context.bug.title).obfuscate_email()
663
 
        return smartquote('%s: "%s"') % (heading, title)
664
 
 
665
 
    @cachedproperty
666
 
    def page_description(self):
667
 
        return IBug(self.context).description
 
652
        return smartquote('%s: "%s"') % (heading, self.context.bug.title)
668
653
 
669
654
    @property
670
655
    def next_url(self):
692
677
            cancel_url = canonical_url(self.context)
693
678
        return cancel_url
694
679
 
695
 
    @cachedproperty
696
 
    def api_request(self):
697
 
        return IWebServiceClientRequest(self.request)
698
 
 
699
 
    @cachedproperty
700
 
    def recommended_canonical_url(self):
701
 
        return canonical_url(self.context.bug, rootsite='bugs')
702
 
 
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),
711
 
            }
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
719
684
 
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))
759
724
 
 
725
    def isSeriesTargetableContext(self):
 
726
        """Is the context something that supports Series targeting?
 
727
 
 
728
        Returns True or False.
 
729
        """
 
730
        return (
 
731
            IDistroBugTask.providedBy(self.context) or
 
732
            IDistroSeriesBugTask.providedBy(self.context))
 
733
 
760
734
    @cachedproperty
761
735
    def comments(self):
762
736
        """Return the bugtask's comments."""
763
 
        return self._getComments()
764
 
 
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,
771
 
            user=self.user)
 
737
        return get_comments_for_bugtask(self.context, truncate=True,
 
738
            for_display=True)
772
739
 
773
740
    @cachedproperty
774
741
    def interesting_activity(self):
775
 
        return self._getInterestingActivity()
776
 
 
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)
787
 
        else:
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(
 
751
        return 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
805
 
 
806
 
    def _getEventGroups(self, batch_size=None, offset=None):
 
755
 
 
756
    @cachedproperty
 
757
    def activity_and_comments(self):
 
758
        """Build list of comments interleaved with activities
 
759
 
 
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
 
763
        that day.
 
764
 
 
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.
 
767
 
 
768
        The division between the most recent and oldest is marked by an entry
 
769
        in the list with the key 'num_hidden' defined.
 
770
        """
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)
811
775
 
812
 
        if (not self.visible_comments_truncated_for_display and
813
 
            batch_size is None):
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.
819
 
            if offset is None:
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
823
778
        else:
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
826
781
            # to adjust.
827
782
            oldest_count = 1 + self.visible_initial_comments
828
 
            new_count = 1 + self.total_comments - self.visible_recent_comments
829
 
            slice_info = [
830
 
                slice(None, oldest_count),
831
 
                slice(new_count, None),
832
 
                ]
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,
 
786
                slice_info=[
 
787
                    slice(None, oldest_count), slice(new_count, None)])
834
788
 
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))
844
 
        else:
845
 
            interesting_activity = self.interesting_activity
846
791
 
847
792
        event_groups = group_comments_with_activity(
848
793
            comments=visible_comments,
849
 
            activities=interesting_activity)
850
 
        return event_groups
851
 
 
852
 
    @cachedproperty
853
 
    def _event_groups(self):
854
 
        """Return a sorted list of event groups for the current BugTask.
855
 
 
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
859
 
        go.
860
 
        """
861
 
        return self._getEventGroups()
862
 
 
863
 
    @cachedproperty
864
 
    def activity_and_comments(self):
865
 
        """Build list of comments interleaved with activities
866
 
 
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
870
 
        that day.
871
 
 
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.
874
 
 
875
 
        The division between the most recent and oldest is marked by an entry
876
 
        in the list with the key 'num_hidden' defined.
877
 
        """
878
 
        event_groups = self._event_groups
 
794
            activities=self.interesting_activity)
879
795
 
880
796
        def group_activities_by_target(activities):
881
797
            activities = sorted(
968
884
        """We count all comments because the db cannot do visibility yet."""
969
885
        return self.context.bug.bug_messages.count() - 1
970
886
 
971
 
    @cachedproperty
972
 
    def total_activity(self):
973
 
        """Return the count of all activity items for the bug."""
974
 
        # Ignore the first activity item, since it relates to the bug's
975
 
        # creation.
976
 
        return self.context.bug.activity.count() - 1
977
 
 
978
887
    def wasDescriptionModified(self):
979
888
        """Return a boolean indicating whether the description was modified"""
980
889
        return (self.context.bug._indexed_messages(
984
893
    @cachedproperty
985
894
    def linked_branches(self):
986
895
        """Filter out the bug_branch links to non-visible private branches."""
987
 
        linked_branches = list(
988
 
            self.context.bug.getVisibleLinkedBranches(
989
 
                self.user, eager_load=True))
990
 
        # This is an optimization for when we look at the merge proposals.
991
 
        if linked_branches:
992
 
            list(getUtility(IAllBranches).getMergeProposals(
993
 
                for_branches=[link.branch for link in linked_branches],
994
 
                eager_load=True))
 
896
        linked_branches = []
 
897
        for linked_branch in self.context.bug.linked_branches:
 
898
            if check_permission('launchpad.View', linked_branch.branch):
 
899
                linked_branches.append(linked_branch)
995
900
        return linked_branches
996
901
 
997
902
    @property
1117
1022
        max_bug_heat = 5000
1118
1023
    heat_ratio = calculate_heat_display(bugtask.bug.heat, max_bug_heat)
1119
1024
    html = (
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>'
1123
1028
        '</span>'
1124
1029
        % {'ratio': heat_ratio, 'heat': bugtask.bug.heat})
1125
1030
    return html
1126
1031
 
1127
1032
 
1128
 
class BugTaskBatchedCommentsAndActivityView(BugTaskView):
1129
 
    """A view for displaying batches of bug comments and activity."""
1130
 
 
1131
 
    # We never truncate comments in this view; there would be no point.
1132
 
    visible_comments_truncated_for_display = False
1133
 
 
1134
 
    @property
1135
 
    def offset(self):
1136
 
        try:
1137
 
            return int(self.request.form_ng.getOne('offset'))
1138
 
        except TypeError:
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
1145
 
 
1146
 
    @property
1147
 
    def batch_size(self):
1148
 
        try:
1149
 
            return int(self.request.form_ng.getOne('batch_size'))
1150
 
        except TypeError:
1151
 
            return config.malone.comments_list_default_batch_size
1152
 
 
1153
 
    @property
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)
1158
 
 
1159
 
    @property
1160
 
    def next_offset(self):
1161
 
        return self.offset + self.batch_size
1162
 
 
1163
 
    @property
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
1171
 
            # re-show things.
1172
 
            if self.offset == self.visible_initial_comments + 1:
1173
 
                offset_to_remove = self.visible_initial_comments
1174
 
            else:
1175
 
                offset_to_remove = self.offset
1176
 
            batch_size = (
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.
1180
 
                offset_to_remove)
1181
 
        return self._getEventGroups(
1182
 
            batch_size=batch_size, offset=self.offset)
1183
 
 
1184
 
    @cachedproperty
1185
 
    def has_more_comments_and_activity(self):
1186
 
        """Return True if there are more camments and activity to load."""
1187
 
        return (
1188
 
            self.next_offset < (self.total_comments + self.total_activity))
 
1033
class BugTaskPortletView:
 
1034
    """A portlet for displaying a bug's bugtasks."""
 
1035
 
 
1036
    def alsoReportedIn(self):
 
1037
        """Return a list of IUpstreamBugTasks in which this bug is reported.
 
1038
 
 
1039
        If self.context is an IUpstreamBugTasks, it will be excluded
 
1040
        from this list.
 
1041
        """
 
1042
        return [
 
1043
            task for task in self.context.bug.bugtasks
 
1044
            if task.id is not self.context.id]
1189
1045
 
1190
1046
 
1191
1047
def get_prefix(bugtask):
1197
1053
    keeping the field ids unique.
1198
1054
    """
1199
1055
    parts = []
1200
 
    parts.append(bugtask.pillar.name)
1201
 
 
1202
 
    series = bugtask.productseries or bugtask.distroseries
1203
 
    if series:
1204
 
        parts.append(series.name)
1205
 
 
1206
 
    if bugtask.sourcepackagename is not None:
1207
 
        parts.append(bugtask.sourcepackagename.name)
1208
 
 
 
1056
    if IUpstreamBugTask.providedBy(bugtask):
 
1057
        parts.append(bugtask.product.name)
 
1058
 
 
1059
    elif IProductSeriesBugTask.providedBy(bugtask):
 
1060
        parts.append(bugtask.productseries.name)
 
1061
        parts.append(bugtask.productseries.product.name)
 
1062
 
 
1063
    elif IDistroBugTask.providedBy(bugtask):
 
1064
        parts.append(bugtask.distribution.name)
 
1065
        if bugtask.sourcepackagename is not None:
 
1066
            parts.append(bugtask.sourcepackagename.name)
 
1067
 
 
1068
    elif IDistroSeriesBugTask.providedBy(bugtask):
 
1069
        parts.append(bugtask.distroseries.distribution.name)
 
1070
        parts.append(bugtask.distroseries.name)
 
1071
 
 
1072
        if bugtask.sourcepackagename is not None:
 
1073
            parts.append(bugtask.sourcepackagename.name)
 
1074
 
 
1075
    else:
 
1076
        raise AssertionError("Unknown IBugTask: %r" % bugtask)
1209
1077
    return '_'.join(parts)
1210
1078
 
1211
1079
 
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'
1216
1084
    else:
1217
 
        vocab_name = 'AllUserTeamsParticipation'
1218
 
    vocab = vocabulary_registry.get(None, vocab_name)
1219
 
    return vocab_name, vocab.supportedFilters()
 
1085
        return 'AllUserTeamsParticipation'
1220
1086
 
1221
1087
 
1222
1088
class BugTaskBugWatchMixin:
1223
1089
    """A mixin to be used where a BugTask view displays BugWatch data."""
1224
1090
 
1225
 
    @cachedproperty
 
1091
    @property
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:
1276
1142
            }
1277
1143
 
1278
1144
 
1279
 
class BugTaskPrivilegeMixin:
1280
 
 
1281
 
    @cachedproperty
1282
 
    def user_has_privileges(self):
1283
 
        """Is the user privileged? That is, an admin, pillar owner, driver
1284
 
        or bug supervisor.
1285
 
 
1286
 
        If yes, return True, otherwise return False.
1287
 
        """
1288
 
        return self.context.userHasBugSupervisorPrivileges(self.user)
1289
 
 
1290
 
 
1291
 
class BugTaskEditView(LaunchpadEditFormView, BugTaskBugWatchMixin,
1292
 
                      BugTaskPrivilegeMixin):
 
1145
class BugTaskEditView(LaunchpadEditFormView, BugTaskBugWatchMixin):
1293
1146
    """The view class used for the task +editstatus page."""
1294
1147
 
1295
1148
    schema = IBugTask
1297
1150
    user_is_subscribed = None
1298
1151
    edit_form = ViewPageTemplateFile('../templates/bugtask-edit-form.pt')
1299
1152
 
1300
 
    _next_url_override = None
1301
 
 
1302
1153
    # The field names that we use by default. This list will be mutated
1303
1154
    # depending on the current context and the permissions of the user viewing
1304
1155
    # the form.
1305
1156
    default_field_names = ['assignee', 'bugwatch', 'importance', 'milestone',
1306
 
                           'status']
1307
 
    custom_widget('target', BugTaskTargetWidget)
 
1157
                           'product', 'sourcepackagename', 'status',
 
1158
                           'statusexplanation']
1308
1159
    custom_widget('sourcepackagename', BugTaskSourcePackageNameWidget)
1309
1160
    custom_widget('bugwatch', BugTaskBugWatchWidget)
1310
1161
    custom_widget('assignee', BugTaskAssigneeWidget)
1317
1168
 
1318
1169
    page_title = 'Edit status'
1319
1170
 
1320
 
    @property
1321
 
    def show_target_widget(self):
1322
 
        # Only non-series tasks can be retargetted.
1323
 
        return not ISeriesBugTarget.providedBy(self.context.target)
1324
 
 
1325
 
    @property
1326
 
    def show_sourcepackagename_widget(self):
1327
 
        # SourcePackage tasks can have only their sourcepackagename changed.
1328
 
        # Conjoinment means we can't rely on editing the
1329
 
        # DistributionSourcePackage task for this :(
1330
 
        return (IDistroSeries.providedBy(self.context.target) or
1331
 
                ISourcePackage.providedBy(self.context.target))
1332
 
 
1333
1171
    @cachedproperty
1334
1172
    def field_names(self):
1335
1173
        """Return the field names that can be edited by the user."""
1354
1192
 
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")
 
1198
 
 
1199
            if ('importance' in editable_field_names and
 
1200
                not self.userCanEditImportance()):
 
1201
                editable_field_names.remove("importance")
1362
1202
        else:
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')
1369
1217
            else:
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')
1376
1224
 
1377
 
        if self.show_target_widget:
1378
 
            editable_field_names.add('target')
1379
 
        elif self.show_sourcepackagename_widget:
1380
 
            editable_field_names.add('sourcepackagename')
1381
 
 
1382
1225
        # To help with caching, return an immutable object.
1383
1226
        return frozenset(editable_field_names)
1384
1227
 
1393
1236
    @property
1394
1237
    def next_url(self):
1395
1238
        """See `LaunchpadFormView`."""
1396
 
        if self._next_url_override is None:
1397
 
            return canonical_url(self.context)
1398
 
        else:
1399
 
            return self._next_url_override
 
1239
        return canonical_url(self.context)
1400
1240
 
1401
1241
    @property
1402
1242
    def initial_values(self):
1426
1266
        super(BugTaskEditView, self).setUpFields()
1427
1267
        read_only_field_names = self._getReadOnlyFieldNames()
1428
1268
 
1429
 
        if 'target' in self.editable_field_names:
1430
 
            self.form_fields = self.form_fields.omit('target')
1431
 
            target_field = copy_field(IBugTask['target'], readonly=False)
1432
 
            self.form_fields += formlib.form.Fields(target_field)
1433
 
 
1434
1269
        # The status field is a special case because we alter the vocabulary
1435
1270
        # it uses based on the permissions of the user viewing form.
1436
1271
        if 'status' in self.editable_field_names:
1513
1348
            self.form_fields.get('assignee', False)):
1514
1349
            # Make the assignee field editable
1515
1350
            self.form_fields = self.form_fields.omit('assignee')
1516
 
            vocabulary, ignored = get_assignee_vocabulary_info(self.context)
1517
1351
            self.form_fields += formlib.form.Fields(PersonChoice(
1518
1352
                __name__='assignee', title=_('Assigned to'), required=False,
1519
 
                vocabulary=vocabulary, readonly=False))
 
1353
                vocabulary=get_assignee_vocabulary(self.context),
 
1354
                readonly=False))
1520
1355
            self.form_fields['assignee'].custom_widget = CustomWidgetFactory(
1521
1356
                BugTaskAssigneeWidget)
1522
1357
 
1525
1360
        if self.context.target_uses_malone:
1526
1361
            read_only_field_names = []
1527
1362
 
1528
 
            if not self.user_has_privileges:
 
1363
            if not self.userCanEditMilestone():
1529
1364
                read_only_field_names.append("milestone")
 
1365
 
 
1366
            if not self.userCanEditImportance():
1530
1367
                read_only_field_names.append("importance")
1531
1368
        else:
1532
1369
            editable_field_names = self.editable_field_names
1536
1373
 
1537
1374
        return read_only_field_names
1538
1375
 
 
1376
    def userCanEditMilestone(self):
 
1377
        """Can the user edit the Milestone field?
 
1378
 
 
1379
        If yes, return True, otherwise return False.
 
1380
        """
 
1381
        return self.context.userCanEditMilestone(self.user)
 
1382
 
 
1383
    def userCanEditImportance(self):
 
1384
        """Can the user edit the Importance field?
 
1385
 
 
1386
        If yes, return True, otherwise return False.
 
1387
        """
 
1388
        return self.context.userCanEditImportance(self.user)
 
1389
 
 
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
 
1399
        else:
 
1400
            return bugtask.distroseries.distribution
 
1401
 
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')
1543
 
            if spn:
1544
 
                data['target'] = data['target'].getSourcePackage(spn)
1545
 
            del data['sourcepackagename']
1546
 
            error_field = 'sourcepackagename'
1547
 
        else:
1548
 
            error_field = 'target'
1549
 
 
1550
 
        new_target = data.get('target')
1551
 
        if new_target and new_target != self.context.target:
1552
 
            try:
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
 
1407
        else:
 
1408
            distro = bugtask.distribution
 
1409
        sourcename = bugtask.sourcepackagename
 
1410
        old_product = bugtask.product
 
1411
 
 
1412
        if distro is not None and sourcename != data.get('sourcepackagename'):
 
1413
            try:
 
1414
                validate_distrotask(
 
1415
                    bugtask.bug, distro, data.get('sourcepackagename'))
 
1416
            except LaunchpadValidationError, error:
 
1417
                self.setFieldError('sourcepackagename', str(error))
 
1418
 
 
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.
 
1425
            return
 
1426
 
 
1427
        if new_product is None:
 
1428
            self.setFieldError('product', 'Enter a project name')
 
1429
        else:
 
1430
            try:
 
1431
                valid_upstreamtask(bugtask.bug, new_product)
 
1432
            except WidgetsError, errors:
 
1433
                self.setFieldError('product', errors.args[0])
1556
1434
 
1557
1435
    def updateContextFromData(self, data, context=None):
1558
1436
        """Updates the context object using the submitted form data.
1568
1446
        if self.request.form.get('subscribe', False):
1569
1447
            bugtask.bug.subscribe(self.user, self.user)
1570
1448
            self.request.response.addNotification(
1571
 
                "You have subscribed to this bug report.")
 
1449
                "You have been subscribed to this bug.")
1572
1450
 
1573
1451
        # Save the field names we extract from the form in a separate
1574
1452
        # list, because we modify this list of names later if the
1584
1462
        # product, we'll clear out the milestone value, to avoid
1585
1463
        # violating DB constraints that ensure an upstream task can't
1586
1464
        # be assigned to a milestone on a different product.
1587
 
        # This is also done by transitionToTarget, but do it here so we
1588
 
        # can display notifications and remove the milestone from the
1589
 
        # submitted data.
1590
1465
        milestone_cleared = None
1591
1466
        milestone_ignored = False
1592
 
        missing = object()
1593
 
        new_target = new_values.pop("target", missing)
1594
 
        if (new_target is not missing and
1595
 
            bugtask.target.pillar != new_target.pillar):
 
1467
        if (IUpstreamBugTask.providedBy(bugtask) and
 
1468
            (bugtask.product != new_values.get("product"))):
1596
1469
            # We clear the milestone value if one was already set. We ignore
1597
1470
            # the milestone value if it was currently None, and the user tried
1598
1471
            # to set a milestone value while also changing the product. This
1611
1484
            # what it was!
1612
1485
            data_to_apply.pop('milestone', None)
1613
1486
 
1614
 
        # We special case setting target, status and assignee, because
1615
 
        # there's a workflow associated with changes to these fields.
1616
 
        for manual_field in ('target', 'status', 'assignee'):
1617
 
            data_to_apply.pop(manual_field, None)
 
1487
        # We special case setting assignee and status, because there's
 
1488
        # a workflow associated with changes to these fields.
 
1489
        if "assignee" in data_to_apply:
 
1490
            del data_to_apply["assignee"]
 
1491
        if "status" in data_to_apply:
 
1492
            del data_to_apply["status"]
1618
1493
 
1619
1494
        # We grab the comment_on_change field before we update bugtask so as
1620
1495
        # to avoid problems accessing the field if the user has changed the
1625
1500
        changed = formlib.form.applyChanges(
1626
1501
            bugtask, self.form_fields, data_to_apply, self.adapters)
1627
1502
 
1628
 
        # Set the "changed" flag properly, just in case status and/or assignee
1629
 
        # happen to be the only values that changed. We explicitly verify that
1630
 
        # we got a new status and/or assignee, because the form is not always
1631
 
        # guaranteed to pass all the values. For example: bugtasks linked to a
1632
 
        # bug watch don't allow editing the form, and the value is missing
1633
 
        # from the form.
1634
 
        if new_target is not missing and bugtask.target != new_target:
1635
 
            changed = True
1636
 
            bugtask.transitionToTarget(new_target)
1637
 
 
1638
1503
        # Now that we've updated the bugtask we can add messages about
1639
1504
        # milestone changes, if there were any.
1640
1505
        if milestone_cleared:
1655
1520
                subject=bugtask.bug.followup_subject(),
1656
1521
                content=comment_on_change)
1657
1522
 
 
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
 
1528
        # from the form.
 
1529
        missing = object()
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:
1661
1533
            changed = True
1662
 
            try:
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.
1667
 
                transaction.abort()
1668
 
                self.setFieldError(
1669
 
                    'status',
1670
 
                    "Only the Bug Supervisor for %s can set the bug's "
1671
 
                    "status to %s" %
1672
 
                    (bugtask.target.displayname, new_status.title))
1673
 
                return
 
1534
            bugtask.transitionToStatus(new_status, self.user)
1674
1535
 
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:
1688
1549
                        <br /><br />
1689
1550
                        If this bug was assigned by mistake,
1690
1551
                        you may <a href="%s/+editstatus"
1691
 
                        >change the assignment</a>.""",
 
1552
                        >change the assignment</a>.""" % (
1692
1553
                        canonical_url(new_assignee),
1693
1554
                        new_assignee.displayname,
1694
1555
                        canonical_url(bugtask.pillar),
1695
1556
                        bugtask.pillar.title,
1696
 
                        canonical_url(bugtask)))
 
1557
                        canonical_url(bugtask))))
1697
1558
            changed = True
1698
1559
            bugtask.transitionToAssignee(new_assignee)
1699
1560
 
1721
1582
                bugtask.transitionToAssignee(None)
1722
1583
 
1723
1584
        if changed:
 
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
 
1590
            else:
 
1591
                bugtask.statusexplanation = ""
 
1592
 
1724
1593
            notify(
1725
1594
                ObjectModifiedEvent(
1726
1595
                    object=bugtask,
1727
1596
                    object_before_modification=bugtask_before_modification,
1728
1597
                    edited_fields=field_names))
1729
1598
 
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')
1742
 
 
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
1747
1601
 
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
1754
 
            else:
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)
1757
1608
 
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})
1767
1618
 
 
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))
 
1626
 
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)
1772
1631
 
1773
1632
 
1774
 
class BugTaskDeletionView(ReturnToReferrerMixin, LaunchpadFormView):
1775
 
    """Used to delete a bugtask."""
1776
 
 
1777
 
    schema = IBugTask
1778
 
    field_names = []
1779
 
 
1780
 
    label = 'Remove bug task'
1781
 
    page_title = label
1782
 
 
1783
 
    @property
1784
 
    def next_url(self):
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
1788
 
        return None
1789
 
 
1790
 
    @action('Delete', name='delete_bugtask')
1791
 
    def delete_bugtask_action(self, action, data):
1792
 
        bugtask = self.context
1793
 
        bug = bugtask.bug
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
1798
 
 
1799
 
        try:
1800
 
            bugtask.delete()
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:
1806
 
            if error_message:
1807
 
                self.request.response.setHeader('Content-type',
1808
 
                    'application/json')
1809
 
                return dumps(None)
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',
1821
 
                    'application/json')
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')
1827
 
            view.initialize()
1828
 
            return view.render()
 
1633
class BugTaskStatusView(LaunchpadView):
 
1634
    """Viewing the status of a bug task."""
 
1635
 
 
1636
    page_title = 'View status'
 
1637
 
 
1638
    def initialize(self):
 
1639
        """Set up the appropriate widgets.
 
1640
 
 
1641
        Different widgets are shown depending on if it's a remote bug
 
1642
        task or not.
 
1643
        """
 
1644
        field_names = [
 
1645
            'status', 'importance', 'assignee', 'statusexplanation']
 
1646
        if not self.context.target_uses_malone:
 
1647
            field_names += ['bugwatch']
 
1648
            self.milestone_widget = None
 
1649
        else:
 
1650
            field_names += ['milestone']
 
1651
            self.bugwatch_widget = None
 
1652
 
 
1653
        if not IUpstreamBugTask.providedBy(self.context):
 
1654
            field_names += ['sourcepackagename']
 
1655
 
 
1656
        self.assignee_widget = CustomWidgetFactory(AssigneeDisplayWidget)
 
1657
        self.status_widget = CustomWidgetFactory(DBItemDisplayWidget)
 
1658
        self.importance_widget = CustomWidgetFactory(DBItemDisplayWidget)
 
1659
 
 
1660
        setUpWidgets(self, IBugTask, IDisplayWidget, names=field_names)
1829
1661
 
1830
1662
 
1831
1663
class BugTaskListingView(LaunchpadView):
1969
1801
            return get_buglisting_search_filter_url(assignee=self.user.name)
1970
1802
 
1971
1803
    @property
1972
 
    def my_affecting_bugs_url(self):
1973
 
        """A URL to a list of bugs affecting the current user, or None if
1974
 
        there is no current user.
1975
 
        """
1976
 
        if self.user is None:
1977
 
            return None
1978
 
        return get_buglisting_search_filter_url(
1979
 
            affecting_me=True,
1980
 
            orderby='-date_last_updated')
1981
 
 
1982
 
    @property
1983
1804
    def my_reported_bugs_url(self):
1984
1805
        """A URL to a list of bugs reported by the user, or None."""
1985
1806
        if self.user is None:
1995
1816
 
1996
1817
    @cachedproperty
1997
1818
    def _bug_stats(self):
1998
 
        # Circular fail.
1999
 
        from lp.bugs.model.bugsummary import BugSummary
2000
1819
        bug_task_set = getUtility(IBugTaskSet)
2001
 
        groups = (BugSummary.status, BugSummary.importance,
2002
 
            BugSummary.has_patch, BugSummary.fixed_upstream)
2003
 
        counts = bug_task_set.countBugs(self.user, [self.context], groups)
 
1820
        upstream_open_bugs = bug_task_set.open_bugtask_search
 
1821
        upstream_open_bugs.setTarget(self.context)
 
1822
        upstream_open_bugs.resolved_upstream = True
 
1823
        fixed_upstream_clause = SQL(
 
1824
            bug_task_set.buildUpstreamClause(upstream_open_bugs))
 
1825
        open_bugs = bug_task_set.open_bugtask_search
 
1826
        open_bugs.setTarget(self.context)
 
1827
        groups = (BugTask.status, BugTask.importance,
 
1828
            Bug.latest_patch_uploaded != None, fixed_upstream_clause)
 
1829
        counts = bug_task_set.countBugs(open_bugs, groups)
2004
1830
        # Sum the split out aggregates.
2005
1831
        new = 0
2006
1832
        open = 0
2067
1893
        The bugtarget may be an `IDistribution`, `IDistroSeries`, `IProduct`,
2068
1894
        or `IProductSeries`.
2069
1895
        """
 
1896
        days_old = config.malone.days_before_expiration
 
1897
 
2070
1898
        if target_has_expirable_bugs_listing(self.context):
2071
1899
            return getUtility(IBugTaskSet).findExpirableBugTasks(
2072
 
                0, user=self.user, target=self.context).count()
 
1900
                days_old, user=self.user, target=self.context).count()
2073
1901
        else:
2074
1902
            return None
2075
1903
 
2118
1946
        return self.context.searchTasks(params).count()
2119
1947
 
2120
1948
    @property
2121
 
    def my_affecting_bugs_count(self):
2122
 
        """A count of bugs affecting the user, or None."""
2123
 
        if self.user is None:
2124
 
            return None
2125
 
        params = get_default_search_params(self.user)
2126
 
        params.affects_me = True
2127
 
        return self.context.searchTasks(params).count()
2128
 
 
2129
 
    @property
2130
1949
    def bugs_with_patches_count(self):
2131
1950
        """A count of unresolved bugs with patches."""
2132
1951
        return self._bug_stats['with_patch']
2142
1961
 
2143
1962
def get_buglisting_search_filter_url(
2144
1963
        assignee=None, importance=None, status=None, status_upstream=None,
2145
 
        has_patches=None, bug_reporter=None,
2146
 
        affecting_me=None,
2147
 
        orderby=None):
 
1964
        has_patches=None, bug_reporter=None):
2148
1965
    """Return the given URL with the search parameters specified."""
2149
1966
    search_params = []
2150
1967
 
2160
1977
        search_params.append(('field.has_patch', 'on'))
2161
1978
    if bug_reporter is not None:
2162
1979
        search_params.append(('field.bug_reporter', bug_reporter))
2163
 
    if affecting_me is not None:
2164
 
        search_params.append(('field.affects_me', 'on'))
2165
 
    if orderby is not None:
2166
 
        search_params.append(('orderby', orderby))
2167
1980
 
2168
1981
    query_string = urllib.urlencode(search_params, doseq=True)
2169
1982
 
2174
1987
    return search_filter_url
2175
1988
 
2176
1989
 
 
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.
 
1993
 
 
1994
    >>> initial = getInitialValuesFromSearchParams(
 
1995
    ...     {'status': any(*UNRESOLVED_BUGTASK_STATUSES)}, IBugTaskSearch)
 
1996
    >>> for status in initial['status']:
 
1997
    ...     print status.name
 
1998
    NEW
 
1999
    INCOMPLETE
 
2000
    CONFIRMED
 
2001
    TRIAGED
 
2002
    INPROGRESS
 
2003
    FIXCOMMITTED
 
2004
 
 
2005
    >>> initial = getInitialValuesFromSearchParams(
 
2006
    ...     {'status': BugTaskStatus.INVALID}, IBugTaskSearch)
 
2007
    >>> [status.name for status in initial['status']]
 
2008
    ['INVALID']
 
2009
 
 
2010
    >>> initial = getInitialValuesFromSearchParams(
 
2011
    ...     {'importance': [BugTaskImportance.CRITICAL,
 
2012
    ...                   BugTaskImportance.HIGH]}, IBugTaskSearch)
 
2013
    >>> [importance.name for importance in initial['importance']]
 
2014
    ['CRITICAL', 'HIGH']
 
2015
 
 
2016
    >>> getInitialValuesFromSearchParams(
 
2017
    ...     {'assignee': NULL}, IBugTaskSearch)
 
2018
    {'assignee': None}
 
2019
    """
 
2020
    initial = {}
 
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)):
 
2026
                value = value
 
2027
            else:
 
2028
                value = [value]
 
2029
        elif value == NULL:
 
2030
            value = None
 
2031
        else:
 
2032
            # Should be safe to pass value as it is to setUpWidgets, no need
 
2033
            # to worry
 
2034
            pass
 
2035
 
 
2036
        initial[key] = value
 
2037
 
 
2038
    return initial
 
2039
 
 
2040
 
2177
2041
class BugTaskListingItem:
2178
2042
    """A decorated bug task.
2179
2043
 
2206
2070
        """Returns the bug heat flames HTML."""
2207
2071
        return bugtask_heat_html(self.bugtask, target=self.target_context)
2208
2072
 
2209
 
    @property
2210
 
    def model(self):
2211
 
        """Provide flattened data about bugtask for simple templaters."""
2212
 
        age = DateTimeFormatterAPI(self.bug.datecreated).durationsince()
2213
 
        age += ' old'
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
2224
 
        else:
2225
 
            milestone_name = None
2226
 
        assignee = None
2227
 
        if self.assignee is not None:
2228
 
            assignee = self.assignee.displayname
2229
 
        return {
2230
 
            'age': age,
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,
2236
 
            'badges': badges,
2237
 
            'id': self.bug.id,
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,
2247
 
            }
2248
 
 
2249
2073
 
2250
2074
class BugListingBatchNavigator(TableBatchNavigator):
2251
2075
    """A specialised batch navigator to load smartly extra bug information."""
2256
2080
        # rules to a mixin so that MilestoneView and others can use it.
2257
2081
        self.request = request
2258
2082
        self.target_context = target_context
2259
 
        self.user = getUtility(ILaunchBag).user
2260
 
        self.field_visibility_defaults = {
2261
 
            'show_datecreated': False,
2262
 
            'show_assignee': False,
2263
 
            'show_targetname': True,
2264
 
            'show_heat': True,
2265
 
            'show_id': True,
2266
 
            'show_importance': True,
2267
 
            'show_date_last_updated': False,
2268
 
            'show_milestone_name': False,
2269
 
            'show_reporter': False,
2270
 
            'show_status': True,
2271
 
            'show_tag': False,
2272
 
        }
2273
 
        self.field_visibility = None
2274
 
        self._setFieldVisibility()
2275
2083
        TableBatchNavigator.__init__(
2276
2084
            self, tasks, request, columns_to_show=columns_to_show, size=size)
2277
2085
 
2280
2088
        return getUtility(IBugTaskSet).getBugTaskBadgeProperties(
2281
2089
            self.currentBatch())
2282
2090
 
2283
 
    def getCookieName(self):
2284
 
        """Return the cookie name used in bug listings js code."""
2285
 
        cookie_name_template = '%s-buglist-fields'
2286
 
        cookie_name = ''
2287
 
        if self.user is not None:
2288
 
            cookie_name = cookie_name_template % self.user.name
2289
 
        else:
2290
 
            cookie_name = cookie_name_template % 'anon'
2291
 
        return cookie_name
2292
 
 
2293
 
    def _setFieldVisibility(self):
2294
 
        """Set field_visibility for the page load.
2295
 
 
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.
2299
 
        """
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.
2306
 
        if cookie is None:
2307
 
            return
2308
 
        for field, value in urlparse.parse_qsl(cookie):
2309
 
            # Skip unsupported fields (from old cookies).
2310
 
            if field not in self.field_visibility:
2311
 
                continue
2312
 
            # We only record True or False for field values.
2313
 
            self.field_visibility[field] = (value == 'true')
2314
 
 
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]
2337
2113
 
2338
 
    @cachedproperty
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()
2344
 
 
2345
 
    @property
2346
 
    def mustache_listings(self):
2347
 
        return 'LP.mustache_listings = %s;' % dumps(
2348
 
            self.mustache_template, cls=JSONEncoderForHTML)
2349
 
 
2350
 
    @property
2351
 
    def mustache(self):
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'])
2358
 
 
2359
 
    @property
2360
 
    def model(self):
2361
 
        bugtasks = [bugtask.model for bugtask in self.getBugListingItems()]
2362
 
        for bugtask in bugtasks:
2363
 
            bugtask.update(self.field_visibility)
2364
 
        return {'bugtasks': bugtasks}
2365
 
 
2366
2114
 
2367
2115
class NominatedBugReviewAction(EnumeratedType):
2368
2116
    """Enumeration for nomination review actions"""
2444
2192
        bug_target = self.context.context
2445
2193
        if IDistribution.providedBy(bug_target):
2446
2194
            return (
 
2195
                'bugsupervisor',
 
2196
                'securitycontact',
2447
2197
                'cve',
2448
2198
                )
2449
2199
        elif IDistroSeries.providedBy(bug_target):
2453
2203
                )
2454
2204
        elif IProduct.providedBy(bug_target):
2455
2205
            return (
 
2206
                'bugsupervisor',
 
2207
                'securitycontact',
2456
2208
                'cve',
2457
2209
                )
2458
2210
        elif IProductSeries.providedBy(bug_target):
2483
2235
 
2484
2236
    implements(IBugTaskSearchListingMenu)
2485
2237
 
2486
 
    related_features = (
2487
 
        'bugs.dynamic_bug_listings.enabled',
2488
 
        'bugs.dynamic_bug_listings.pre_fetch',
2489
 
    )
2490
 
 
2491
2238
    # Only include <link> tags for bug feeds when using this view.
2492
2239
    feed_types = (
2493
2240
        BugTargetLatestBugsFeedLink,
2501
2248
    custom_widget('tag', BugTagsWidget)
2502
2249
    custom_widget('tags_combinator', RadioWidget)
2503
2250
    custom_widget('component', LabeledMultiCheckBoxWidget)
2504
 
    custom_widget('assignee', PersonPickerWidget)
2505
 
    custom_widget('bug_reporter', PersonPickerWidget)
2506
 
    custom_widget('bug_commenter', PersonPickerWidget)
2507
 
    custom_widget('structural_subscriber', PersonPickerWidget)
2508
 
    custom_widget('subscriber', PersonPickerWidget)
2509
2251
 
2510
2252
    @cachedproperty
2511
2253
    def bug_tracking_usage(self):
2538
2280
        return False
2539
2281
 
2540
2282
    @property
2541
 
    def can_have_external_bugtracker(self):
2542
 
        return (IProduct.providedBy(self.context)
2543
 
                or IProductSeries.providedBy(self.context))
2544
 
 
2545
 
    @property
2546
 
    def bugtracker(self):
2547
 
        """Description of the context's bugtracker.
2548
 
 
2549
 
        :returns: str which may contain HTML.
2550
 
        """
2551
 
        if self.bug_tracking_usage == ServiceUsage.LAUNCHPAD:
2552
 
            return 'Launchpad'
2553
 
        elif self.external_bugtracker:
2554
 
            return BugTrackerFormatterAPI(self.external_bugtracker).link(None)
2555
 
        else:
2556
 
            return 'None specified'
2557
 
 
2558
 
    @property
2559
2283
    def upstream_launchpad_project(self):
2560
2284
        """The linked upstream `IProduct` for the package.
2561
2285
 
2582
2306
 
2583
2307
    @property
2584
2308
    def page_title(self):
2585
 
        return "Bugs : %s" % self.context.displayname
 
2309
        return "Bugs in %s" % self.context.title
2586
2310
 
2587
2311
    label = page_title
2588
2312
 
2640
2364
 
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()
2657
 
 
2658
 
            def _getBatchInfo(batch):
2659
 
                if batch is None:
2660
 
                    return None
2661
 
                return {'memo': batch.range_memo,
2662
 
                        'start': batch.startNumber() - 1}
2663
 
 
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))
2675
 
 
2676
 
    @property
2677
 
    def show_config_portlet(self):
2678
 
        if (IDistribution.providedBy(self.context) or
2679
 
            IProduct.providedBy(self.context)):
2680
 
            return True
2681
 
        else:
2682
 
            return False
2683
2367
 
2684
2368
    @property
2685
2369
    def columns_to_show(self):
2708
2392
                "Unrecognized context; don't know which report "
2709
2393
                "columns to show.")
2710
2394
 
2711
 
    bugtask_table_template = ViewPageTemplateFile(
2712
 
        '../templates/bugs-table-include.pt')
2713
 
 
2714
 
    @property
2715
 
    def template(self):
2716
 
        query_string = self.request.get('QUERY_STRING') or ''
2717
 
        query_params = urlparse.parse_qs(query_string)
2718
 
        if 'batch_request' in query_params:
2719
 
            return self.bugtask_table_template
2720
 
        else:
2721
 
            return super(BugTaskSearchListingView, self).template
2722
 
 
2723
2395
    def validate_search_params(self):
2724
2396
        """Validate the params passed for the search.
2725
2397
 
2993
2665
        search_params = self.buildSearchParams(
2994
2666
            searchtext=searchtext, extra_params=extra_params)
2995
2667
        search_params.user = self.user
2996
 
        try:
2997
 
            tasks = context.searchTasks(search_params, prejoins=prejoins)
2998
 
        except ValueError as e:
2999
 
            self.request.response.addErrorNotification(str(e))
3000
 
            self.request.response.redirect(canonical_url(
3001
 
                self.context, rootsite='bugs', view_name='+bugs'))
3002
 
            tasks = None
 
2668
        tasks = context.searchTasks(search_params, prejoins=prejoins)
3003
2669
        return tasks
3004
2670
 
3005
2671
    def getWidgetValues(
3011
2677
 
3012
2678
        if vocabulary is None:
3013
2679
            assert vocabulary_name is not None, 'No vocabulary specified.'
 
2680
            vocabulary_registry = getVocabularyRegistry()
3014
2681
            vocabulary = vocabulary_registry.get(
3015
2682
                self.context, vocabulary_name)
3016
2683
        for term in vocabulary:
3055
2722
            IDistroSeries.providedBy(context) or
3056
2723
            ISourcePackage.providedBy(context))
3057
2724
 
3058
 
    def shouldShowStructuralSubscriberWidget(self):
3059
 
        """Should the structural subscriber widget be shown on the page?
3060
 
 
3061
 
        Show the widget when there are subordinate structures.
3062
 
        """
3063
 
        return self.structural_subscriber_label is not None
 
2725
    def shouldShowSupervisorWidget(self):
 
2726
        """
 
2727
        Should the bug supervisor widget be shown on the advanced search page?
 
2728
        """
 
2729
        return True
3064
2730
 
3065
2731
    def shouldShowNoPackageWidget(self):
3066
2732
        """Should the widget to filter on bugs with no package be shown?
3099
2765
            IProduct.providedBy(self.context) or
3100
2766
            IProjectGroup.providedBy(self.context))
3101
2767
 
3102
 
    def shouldShowTeamPortlet(self):
3103
 
        """Should the User's Teams portlet me shown in the results?"""
3104
 
        return False
3105
 
 
3106
 
    @property
3107
 
    def structural_subscriber_label(self):
3108
 
        if IDistribution.providedBy(self.context):
3109
 
            return 'Package or series subscriber'
3110
 
        elif IDistroSeries.providedBy(self.context):
3111
 
            return 'Package subscriber'
3112
 
        elif IProduct.providedBy(self.context):
3113
 
            return 'Series subscriber'
3114
 
        elif IProjectGroup.providedBy(self.context):
3115
 
            return 'Project or series subscriber'
3116
 
        elif IPerson.providedBy(self.context):
3117
 
            return 'Project, distribution, package, or series subscriber'
3118
 
        else:
3119
 
            return None
3120
 
 
3121
2768
    def getSortLink(self, colname):
3122
2769
        """Return a link that can be used to sort results by colname."""
3123
2770
        form = self.request.form
3220
2867
        error_message = _(
3221
2868
            "There's no person with the name or email address '%s'.")
3222
2869
 
3223
 
        for name in ('assignee', 'bug_reporter', 'structural_subscriber',
 
2870
        for name in ('assignee', 'bug_reporter', 'bug_supervisor',
3224
2871
                     'bug_commenter', 'subscriber'):
3225
2872
            if self.getFieldError(name):
3226
2873
                self.setFieldError(
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')
3302
2946
        else:
3303
2947
            return None
3304
2948
 
3305
 
    @cachedproperty
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'))
3309
 
 
3310
 
    @property
3311
 
    def search_macro_title(self):
3312
 
        """The search macro's title text."""
3313
 
        return u"Search bugs %s" % self.context_description
3314
 
 
3315
 
    @property
3316
 
    def context_description(self):
3317
 
        """A phrase describing the context of the bug.
3318
 
 
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.
3322
 
        """
3323
 
        return "in %s" % self.context.displayname
3324
 
 
3325
2949
 
3326
2950
class BugNominationsView(BugTaskSearchListingView):
3327
2951
    """View for accepting/declining bug nominations."""
3366
2990
            (True, bug_listing_item.review_action_widget)
3367
2991
            for bug_listing_item in self.context.getBugListingItems()
3368
2992
            if bug_listing_item.review_action_widget is not None]
3369
 
        self.widgets = formlib.form.Widgets(
3370
 
            widgets_list, len(self.prefix) + 1)
 
2993
        self.widgets = formlib.form.Widgets(widgets_list, len(self.prefix)+1)
3371
2994
 
3372
2995
    @action('Save changes', name='submit', condition=canApproveNominations)
3373
2996
    def submit_action(self, action, data):
3521
3144
        self.cached_milestone_source = CachedMilestoneSourceFactory()
3522
3145
        self.user_is_subscribed = self.context.isSubscribed(self.user)
3523
3146
 
3524
 
        # If we have made it to here then the logged in user can see the
3525
 
        # bug, hence they can see any assignees.
3526
 
        authorised_people = [task.assignee for task in self.bugtasks
3527
 
                             if task.assignee is not None]
3528
 
        precache_permission_for_objects(
3529
 
            self.request, 'launchpad.LimitedView', authorised_people)
3530
 
 
3531
3147
        # Pull all of the related milestones, if any, into the storm cache,
3532
3148
        # since they'll be needed for the vocabulary used in this view.
3533
3149
        if self.bugtasks:
3534
3150
            self.milestones = list(
3535
3151
                bugtask_set.getBugTaskTargetMilestones(self.bugtasks))
3536
3152
        else:
3537
 
            self.milestones = []
 
3153
            self.milestones = []    
3538
3154
        distro_packages = defaultdict(list)
3539
3155
        distro_series_packages = defaultdict(list)
3540
3156
        for bugtask in self.bugtasks:
3654
3270
        # iteration.
3655
3271
        bugtasks_by_package = bug.getBugTasksByPackageName(all_bugtasks)
3656
3272
 
3657
 
        latest_parent = None
3658
 
 
3659
3273
        for bugtask in all_bugtasks:
3660
 
            # Series bug targets only display the series name, so they
3661
 
            # must always be preceded by their parent context. Normally
3662
 
            # the parent will have a task, but if not we need to show a
3663
 
            # fake one.
3664
 
            if ISeriesBugTarget.providedBy(bugtask.target):
3665
 
                parent = bugtask.target.bugtarget_parent
3666
 
            else:
3667
 
                latest_parent = parent = bugtask.target
3668
 
 
3669
 
            if parent != latest_parent:
3670
 
                latest_parent = parent
3671
 
                bugtask_and_nomination_views.append(
3672
 
                    getMultiAdapter(
3673
 
                        (parent, self.request),
3674
 
                        name='+bugtasks-and-nominations-table-row'))
3675
 
 
3676
3274
            conjoined_master = bugtask.getConjoinedMaster(
3677
3275
                bugtasks, bugtasks_by_package)
3678
3276
            view = self._getTableRowView(
3722
3320
        else:
3723
3321
            return 'false'
3724
3322
 
3725
 
    @cachedproperty
 
3323
    @property
3726
3324
    def other_users_affected_count(self):
3727
 
        """The number of other users affected by this bug.
3728
 
        """
3729
 
        if getFeatureFlag('bugs.affected_count_includes_dupes.disabled'):
3730
 
            if self.current_user_affected_status:
3731
 
                return self.context.users_affected_count - 1
3732
 
            else:
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
3734
3328
        else:
3735
 
            return self.context.other_users_affected_count_with_dupes
3736
 
 
3737
 
    @cachedproperty
3738
 
    def total_users_affected_count(self):
3739
 
        """The number of affected users, typically across all users.
3740
 
 
3741
 
        Counting across duplicates may be disabled at run time.
3742
 
        """
3743
 
        if getFeatureFlag('bugs.affected_count_includes_dupes.disabled'):
3744
3329
            return self.context.users_affected_count
3745
 
        else:
3746
 
            return self.context.users_affected_count_with_dupes
3747
3330
 
3748
 
    @cachedproperty
 
3331
    @property
3749
3332
    def affected_statement(self):
3750
3333
        """The default "this bug affects" statement to show.
3751
3334
 
3752
3335
        The outputs of this method should be mirrored in
3753
3336
        MeTooChoiceSource._getSourceNames() (Javascript).
3754
3337
        """
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"
 
3343
            else:
 
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:
3761
3347
                return (
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)
3764
3353
            else:
 
3354
                return "This bug affects %d people, but not you" % (
 
3355
                    self.other_users_affected_count)
 
3356
        else:
 
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"
3771
3361
            else:
3772
 
                return "This bug affects you and %d other people" % (
3773
 
                    other_affected)
3774
 
        else:
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" % (
3781
 
                    other_affected)
3782
3363
 
3783
 
    @cachedproperty
 
3364
    @property
3784
3365
    def anon_affected_statement(self):
3785
3366
        """The "this bug affects" statement to show to anonymous users.
3786
3367
 
3787
3368
        The outputs of this method should be mirrored in
3788
3369
        MeTooChoiceSource._getSourceNames() (Javascript).
3789
3370
        """
3790
 
        affected = self.total_users_affected_count
3791
 
        if affected == 1:
 
3371
        if self.context.users_affected_count == 1:
3792
3372
            return "This bug affects 1 person"
3793
 
        elif affected > 1:
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)
3795
3376
        else:
3796
3377
            return None
3797
3378
 
3798
 
    @property
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'))
3803
 
 
3804
 
    def canAddProjectTask(self):
3805
 
        """Can a new bug task on a project be added to this bug?
3806
 
 
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.
3809
 
 
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.
3815
 
 
3816
 
        """
3817
 
        bug = self.context
3818
 
        if self._allow_multipillar_private_bugs:
3819
 
            return True
3820
 
        return len(bug.bugtasks) == 0
3821
 
 
3822
 
    def canAddPackageTask(self):
3823
 
        """Can a new bug task on a src pkg be added to this bug?
3824
 
 
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,
3827
 
        so return False.
3828
 
 
3829
 
        A task on a given package may still be illegal to add, but
3830
 
        this will be caught when bug.addTask() is attempted.
3831
 
 
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.
3837
 
        """
3838
 
        bug = self.context
3839
 
        if self._allow_multipillar_private_bugs:
3840
 
            return True
3841
 
        for pillar in bug.affected_pillars:
3842
 
            if IProduct.providedBy(pillar):
3843
 
                return False
3844
 
        return True
3845
 
 
3846
 
 
3847
 
class BugTaskTableRowView(LaunchpadView, BugTaskBugWatchMixin,
3848
 
                          BugTaskPrivilegeMixin):
 
3379
 
 
3380
class BugTaskTableRowView(LaunchpadView, BugTaskBugWatchMixin):
3849
3381
    """Browser class for rendering a bugtask row on the bug page."""
3850
3382
 
3851
3383
    is_conjoined_slave = None
3853
3385
    target_link_title = None
3854
3386
    many_bugtasks = False
3855
3387
 
3856
 
    template = ViewPageTemplateFile(
3857
 
        '../templates/bugtask-tasks-and-nominations-table-row.pt')
3858
 
 
3859
3388
    def __init__(self, context, request):
3860
3389
        super(BugTaskTableRowView, self).__init__(context, request)
3861
3390
        self.milestone_source = MilestoneVocabulary
3862
3391
 
3863
 
    @cachedproperty
3864
 
    def api_request(self):
3865
 
        return IWebServiceClientRequest(self.request)
3866
 
 
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
3877
 
        self.data = dict(
3878
 
            # Looking at many_bugtasks is an important optimization.  With
3879
 
            # 150+ bugtasks, it can save three or four seconds of rendering
3880
 
            # time.
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,
3886
 
            can_edit=can_edit,
3887
 
            link=link,
3888
 
            id=bugtask_id,
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,
3903
 
            )
3904
 
 
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()
3912
 
 
3913
3392
    def canSeeTaskDetails(self):
3914
3393
        """Whether someone can see a task's status details.
3915
3394
 
3928
3407
                self.context.bug.duplicateof is None and
3929
3408
                not self.is_converted_to_question)
3930
3409
 
 
3410
    def getTaskRowCSSClass(self):
 
3411
        """The appropriate CSS class for the row in the Affects table.
 
3412
 
 
3413
        Currently this consists solely of highlighting the current context.
 
3414
        """
 
3415
        bugtask = self.context
 
3416
        if bugtask == getUtility(ILaunchBag).bugtask:
 
3417
            return 'highlight'
 
3418
        else:
 
3419
            return None
 
3420
 
 
3421
    def shouldIndentTask(self):
 
3422
        """Should this task be indented in the task listing on the bug page?
 
3423
 
 
3424
        Returns True or False.
 
3425
        """
 
3426
        bugtask = self.context
 
3427
        return (IDistroSeriesBugTask.providedBy(bugtask) or
 
3428
                IProductSeriesBugTask.providedBy(bugtask))
 
3429
 
 
3430
    def taskLink(self):
 
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"
 
3436
        else:
 
3437
            return canonical_url(bugtask) + "/+viewstatus"
 
3438
 
3931
3439
    def _getSeriesTargetNameHelper(self, bugtask):
3932
3440
        """Return the short name of bugtask's targeted series."""
3933
 
        series = bugtask.distroseries or bugtask.productseries
3934
 
        if not series:
3935
 
            return None
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()
 
3445
        else:
 
3446
            assert (
 
3447
                "Expected IDistroSeriesBugTask or IProductSeriesBugTask. "
 
3448
                "Got: %r" % bugtask)
3937
3449
 
3938
3450
    def getSeriesTargetName(self):
3939
3451
        """Get the series to which this task is targeted."""
4012
3524
            items = vocabulary_to_choice_edit_items(
4013
3525
                self._visible_milestones,
4014
3526
                value_fn=lambda item: canonical_url(
4015
 
                    item, request=self.api_request))
 
3527
                    item, request=IWebServiceClientRequest(self.request)))
4016
3528
            items.append({
4017
3529
                "name": "Remove milestone",
4018
3530
                "disabled": False,
4022
3534
 
4023
3535
        return items
4024
3536
 
 
3537
    @cachedproperty
 
3538
    def target_has_milestones(self):
 
3539
        """Are there any milestones we can target?
 
3540
 
 
3541
        We always look up all milestones, so there's no harm
 
3542
        using len on the list here and avoid the COUNT query.
 
3543
        """
 
3544
        return len(self._visible_milestones) > 0
 
3545
 
4025
3546
    def bugtask_canonical_url(self):
4026
3547
        """Return the canonical url for the bugtask."""
4027
3548
        return canonical_url(self.context)
4028
3549
 
4029
 
    @cachedproperty
 
3550
    @property
4030
3551
    def user_can_edit_importance(self):
4031
3552
        """Can the user edit the Importance field?
4032
3553
 
4033
3554
        If yes, return True, otherwise return False.
4034
3555
        """
4035
 
        return self.user_can_edit_status and self.user_has_privileges
4036
 
 
4037
 
    @cachedproperty
4038
 
    def user_can_edit_status(self):
4039
 
        """Can the user edit the Status field?
4040
 
 
4041
 
        If yes, return True, otherwise return False.
4042
 
        """
4043
 
        bugtask = self.context
4044
 
        edit_allowed = bugtask.target_uses_malone or bugtask.bugwatch
4045
 
        if bugtask.bugwatch:
4046
 
            bugtracker = bugtask.bugwatch.bugtracker
4047
 
            edit_allowed = (
4048
 
                bugtracker.bugtrackertype == BugTrackerType.EMAILADDRESS)
4049
 
        return edit_allowed
 
3556
        return self.context.userCanEditImportance(self.user)
4050
3557
 
4051
3558
    @property
4052
 
    def user_can_edit_assignee(self):
4053
 
        """Can the user edit the Assignee field?
4054
 
 
4055
 
        If yes, return True, otherwise return False.
4056
 
        """
4057
 
        return self.user is not None
4058
 
 
4059
 
    @cachedproperty
4060
 
    def user_can_delete_bugtask(self):
4061
 
        """Can the user delete the bug task?
4062
 
 
4063
 
        If yes, return True, otherwise return False.
4064
 
        """
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?
 
3561
 
 
3562
        If yes, return True, otherwise return False.
 
3563
        """
 
3564
        return self.context.userCanEditMilestone(self.user)
4068
3565
 
4069
3566
    @property
4070
3567
    def style_for_add_milestone(self):
4071
3568
        if self.context.milestone is None:
4072
3569
            return ''
4073
3570
        else:
4074
 
            return 'hidden'
 
3571
            return 'display: none'
4075
3572
 
4076
3573
    @property
4077
3574
    def style_for_edit_milestone(self):
4078
3575
        if self.context.milestone is None:
4079
 
            return 'hidden'
 
3576
            return 'display: none'
4080
3577
        else:
4081
3578
            return ''
4082
3579
 
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.
4089
 
        filter_details = []
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,
4098
 
                    })
 
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
4100
3584
        # or team
4101
 
        user = self.user
 
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))
4105
 
        cx = self.context
4106
 
        return dict(
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,
4127
 
            milestone_value=(
4128
 
                canonical_url(
4129
 
                    cx.milestone,
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,
4136
 
            )
 
3589
        return dumps({
 
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
 
3604
                                canonical_url(
 
3605
                                    self.context.milestone,
 
3606
                                    request=IWebServiceClientRequest(
 
3607
                                        self.request)) or
 
3608
                                None),
 
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)})
4137
3614
 
4138
3615
 
4139
3616
class BugsBugTaskSearchListingView(BugTaskSearchListingView):
4152
3629
            self._redirectToSearchContext()
4153
3630
 
4154
3631
    def _redirectToSearchContext(self):
4155
 
        """Check whether a target was given and redirect to it.
 
3632
        """Check wether a target was given and redirect to it.
4156
3633
 
4157
3634
        All the URL parameters will be passed on to the target's +bugs
4158
3635
        page.
4303
3780
    page_title = label
4304
3781
 
4305
3782
 
4306
 
class BugTaskExpirableListingView(BugTaskSearchListingView):
 
3783
class BugTaskExpirableListingView(LaunchpadView):
4307
3784
    """View for listing Incomplete bugs that can expire."""
4308
3785
 
4309
3786
    @property
4326
3803
        else:
4327
3804
            return ['id', 'summary', 'date_last_updated', 'heat']
4328
3805
 
 
3806
    @property
4329
3807
    def search(self):
4330
3808
        """Return an `ITableBatchNavigator` for the expirable bugtasks."""
 
3809
        days_old = config.malone.days_before_expiration
4331
3810
        bugtaskset = getUtility(IBugTaskSet)
4332
3811
        bugtasks = bugtaskset.findExpirableBugTasks(
4333
 
            user=self.user, target=self.context, min_days_old=0)
 
3812
            days_old, user=self.user, target=self.context)
4334
3813
        return BugListingBatchNavigator(
4335
3814
            bugtasks, self.request, columns_to_show=self.columns_to_show,
4336
3815
            size=config.malone.buglist_batch_size)
4352
3831
        """Return a formatted summary of the change."""
4353
3832
        if self.target is not None:
4354
3833
            # This is a bug task.  We want the attribute, as filtered out.
4355
 
            summary = self.attribute
 
3834
            return self.attribute
4356
3835
        else:
4357
3836
            # Otherwise, the attribute is more normalized than what we want.
4358
3837
            # Use "whatchanged," which sometimes is more descriptive.
4359
 
            summary = self.whatchanged
4360
 
        return self.get_better_summary(summary)
4361
 
 
4362
 
    def get_better_summary(self, summary):
4363
 
        """For some activities, we want a different summary for the UI.
4364
 
 
4365
 
        Some event names are more descriptive as data, but less relevant to
4366
 
        users, who are unfamiliar with the lp code."""
4367
 
        better_summaries = {
4368
 
            'bug task deleted': 'no longer affects',
4369
 
            }
4370
 
        return better_summaries.get(summary, summary)
 
3838
            return self.whatchanged
4371
3839
 
4372
3840
    @property
4373
3841
    def _formatted_tags_change(self):
4428
3896
        elif attribute == 'tags':
4429
3897
            # We special-case tags because we can work out what's been
4430
3898
            # added and what's been removed.
4431
 
            return cgi.escape(self._formatted_tags_change).replace(
4432
 
                '\n', '<br />')
 
3899
            return self._formatted_tags_change.replace('\n', '<br />')
4433
3900
 
4434
3901
        elif attribute == 'assignee':
4435
3902
            for key in return_dict:
4436
3903
                if return_dict[key] is None:
4437
3904
                    return_dict[key] = 'nobody'
4438
 
                else:
4439
 
                    return_dict[key] = cgi.escape(return_dict[key])
4440
3905
 
4441
3906
        elif attribute == 'milestone':
4442
3907
            for key in return_dict:
4443
3908
                if return_dict[key] is None:
4444
3909
                    return_dict[key] = 'none'
4445
 
                else:
4446
 
                    return_dict[key] = cgi.escape(return_dict[key])
4447
 
 
4448
 
        elif attribute == 'bug task deleted':
4449
 
            return self.oldvalue
4450
3910
 
4451
3911
        else:
4452
3912
            # Our default state is to just return oldvalue and newvalue.