~launchpad-pqm/launchpad/devel

« back to all changes in this revision

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

Merge db-devel.

Show diffs side-by-side

added added

removed removed

Lines of Context:
18
18
    'BugTaskBreadcrumb',
19
19
    'BugTaskContextMenu',
20
20
    'BugTaskCreateQuestionView',
 
21
    'BugTaskDeletionView',
21
22
    'BugTaskEditView',
22
23
    'BugTaskExpirableListingView',
23
24
    'BugTaskListingItem',
91
92
    InputErrors,
92
93
    )
93
94
from zope.app.form.utility import setUpWidget
 
95
from zope.app.security.interfaces import IUnauthenticatedPrincipal
94
96
from zope.component import (
95
97
    ComponentLookupError,
96
98
    getAdapter,
118
120
    isinstance as zope_isinstance,
119
121
    removeSecurityProxy,
120
122
    )
 
123
from zope.traversing.browser import absoluteURL
121
124
from zope.traversing.interfaces import IPathAdapter
122
125
 
123
126
from canonical.config import config
159
162
    custom_widget,
160
163
    LaunchpadEditFormView,
161
164
    LaunchpadFormView,
 
165
    ReturnToReferrerMixin,
162
166
    )
163
167
from lp.app.browser.lazrjs import (
164
168
    TextAreaEditorWidget,
167
171
    )
168
172
from lp.app.browser.stringformatter import FormattersAPI
169
173
from lp.app.browser.tales import (
 
174
    DateTimeFormatterAPI,
170
175
    ObjectImageDisplayAPI,
171
176
    PersonFormatterAPI,
172
177
    )
274
279
    cachedproperty,
275
280
    get_property_cache,
276
281
    )
 
282
from lp.services.utils import obfuscate_structure
277
283
 
278
284
vocabulary_registry = getVocabularyRegistry()
279
285
 
676
682
            cancel_url = canonical_url(self.context)
677
683
        return cancel_url
678
684
 
 
685
    @cachedproperty
 
686
    def api_request(self):
 
687
        return IWebServiceClientRequest(self.request)
 
688
 
679
689
    def initialize(self):
680
690
        """Set up the needed widgets."""
681
691
        bug = self.context.bug
682
692
        cache = IJSONRequestCache(self.request)
683
693
        cache.objects['bug'] = bug
 
694
        subscribers_url_data = {
 
695
            'web_link': canonical_url(bug, rootsite='bugs'),
 
696
            'self_link': absoluteURL(bug, self.api_request),
 
697
            }
 
698
        cache.objects['subscribers_portlet_url_data'] = subscribers_url_data
684
699
        cache.objects['total_comments_and_activity'] = (
685
700
            self.total_comments + self.total_activity)
