~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-08-21 14:21:06 UTC
  • mto: This revision was merged to the branch mainline in revision 13745.
  • Revision ID: curtis.hovey@canonical.com-20110821142106-x93hajd6iguma8gx
Update test that was enforcing bad grammar.

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',
53
54
    log,
54
55
    )
55
56
from operator import attrgetter
56
 
import os.path
57
57
import re
 
58
import transaction
58
59
import urllib
59
 
import urlparse
60
60
 
61
61
from lazr.delegates import delegates
62
62
from lazr.enum import (
72
72
    IReference,
73
73
    IWebServiceClientRequest,
74
74
    )
75
 
from lazr.restful.utils import smartquote
76
75
from lazr.uri import URI
77
 
import pystache
78
76
from pytz import utc
79
77
from simplejson import dumps
80
 
from simplejson.encoder import JSONEncoderForHTML
81
 
import transaction
82
 
from z3c.pt.pagetemplate import ViewPageTemplateFile
 
78
from z3c.ptcompat import ViewPageTemplateFile
83
79
from zope import (
84
80
    component,
85
81
    formlib,
87
83
from zope.app.form import CustomWidgetFactory
88
84
from zope.app.form.browser.itemswidgets import RadioWidget
89
85
from zope.app.form.interfaces import (
 
86
    IDisplayWidget,
90
87
    IInputWidget,
91
88
    InputErrors,
92
89
    )
93
 
from zope.app.form.utility import setUpWidget
94
 
from zope.app.security.interfaces import IUnauthenticatedPrincipal
 
90
from zope.app.form.utility import (
 
91
    setUpWidget,
 
92
    setUpWidgets,
 
93
    )
95
94
from zope.component import (
96
95
    ComponentLookupError,
97
96
    getAdapter,
107
106
    providedBy,
108
107
    )
109
108
from zope.schema import Choice
110
 
from zope.schema.interfaces import IContextSourceBinder
 
109
from zope.schema.interfaces import (
 
110
    IContextSourceBinder,
 
111
    IList,
 
112
    )
111
113
from zope.schema.vocabulary import (
112
114
    getVocabularyRegistry,
113
115
    SimpleVocabulary,
117
119
    isinstance as zope_isinstance,
118
120
    removeSecurityProxy,
119
121
    )
120
 
from zope.traversing.browser import absoluteURL
121
122
from zope.traversing.interfaces import IPathAdapter
122
123
 
123
124
from canonical.config import config
125
126
    _,
126
127
    helpers,
127
128
    )
128
 
from lp.services.feeds.browser import (
 
129
from canonical.launchpad.browser.feeds import (
129
130
    BugTargetLatestBugsFeedLink,
130
131
    FeedsMixin,
131
132
    )
132
 
from lp.bugs.interfaces.bugtracker import IHasExternalBugTracker
133
 
from lp.services.mail.notification import get_unified_diff
 
133
from canonical.launchpad.interfaces.launchpad import IHasExternalBugTracker
 
134
from canonical.launchpad.mailnotification import get_unified_diff
134
135
from canonical.launchpad.searchbuilder import (
135
136
    all,
136
137
    any,
147
148
    redirection,
148
149
    stepthrough,
149
150
    )
150
 
from canonical.launchpad.webapp.authorization import (
151
 
    check_permission,
152
 
    precache_permission_for_objects,
153
 
    )
 
151
from canonical.launchpad.webapp.authorization import check_permission
154
152
from canonical.launchpad.webapp.batching import TableBatchNavigator
155
153
from canonical.launchpad.webapp.breadcrumb import Breadcrumb
156
154
from canonical.launchpad.webapp.interfaces import ILaunchBag
157
155
from canonical.launchpad.webapp.menu import structured
158
156
from canonical.lazr.interfaces import IObjectPrivacy
 
157
from canonical.lazr.utils import smartquote
159
158
from lp.answers.interfaces.questiontarget import IQuestionTarget
160
 
from lp.app.browser.launchpad import iter_view_registrations
161
159
from lp.app.browser.launchpadform import (
162
160
    action,
163
161
    custom_widget,
164
162
    LaunchpadEditFormView,
165
163
    LaunchpadFormView,
166
 
    ReturnToReferrerMixin,
167
164
    )
168
165
from lp.app.browser.lazrjs import (
169
166
    TextAreaEditorWidget,
172
169
    )
173
170
from lp.app.browser.stringformatter import FormattersAPI
174
171
from lp.app.browser.tales import (
175
 
    BugTrackerFormatterAPI,
176
 
    DateTimeFormatterAPI,
177
172
    ObjectImageDisplayAPI,
178
173
    PersonFormatterAPI,
179
174
    )
187
182
    IServiceUsage,
188
183
    )
189
184
from lp.app.widgets.itemswidgets import LabeledMultiCheckBoxWidget
 
185
from lp.app.widgets.launchpadtarget import LaunchpadTargetWidget
190
186
from lp.app.widgets.popup import PersonPickerWidget
191
187
from lp.app.widgets.project import ProjectScopeWidget
192
188
from lp.bugs.browser.bug import (
207
203
    BugTaskAssigneeWidget,
208
204
    BugTaskBugWatchWidget,
209
205
    BugTaskSourcePackageNameWidget,
210
 
    BugTaskTargetWidget,
211
206
    DBItemDisplayWidget,
212
207
    NewLineToSpacesWidget,
213
208
    NominationReviewActionWidget,
233
228
    BugTaskImportance,
234
229
    BugTaskSearchParams,
235
230
    BugTaskStatus,
236
 
    BugTaskStatusSearch,
237
231
    BugTaskStatusSearchDisplay,
238
 
    CannotDeleteBugtask,
239
232
    DEFAULT_SEARCH_BUGTASK_STATUSES_FOR_DISPLAY,
240
233
    IBugTask,
241
234
    IBugTaskSearch,
254
247
from lp.bugs.interfaces.bugwatch import BugWatchActivityStatus
255
248
from lp.bugs.interfaces.cve import ICveSet
256
249
from lp.bugs.interfaces.malone import IMaloneApplication
257
 
from lp.code.interfaces.branchcollection import IAllBranches
258
250
from lp.registry.interfaces.distribution import (
259
251
    IDistribution,
260
252
    IDistributionSet,
276
268
from lp.registry.interfaces.sourcepackage import ISourcePackage
277
269
from lp.registry.model.personroles import PersonRoles
278
270
from lp.registry.vocabularies import MilestoneVocabulary
279
 
from lp.services.features import getFeatureFlag
280
271
from lp.services.fields import PersonChoice
281
 
from lp.services.propertycache import (
282
 
    cachedproperty,
283
 
    get_property_cache,
284
 
    )
285
 
from lp.services.utils import obfuscate_structure
286
 
 
287
 
 
288
 
vocabulary_registry = getVocabularyRegistry()
 
272
from lp.services.propertycache import cachedproperty
 
273
 
289
274
 
290
275
DISPLAY_BUG_STATUS_FOR_PATCHES = {
291
276
    BugTaskStatus.NEW: True,
299
284
    BugTaskStatus.FIXRELEASED: False,
300
285
    BugTaskStatus.UNKNOWN: False,
301
286
    BugTaskStatus.EXPIRED: False,
302
 
    BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE: True,
303
 
    BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE: True,
304
287
    }
305
288
 
306
289
 
333
316
 
334
317
 
335
318
def get_comments_for_bugtask(bugtask, truncate=False, for_display=False,
336
 
    slice_info=None, show_spam_controls=False, user=None):
 
319
    slice_info=None, show_spam_controls=False):
337
320
    """Return BugComments related to a bugtask.
338
321
 
339
322
    This code builds a sorted list of BugComments in one shot,
346
329
        to retrieve.
347
330
    """
348
331
    comments = build_comments_from_chunks(bugtask, truncate=truncate,
349
 
        slice_info=slice_info, show_spam_controls=show_spam_controls,
350
 
        user=user)
 
332
        slice_info=slice_info, show_spam_controls=show_spam_controls)
351
333
    # TODO: further fat can be shaved off here by limiting the attachments we
352
334
    # query to those that slice_info would include.
353
335
    for attachment in bugtask.bug.attachments_unpopulated:
558
540
                # Security proxy this object on the way out.
559
541
                return getUtility(IBugTaskSet).get(bugtask.id)
560
542
 
561
 
        # If we've come this far, there's no task for the requested context.
562
 
        # If we are attempting to navigate past the non-existent bugtask,
563
 
        # we raise NotFound error. eg +delete or +edit etc.
564
 
        # Otherwise we are simply navigating to a non-existent task and so we
565
 
        # redirect to one that exists.
566
 
        travseral_stack = self.request.getTraversalStack()
567
 
        if len(travseral_stack) > 0:
568
 
            raise NotFoundError
 
543
        # If we've come this far, there's no task for the requested
 
544
        # context. Redirect to one that exists.
569
545
        return self.redirectSubTree(canonical_url(bug.default_bugtask))
570
546
 
571
547
 
667
643
        title = FormattersAPI(self.context.bug.title).obfuscate_email()
668
644
        return smartquote('%s: "%s"') % (heading, title)
669
645
 
670
 
    @cachedproperty
671
 
    def page_description(self):
672
 
        return IBug(self.context).description
673
 
 
674
646
    @property
675
647
    def next_url(self):
676
648
        """Provided so returning to the page they came from works."""
697
669
            cancel_url = canonical_url(self.context)
698
670
        return cancel_url
699
671
 
700
 
    @cachedproperty
701
 
    def api_request(self):
702
 
        return IWebServiceClientRequest(self.request)
703
 
 
704
 
    @cachedproperty
705
 
    def recommended_canonical_url(self):
706
 
        return canonical_url(self.context.bug, rootsite='bugs')
707
 
 
708
672
    def initialize(self):
709
673
        """Set up the needed widgets."""
710
674
        bug = self.context.bug
711
 
        cache = IJSONRequestCache(self.request)
712
 
        cache.objects['bug'] = bug
713
 
        subscribers_url_data = {
714
 
            'web_link': canonical_url(bug, rootsite='bugs'),
715
 
            'self_link': absoluteURL(bug, self.api_request),
716
 
            }
717
 
        cache.objects['subscribers_portlet_url_data'] = subscribers_url_data
718
 
        cache.objects['total_comments_and_activity'] = (
719
 
            self.total_comments + self.total_activity)
720
 
        cache.objects['initial_comment_batch_offset'] = (
721
 
            self.visible_initial_comments + 1)
722
 
        cache.objects['first visible_recent_comment'] = (
723
 
            self.total_comments - self.visible_recent_comments)
 
675
        IJSONRequestCache(self.request).objects['bug'] = bug
724
676
 
725
677
        # See render() for how this flag is used.
726
678
        self._redirecting_to_bug_list = False
765
717
    @cachedproperty
766
718
    def comments(self):
767
719
        """Return the bugtask's comments."""
768
 
        return self._getComments()
769
 
 
770
 
    def _getComments(self, slice_info=None):
771
 
        bug = self.context.bug
