~launchpad-pqm/launchpad/devel

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
# Copyright 2009-2011 Canonical Ltd.  This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).

__metaclass__ = type

__all__ = [
    'expose_enum_to_js',
    'expose_structural_subscription_data_to_js',
    'expose_user_administered_teams_to_js',
    'expose_user_subscriptions_to_js',
    'StructuralSubscriptionMenuMixin',
    'StructuralSubscriptionTargetTraversalMixin',
    'StructuralSubscriptionView',
    'StructuralSubscribersPortletView',
    ]

from operator import (
    attrgetter,
    itemgetter,
    )

from lazr.restful.interfaces import (
    IJSONRequestCache,
    IWebServiceClientRequest,
    )
from zope.component import getUtility
from zope.formlib import form
from zope.schema import (
    Choice,
    List,
    )
from zope.schema.vocabulary import (
    SimpleTerm,
    SimpleVocabulary,
    )
from zope.traversing.browser import absoluteURL

from canonical.launchpad.webapp.authorization import check_permission
from canonical.launchpad.webapp.interfaces import NoCanonicalUrl
from canonical.launchpad.webapp.menu import (
    enabled_with_permission,
    Link,
    )
from canonical.launchpad.webapp.publisher import (
    canonical_url,
    LaunchpadView,
    Navigation,
    stepthrough,
    )
from lp.app.browser.launchpadform import (
    action,
    custom_widget,
    LaunchpadFormView,
    )
from lp.app.enums import ServiceUsage
from lp.app.widgets.itemswidgets import LabeledMultiCheckBoxWidget
from lp.bugs.interfaces.bugtask import (
    BugTaskImportance,
    BugTaskStatus,
    )
from lp.bugs.interfaces.structuralsubscription import (
    IStructuralSubscription,
    IStructuralSubscriptionForm,
    IStructuralSubscriptionTarget,
    IStructuralSubscriptionTargetHelper,
    )
from lp.registry.interfaces.distribution import (
    IDistribution,
    )
from lp.registry.interfaces.distributionsourcepackage import (
    IDistributionSourcePackage,
    )
from lp.registry.interfaces.milestone import IProjectGroupMilestone
from lp.registry.interfaces.person import IPersonSet
from lp.services.propertycache import cachedproperty


class StructuralSubscriptionNavigation(Navigation):

    usedfor = IStructuralSubscription

    @stepthrough("+filter")
    def bug_filter(self, filter_id):
        bug_filter_id = int(filter_id)
        for bug_filter in self.context.bug_filters:
            if bug_filter.id == bug_filter_id:
                return bug_filter
        return None