686
701
        cache.objects['initial_comment_batch_offset'] = (
1760
1775
        self.updateContextFromData(data)
1761
1776
 
1762
1777
 
 
1778
class BugTaskDeletionView(ReturnToReferrerMixin, LaunchpadFormView):
 
1779
    """Used to delete a bugtask."""
 
1780
 
 
1781
    schema = IBugTask
 
1782
    field_names = []
 
1783
 
 
1784
    label = 'Remove bug task'
 
1785
    page_title = label
 
1786
 
 
1787
    @property
 
1788
    def next_url(self):
 
1789
        """Return the next URL to call when this call completes."""
 
1790
        if not self.request.is_ajax:
 
1791
            return super(BugTaskDeletionView, self).next_url
 
1792
        return None
 
1793
 
 
1794
    @action('Delete', name='delete_bugtask')
 
1795
    def delete_bugtask_action(self, action, data):
 
1796
        bugtask = self.context
 
1797
        bug = bugtask.bug
 
1798
        deleted_bugtask_url = canonical_url(self.context, rootsite='bugs')
 
1799
        message = ("This bug no longer affects %s."
 
1800
                    % bugtask.bugtargetdisplayname)
 
1801
        bugtask.delete()
 
1802
        self.request.response.addNotification(message)
 
1803
        if self.request.is_ajax:
 
1804
            launchbag = getUtility(ILaunchBag)
 
1805
            launchbag.add(bug.default_bugtask)
 
1806
            # If we are deleting the current highlighted bugtask via ajax,
 
1807
            # we must force a redirect to the new default bugtask to ensure
 
1808
            # all URLs and other client cache content is correctly refreshed.
 
1809
            # We can't do the redirect here since the XHR caller won't see it
 
1810
            # so we return the URL to go to and let the caller do it.
 
1811
            if self._return_url == deleted_bugtask_url:
 
1812
                next_url = canonical_url(
 
1813
                    bug.default_bugtask, rootsite='bugs')
 
1814
                self.request.response.setHeader('Content-type',
 
1815
                    'application/json')
 
1816
                return dumps(dict(bugtask_url=next_url))
 
1817
            # No redirect required so return the new bugtask table HTML.
 
1818
            view = getMultiAdapter(
 
1819
                (bug, self.request),
 
1820
                name='+bugtasks-and-nominations-table')
 
1821
            view.initialize()
 
1822
            return view.render()
 
1823
 
 
1824
 
1763
1825
class BugTaskListingView(LaunchpadView):
1764
1826
    """A view designed for displaying bug tasks in lists."""
1765
1827
    # Note that this right now is only used in tests and to render
2143
2205
    @property
2144
2206
    def model(self):
2145
2207
        """Provide flattened data about bugtask for simple templaters."""
 
2208
        age = DateTimeFormatterAPI(self.bug.datecreated).durationsince()
 
2209
        age += ' old'
 
2210
        date_last_updated = self.bug.date_last_message
 
2211
        if (date_last_updated is None or
 
2212
            self.bug.date_last_updated > date_last_updated):
 
2213
            date_last_updated = self.bug.date_last_updated
 
2214
        last_updated_formatter = DateTimeFormatterAPI(date_last_updated)
 
2215
        last_updated = last_updated_formatter.displaydate()
2146
2216
        badges = getAdapter(self.bugtask, IPathAdapter, 'image').badges()
2147
2217
        target_image = getAdapter(self.target, IPathAdapter, 'image')
 
2218
        if self.bugtask.milestone is not None:
 
2219
            milestone_name = self.bugtask.milestone.displayname
 
2220
        else:
 
2221
            milestone_name = None
 
2222
        assignee = None
 
2223
        if self.assignee is not None:
 
2224
            assignee = self.assignee.displayname
2148
2225
        return {
 
2226
            'age': age,
 
2227
            'assignee': assignee,
 
2228
            'bug_url': canonical_url(self.bugtask),
 
2229
            'bugtarget': self.bugtargetdisplayname,
 
2230
            'bugtarget_css': target_image.sprite_css(),
 
2231
            'bug_heat_html': self.bug_heat_html,
 
2232
            'badges': badges,
 
2233
            'id': self.bug.id,
2149
2234
            'importance': self.importance.title,
2150
2235
            'importance_class': 'importance' + self.importance.name,
 
2236
            'last_updated': last_updated,
 
2237
            'milestone_name': milestone_name,
 
2238
            'reporter': self.bug.owner.displayname,
2151
2239
            'status': self.status.title,
2152
2240
            'status_class': 'status' + self.status.name,
 
2241
            'tags': ' '.join(self.bug.tags),
2153
2242
            'title': self.bug.title,
2154
 
            'id': self.bug.id,
2155
 
            'bug_url': canonical_url(self.bugtask),
2156
 
            'bugtarget': self.bugtargetdisplayname,
2157
 
            'bugtarget_css': target_image.sprite_css(),
2158
 
            'bug_heat_html': self.bug_heat_html,
2159
 
            'badges': badges,
2160
2243
            }
2161
2244
 
2162
2245
 
2169
2252
        # rules to a mixin so that MilestoneView and others can use it.
2170
2253
        self.request = request
2171
2254
        self.target_context = target_context
 
2255
        self.field_visibility = {
 
2256
            'show_age': False,
 
2257
            'show_assignee': False,
 
2258
            'show_bugtarget': True,
 
2259
            'show_bug_heat': True,
 
2260
            'show_id': True,
 
2261
            'show_importance': True,
 
2262
            'show_last_updated': False,
 
2263
            'show_milestone_name': False,
 
2264
            'show_reporter': False,
 
2265
            'show_status': True,
 
2266
            'show_tags': False,
 
2267
            'show_title': True,
 
2268
        }
2172
2269
        TableBatchNavigator.__init__(
2173
2270
            self, tasks, request, columns_to_show=columns_to_show, size=size)
2174
2271
 
2215
2312
    @property
2216
2313
    def mustache(self):
2217
2314
        """The rendered mustache template."""
2218
 
        cache = IJSONRequestCache(self.request)
 
2315
        objects = IJSONRequestCache(self.request).objects
 
2316
        if IUnauthenticatedPrincipal.providedBy(self.request.principal):
 
2317
            objects = obfuscate_structure(objects)
2219
2318
        return pystache.render(self.mustache_template,
2220
 
                               cache.objects['mustache_model'])
 
2319
                               objects['mustache_model'])