772
 
        show_spam_controls = bug.userCanSetCommentVisibility(self.user)
773
 
        return get_comments_for_bugtask(
774
 
            self.context, truncate=True, slice_info=slice_info,
775
 
            for_display=True, show_spam_controls=show_spam_controls,
776
 
            user=self.user)
 
720
        show_spam_controls = check_permission(
 
721
            'launchpad.Admin', self.context.bug)
 
722
        return get_comments_for_bugtask(self.context, truncate=True,
 
723
            for_display=True, show_spam_controls=show_spam_controls)
777
724
 
778
725
    @cachedproperty
779
726
    def interesting_activity(self):
780
 
        return self._getInterestingActivity()
781
 
 
782
 
    def _getInterestingActivity(self, earliest_activity_date=None,
783
 
                                latest_activity_date=None):
784
727
        """A sequence of interesting bug activity."""
785
 
        if (earliest_activity_date is not None and
786
 
            latest_activity_date is not None):
787
 
            # Only get the activity for the date range that we're
788
 
            # interested in to save us from processing too much.
789
 
            activity = self.context.bug.getActivityForDateRange(
790
 
                start_date=earliest_activity_date,
791
 
                end_date=latest_activity_date)
792
 
        else:
793
 
            activity = self.context.bug.activity
794
728
        bug_change_re = (
795
729
            'affects|description|security vulnerability|'
796
 
            'summary|tags|visibility|bug task deleted')
 
730
            'summary|tags|visibility')
797
731
        bugtask_change_re = (
798
732
            '[a-z0-9][a-z0-9\+\.\-]+( \([A-Za-z0-9\s]+\))?: '
799
733
            '(assignee|importance|milestone|status)')
800
734
        interesting_match = re.compile(
801
735
            "^(%s|%s)$" % (bug_change_re, bugtask_change_re)).match
802
 
        interesting_activity = tuple(
 
736
        return tuple(
803
737
            BugActivityItem(activity)
804
 
            for activity in activity
 
738
            for activity in self.context.bug.activity
805
739
            if interesting_match(activity.whatchanged) is not None)
806
 
        # This is a bit kludgy but it means that interesting_activity is
807
 
        # populated correctly for all subsequent calls.
808
 
        self._interesting_activity_cached_value = interesting_activity
809
 
        return interesting_activity
810
 
 
811
 
    def _getEventGroups(self, batch_size=None, offset=None):
 
740
 
 
741
    @cachedproperty
 
742
    def activity_and_comments(self):
 
743
        """Build list of comments interleaved with activities
 
744
 
 
745
        When activities occur on the same day a comment was posted,
 
746
        encapsulate them with that comment.  For the remainder, group
 
747
        then as if owned by the person who posted the first action
 
748
        that day.
 
749
 
 
750
        If the number of comments exceeds the configured maximum limit, the
 
751
        list will be truncated to just the first and last sets of comments.
 
752
 
 
753
        The division between the most recent and oldest is marked by an entry
 
754
        in the list with the key 'num_hidden' defined.
 
755
        """
812
756
        # Ensure truncation results in < max_length comments as expected
813
757
        assert(config.malone.comments_list_truncate_oldest_to
814
758
               + config.malone.comments_list_truncate_newest_to
815
759
               < config.malone.comments_list_max_length)
816
760
 
817
 
        if (not self.visible_comments_truncated_for_display and
818
 
            batch_size is None):
 
761
        if not self.visible_comments_truncated_for_display:
819
762
            comments = self.comments
820
 
        elif batch_size is not None:
821
 
            # If we're limiting to a given set of comments, we work on
822
 
            # just that subset of comments from hereon in, which saves
823
 
            # on processing time a bit.
824
 
            if offset is None:
825
 
                offset = self.visible_initial_comments
826
 
            comments = self._getComments([
827
 
                slice(offset, offset + batch_size)])
828
763
        else:
829
764
            # the comment function takes 0-offset counts where comment 0 is
830
765
            # the initial description, so we need to add one to the limits
831
766
            # to adjust.
832
767
            oldest_count = 1 + self.visible_initial_comments
833
768
            new_count = 1 + self.total_comments - self.visible_recent_comments
834
 
            slice_info = [
835
 
                slice(None, oldest_count),
836
 
                slice(new_count, None),
837
 
                ]
838
 
            comments = self._getComments(slice_info)
 
769
            show_spam_controls = check_permission(
 
770
                'launchpad.Admin', self.context.bug)
 
771
            comments = get_comments_for_bugtask(
 
772
                self.context, truncate=True, for_display=True,
 
773
                slice_info=[
 
774
                    slice(None, oldest_count), slice(new_count, None)],
 
775
                show_spam_controls=show_spam_controls)
839
776
 
840
777
        visible_comments = get_visible_comments(
841
778
            comments, user=self.user)
842
 
        if len(visible_comments) > 0 and batch_size is not None:
843
 
            first_comment = visible_comments[0]
844
 
            last_comment = visible_comments[-1]
845
 
            interesting_activity = (
846
 
                self._getInterestingActivity(
847
 
                    earliest_activity_date=first_comment.datecreated,
848
 
                    latest_activity_date=last_comment.datecreated))
849
 
        else:
850
 
            interesting_activity = self.interesting_activity
851
779
 
852
780
        event_groups = group_comments_with_activity(
853
781
            comments=visible_comments,
854
 
            activities=interesting_activity)
855
 
        return event_groups
856
 
 
857
 
    @cachedproperty
858
 
    def _event_groups(self):
859
 
        """Return a sorted list of event groups for the current BugTask.
860
 
 
861
 
        This is a @cachedproperty wrapper around _getEventGroups(). It's
862
 
        here so that we can override it in descendant views, passing
863
 
        batch size parameters and suchlike to _getEventGroups() as we
864
 
        go.
865
 
        """
866
 
        return self._getEventGroups()
867
 
 
868
 
    @cachedproperty
869
 
    def activity_and_comments(self):
870
 
        """Build list of comments interleaved with activities
871
 
 
872
 
        When activities occur on the same day a comment was posted,
873
 
        encapsulate them with that comment.  For the remainder, group
874
 
        then as if owned by the person who posted the first action
875
 
        that day.
876
 
 
877
 
        If the number of comments exceeds the configured maximum limit, the
878
 
        list will be truncated to just the first and last sets of comments.
879
 
 
880
 
        The division between the most recent and oldest is marked by an entry
881
 
        in the list with the key 'num_hidden' defined.
882
 
        """
883
 
        event_groups = self._event_groups
 
782
            activities=self.interesting_activity)
884
783
 
885
784
        def group_activities_by_target(activities):