class StructuralSubscriptionView(LaunchpadFormView):

    """View class for structural subscriptions."""

    schema = IStructuralSubscriptionForm

    custom_widget('subscriptions_team', LabeledMultiCheckBoxWidget)
    custom_widget('remove_other_subscriptions', LabeledMultiCheckBoxWidget)

    override_title_breadcrumbs = True

    @property
    def page_title(self):
        return 'Subscribe to Bugs in %s' % self.context.title

    @property
    def label(self):
        return self.page_title

    @property
    def next_url(self):
        return canonical_url(self.context)

    def setUpFields(self):
        """See LaunchpadFormView."""
        LaunchpadFormView.setUpFields(self)
        team_subscriptions = self._createTeamSubscriptionsField()
        if team_subscriptions:
            self.form_fields += form.Fields(team_subscriptions)
        if self.userIsDriver():
            add_other = form.Fields(self._createAddOtherSubscriptionsField())
            self.form_fields += add_other
            remove_other = self._createRemoveOtherSubscriptionsField()
            if remove_other:
                self.form_fields += form.Fields(remove_other)

    def _createTeamSubscriptionsField(self):
        """Create field with a list of the teams the user is a member of.

        Return a FormField instance, if the user is a member of at least
        one team, else return None.
        """
        teams = self.user_teams
        if not teams:
            return None
        teams.sort(key=attrgetter('displayname'))
        terms = [
            SimpleTerm(team, team.name, team.displayname)
            for team in teams]
        team_vocabulary = SimpleVocabulary(terms)
        team_subscriptions_field = List(
            __name__='subscriptions_team',
            title=u'Team subscriptions',
            description=(u'You can subscribe the teams of '
                          'which you are an administrator.'),
            value_type=Choice(vocabulary=team_vocabulary),
            required=False)
        return form.FormField(team_subscriptions_field)

    def _createRemoveOtherSubscriptionsField(self):
        """Create a field with a list of subscribers.

        Return a FormField instance, if subscriptions exist that can
        be removed, else return None.
        """
        teams = set(self.user_teams)
        other_subscriptions = set(
            subscription.subscriber
            for subscription
            in self.context.bug_subscriptions)

        # Teams and the current user have their own UI elements. Remove
        # them to avoid duplicates.
        other_subscriptions.difference_update(teams)
        other_subscriptions.discard(self.user)

        if not other_subscriptions:
            return None

        other_subscriptions = sorted(
            other_subscriptions, key=attrgetter('displayname'))

        terms = [
            SimpleTerm(subscriber, subscriber.name, subscriber.displayname)
            for subscriber in other_subscriptions]

        subscriptions_vocabulary = SimpleVocabulary(terms)
        other_subscriptions_field = List(
            __name__='remove_other_subscriptions',
            title=u'Unsubscribe',
            value_type=Choice(vocabulary=subscriptions_vocabulary),
            required=False)
        return form.FormField(other_subscriptions_field)

    def _createAddOtherSubscriptionsField(self):
        """Create a field for a new subscription."""
        new_subscription_field = Choice(
            __name__='new_subscription',
            title=u'Subscribe someone else',
            vocabulary='ValidPersonOrTeam',
            required=False)
        return form.FormField(new_subscription_field)

    @property
    def initial_values(self):
        """See `LaunchpadFormView`."""
        teams = set(self.user_teams)
        subscribed_teams = set(team
                               for team in teams
                               if self.isSubscribed(team))
        return {
            'subscribe_me': self.currentUserIsSubscribed(),
            'subscriptions_team': subscribed_teams,
            }

    def isSubscribed(self, person):
        """Is `person` subscribed to the context target?

        Returns True is the user is subscribed to bug notifications
        for the context target.
        """
        subscription = self.context.getSubscription(person)
        return subscription is not None

    def currentUserIsSubscribed(self):
        """Return True, if the current user is subscribed."""
        return self.isSubscribed(self.user)

    def userCanAlter(self):
        if self.context.userCanAlterBugSubscription(self.user, self.user):
            return True

    @action(u'Save these changes', name='save')
    def save_action(self, action, data):
        """Process the subscriptions submitted by the user."""
        self._handleUserSubscription(data)
        self._handleTeamSubscriptions(data)
        self._handleDriverChanges(data)

    def _handleUserSubscription(self, data):
        """Process the subscription for the user."""
        target = self.context
        # addSubscription raises an exception if called for an already
        # subscribed person, and removeBugSubscription raises an exception
        # for a non-subscriber, hence call these methods only, if the
        # subscription status changed.
        is_subscribed = self.isSubscribed(self.user)
        subscribe = data['subscribe_me']
        if (not is_subscribed) and subscribe:
            target.addBugSubscription(self.user, self.user)
            self.request.response.addNotification(
                'You have subscribed to "%s". You will now receive an '
                'e-mail each time someone reports or changes one of '
                'its public bugs.' % target.displayname)
        elif is_subscribed and not subscribe:
            target.removeBugSubscription(self.user, self.user)
            self.request.response.addNotification(
                'You have unsubscribed from "%s". You '
                'will no longer automatically receive e-mail about '
                'changes to its public bugs.'
                % target.displayname)
        else:
            # The subscription status did not change: nothing to do.
            pass

    def _handleTeamSubscriptions(self, data):
        """Process subscriptions for teams."""
        form_selected_teams = data.get('subscriptions_team', None)
        if form_selected_teams is None:
            return

        target = self.context
        teams = set(self.user_teams)
        form_selected_teams = teams & set(form_selected_teams)
        subscriptions = set(
            team for team in teams if self.isSubscribed(team))

        for team in form_selected_teams - subscriptions:
            target.addBugSubscription(team, self.user)
            self.request.response.addNotification(
                'The %s team will now receive an e-mail each time '
                'someone reports or changes a public bug in "%s".' % (
                team.displayname, self.context.displayname))

        for team in subscriptions - form_selected_teams:
            target.removeBugSubscription(team, self.user)
            self.request.response.addNotification(
                'The %s team will no longer automatically receive '
                'e-mail about changes to public bugs in "%s".' % (
                    team.displayname, self.context.displayname))

    def _handleDriverChanges(self, data):
        """Process subscriptions for other persons."""
        if not self.userIsDriver():
            return

        target = self.context
        new_subscription = data['new_subscription']
        if new_subscription is not None:
            target.addBugSubscription(new_subscription, self.user)
            self.request.response.addNotification(
                '%s will now receive an e-mail each time someone '
                'reports or changes a public bug in "%s".' % (
                new_subscription.displayname,
                target.displayname))

        subscriptions_to_remove = data.get('remove_other_subscriptions', [])
        for subscription in subscriptions_to_remove:
            target.removeBugSubscription(subscription, self.user)
            self.request.response.addNotification(
                '%s will no longer automatically receive e-mail about '
                'public bugs in "%s".' % (
                    subscription.displayname, target.displayname))

    def userIsDriver(self):
        """Has the current user driver permissions?"""
        # We only want to look at this if the target is a
        # distribution source package, in order to maintain
        # compatibility with the bug contacts feature.
        if IDistributionSourcePackage.providedBy(self.context):
            return check_permission(
                "launchpad.Driver", self.context.distribution)
        else:
            return False

    @cachedproperty
    def user_teams(self):
        """Return the teams that the current user is an administrator of."""
        return list(self.user.getAdministratedTeams())

    @property
    def show_details_portlet(self):
        """Show details portlet?

        Returns `True` if the portlet details is available
        and should be shown for the context.
        """
        return IDistributionSourcePackage.providedBy(self.context)