2221
2320
 
2222
2321
    @property
2223
2322
    def model(self):
2224
2323
        bugtasks = [bugtask.model for bugtask in self.getBugListingItems()]
 
2324
        for bugtask in bugtasks:
 
2325
            bugtask.update(self.field_visibility)
2225
2326
        return {'bugtasks': bugtasks}
2226
2327
 
2227
2328
 
2348
2449
 
2349
2450
    implements(IBugTaskSearchListingMenu)
2350
2451
 
 
2452
    beta_features = ['bugs.dynamic_bug_listings.enabled']
 
2453
 
2351
2454
    # Only include <link> tags for bug feeds when using this view.
2352
2455
    feed_types = (
2353
2456
        BugTargetLatestBugsFeedLink,
2486
2589
            cache = IJSONRequestCache(self.request)
2487
2590
            batch_navigator = self.search()
2488
2591
            cache.objects['mustache_model'] = batch_navigator.model
 
2592
            cache.objects['field_visibility'] = (
 
2593
                batch_navigator.field_visibility)
2489
2594
 
2490
2595
            def _getBatchInfo(batch):
2491
2596
                if batch is None:
3523
3628
        else:
3524
3629
            return 'false'
3525
3630
 
3526
 
    @property
 
3631
    @cachedproperty
3527
3632
    def other_users_affected_count(self):
3528
 
        """The number of other users affected by this bug."""
3529
 
        if self.current_user_affected_status:
3530
 
            return self.context.users_affected_count - 1
 
3633
        """The number of other users affected by this bug.
 
3634
        """
 
3635
        if getFeatureFlag('bugs.affected_count_includes_dupes.disabled'):
 
3636
            if self.current_user_affected_status:
 
3637
                return self.context.users_affected_count - 1
 
3638
            else:
 
3639
                return self.context.users_affected_count
3531
3640
        else:
 
3641
            return self.context.other_users_affected_count_with_dupes
 
3642
 
 
3643
    @cachedproperty
 
3644
    def total_users_affected_count(self):
 
3645
        """The number of affected users, typically across all users.
 
3646
 
 
3647
        Counting across duplicates may be disabled at run time.
 
3648
        """
 
3649
        if getFeatureFlag('bugs.affected_count_includes_dupes.disabled'):
3532
3650
            return self.context.users_affected_count
 
3651
        else:
 
3652
            return self.context.users_affected_count_with_dupes
3533
3653
 
3534
 
    @property
 
3654
    @cachedproperty
3535
3655
    def affected_statement(self):
3536
3656
        """The default "this bug affects" statement to show.
3537
3657
 
3538
3658
        The outputs of this method should be mirrored in
3539
3659
        MeTooChoiceSource._getSourceNames() (Javascript).
3540
3660
        """
3541
 
        if self.other_users_affected_count == 1:
3542
 
            if self.current_user_affected_status is None:
 
3661
        me_affected = self.current_user_affected_status
 
3662
        other_affected = self.other_users_affected_count
 
3663
        if me_affected is None:
 
3664
            if other_affected == 1:
3543
3665
                return "This bug affects 1 person. Does this bug affect you?"
3544
 
            elif self.current_user_affected_status:
3545
 
                return "This bug affects you and 1 other person"
3546
 
            else:
3547
 
                return "This bug affects 1 person, but not you"
3548
 
        elif self.other_users_affected_count > 1:
3549
 
            if self.current_user_affected_status is None:
 
3666
            elif other_affected > 1:
3550
3667
                return (
3551
3668
                    "This bug affects %d people. Does this bug "
3552
 
                    "affect you?" % (self.other_users_affected_count))
3553
 
            elif self.current_user_affected_status:
3554
 
                return "This bug affects you and %d other people" % (
3555
 
                    self.other_users_affected_count)
 
3669
                    "affect you?" % (other_affected))
3556
3670
            else:
3557
 
                return "This bug affects %d people, but not you" % (
3558
 
                    self.other_users_affected_count)
3559
 
        else:
3560
 
            if self.current_user_affected_status is None:
3561
3671
                return "Does this bug affect you?"
3562
 
            elif self.current_user_affected_status:
 
3672
        elif me_affected is True:
 
3673
            if other_affected == 0:
3563
3674
                return "This bug affects you"
 
3675
            elif other_affected == 1:
 