886
785
            activities = sorted(
973
872
        """We count all comments because the db cannot do visibility yet."""
974
873
        return self.context.bug.bug_messages.count() - 1
975
874
 
976
 
    @cachedproperty
977
 
    def total_activity(self):
978
 
        """Return the count of all activity items for the bug."""
979
 
        # Ignore the first activity item, since it relates to the bug's
980
 
        # creation.
981
 
        return self.context.bug.activity.count() - 1
982
 
 
983
875
    def wasDescriptionModified(self):
984
876
        """Return a boolean indicating whether the description was modified"""
985
877
        return (self.context.bug._indexed_messages(
989
881
    @cachedproperty
990
882
    def linked_branches(self):
991
883
        """Filter out the bug_branch links to non-visible private branches."""
992
 
        linked_branches = list(
993
 
            self.context.bug.getVisibleLinkedBranches(
994
 
                self.user, eager_load=True))
995
 
        # This is an optimization for when we look at the merge proposals.
996
 
        if linked_branches:
997
 
            list(getUtility(IAllBranches).getMergeProposals(
998
 
                for_branches=[link.branch for link in linked_branches],
999
 
                eager_load=True))
 
884
        linked_branches = []
 
885
        for linked_branch in self.context.bug.linked_branches:
 
886
            if check_permission('launchpad.View', linked_branch.branch):
 
887
                linked_branches.append(linked_branch)
1000
888
        return linked_branches
1001
889
 
1002
890
    @property
1122
1010
        max_bug_heat = 5000
1123
1011
    heat_ratio = calculate_heat_display(bugtask.bug.heat, max_bug_heat)
1124
1012
    html = (
1125
 
        '<span><a href="/+help-bugs/bug-heat.html" target="help" '
1126
 
        'class="icon"><img src="/@@/bug-heat-%(ratio)i.png" '
 
1013
        '<span><a href="/+help/bug-heat.html" target="help" class="icon"><img'
 
1014
        ' src="/@@/bug-heat-%(ratio)i.png" '
1127
1015
        'alt="%(ratio)i out of 4 heat flames" title="Heat: %(heat)i" /></a>'
1128
1016
        '</span>'
1129
1017
        % {'ratio': heat_ratio, 'heat': bugtask.bug.heat})
1130
1018
    return html
1131
1019
 
1132
1020
 
1133
 
class BugTaskBatchedCommentsAndActivityView(BugTaskView):
1134
 
    """A view for displaying batches of bug comments and activity."""
1135
 
 
1136
 
    # We never truncate comments in this view; there would be no point.
1137
 
    visible_comments_truncated_for_display = False
1138
 
 
1139
 
    @property
1140
 
    def offset(self):
1141
 
        try:
1142
 
            return int(self.request.form_ng.getOne('offset'))
1143
 
        except TypeError:
1144
 
            # We return visible_initial_comments + 1, since otherwise we'd
1145
 
            # end up repeating comments that are already visible on the
1146
 
            # page. The +1 accounts for the fact that bug comments are
1147
 
            # essentially indexed from 1 due to comment 0 being the
1148
 
            # initial bug description.
1149
 
            return self.visible_initial_comments + 1
1150
 
 
1151
 
    @property
1152
 
    def batch_size(self):
1153
 
        try:
1154
 
            return int(self.request.form_ng.getOne('batch_size'))
1155
 
        except TypeError:
1156
 
            return config.malone.comments_list_default_batch_size
1157
 
 
1158
 
    @property
1159
 
    def next_batch_url(self):
1160
 
        return "%s?offset=%s&batch_size=%s" % (
1161
 
            canonical_url(self.context, view_name='+batched-comments'),
1162
 
            self.next_offset, self.batch_size)
1163
 
 
1164
 
    @property
1165
 
    def next_offset(self):
1166
 
        return self.offset + self.batch_size
1167
 
 
1168
 
    @property
1169
 
    def _event_groups(self):
1170
 
        """See `BugTaskView`."""
1171
 
        batch_size = self.batch_size
1172
 
        if (batch_size > (self.total_comments) or
1173
 
            not self.has_more_comments_and_activity):
1174
 
            # If the batch size is big enough to encompass all the
1175
 
            # remaining comments and activity, trim it so that we don't
1176
 
            # re-show things.
1177
 
            if self.offset == self.visible_initial_comments + 1:
1178
 
                offset_to_remove = self.visible_initial_comments
1179
 
            else:
1180
 
                offset_to_remove = self.offset
1181
 
            batch_size = (
1182
 
                self.total_comments - self.visible_recent_comments -
1183
 
                # This last bit is to make sure that _getEventGroups()
1184
 
                # doesn't accidentally inflate the batch size later on.
1185
 
                offset_to_remove)
1186
 
        return self._getEventGroups(
1187
 
            batch_size=batch_size, offset=self.offset)
1188
 
 
1189
 
    @cachedproperty
1190
 
    def has_more_comments_and_activity(self):
1191
 
        """Return True if there are more camments and activity to load."""
1192
 
        return (
1193
 
            self.next_offset < (self.total_comments + self.total_activity))
 
1021
class BugTaskPortletView:
 
1022
    """A portlet for displaying a bug's bugtasks."""
 
1023
 
 
1024
    def alsoReportedIn(self):
 
1025
        """Return a list of IUpstreamBugTasks in which this bug is reported.
 
1026
 
 
1027
        If self.context is an IUpstreamBugTasks, it will be excluded
 
1028
        from this list.
 
1029
        """
 
1030
        return [
 
1031
            task for task in self.context.bug.bugtasks
 
1032
            if task.id is not self.context.id]
1194
1033
 
1195
1034
 
1196
1035
def get_prefix(bugtask):
1214
1053
    return '_'.join(parts)
1215
1054
 
1216
1055
 
1217
 
def get_assignee_vocabulary_info(context):
 
1056
def get_assignee_vocabulary(context):
1218
1057
    """The vocabulary of bug task assignees the current user can set."""
1219
1058
    if context.userCanSetAnyAssignee(getUtility(ILaunchBag).user):
1220
 
        vocab_name = 'ValidAssignee'
 
1059
        return 'ValidAssignee'
1221
1060
    else:
1222
 
        vocab_name = 'AllUserTeamsParticipation'
1223
 
    vocab = vocabulary_registry.get(None, vocab_name)
1224
 
    return vocab_name, vocab.supportedFilters()
 
1061
        return 'AllUserTeamsParticipation'
1225
1062
 
1226
1063
 
1227
1064
class BugTaskBugWatchMixin:
1228
1065
    """A mixin to be used where a BugTask view displays BugWatch data."""
1229
1066
 
1230
 
    @cachedproperty
 
1067
    @property
1231
1068
    def bug_watch_error_message(self):
1232
1069
        """Return a browser-useable error message for a bug watch."""
1233
1070
        if not self.context.bugwatch:
1281
1118
            }
1282
1119
 
1283
1120
 
1284
 
class BugTaskPrivilegeMixin:
1285
 
 
1286
 
    @cachedproperty
1287
 
    def user_has_privileges(self):
1288
 
        """Is the user privileged? That is, an admin, pillar owner, driver
1289
 
        or bug supervisor.
1290
 
 
1291
 
        If yes, return True, otherwise return False.
1292
 
        """
1293
 
        return self.context.userHasBugSupervisorPrivileges(self.user)
1294
 
 
1295
 
 
1296
 
class BugTaskEditView(LaunchpadEditFormView, BugTaskBugWatchMixin,
1297
 
                      BugTaskPrivilegeMixin):
 
1121
class BugTaskEditView(LaunchpadEditFormView, BugTaskBugWatchMixin):
1298
1122
    """The view class used for the task +editstatus page."""
1299
1123
 
1300
1124
    schema = IBugTask
1302
1126
    user_is_subscribed = None
1303
1127
    edit_form = ViewPageTemplateFile('../templates/bugtask-edit-form.pt')
1304
1128
 
1305
 
    _next_url_override = None
1306
 
 
1307
1129
    # The field names that we use by default. This list will be mutated
1308
1130
    # depending on the current context and the permissions of the user viewing
1309
1131
    # the form.
1310
1132
    default_field_names = ['assignee', 'bugwatch', 'importance', 'milestone',
1311
 
                           'status']
1312
 
    custom_widget('target', BugTaskTargetWidget)
 
1133
                           'status', 'statusexplanation']
 
1134
    custom_widget('target', LaunchpadTargetWidget)
1313
1135
    custom_widget('sourcepackagename', BugTaskSourcePackageNameWidget)
1314
1136
    custom_widget('bugwatch', BugTaskBugWatchWidget)
1315
1137
    custom_widget('assignee', BugTaskAssigneeWidget)
1359
1181
 
1360
1182
            # XXX: Brad Bollenbach 2006-09-29 bug=63000: Permission checking
1361
1183
            # doesn't belong here!
1362
 
            if not self.user_has_privileges:
1363
 
                if 'milestone' in editable_field_names:
1364
 
                    editable_field_names.remove("milestone")
1365
 
                if 'importance' in editable_field_names:
1366
 
                    editable_field_names.remove("importance")
 
1184
            if ('milestone' in editable_field_names and
 
1185
                not self.userCanEditMilestone()):
 
1186
                editable_field_names.remove("milestone")
 
1187
 
 
1188
            if ('importance' in editable_field_names and
 
1189
                not self.userCanEditImportance()):
 
1190
                editable_field_names.remove("importance")
1367
1191
        else:
1368
1192
            editable_field_names = set(('bugwatch', ))
1369
1193
            if self.context.bugwatch is None:
1370
1194
                editable_field_names.update(('status', 'assignee'))
1371
1195
                if ('importance' in self.default_field_names
1372
 
                    and self.user_has_privileges):
 
1196
                    and self.userCanEditImportance()):
1373
1197
                    editable_field_names.add('importance')
1374
1198
            else:
1375
1199
                bugtracker = self.context.bugwatch.bugtracker
1376
1200
                if bugtracker.bugtrackertype == BugTrackerType.EMAILADDRESS:
1377
1201
                    editable_field_names.add('status')
1378
1202
                    if ('importance' in self.default_field_names
1379
 
                        and self.user_has_privileges):
 
1203
                        and self.userCanEditImportance()):
1380
1204
                        editable_field_names.add('importance')
1381
1205
 
1382
1206
        if self.show_target_widget:
1398
1222
    @property
1399
1223
    def next_url(self):
1400
1224
        """See `LaunchpadFormView`."""
1401
 
        if self._next_url_override is None:
1402
 
            return canonical_url(self.context)
1403
 
        else:
1404
 
            return self._next_url_override
 
1225
        return canonical_url(self.context)
1405
1226
 
1406
1227
    @property
1407
1228
    def initial_values(self):
1518
1339
            self.form_fields.get('assignee', False)):
1519
1340
            # Make the assignee field editable
1520
1341
            self.form_fields = self.form_fields.omit('assignee')
1521
 
            vocabulary, ignored = get_assignee_vocabulary_info(self.context)
1522
1342
            self.form_fields += formlib.form.Fields(PersonChoice(
1523
1343
                __name__='assignee', title=_('Assigned to'), required=False,
1524
 
                vocabulary=vocabulary, readonly=False))
 
1344
                vocabulary=get_assignee_vocabulary(self.context),
 
1345
                readonly=False))
1525
1346
            self.form_fields['assignee'].custom_widget = CustomWidgetFactory(
1526
1347
                BugTaskAssigneeWidget)
1527
1348
 
1530
1351
        if self.context.target_uses_malone:
1531
1352
            read_only_field_names = []
1532
1353
 
1533
 
            if not self.user_has_privileges:
 
1354
            if not self.userCanEditMilestone():
1534
1355
                read_only_field_names.append("milestone")
 
1356
 
 
1357
            if not self.userCanEditImportance():
1535
1358
                read_only_field_names.append("importance")
1536
1359
        else:
1537
1360
            editable_field_names = self.editable_field_names
1541
1364
 
1542
1365
        return read_only_field_names
1543
1366
 
 
1367
    def userCanEditMilestone(self):
 
1368
        """Can the user edit the Milestone field?
 
1369
 
 
1370
        If yes, return True, otherwise return False.
 
1371
        """
 
1372
        return self.context.userCanEditMilestone(self.user)
 
1373
 
 
1374
    def userCanEditImportance(self):
 
1375
        """Can the user edit the Importance field?
 
1376
 
 
1377
        If yes, return True, otherwise return False.
 
1378
        """
 
1379
        return self.context.userCanEditImportance(self.user)
 
1380
 
1544
1381
    def validate(self, data):
1545
1382
        if self.show_sourcepackagename_widget and 'sourcepackagename' in data:
1546
1383
            data['target'] = self.context.distroseries
1596
1433
        milestone_ignored = False
1597
1434
        missing = object()
1598
1435
        new_target = new_values.pop("target", missing)
1599
 
        if (new_target is not missing and
1600
 
            bugtask.target.pillar != new_target.pillar):
 
1436
        if new_target is not missing and bugtask.target != new_target:
1601
1437
            # We clear the milestone value if one was already set. We ignore
1602
1438
            # the milestone value if it was currently None, and the user tried
1603
1439
            # to set a milestone value while also changing the product. This
1726
1562
                bugtask.transitionToAssignee(None)
1727
1563
 
1728
1564
        if changed:
 
1565
            # We only set the statusexplanation field to the value of the
 
1566
            # change comment if the BugTask has actually been changed in some
 
1567
            # way. Otherwise, we just leave it as a comment on the bug.
 
1568
            if comment_on_change:
 
1569
                bugtask.statusexplanation = comment_on_change
 
1570
            else:
 
1571
                bugtask.statusexplanation = ""
 
1572
 
1729
1573
            notify(
1730
1574
                ObjectModifiedEvent(
1731
1575
                    object=bugtask,
1732
1576
                    object_before_modification=bugtask_before_modification,
1733
1577
                    edited_fields=field_names))
1734
1578
 
1735
 
            # We clear the known views cache because the bug may not be
1736
 
            # viewable anymore by the current user. If the bug is not
1737
 
            # viewable, then we redirect to the current bugtask's pillar's
1738
 
            # bug index page with a message.
1739
 
            get_property_cache(bugtask.bug)._known_viewers = set()
1740
 
            if not bugtask.bug.userCanView(self.user):
1741
 
                self.request.response.addWarningNotification(
1742
 
                    "The bug you have just updated is now a private bug for "
1743
 
                    "%s. You do not have permission to view such bugs."
1744
 
                    % bugtask.pillar.displayname)
1745
 
                self._next_url_override = canonical_url(
1746
 
                    new_target.pillar, rootsite='bugs')
1747
 
 
1748
1579
        if (bugtask.sourcepackagename and (
1749
1580
            self.widgets.get('target') or
1750
1581
            self.widgets.get('sourcepackagename'))):
1776
1607
        self.updateContextFromData(data)
1777
1608
 
1778
1609
 
1779
 
class BugTaskDeletionView(ReturnToReferrerMixin, LaunchpadFormView):
1780
 
    """Used to delete a bugtask."""
1781
 
 
1782
 
    schema = IBugTask
1783
 
    field_names = []
1784
 
 
1785
 
    label = 'Remove bug task'
1786
 
    page_title = label
1787
 
 
1788
 
    @property
1789
 
    def next_url(self):
1790
 
        """Return the next URL to call when this call completes."""
1791
 
        if not self.request.is_ajax:
1792
 
            return self._next_url or self._return_url