class StructuralSubscriptionTargetTraversalMixin:
    """Mix-in class that provides +subscription/<SUBSCRIBER> traversal."""

    @stepthrough('+subscription')
    def traverse_structuralsubscription(self, name):
        """Traverses +subscription portions of URLs."""
        person = getUtility(IPersonSet).getByName(name)
        return self.context.getSubscription(person)


class StructuralSubscriptionMenuMixin:
    """Mix-in class providing the subscription add/edit menu link."""

    def _getSST(self):
        if IStructuralSubscriptionTarget.providedBy(self.context):
            sst = self.context
        else:
            # self.context is a view, and the target is its context
            sst = self.context.context
        return sst

    def subscribe(self):
        """The subscribe menu link.

        If the user, or any of the teams he's a member of, already has a
        subscription to the context, the link offer to edit the subscriptions
        and displays the edit icon. Otherwise, the link offers to subscribe
        and displays the add icon.
        """
        sst = self._getSST()

        if sst.userHasBugSubscriptions(self.user):
            text = 'Edit bug mail subscription'
            icon = 'edit'
        else:
            text = 'Subscribe to bug mail'
            icon = 'add'
        # ProjectGroup milestones aren't really structural subscription
        # targets as they're not real milestones, so you can't subscribe to
        # them.
        if (not IProjectGroupMilestone.providedBy(sst) and
            sst.userCanAlterBugSubscription(self.user, self.user)):
            enabled = True
        else:
            enabled = False

        return Link('+subscribe', text, icon=icon, enabled=enabled)

    @property
    def _enabled(self):
        """Should the link be enabled?

        True if the target uses Launchpad for bugs and the user can alter the
        bug subscriptions.
        """
        sst = self._getSST()
        # ProjectGroup milestones aren't really structural subscription
        # targets as they're not real milestones, so you can't subscribe to
        # them.
        if IProjectGroupMilestone.providedBy(sst):
            return False
        pillar = IStructuralSubscriptionTargetHelper(sst).pillar
        return (pillar.bug_tracking_usage == ServiceUsage.LAUNCHPAD and
                sst.userCanAlterBugSubscription(self.user, self.user))

    @enabled_with_permission('launchpad.AnyPerson')
    def subscribe_to_bug_mail(self):
        text = 'Subscribe to bug mail'
        # Clicks to this link will be intercepted by the on-page JavaScript,
        # but we want a link target for non-JS-enabled browsers.
        return Link('+subscribe', text, icon='add', hidden=True,
            enabled=self._enabled)

    @enabled_with_permission('launchpad.AnyPerson')
    def edit_bug_mail(self):
        text = 'Edit bug mail'
        return Link('+subscriptions', text, icon='edit', site='bugs',
                    enabled=self._enabled)