3676
                return "This bug affects you and 1 other person"
3564
3677
            else:
 
3678
                return "This bug affects you and %d other people" % (
 
3679
                    other_affected)
 
3680
        else:
 
3681
            if other_affected == 0:
3565
3682
                return "This bug doesn't affect you"
 
3683
            elif other_affected == 1:
 
3684
                return "This bug affects 1 person, but not you"
 
3685
            elif other_affected > 1:
 
3686
                return "This bug affects %d people, but not you" % (
 
3687
                    other_affected)
3566
3688
 
3567
 
    @property
 
3689
    @cachedproperty
3568
3690
    def anon_affected_statement(self):
3569
3691
        """The "this bug affects" statement to show to anonymous users.
3570
3692
 
3571
3693
        The outputs of this method should be mirrored in
3572
3694
        MeTooChoiceSource._getSourceNames() (Javascript).
3573
3695
        """
3574
 
        if self.context.users_affected_count == 1:
 
3696
        affected = self.total_users_affected_count
 
3697
        if affected == 1:
3575
3698
            return "This bug affects 1 person"
3576
 
        elif self.context.users_affected_count > 1:
3577
 
            return "This bug affects %d people" % (
3578
 
                self.context.users_affected_count)
 
3699
        elif affected > 1:
 
3700
            return "This bug affects %d people" % affected
3579
3701
        else:
3580
3702
            return None
3581
3703
 
3643
3765
        super(BugTaskTableRowView, self).__init__(context, request)
3644
3766
        self.milestone_source = MilestoneVocabulary
3645
3767
 
 
3768
    @cachedproperty
 
3769
    def api_request(self):
 
3770
        return IWebServiceClientRequest(self.request)
 
3771
 
3646
3772
    def initialize(self):
3647
3773
        super(BugTaskTableRowView, self).initialize()
3648
3774
        link = canonical_url(self.context)
3649
 
        task_link = edit_link = link + '/+editstatus'
 
3775
        task_link = edit_link = canonical_url(
 
3776
                                    self.context, view_name='+editstatus')
 
3777
        delete_link = canonical_url(self.context, view_name='+delete')
3650
3778
        can_edit = check_permission('launchpad.Edit', self.context)
3651
3779
        bugtask_id = self.context.id
3652
3780
        launchbag = getUtility(ILaunchBag)
3668
3796
            row_css_class='highlight' if is_primary else None,
3669
3797
            target_link=canonical_url(self.context.target),
3670
3798
            target_link_title=self.target_link_title,
3671
 
            user_can_edit_importance=self.context.userCanEditImportance(
3672
 
                self.user),
 
3799
            user_can_delete=self.user_can_delete_bugtask,
 
3800
            delete_link=delete_link,
 
3801
            user_can_edit_importance=self.user_can_edit_importance,
3673
3802
            importance_css_class='importance' + self.context.importance.name,
3674
3803
            importance_title=self.context.importance.title,
3675
3804
            # We always look up all milestones, so there's no harm
3676
3805
            # using len on the list here and avoid the COUNT query.
3677
3806
            target_has_milestones=len(self._visible_milestones) > 0,
 
3807
            user_can_edit_status=self.user_can_edit_status,
3678
3808
            )
3679
3809
 
 
3810
        if not self.many_bugtasks:
 
3811
            cache = IJSONRequestCache(self.request)
 
3812
            bugtask_data = cache.objects.get('bugtask_data', None)
 
3813
            if bugtask_data is None:
 
3814
                bugtask_data = dict()
 
3815
                cache.objects['bugtask_data'] = bugtask_data
 
3816
            bugtask_data[bugtask_id] = self.bugtask_config()
 
3817
 
3680
3818
    def canSeeTaskDetails(self):
3681
3819
        """Whether someone can see a task's status details.
3682
3820
 
3779
3917
            items = vocabulary_to_choice_edit_items(
3780
3918
                self._visible_milestones,
3781
3919
                value_fn=lambda item: canonical_url(
3782
 
                    item, request=IWebServiceClientRequest(self.request)))
 
3920
                    item, request=self.api_request))
3783
3921
            items.append({
3784
3922
                "name": "Remove milestone",
3785
3923
                "disabled": False,
3793
3931
        """Return the canonical url for the bugtask."""
3794
3932
        return canonical_url(self.context)
3795
3933
 
3796
 
    @property
 
3934
    @cachedproperty
3797
3935
    def user_can_edit_importance(self):
3798
3936
        """Can the user edit the Importance field?
3799
3937
 