1793
 
        return None
1794
 
 
1795
 
    @action('Delete', name='delete_bugtask')
1796
 
    def delete_bugtask_action(self, action, data):
1797
 
        bugtask = self.context
1798
 
        bug = bugtask.bug
1799
 
        deleted_bugtask_url = canonical_url(self.context, rootsite='bugs')
1800
 
        success_message = ("This bug no longer affects %s."
1801
 
                    % bugtask.bugtargetdisplayname)
1802
 
        error_message = None
1803
 
        # We set the next_url here before the bugtask is deleted since later
1804
 
        # the bugtask will not be available if required to construct the url.
1805
 
        self._next_url = self._return_url
1806
 
 
1807
 
        try:
1808
 
            bugtask.delete()
1809
 
            self.request.response.addNotification(success_message)
1810
 
        except CannotDeleteBugtask as e:
1811
 
            error_message = str(e)
1812
 
            self.request.response.addErrorNotification(error_message)
1813
 
        if self.request.is_ajax:
1814
 
            if error_message:
1815
 
                self.request.response.setHeader('Content-type',
1816
 
                    'application/json')
1817
 
                return dumps(None)
1818
 
            launchbag = getUtility(ILaunchBag)
1819
 
            launchbag.add(bug.default_bugtask)
1820
 
            # If we are deleting the current highlighted bugtask via ajax,
1821
 
            # we must force a redirect to the new default bugtask to ensure
1822
 
            # all URLs and other client cache content is correctly refreshed.
1823
 
            # We can't do the redirect here since the XHR caller won't see it
1824
 
            # so we return the URL to go to and let the caller do it.
1825
 
            if self._return_url == deleted_bugtask_url:
1826
 
                next_url = canonical_url(
1827
 
                    bug.default_bugtask, rootsite='bugs')
1828
 
                self.request.response.setHeader('Content-type',
1829
 
                    'application/json')
1830
 
                return dumps(dict(bugtask_url=next_url))
1831
 
            # No redirect required so return the new bugtask table HTML.
1832
 
            view = getMultiAdapter(
1833
 
                (bug, self.request),
1834
 
                name='+bugtasks-and-nominations-table')
1835
 
            view.initialize()
1836
 
            return view.render()
 
1610
class BugTaskStatusView(LaunchpadView):
 
1611
    """Viewing the status of a bug task."""
 
1612
 
 
1613
    page_title = 'View status'
 
1614
 
 
1615
    def initialize(self):
 
1616
        """Set up the appropriate widgets.
 
1617
 
 
1618
        Different widgets are shown depending on if it's a remote bug
 
1619
        task or not.
 
1620
        """
 
1621
        field_names = [
 
1622
            'status', 'importance', 'assignee', 'statusexplanation']
 
1623
        if not self.context.target_uses_malone:
 
1624
            field_names += ['bugwatch']
 
1625
            self.milestone_widget = None
 
1626
        else:
 
1627
            field_names += ['milestone']
 
1628
            self.bugwatch_widget = None
 
1629
 
 
1630
        if self.context.distroseries or self.context.distribution:
 
1631
            field_names += ['sourcepackagename']
 
1632
 
 
1633
        self.assignee_widget = CustomWidgetFactory(AssigneeDisplayWidget)
 
1634
        self.status_widget = CustomWidgetFactory(DBItemDisplayWidget)
 
1635
        self.importance_widget = CustomWidgetFactory(DBItemDisplayWidget)
 
1636
 
 
1637
        setUpWidgets(self, IBugTask, IDisplayWidget, names=field_names)
1837
1638
 
1838
1639
 
1839
1640
class BugTaskListingView(LaunchpadView):
1977
1778
            return get_buglisting_search_filter_url(assignee=self.user.name)
1978
1779
 
1979
1780
    @property
1980
 
    def my_affecting_bugs_url(self):
1981
 
        """A URL to a list of bugs affecting the current user, or None if
1982
 
        there is no current user.
1983
 
        """
1984
 
        if self.user is None:
1985
 
            return None
1986
 
        return get_buglisting_search_filter_url(
1987
 
            affecting_me=True,
1988
 
            orderby='-date_last_updated')
1989
 
 
1990
 
    @property
1991
1781
    def my_reported_bugs_url(self):
1992
1782
        """A URL to a list of bugs reported by the user, or None."""
1993
1783
        if self.user is None:
2075
1865
        The bugtarget may be an `IDistribution`, `IDistroSeries`, `IProduct`,
2076
1866
        or `IProductSeries`.
2077
1867
        """
 
1868
        days_old = config.malone.days_before_expiration
 
1869
 
2078
1870
        if target_has_expirable_bugs_listing(self.context):
2079
1871
            return getUtility(IBugTaskSet).findExpirableBugTasks(
2080
 
                0, user=self.user, target=self.context).count()
 
1872
                days_old, user=self.user, target=self.context).count()
2081
1873
        else:
2082
1874
            return None
2083
1875
 
2126
1918
        return self.context.searchTasks(params).count()
2127
1919
 
2128
1920
    @property
2129
 
    def my_affecting_bugs_count(self):
2130
 
        """A count of bugs affecting the user, or None."""
2131
 
        if self.user is None:
2132
 
            return None
2133
 
        params = get_default_search_params(self.user)
2134
 
        params.affects_me = True
2135
 
        return self.context.searchTasks(params).count()
2136
 
 
2137
 
    @property
2138
1921
    def bugs_with_patches_count(self):
2139
1922
        """A count of unresolved bugs with patches."""
2140
1923
        return self._bug_stats['with_patch']
2150
1933
 
2151
1934
def get_buglisting_search_filter_url(
2152
1935
        assignee=None, importance=None, status=None, status_upstream=None,
2153
 
        has_patches=None, bug_reporter=None,
2154
 
        affecting_me=None,
2155
 
        orderby=None):
 
1936
        has_patches=None, bug_reporter=None):
2156
1937
    """Return the given URL with the search parameters specified."""
2157
1938
    search_params = []
2158
1939
 
2168
1949
        search_params.append(('field.has_patch', 'on'))
2169
1950
    if bug_reporter is not None:
2170
1951
        search_params.append(('field.bug_reporter', bug_reporter))
2171
 
    if affecting_me is not None:
2172
 
        search_params.append(('field.affects_me', 'on'))
2173
 
    if orderby is not None:
2174
 
        search_params.append(('orderby', orderby))
2175
1952
 
2176
1953
    query_string = urllib.urlencode(search_params, doseq=True)
2177
1954
 
2182
1959
    return search_filter_url
2183
1960
 
2184
1961
 
 
1962
def getInitialValuesFromSearchParams(search_params, form_schema):
 
1963
    """Build a dictionary that can be given as initial values to
 
1964
    setUpWidgets, based on the given search params.
 
1965
 
 
1966
    >>> initial = getInitialValuesFromSearchParams(
 
1967
    ...     {'status': any(*UNRESOLVED_BUGTASK_STATUSES)}, IBugTaskSearch)
 
1968
    >>> for status in initial['status']:
 
1969
    ...     print status.name
 
1970
    NEW
 
1971
    INCOMPLETE
 
1972
    CONFIRMED
 
1973
    TRIAGED
 
1974
    INPROGRESS
 
1975
    FIXCOMMITTED
 
1976
 
 
1977
    >>> initial = getInitialValuesFromSearchParams(
 
1978
    ...     {'status': BugTaskStatus.INVALID}, IBugTaskSearch)
 
1979
    >>> [status.name for status in initial['status']]
 
1980
    ['INVALID']
 
1981
 
 
1982
    >>> initial = getInitialValuesFromSearchParams(
 
1983
    ...     {'importance': [BugTaskImportance.CRITICAL,
 
1984
    ...                   BugTaskImportance.HIGH]}, IBugTaskSearch)
 
1985
    >>> [importance.name for importance in initial['importance']]
 
1986
    ['CRITICAL', 'HIGH']
 
1987
 
 
1988
    >>> getInitialValuesFromSearchParams(
 
1989
    ...     {'assignee': NULL}, IBugTaskSearch)
 
1990
    {'assignee': None}
 
1991
    """
 
1992
    initial = {}
 
1993
    for key, value in search_params.items():
 
1994
        if IList.providedBy(form_schema[key]):
 
1995
            if isinstance(value, any):
 
1996
                value = value.query_values
 
1997
            elif isinstance(value, (list, tuple)):
 
1998
                value = value
 
1999
            else:
 
2000
                value = [value]
 
2001
        elif value == NULL:
 
2002
            value = None
 
2003
        else:
 
2004
            # Should be safe to pass value as it is to setUpWidgets, no need
 
2005
            # to worry
 
2006
            pass
 
2007
 
 
2008
        initial[key] = value
 
2009
 
 
2010
    return initial
 
2011
 
 
2012
 
2185
2013
class BugTaskListingItem:
2186
2014
    """A decorated bug task.
2187
2015
 
2214
2042
        """Returns the bug heat flames HTML."""
2215
2043
        return bugtask_heat_html(self.bugtask, target=self.target_context)
2216
2044
 
2217
 
    @property
2218
 
    def model(self):
2219
 
        """Provide flattened data about bugtask for simple templaters."""
2220
 
        age = DateTimeFormatterAPI(self.bug.datecreated).durationsince()
2221
 
        age += ' old'
2222
 
        date_last_updated = self.bug.date_last_message
2223
 
        if (date_last_updated is None or
2224
 
            self.bug.date_last_updated > date_last_updated):
2225
 
            date_last_updated = self.bug.date_last_updated
2226
 
        last_updated_formatter = DateTimeFormatterAPI(date_last_updated)
2227
 
        last_updated = last_updated_formatter.displaydate()
2228
 
        badges = getAdapter(self.bugtask, IPathAdapter, 'image').badges()
2229
 
        target_image = getAdapter(self.target, IPathAdapter, 'image')
2230
 
        if self.bugtask.milestone is not None:
2231
 
            milestone_name = self.bugtask.milestone.displayname
2232
 
        else:
2233
 
            milestone_name = None
2234
 
        assignee = None
2235
 
        if self.assignee is not None:
2236
 
            assignee = self.assignee.displayname
2237
 
 
2238
 
        base_tag_url = "%s/?field.tag=" % canonical_url(
2239
 
            self.bugtask.target,
2240
 
            view_name="+bugs")
2241
 
 
2242
 
        flattened = {
2243
 
            'age': age,
2244
 
            'assignee': assignee,
2245
 
            'bug_url': canonical_url(self.bugtask),
2246
 
            'bugtarget': self.bugtargetdisplayname,
2247
 
            'bugtarget_css': target_image.sprite_css(),
2248
 
            'bug_heat_html': self.bug_heat_html,
2249
 
            'badges': badges,
2250
 
            'id': self.bug.id,
2251
 
            'importance': self.importance.title,
2252
 
            'importance_class': 'importance' + self.importance.name,
2253
 
            'last_updated': last_updated,
2254
 
            'milestone_name': milestone_name,
2255
 
            'reporter': self.bug.owner.displayname,
2256
 
            'status': self.status.title,
2257
 
            'status_class': 'status' + self.status.name,
2258
 
            'tags': [{'url': base_tag_url + tag, 'tag': tag}
2259
 
                for tag in self.bug.tags],
2260
 
            'title': self.bug.title,
2261
 
            }