def expose_structural_subscription_data_to_js(context, request,
                                              user, subscriptions=None):
    """Expose all of the data for a structural subscription to JavaScript."""
    expose_user_administered_teams_to_js(request, user, context)
    expose_enum_to_js(request, BugTaskImportance, 'importances')
    expose_enum_to_js(request, BugTaskStatus, 'statuses')
    if subscriptions is None:
        try:
            # No subscriptions, which means we are on a target
            # subscriptions page. Let's at least provide target details.
            target_info = {}
            target_info['title'] = context.title
            target_info['url'] = canonical_url(context, rootsite='mainsite')
            IJSONRequestCache(request).objects['target_info'] = target_info
        except NoCanonicalUrl:
            # We export nothing if the target implements no canonical URL.
            pass
    else:
        expose_user_subscriptions_to_js(user, subscriptions, request)


def expose_enum_to_js(request, enum, name):
    """Make a list of enum titles and value available to JavaScript."""
    info = []
    for item in enum:
        info.append(item.title)
    IJSONRequestCache(request).objects[name] = info


def expose_user_administered_teams_to_js(request, user, context,
        absoluteURL=absoluteURL):
    """Make the list of teams the user administers available to JavaScript."""
    # XXX: Robert Collins workaround multiple calls making this cause
    # timeouts: see bug 788510.
    objects = IJSONRequestCache(request).objects
    if 'administratedTeams' in objects:
        return
    info = []
    api_request = IWebServiceClientRequest(request)
    is_distro = IDistribution.providedBy(context)
    if is_distro:
        # If the context is a distro AND a bug supervisor is set then we only
        # allow subscriptions from members of the bug supervisor team.
        bug_supervisor = context.bug_supervisor
    else:
        bug_supervisor = None
    if user is not None:
        administrated_teams = set(user.administrated_teams)
        if administrated_teams:
            # Get this only if we need to.
            membership = set(user.teams_participated_in)
            # Only consider teams the user is both in and administers:
            #  If the user is not a member of the team itself, then
            # skip it, because structural subscriptions and their
            # filters can only be edited by the subscriber.
            # This can happen if the user is an owner but not a member.
            administers_and_in = membership.intersection(administrated_teams)
            for team in administers_and_in:
                if (bug_supervisor is not None and
                    not team.inTeam(bug_supervisor)):
                    continue
                info.append({
                    'has_preferredemail': team.preferredemail is not None,
                    'link': absoluteURL(team, api_request),
                    'title': team.title,
                    'url': canonical_url(team),
                })
    objects['administratedTeams'] = info


def expose_user_subscriptions_to_js(user, subscriptions, request):
    """Make the user's subscriptions available to JavaScript."""
    api_request = IWebServiceClientRequest(request)
    info = {}
    if user is None:
        administered_teams = []
    else:
        administered_teams = user.administrated_teams

    for subscription in subscriptions:
        target = subscription.target
        record = info.get(target)
        if record is None:
            record = dict(target_title=target.title,
                          target_url=canonical_url(
                            target, rootsite='mainsite'),
                          filters=[])
            info[target] = record
        subscriber = subscription.subscriber
        for filter in subscription.bug_filters:
            is_team = subscriber.is_team
            user_is_team_admin = (
                is_team and subscriber in administered_teams)
            team_has_contact_address = (
                is_team and subscriber.preferredemail is not None)
            mailing_list = subscriber.mailing_list
            user_is_on_team_mailing_list = (
                team_has_contact_address and
                mailing_list is not None and
                mailing_list.is_usable and
                mailing_list.getSubscription(subscriber) is not None)
            record['filters'].append(dict(
                filter=filter,
                subscriber_link=absoluteURL(subscriber, api_request),
                subscriber_url=canonical_url(
                    subscriber, rootsite='mainsite'),
                target_bugs_url=canonical_url(
                    target, rootsite='bugs'),
                subscriber_title=subscriber.title,
                subscriber_is_team=is_team,
                user_is_team_admin=user_is_team_admin,
                team_has_contact_address=team_has_contact_address,
                user_is_on_team_mailing_list=user_is_on_team_mailing_list,
                can_mute=filter.isMuteAllowed(user),
                is_muted=filter.muted(user) is not None,
                target_title=target.title))
    info = info.values()
    info.sort(key=itemgetter('target_url'))
    IJSONRequestCache(request).objects['subscription_info'] = info


class StructuralSubscribersPortletView(LaunchpadView):
    """A simple view for displaying the subscribers portlet."""

    @property
    def target_label(self):
        """Return the target label for the portlet."""
        if IDistributionSourcePackage.providedBy(self.context):
            return "To all bugs in %s" % self.context.displayname
        else:
            return "To all %s bugs" % self.context.title

    @property
    def parent_target_label(self):
        """Return the target label for the portlet."""
        return (
            "To all %s bugs" % self.context.parent_subscription_target.title)