3800
3938
        If yes, return True, otherwise return False.
3801
3939
        """
3802
 
        return self.context.userCanEditImportance(self.user)
 
3940
        bugtask = self.context
 
3941
        return (self.user_can_edit_status
 
3942
                and bugtask.userCanEditImportance(self.user))
 
3943
 
 
3944
    @cachedproperty
 
3945
    def user_can_edit_status(self):
 
3946
        """Can the user edit the Status field?
 
3947
 
 
3948
        If yes, return True, otherwise return False.
 
3949
        """
 
3950
        bugtask = self.context
 
3951
        edit_allowed = bugtask.target_uses_malone or bugtask.bugwatch
 
3952
        if bugtask.bugwatch:
 
3953
            bugtracker = bugtask.bugwatch.bugtracker
 
3954
            edit_allowed = (
 
3955
                bugtracker.bugtrackertype == BugTrackerType.EMAILADDRESS)
 
3956
        return edit_allowed
3803
3957
 
3804
3958
    @property
3805
3959
    def user_can_edit_assignee(self):
3817
3971
        """
3818
3972
        return self.context.userCanEditMilestone(self.user)
3819
3973
 
 
3974
    @cachedproperty
 
3975
    def user_can_delete_bugtask(self):
 
3976
        """Can the user delete the bug task?
 
3977
 
 
3978
        If yes, return True, otherwise return False.
 
3979
        """
 
3980
        bugtask = self.context
 
3981
        return (check_permission('launchpad.Delete', bugtask)
 
3982
                and bugtask.canBeDeleted())
 
3983
 
3820
3984
    @property
3821
3985
    def style_for_add_milestone(self):
3822
3986
        if self.context.milestone is None:
3831
3995
        else:
3832
3996
            return ''
3833
3997
 
3834
 
    def js_config(self):
3835
 
        """Configuration for the JS widgets on the row, JSON-serialized."""
 
3998
    def bugtask_config(self):
 
3999
        """Configuration for the bugtask JS widgets on the row."""
3836
4000
        assignee_vocabulary, assignee_vocabulary_filters = (
3837
4001
            get_assignee_vocabulary_info(self.context))
3838
4002
        # If we have no filters or just the ALL filter, then no filtering
3854
4018
            not self.context.userCanSetAnyAssignee(user) and
3855
4019
            (user is None or user.teams_participated_in.count() == 0))
3856
4020
        cx = self.context
3857
 
        return dumps(dict(
 
4021
        return dict(
3858
4022
            row_id=self.data['row_id'],
 
4023
            form_row_id=self.data['form_row_id'],
3859
4024
            bugtask_path='/'.join([''] + self.data['link'].split('/')[3:]),
3860
4025
            prefix=get_prefix(cx),
 
4026
            targetname=cx.bugtargetdisplayname,
 
4027
            bug_title=cx.bug.title,
3861
4028
            assignee_value=cx.assignee and cx.assignee.name,
3862
4029
            assignee_is_team=cx.assignee and cx.assignee.is_team,
3863
4030
            assignee_vocabulary=assignee_vocabulary,
3864
4031
            assignee_vocabulary_filters=filter_details,
3865
4032
            hide_assignee_team_selection=hide_assignee_team_selection,
3866
4033
            user_can_unassign=cx.userCanUnassign(user),
 
4034
            user_can_delete=self.user_can_delete_bugtask,
 
4035
            delete_link=self.data['delete_link'],
3867
4036
            target_is_product=IProduct.providedBy(cx.target),
3868
4037
            status_widget_items=self.status_widget_items,
3869
4038
            status_value=cx.status.title,
3873
4042
            milestone_value=(
3874
4043
                canonical_url(
3875
4044
                    cx.milestone,
3876
 
                    request=IWebServiceClientRequest(self.request))
 
4045
                    request=self.api_request)
3877
4046
                if cx.milestone else None),
3878
4047
            user_can_edit_assignee=self.user_can_edit_assignee,
3879
4048
            user_can_edit_milestone=self.user_can_edit_milestone,
3880
 
            user_can_edit_status=not cx.bugwatch,
3881
 
            user_can_edit_importance=(
3882
 
                self.user_can_edit_importance and not cx.bugwatch)
3883
 
            ))
 
4049
            user_can_edit_status=self.user_can_edit_status,
 
4050
            user_can_edit_importance=self.user_can_edit_importance,
 
4051
            )
3884
4052
 
3885
4053
 
3886
4054
class BugsBugTaskSearchListingView(BugTaskSearchListingView):