2262
 
 
2263
 
        # This is a total hack, but pystache will run both truth/false values
2264
 
        # for an empty list for some reason, and it "works" if it's just a
2265
 
        # flag like this. We need this value for the mustache template to be
2266
 
        # able to tell that there are no tags without looking at the list.
2267
 
        flattened['has_tags'] = True if len(flattened['tags']) else False
2268
 
        return flattened
2269
 
 
2270
2045
 
2271
2046
class BugListingBatchNavigator(TableBatchNavigator):
2272
2047
    """A specialised batch navigator to load smartly extra bug information."""
2277
2052
        # rules to a mixin so that MilestoneView and others can use it.
2278
2053
        self.request = request
2279
2054
        self.target_context = target_context
2280
 
        self.user = getUtility(ILaunchBag).user
2281
 
        self.field_visibility_defaults = {
2282
 
            'show_datecreated': False,
2283
 
            'show_assignee': False,
2284
 
            'show_targetname': True,
2285
 
            'show_heat': True,
2286
 
            'show_id': True,
2287
 
            'show_importance': True,
2288
 
            'show_date_last_updated': False,
2289
 
            'show_milestone_name': False,
2290
 
            'show_reporter': False,
2291
 
            'show_status': True,
2292
 
            'show_tag': False,
2293
 
        }
2294
 
        self.field_visibility = None
2295
 
        self._setFieldVisibility()
2296
2055
        TableBatchNavigator.__init__(
2297
2056
            self, tasks, request, columns_to_show=columns_to_show, size=size)
2298
2057
 
2301
2060
        return getUtility(IBugTaskSet).getBugTaskBadgeProperties(
2302
2061
            self.currentBatch())
2303
2062
 
2304
 
    def getCookieName(self):
2305
 
        """Return the cookie name used in bug listings js code."""
2306
 
        cookie_name_template = '%s-buglist-fields'
2307
 
        cookie_name = ''
2308
 
        if self.user is not None:
2309
 
            cookie_name = cookie_name_template % self.user.name
2310
 
        else:
2311
 
            cookie_name = cookie_name_template % 'anon'
2312
 
        return cookie_name
2313
 
 
2314
 
    def _setFieldVisibility(self):
2315
 
        """Set field_visibility for the page load.
2316
 
 
2317
 
        If a cookie of the form $USER-buglist-fields is found,
2318
 
        we set field_visibility from this cookie; otherwise,
2319
 
        field_visibility will match the defaults.
2320
 
        """
2321
 
        cookie_name = self.getCookieName()
2322
 
        cookie = self.request.cookies.get(cookie_name)
2323
 
        self.field_visibility = dict(self.field_visibility_defaults)
2324
 
        # "cookie" looks like a URL query string, so we split
2325
 
        # on '&' to get items, and then split on '=' to get
2326
 
        # field/value pairs.
2327
 
        if cookie is None:
2328
 
            return
2329
 
        for field, value in urlparse.parse_qsl(cookie):
2330
 
            # Skip unsupported fields (from old cookies).
2331
 
            if field not in self.field_visibility:
2332
 
                continue
2333
 
            # We only record True or False for field values.
2334
 
            self.field_visibility[field] = (value == 'true')
2335
 
 
2336
2063
    def _getListingItem(self, bugtask):
2337
2064
        """Return a decorated bugtask for the bug listing."""
2338
2065
        badge_property = self.bug_badge_properties[bugtask]
2356
2083
        """Return a decorated list of visible bug tasks."""
2357
2084
        return [self._getListingItem(bugtask) for bugtask in self.batch]
2358
2085
 
2359
 
    @cachedproperty
2360
 
    def mustache_template(self):
2361
 
        template_path = os.path.join(
2362
 
            config.root, 'lib/lp/bugs/templates/buglisting.mustache')
2363
 
        with open(template_path) as template_file:
2364
 
            return template_file.read()
2365
 
 
2366
 
    @property
2367
 
    def mustache_listings(self):
2368
 
        return 'LP.mustache_listings = %s;' % dumps(
2369
 
            self.mustache_template, cls=JSONEncoderForHTML)
2370
 
 
2371
 
    @property
2372
 
    def mustache(self):
2373
 
        """The rendered mustache template."""
2374
 
        objects = IJSONRequestCache(self.request).objects
2375
 
        if IUnauthenticatedPrincipal.providedBy(self.request.principal):
2376
 
            objects = obfuscate_structure(objects)
2377
 
        return pystache.render(self.mustache_template,
2378
 
                               objects['mustache_model'])
2379
 
 
2380
 
    @property
2381
 
    def model(self):
2382
 
        items = [bugtask.model for bugtask in self.getBugListingItems()]
2383
 
        for item in items:
2384
 
            item.update(self.field_visibility)
2385
 
        return {'items': items}
2386
 
 
2387
2086
 
2388
2087
class NominatedBugReviewAction(EnumeratedType):
2389
2088
    """Enumeration for nomination review actions"""
2465
2164
        bug_target = self.context.context
2466
2165
        if IDistribution.providedBy(bug_target):
2467
2166
            return (
 
2167
                'bugsupervisor',
 
2168
                'securitycontact',
2468
2169
                'cve',
2469
2170
                )
2470
2171
        elif IDistroSeries.providedBy(bug_target):
2474
2175
                )
2475
2176
        elif IProduct.providedBy(bug_target):
2476
2177
            return (
 
2178
                'bugsupervisor',
 
2179
                'securitycontact',
2477
2180
                'cve',
2478
2181
                )
2479
2182
        elif IProductSeries.providedBy(bug_target):
2499
2202
        return Link('+nominations', 'Review nominations', icon='bug')
2500
2203
 
2501
2204
 
2502
 
# All sort orders supported by BugTaskSet.search() and a title for
2503
 
# them.
2504
 
SORT_KEYS = [
2505
 
    ('importance', 'Importance', 'desc'),
2506
 
    ('status', 'Status', 'asc'),
2507
 
    ('id', 'Number', 'desc'),
2508
 
    ('title', 'Title', 'asc'),
2509
 
    ('targetname', 'Package/Project/Series name', 'asc'),
2510
 
    ('milestone_name', 'Milestone', 'asc'),
2511
 
    ('date_last_updated', 'Date last updated', 'desc'),
2512
 
    ('assignee', 'Assignee', 'asc'),
2513
 
    ('reporter', 'Reporter', 'asc'),
2514
 
    ('datecreated', 'Age', 'desc'),
2515
 
    ('tag', 'Tags', 'asc'),
2516
 
    ('heat', 'Heat', 'desc'),
2517
 
    ('date_closed', 'Date closed', 'desc'),
2518
 
    ('dateassigned', 'Date when the bug task was assigned', 'desc'),
2519
 
    ('number_of_duplicates', 'Number of duplicates', 'desc'),
2520
 
    ('latest_patch_uploaded', 'Date latest patch uploaded', 'desc'),
2521
 
    ('message_count', 'Number of comments', 'desc'),
2522
 
    ('milestone', 'Milestone ID', 'desc'),
2523
 
    ('specification', 'Linked blueprint', 'asc'),
2524
 
    ('task', 'Bug task ID', 'desc'),
2525
 
    ('users_affected_count', 'Number of affected users', 'desc'),
2526
 
    ]
2527
 
 
2528
 
 
2529
2205
class BugTaskSearchListingView(LaunchpadFormView, FeedsMixin, BugsInfoMixin):
2530
2206
    """View that renders a list of bugs for a given set of search criteria."""
2531
2207
 
2532
2208
    implements(IBugTaskSearchListingMenu)
2533
2209
 
2534
 
    related_features = (
2535
 
        'bugs.dynamic_bug_listings.enabled',
2536
 
        'bugs.dynamic_bug_listings.pre_fetch',
2537
 
    )
2538
 
 
2539
2210
    # Only include <link> tags for bug feeds when using this view.
2540
2211
    feed_types = (
2541
2212
        BugTargetLatestBugsFeedLink,
2552
2223
    custom_widget('assignee', PersonPickerWidget)
2553
2224
    custom_widget('bug_reporter', PersonPickerWidget)
2554
2225
    custom_widget('bug_commenter', PersonPickerWidget)
2555
 
    custom_widget('structural_subscriber', PersonPickerWidget)
 
2226
    custom_widget('bug_supervisor', PersonPickerWidget)
2556
2227
    custom_widget('subscriber', PersonPickerWidget)
2557
2228
 
2558
 
    _batch_navigator = None
2559
 
 
2560
2229
    @cachedproperty
2561
2230
    def bug_tracking_usage(self):
2562
2231
        """Whether the context tracks bugs in Launchpad.
2588
2257
        return False
2589
2258
 
2590
2259
    @property
2591
 
    def can_have_external_bugtracker(self):
2592
 
        return (IProduct.providedBy(self.context)
2593
 
                or IProductSeries.providedBy(self.context))
2594
 
 
2595
 
    @property
2596
 
    def bugtracker(self):
2597
 
        """Description of the context's bugtracker.
2598
 
 
2599
 
        :returns: str which may contain HTML.
2600
 
        """
2601
 
        if self.bug_tracking_usage == ServiceUsage.LAUNCHPAD:
2602
 
            return 'Launchpad'
2603
 
        elif self.external_bugtracker:
2604
 
            return BugTrackerFormatterAPI(self.external_bugtracker).link(None)
2605
 
        else:
2606
 
            return 'None specified'
2607
 
 
2608
 
    @property
2609
2260
    def upstream_launchpad_project(self):
2610
2261
        """The linked upstream `IProduct` for the package.
2611
2262
 
2632
2283
 
2633
2284
    @property
2634
2285
    def page_title(self):
2635
 
        return "Bugs : %s" % self.context.displayname
 
2286
        return "Bugs in %s" % self.context.title
2636
2287
 
2637
2288
    label = page_title
2638
2289
 
2690
2341
 
2691
2342
        expose_structural_subscription_data_to_js(
2692
2343
            self.context, self.request, self.user)
2693
 
        if getFeatureFlag('bugs.dynamic_bug_listings.enabled'):
2694
 
            cache = IJSONRequestCache(self.request)
2695
 
            view_names = set(reg.name for reg
2696
 
                in iter_view_registrations(self.__class__))
2697
 
            if len(view_names) != 1:
2698
 
                raise AssertionError("Ambiguous view name.")
2699
 
            cache.objects['view_name'] = view_names.pop()
2700
 
            batch_navigator = self.search()
2701
 
            cache.objects['mustache_model'] = batch_navigator.model
2702
 
            cache.objects['field_visibility'] = (
2703
 
                batch_navigator.field_visibility)
2704
 
            cache.objects['field_visibility_defaults'] = (
2705
 
                batch_navigator.field_visibility_defaults)
2706
 
            cache.objects['cbl_cookie_name'] = batch_navigator.getCookieName()
2707
 
 
2708
 
            def _getBatchInfo(batch):
2709
 
                if batch is None:
2710
 
                    return None
2711
 
                return {'memo': batch.range_memo,
2712
 
                        'start': batch.startNumber() - 1}
2713
 
 
2714
 
            next_batch = batch_navigator.batch.nextBatch()
2715
 
            cache.objects['next'] = _getBatchInfo(next_batch)
2716
 
            prev_batch = batch_navigator.batch.prevBatch()
2717
 
            cache.objects['prev'] = _getBatchInfo(prev_batch)
2718
 
            cache.objects['total'] = batch_navigator.batch.total()
2719
 
            cache.objects['order_by'] = ','.join(
2720
 
                get_sortorder_from_request(self.request))
2721
 
            cache.objects['forwards'] = batch_navigator.batch.range_forwards
2722
 
            last_batch = batch_navigator.batch.lastBatch()
2723
 
            cache.objects['last_start'] = last_batch.startNumber() - 1
2724
 
            cache.objects.update(_getBatchInfo(batch_navigator.batch))
2725
 
            cache.objects['sort_keys'] = SORT_KEYS
2726
 
 
2727
 
    @property
2728
 
    def show_config_portlet(self):
2729
 
        if (IDistribution.providedBy(self.context) or
2730
 
            IProduct.providedBy(self.context)):
2731
 
            return True
2732
 
        else:
2733
 
            return False
2734
2344
 
2735
2345
    @property
2736
2346
    def columns_to_show(self):
2759
2369
                "Unrecognized context; don't know which report "
2760
2370
                "columns to show.")
2761
2371
 
2762
 
    bugtask_table_template = ViewPageTemplateFile(
2763
 
        '../templates/bugs-table-include.pt')
2764
 
 
2765
 
    @property
2766
 
    def template(self):
2767
 
        query_string = self.request.get('QUERY_STRING') or ''
2768
 
        query_params = urlparse.parse_qs(query_string)
2769
 
        if 'batch_request' in query_params:
2770
 
            return self.bugtask_table_template
2771
 
        else:
2772
 
            return super(BugTaskSearchListingView, self).template
2773
 
 
2774
2372
    def validate_search_params(self):
2775
2373
        """Validate the params passed for the search.
2776
2374
 
2794
2392
                orderby_col = orderby_col[1:]
2795
2393
 
2796
2394
            try:
2797
 
                bugset.orderby_expression[orderby_col]
 
2395
                bugset.getOrderByColumnDBName(orderby_col)
2798
2396
            except KeyError:
2799
2397
                raise UnexpectedFormData(
2800
2398
                    "Unknown sort column '%s'" % orderby_col)
3021
2619
            the search criteria taken from the request. Params in
3022
2620
            `extra_params` take precedence over request params.
3023
2621
        """
3024
 
        if self._batch_navigator is None:
3025
 
            unbatchedTasks = self.searchUnbatched(
3026
 
                searchtext, context, extra_params)
3027
 
            self._batch_navigator = self._getBatchNavigator(unbatchedTasks)
3028
 
        return self._batch_navigator
 
2622
        unbatchedTasks = self.searchUnbatched(
 
2623
            searchtext, context, extra_params)
 
2624
        return self._getBatchNavigator(unbatchedTasks)
3029
2625
 
3030
2626
    def searchUnbatched(self, searchtext=None, context=None,
3031
2627
                        extra_params=None, prejoins=[]):
3046
2642
        search_params = self.buildSearchParams(
3047
2643
            searchtext=searchtext, extra_params=extra_params)
3048
2644
        search_params.user = self.user
3049
 
        try:
3050
 
            tasks = context.searchTasks(search_params, prejoins=prejoins)
3051
 
        except ValueError as e:
3052
 
            self.request.response.addErrorNotification(str(e))
3053
 
            self.request.response.redirect(canonical_url(
3054
 
                self.context, rootsite='bugs', view_name='+bugs'))
3055
 
            tasks = None
 
2645
        tasks = context.searchTasks(search_params, prejoins=prejoins)
3056
2646
        return tasks
3057
2647
 
3058
2648
    def getWidgetValues(
3064
2654
 
3065
2655
        if vocabulary is None:
3066
2656
            assert vocabulary_name is not None, 'No vocabulary specified.'
 
2657
            vocabulary_registry = getVocabularyRegistry()
3067
2658
            vocabulary = vocabulary_registry.get(
3068
2659
                self.context, vocabulary_name)
3069
2660
        for term in vocabulary:
3108
2699
            IDistroSeries.providedBy(context) or
3109
2700
            ISourcePackage.providedBy(context))
3110
2701
 
3111
 
    def shouldShowStructuralSubscriberWidget(self):
3112
 
        """Should the structural subscriber widget be shown on the page?
3113
 
 
3114
 
        Show the widget when there are subordinate structures.
3115
 
        """
3116
 
        return self.structural_subscriber_label is not None
 
2702
    def shouldShowSupervisorWidget(self):
 
2703
        """
 
2704
        Should the bug supervisor widget be shown on the advanced search page?
 
2705
        """
 
2706
        return True
3117
2707
 
3118
2708
    def shouldShowNoPackageWidget(self):
3119
2709
        """Should the widget to filter on bugs with no package be shown?
3152
2742
            IProduct.providedBy(self.context) or
3153
2743
            IProjectGroup.providedBy(self.context))
3154
2744
 
3155
 
    def shouldShowTeamPortlet(self):
3156
 
        """Should the User's Teams portlet me shown in the results?"""
3157
 
        return False
3158
 
 
3159
 
    @property
3160
 
    def structural_subscriber_label(self):
3161
 
        if IDistribution.providedBy(self.context):
3162
 
            return 'Package or series subscriber'
3163
 
        elif IDistroSeries.providedBy(self.context):
3164
 
            return 'Package subscriber'
3165
 
        elif IProduct.providedBy(self.context):
3166
 
            return 'Series subscriber'
3167
 
        elif IProjectGroup.providedBy(self.context):
3168
 
            return 'Project or series subscriber'
3169
 
        elif IPerson.providedBy(self.context):
3170
 
            return 'Project, distribution, package, or series subscriber'
3171
 
        else:
3172
 
            return None
3173
 
 
3174
2745
    def getSortLink(self, colname):
3175
2746
        """Return a link that can be used to sort results by colname."""
3176
2747
        form = self.request.form
3273
2844
        error_message = _(
3274
2845
            "There's no person with the name or email address '%s'.")
3275
2846
 
3276
 
        for name in ('assignee', 'bug_reporter', 'structural_subscriber',
 
2847
        for name in ('assignee', 'bug_reporter', 'bug_supervisor',
3277
2848
                     'bug_commenter', 'subscriber'):
3278
2849
            if self.getFieldError(name):
3279
2850
                self.setFieldError(
3347
2918
    def addquestion_url(self):
3348
2919
        """Return the URL for the +addquestion view for the context."""
3349
2920
        if IQuestionTarget.providedBy(self.context):
3350
 
            answers_usage = IServiceUsage(self.context).answers_usage
3351
 
            if answers_usage == ServiceUsage.LAUNCHPAD:
3352
 
                return canonical_url(
3353
 
                    self.context, rootsite='answers',
3354
 
                    view_name='+addquestion')
 
2921
            return canonical_url(
 
2922
                self.context, rootsite='answers', view_name='+addquestion')
3355
2923
        else:
3356
2924
            return None
3357
2925
 
3358
 
    @cachedproperty
3359
 
    def dynamic_bug_listing_enabled(self):
3360
 
        """Feature flag: Can the bug listing be customized?"""
3361
 
        return bool(getFeatureFlag('bugs.dynamic_bug_listings.enabled'))
3362
 
 
3363
 
    @property
3364
 
    def search_macro_title(self):
3365
 
        """The search macro's title text."""
3366
 
        return u"Search bugs %s" % self.context_description
3367
 
 
3368
 
    @property
3369
 
    def context_description(self):
3370
 
        """A phrase describing the context of the bug.
3371
 
 
3372
 
        The phrase is intended to be used for headings like
3373
 
        "Bugs in $context", "Search bugs in $context". This
3374
 
        property should be overridden for person related views.
3375
 
        """
3376
 
        return "in %s" % self.context.displayname
3377
 
 
3378
2926
 
3379
2927
class BugNominationsView(BugTaskSearchListingView):
3380
2928
    """View for accepting/declining bug nominations."""
3574
3122
        self.cached_milestone_source = CachedMilestoneSourceFactory()
3575
3123
        self.user_is_subscribed = self.context.isSubscribed(self.user)
3576
3124
 
3577
 
        # If we have made it to here then the logged in user can see the
3578
 
        # bug, hence they can see any assignees.
3579
 
        # The security adaptor will do the job also but we don't want or need
3580
 
        # the expense of running several complex SQL queries.
3581
 
        authorised_people = [task.assignee for task in self.bugtasks
3582
 
                             if task.assignee is not None]
3583
 
        precache_permission_for_objects(
3584
 
            self.request, 'launchpad.LimitedView', authorised_people)
3585
 
 
3586
3125
        # Pull all of the related milestones, if any, into the storm cache,
3587
3126
        # since they'll be needed for the vocabulary used in this view.
3588
3127
        if self.bugtasks:
3777
3316
        else:
3778
3317
            return 'false'
3779
3318
 
3780
 
    @cachedproperty
 
3319
    @property
3781
3320
    def other_users_affected_count(self):
3782
 
        """The number of other users affected by this bug.
3783
 
        """
3784
 
        if getFeatureFlag('bugs.affected_count_includes_dupes.disabled'):
3785
 
            if self.current_user_affected_status:
3786
 
                return self.context.users_affected_count - 1
3787
 
            else:
3788
 
                return self.context.users_affected_count
 
3321
        """The number of other users affected by this bug."""
 
3322
        if self.current_user_affected_status:
 
3323
            return self.context.users_affected_count - 1
3789
3324
        else:
3790
 
            return self.context.other_users_affected_count_with_dupes
3791
 
 
3792
 
    @cachedproperty
3793
 
    def total_users_affected_count(self):
3794
 
        """The number of affected users, typically across all users.
3795
 
 
3796
 
        Counting across duplicates may be disabled at run time.
3797
 
        """
3798
 
        if getFeatureFlag('bugs.affected_count_includes_dupes.disabled'):
3799
3325
            return self.context.users_affected_count
3800
 
        else:
3801
 
            return self.context.users_affected_count_with_dupes
3802
3326
 
3803
 
    @cachedproperty
 
3327
    @property
3804
3328
    def affected_statement(self):
3805
3329
        """The default "this bug affects" statement to show.
3806
3330
 
3807
3331
        The outputs of this method should be mirrored in
3808
3332
        MeTooChoiceSource._getSourceNames() (Javascript).
3809
3333
        """
3810
 
        me_affected = self.current_user_affected_status
3811
 
        other_affected = self.other_users_affected_count
3812
 
        if me_affected is None:
3813
 
            if other_affected == 1:
 
3334
        if self.other_users_affected_count == 1:
 
3335
            if self.current_user_affected_status is None:
3814
3336
                return "This bug affects 1 person. Does this bug affect you?"
3815
 
            elif other_affected > 1:
 
3337
            elif self.current_user_affected_status:
 
3338
                return "This bug affects you and 1 other person"
 
3339
            else:
 
3340
                return "This bug affects 1 person, but not you"
 
3341
        elif self.other_users_affected_count > 1:
 
3342
            if self.current_user_affected_status is None:
3816
3343
                return (
3817
3344
                    "This bug affects %d people. Does this bug "
3818
 
                    "affect you?" % (other_affected))
 
3345
                    "affect you?" % (self.other_users_affected_count))
 
3346
            elif self.current_user_affected_status:
 
3347
                return "This bug affects you and %d other people" % (
 
3348
                    self.other_users_affected_count)
3819
3349
            else:
 
3350
                return "This bug affects %d people, but not you" % (
 
3351
                    self.other_users_affected_count)
 
3352
        else:
 
3353
            if self.current_user_affected_status is None:
3820
3354
                return "Does this bug affect you?"
3821
 
        elif me_affected is True:
3822
 
            if other_affected == 0:
 
3355
            elif self.current_user_affected_status:
3823
3356
                return "This bug affects you"
3824
 
            elif other_affected == 1:
3825
 
                return "This bug affects you and 1 other person"
3826
3357
            else:
3827
 
                return "This bug affects you and %d other people" % (
3828
 
                    other_affected)
3829
 
        else:
3830
 
            if other_affected == 0:
3831
3358
                return "This bug doesn't affect you"
3832
 
            elif other_affected == 1:
3833
 
                return "This bug affects 1 person, but not you"
3834
 
            elif other_affected > 1:
3835
 
                return "This bug affects %d people, but not you" % (
3836
 
                    other_affected)
3837
3359
 
3838
 
    @cachedproperty
 
3360
    @property
3839
3361
    def anon_affected_statement(self):
3840
3362
        """The "this bug affects" statement to show to anonymous users.
3841
3363
 
3842
3364
        The outputs of this method should be mirrored in
3843
3365
        MeTooChoiceSource._getSourceNames() (Javascript).
3844
3366
        """
3845
 
        affected = self.total_users_affected_count
3846
 
        if affected == 1:
 
3367
        if self.context.users_affected_count == 1:
3847
3368
            return "This bug affects 1 person"
3848
 
        elif affected > 1:
3849
 
            return "This bug affects %d people" % affected
 
3369
        elif self.context.users_affected_count > 1:
 
3370
            return "This bug affects %d people" % (
 
3371
                self.context.users_affected_count)
3850
3372
        else:
3851
3373
            return None
3852
3374
 
3853
 
    @property
3854
 
    def _allow_multipillar_private_bugs(self):
3855
 
        """ Some teams still need to have multi pillar private bugs."""
3856
 
        return bool(getFeatureFlag(
3857
 
            'disclosure.allow_multipillar_private_bugs.enabled'))
3858
 
 
3859
 
    def canAddProjectTask(self):
3860
 
        """Can a new bug task on a project be added to this bug?
3861
 
 
3862
 
        If a bug has any bug tasks already, were it to be private, it cannot
3863
 
        be marked as also affecting any other project, so return False.
3864
 
 
3865
 
        Note: this check is currently only relevant if a bug is private.
3866
 
        Eventually, even public bugs will have this restriction too. So what
3867
 
        happens now is that this API is used by the tales to add a class
3868
 
        called 'disallow-private' to the Also Affects Project link. A css rule
3869
 
        is used to hide the link when body.private is True.
3870
 
 
3871
 
        """
3872
 
        bug = self.context
3873
 
        if self._allow_multipillar_private_bugs:
3874
 
            return True
3875
 
        return len(bug.bugtasks) == 0
3876
 
 
3877
 
    def canAddPackageTask(self):
3878
 
        """Can a new bug task on a src pkg be added to this bug?
3879
 
 
3880
 
        If a bug has any existing bug tasks on a project, were it to
3881
 
        be private, then it cannot be marked as affecting a package,
3882
 
        so return False.
3883
 
 
3884
 
        A task on a given package may still be illegal to add, but
3885
 
        this will be caught when bug.addTask() is attempted.
3886
 
 
3887
 
        Note: this check is currently only relevant if a bug is private.
3888
 
        Eventually, even public bugs will have this restriction too. So what
3889
 
        happens now is that this API is used by the tales to add a class
3890
 
        called 'disallow-private' to the Also Affects Package link. A css rule
3891
 
        is used to hide the link when body.private is True.
3892
 
        """
3893
 
        bug = self.context
3894
 
        if self._allow_multipillar_private_bugs:
3895
 
            return True
3896
 
        for pillar in bug.affected_pillars:
3897
 
            if IProduct.providedBy(pillar):
3898
 
                return False
3899
 
        return True
3900
 
 
3901
 
 
3902
 
class BugTaskTableRowView(LaunchpadView, BugTaskBugWatchMixin,
3903
 
                          BugTaskPrivilegeMixin):
 
3375
 
 
3376
class BugTaskTableRowView(LaunchpadView, BugTaskBugWatchMixin):
3904
3377
    """Browser class for rendering a bugtask row on the bug page."""
3905
3378
 
3906
3379
    is_conjoined_slave = None
3908
3381
    target_link_title = None
3909
3382
    many_bugtasks = False
3910
3383
 
3911
 
    template = ViewPageTemplateFile(
3912
 
        '../templates/bugtask-tasks-and-nominations-table-row.pt')
3913
 
 
3914
3384
    def __init__(self, context, request):
3915
3385
        super(BugTaskTableRowView, self).__init__(context, request)
3916
3386
        self.milestone_source = MilestoneVocabulary
3917
3387
 
3918
 
    @cachedproperty
3919
 
    def api_request(self):
3920
 
        return IWebServiceClientRequest(self.request)
3921
 
 
3922
 
    def initialize(self):
3923
 
        super(BugTaskTableRowView, self).initialize()
3924
 
        link = canonical_url(self.context)
3925
 
        task_link = edit_link = canonical_url(
3926
 
                                    self.context, view_name='+editstatus')
3927
 
        delete_link = canonical_url(self.context, view_name='+delete')
3928
 
        can_edit = check_permission('launchpad.Edit', self.context)
3929
 
        bugtask_id = self.context.id
3930
 
        launchbag = getUtility(ILaunchBag)
3931
 
        is_primary = self.context.id == launchbag.bugtask.id
3932
 
        self.data = dict(
3933
 
            # Looking at many_bugtasks is an important optimization.  With
3934
 
            # 150+ bugtasks, it can save three or four seconds of rendering
3935
 
            # time.
3936
 
            expandable=(not self.many_bugtasks and self.canSeeTaskDetails()),
3937
 
            indent_task=ISeriesBugTarget.providedBy(self.context.target),
3938
 
            is_conjoined_slave=self.is_conjoined_slave,
3939
 
            task_link=task_link,
3940
 
            edit_link=edit_link,
3941
 
            can_edit=can_edit,
3942
 
            link=link,
3943
 
            id=bugtask_id,
3944
 
            row_id='tasksummary%d' % bugtask_id,
3945
 
            form_row_id='task%d' % bugtask_id,
3946
 
            row_css_class='highlight' if is_primary else None,
3947
 
            target_link=canonical_url(self.context.target),
3948
 
            target_link_title=self.target_link_title,
3949
 
            user_can_delete=self.user_can_delete_bugtask,
3950
 
            delete_link=delete_link,
3951
 
            user_can_edit_importance=self.user_has_privileges,
3952
 
            importance_css_class='importance' + self.context.importance.name,
3953
 
            importance_title=self.context.importance.title,
3954
 
            # We always look up all milestones, so there's no harm
3955
 
            # using len on the list here and avoid the COUNT query.
3956
 
            target_has_milestones=len(self._visible_milestones) > 0,
3957
 
            user_can_edit_status=self.user_can_edit_status,
3958
 
            )
3959
 
 
3960
 
        if not self.many_bugtasks:
3961
 
            cache = IJSONRequestCache(self.request)
3962
 
            bugtask_data = cache.objects.get('bugtask_data', None)
3963
 
            if bugtask_data is None:
3964
 
                bugtask_data = dict()
3965
 
                cache.objects['bugtask_data'] = bugtask_data
3966
 
            bugtask_data[bugtask_id] = self.bugtask_config()
3967
 
 
3968
3388
    def canSeeTaskDetails(self):
3969
3389
        """Whether someone can see a task's status details.
3970
3390
 
3983
3403
                self.context.bug.duplicateof is None and
3984
3404
                not self.is_converted_to_question)
3985
3405
 
 
3406
    def expandable(self):
 
3407
        """Can the task's details be expanded?
 
3408
 
 
3409
        They can if there are not too many bugtasks, and if the user can see
 
3410
        the task details."""
 
3411
        # Looking at many_bugtasks is an important optimization.  With 150+
 
3412
        # bugtasks, it can save three or four seconds of rendering time.
 
3413
        return not self.many_bugtasks and self.canSeeTaskDetails()
 
3414
 
 
3415
    def getTaskRowCSSClass(self):
 
3416
        """The appropriate CSS class for the row in the Affects table.
 
3417
 
 
3418
        Currently this consists solely of highlighting the current context.
 
3419
        """
 
3420
        bugtask = self.context
 
3421
        if bugtask == getUtility(ILaunchBag).bugtask:
 
3422
            return 'highlight'
 
3423
        else:
 
3424
            return None
 
3425
 
 
3426
    def shouldIndentTask(self):
 
3427
        """Should this task be indented in the task listing on the bug page?
 
3428
 
 
3429
        Returns True or False.
 
3430
        """
 
3431
        return ISeriesBugTarget.providedBy(self.context.target)
 
3432
 
 
3433
    def taskLink(self):
 
3434
        """Return the proper link to the bugtask whether it's editable."""
 
3435
        user = getUtility(ILaunchBag).user
 
3436
        bugtask = self.context
 
3437
        if check_permission('launchpad.Edit', user):
 
3438
            return canonical_url(bugtask) + "/+editstatus"
 
3439
        else:
 
3440
            return canonical_url(bugtask) + "/+viewstatus"
 
3441
 
3986
3442
    def _getSeriesTargetNameHelper(self, bugtask):
3987
3443
        """Return the short name of bugtask's targeted series."""
3988
3444
        series = bugtask.distroseries or bugtask.productseries
4067
3523
            items = vocabulary_to_choice_edit_items(
4068
3524
                self._visible_milestones,
4069
3525
                value_fn=lambda item: canonical_url(
4070
 
                    item, request=self.api_request))
 
3526
                    item, request=IWebServiceClientRequest(self.request)))
4071
3527
            items.append({
4072
3528
                "name": "Remove milestone",
4073
3529
                "disabled": False,
4077
3533
 
4078
3534
        return items
4079
3535
 
 
3536
    @cachedproperty
 
3537
    def target_has_milestones(self):
 
3538
        """Are there any milestones we can target?
 
3539
 
 
3540
        We always look up all milestones, so there's no harm
 
3541
        using len on the list here and avoid the COUNT query.
 
3542
        """
 
3543
        return len(self._visible_milestones) > 0
 
3544
 
4080
3545
    def bugtask_canonical_url(self):
4081
3546
        """Return the canonical url for the bugtask."""
4082
3547
        return canonical_url(self.context)
4083
3548
 
4084
 
    @cachedproperty
 
3549
    @property
4085
3550
    def user_can_edit_importance(self):
4086
3551
        """Can the user edit the Importance field?
4087
3552
 
4088
3553
        If yes, return True, otherwise return False.
4089
3554
        """
4090
 
        return self.user_can_edit_status and self.user_has_privileges
4091
 
 
4092
 
    @cachedproperty
4093
 
    def user_can_edit_status(self):
4094
 
        """Can the user edit the Status field?
4095
 
 
4096
 
        If yes, return True, otherwise return False.
4097
 
        """
4098
 
        bugtask = self.context
4099
 
        edit_allowed = bugtask.target_uses_malone or bugtask.bugwatch
4100
 
        if bugtask.bugwatch:
4101
 
            bugtracker = bugtask.bugwatch.bugtracker
4102
 
            edit_allowed = (
4103
 
                bugtracker.bugtrackertype == BugTrackerType.EMAILADDRESS)
4104
 
        return edit_allowed
 
3555
        return self.context.userCanEditImportance(self.user)
4105
3556
 
4106
3557
    @property
4107
3558
    def user_can_edit_assignee(self):
4108
 
        """Can the user edit the Assignee field?
 
3559
        """Can the user edit the Milestone field?
4109
3560
 
4110
3561
        If yes, return True, otherwise return False.
4111
3562
        """
4112
3563
        return self.user is not None
4113
3564
 
4114
 
    @cachedproperty
4115
 
    def user_can_delete_bugtask(self):
4116
 
        """Can the user delete the bug task?
 
3565
    @property
 
3566
    def user_can_edit_milestone(self):
 
3567
        """Can the user edit the Milestone field?
4117
3568
 
4118
3569
        If yes, return True, otherwise return False.
4119
3570
        """
4120
 
        bugtask = self.context
4121
 
        return (check_permission('launchpad.Delete', bugtask)
4122
 
                and bugtask.canBeDeleted())
 
3571
        return self.context.userCanEditMilestone(self.user)
4123
3572
 
4124
3573
    @property
4125
3574
    def style_for_add_milestone(self):
4126
3575
        if self.context.milestone is None:
4127
3576
            return ''
4128
3577
        else:
4129
 
            return 'hidden'
 
3578
            return 'display: none'
4130
3579
 
4131
3580
    @property
4132
3581
    def style_for_edit_milestone(self):
4133
3582
        if self.context.milestone is None:
4134
 
            return 'hidden'
 
3583
            return 'display: none'
4135
3584
        else:
4136
3585
            return ''
4137
3586
 
4138
 
    def bugtask_config(self):
4139
 
        """Configuration for the bugtask JS widgets on the row."""
4140
 
        assignee_vocabulary, assignee_vocabulary_filters = (
4141
 
            get_assignee_vocabulary_info(self.context))
4142
 
        # If we have no filters or just the ALL filter, then no filtering
4143
 
        # support is required.
4144
 
        filter_details = []
4145
 
        if (len(assignee_vocabulary_filters) > 1 or
4146
 
               (len(assignee_vocabulary_filters) == 1
4147
 
                and assignee_vocabulary_filters[0].name != 'ALL')):
4148
 
            for filter in assignee_vocabulary_filters:
4149
 
                filter_details.append({
4150
 
                    'name': filter.name,
4151
 
                    'title': filter.title,
4152
 
                    'description': filter.description,
4153
 
                    })
 
3587
    def js_config(self):
 
3588
        """Configuration for the JS widgets on the row, JSON-serialized."""
 
3589
        assignee_vocabulary = get_assignee_vocabulary(self.context)
4154
3590
        # Display the search field only if the user can set any person
4155
3591
        # or team
4156
 
        user = self.user
 
3592
        user = getUtility(ILaunchBag).user
4157
3593
        hide_assignee_team_selection = (
4158
3594
            not self.context.userCanSetAnyAssignee(user) and
4159
3595
            (user is None or user.teams_participated_in.count() == 0))
4160
 
        cx = self.context
4161
 
        return dict(
4162
 
            id=cx.id,
4163
 
            row_id=self.data['row_id'],
4164
 
            form_row_id=self.data['form_row_id'],
4165
 
            bugtask_path='/'.join([''] + self.data['link'].split('/')[3:]),
4166
 
            prefix=get_prefix(cx),
4167
 
            targetname=cx.bugtargetdisplayname,
4168
 
            bug_title=cx.bug.title,
4169
 
            assignee_value=cx.assignee and cx.assignee.name,
4170
 
            assignee_is_team=cx.assignee and cx.assignee.is_team,
4171
 
            assignee_vocabulary=assignee_vocabulary,
4172
 
            assignee_vocabulary_filters=filter_details,
4173
 
            hide_assignee_team_selection=hide_assignee_team_selection,
4174
 
            user_can_unassign=cx.userCanUnassign(user),
4175
 
            user_can_delete=self.user_can_delete_bugtask,
4176
 
            delete_link=self.data['delete_link'],
4177
 
            target_is_product=IProduct.providedBy(cx.target),
4178
 
            status_widget_items=self.status_widget_items,
4179
 
            status_value=cx.status.title,
4180
 
            importance_widget_items=self.importance_widget_items,
4181
 
            importance_value=cx.importance.title,
4182
 
            milestone_widget_items=self.milestone_widget_items,
4183
 
            milestone_value=(
4184
 
                canonical_url(
4185
 
                    cx.milestone,
4186
 
                    request=self.api_request)
4187
 
                if cx.milestone else None),
4188
 
            user_can_edit_assignee=self.user_can_edit_assignee,
4189
 
            user_can_edit_milestone=self.user_has_privileges,
4190
 
            user_can_edit_status=self.user_can_edit_status,
4191
 
            user_can_edit_importance=self.user_has_privileges,
4192
 
            )
 
3596
        return dumps({
 
3597
            'row_id': 'tasksummary%s' % self.context.id,
 
3598
            'bugtask_path': '/'.join(
 
3599
                [''] + canonical_url(self.context).split('/')[3:]),
 
3600
            'prefix': get_prefix(self.context),
 
3601
            'assignee_value': self.context.assignee
 
3602
                and self.context.assignee.name,
 
3603
            'assignee_is_team': self.context.assignee
 
3604
                and self.context.assignee.is_team,
 
3605
            'assignee_vocabulary': assignee_vocabulary,
 
3606
            'hide_assignee_team_selection': hide_assignee_team_selection,
 
3607
            'user_can_unassign': self.context.userCanUnassign(user),
 
3608
            'target_is_product': IProduct.providedBy(self.context.target),
 
3609
            'status_widget_items': self.status_widget_items,
 
3610
            'status_value': self.context.status.title,
 
3611
            'importance_widget_items': self.importance_widget_items,
 
3612
            'importance_value': self.context.importance.title,
 
3613
            'milestone_widget_items': self.milestone_widget_items,
 
3614
            'milestone_value': (self.context.milestone and
 
3615
                                canonical_url(
 
3616
                                    self.context.milestone,
 
3617
                                    request=IWebServiceClientRequest(
 
3618
                                        self.request)) or
 
3619
                                None),
 
3620
            'user_can_edit_assignee': self.user_can_edit_assignee,
 
3621
            'user_can_edit_milestone': self.user_can_edit_milestone,
 
3622
            'user_can_edit_status': not self.context.bugwatch,
 
3623
            'user_can_edit_importance': (
 
3624
                self.user_can_edit_importance and
 
3625
                not self.context.bugwatch)})
4193
3626
 
4194
3627
 
4195
3628
class BugsBugTaskSearchListingView(BugTaskSearchListingView):
4208
3641
            self._redirectToSearchContext()
4209
3642
 
4210
3643
    def _redirectToSearchContext(self):
4211
 
        """Check whether a target was given and redirect to it.
 
3644
        """Check wether a target was given and redirect to it.
4212
3645
 
4213
3646
        All the URL parameters will be passed on to the target's +bugs
4214
3647
        page.
4359
3792
    page_title = label
4360
3793
 
4361
3794
 
4362
 
class BugTaskExpirableListingView(BugTaskSearchListingView):
 
3795
class BugTaskExpirableListingView(LaunchpadView):
4363
3796
    """View for listing Incomplete bugs that can expire."""
4364
3797
 
4365
3798
    @property
4382
3815
        else:
4383
3816
            return ['id', 'summary', 'date_last_updated', 'heat']
4384
3817
 
 
3818
    @property
4385
3819
    def search(self):
4386
3820
        """Return an `ITableBatchNavigator` for the expirable bugtasks."""
 
3821
        days_old = config.malone.days_before_expiration
4387
3822
        bugtaskset = getUtility(IBugTaskSet)
4388
3823
        bugtasks = bugtaskset.findExpirableBugTasks(
4389
 
            user=self.user, target=self.context, min_days_old=0)
 
3824
            days_old, user=self.user, target=self.context)
4390
3825
        return BugListingBatchNavigator(
4391
3826
            bugtasks, self.request, columns_to_show=self.columns_to_show,
4392
3827
            size=config.malone.buglist_batch_size)
4408
3843
        """Return a formatted summary of the change."""
4409
3844
        if self.target is not None:
4410
3845
            # This is a bug task.  We want the attribute, as filtered out.
4411
 
            summary = self.attribute
 
3846
            return self.attribute
4412
3847
        else:
4413
3848
            # Otherwise, the attribute is more normalized than what we want.
4414
3849
            # Use "whatchanged," which sometimes is more descriptive.
4415
 
            summary = self.whatchanged
4416
 
        return self.get_better_summary(summary)
4417
 
 
4418
 
    def get_better_summary(self, summary):
4419
 
        """For some activities, we want a different summary for the UI.
4420
 
 
4421
 
        Some event names are more descriptive as data, but less relevant to
4422
 
        users, who are unfamiliar with the lp code."""
4423
 
        better_summaries = {
4424
 
            'bug task deleted': 'no longer affects',
4425
 
            }
4426
 
        return better_summaries.get(summary, summary)
 
3850
            return self.whatchanged
4427
3851
 
4428
3852
    @property
4429
3853
    def _formatted_tags_change(self):
4501
3925
                else:
4502
3926
                    return_dict[key] = cgi.escape(return_dict[key])
4503
3927
 
4504
 
        elif attribute == 'bug task deleted':
4505
 
            return self.oldvalue
4506
 
 
4507
3928
        else:
4508
3929
            # Our default state is to just return oldvalue and newvalue.
4509
3930
            # Since we don't necessarily know what they are, we escape