~launchpad-pqm/launchpad/devel

« back to all changes in this revision

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

  • Committer: Launchpad Patch Queue Manager
  • Date: 2011-12-22 04:55:30 UTC
  • mfrom: (14577.1.1 testfix)
  • Revision ID: launchpad@pqm.canonical.com-20111222045530-wki9iu6c0ysqqwkx
[r=wgrant][no-qa] Fix test_publisherconfig lpstorm import. Probably a
        silent conflict between megalint and apocalypse.

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# Copyright 2010-2011 Canonical Ltd.  This software is licensed under the
 
2
# GNU Affero General Public License version 3 (see the file LICENSE).
 
3
 
 
4
"""IBugTarget-related browser views."""
 
5
 
 
6
__metaclass__ = type
 
7
 
 
8
__all__ = [
 
9
    "BugsVHostBreadcrumb",
 
10
    "BugsPatchesView",
 
11
    "BugTargetBugListingView",
 
12
    "BugTargetBugTagsView",
 
13
    "FileBugAdvancedView",
 
14
    "FileBugGuidedView",
 
15
    "FileBugViewBase",
 
16
    "IProductBugConfiguration",
 
17
    "OfficialBugTagsManageView",
 
18
    "ProductConfigureBugTrackerView",
 
19
    "ProjectFileBugGuidedView",
 
20
    "product_to_productbugconfiguration",
 
21
    ]
 
22
 
 
23
import cgi
 
24
from cStringIO import StringIO
 
25
from datetime import datetime
 
26
from functools import partial
 
27
import httplib
 
28
from operator import itemgetter
 
29
import urllib
 
30
from urlparse import urljoin
 
31
 
 
32
from lazr.restful.interface import copy_field
 
33
from pytz import timezone
 
34
from simplejson import dumps
 
35
from sqlobject import SQLObjectNotFound
 
36
from z3c.ptcompat import ViewPageTemplateFile
 
37
from zope import formlib
 
38
from zope.app.form.browser import TextWidget
 
39
from zope.app.form.interfaces import InputErrors
 
40
from zope.component import getUtility
 
41
from zope.interface import (
 
42
    alsoProvides,
 
43
    implements,
 
44
    Interface,
 
45
    )
 
46
from zope.publisher.interfaces import NotFound
 
47
from zope.publisher.interfaces.browser import IBrowserPublisher
 
48
from zope.schema import (
 
49
    Bool,
 
50
    Choice,
 
51
    )
 
52
from zope.schema.interfaces import TooLong
 
53
from zope.schema.vocabulary import SimpleVocabulary
 
54
from zope.security.proxy import removeSecurityProxy
 
55
 
 
56
from canonical.config import config
 
57
from canonical.launchpad import _
 
58
from canonical.launchpad.browser.librarian import ProxiedLibraryFileAlias
 
59
from canonical.launchpad.webapp import (
 
60
    canonical_url,
 
61
    LaunchpadView,
 
62
    urlappend,
 
63
    )
 
64
from canonical.launchpad.webapp.authorization import check_permission
 
65
from canonical.launchpad.webapp.batching import BatchNavigator
 
66
from canonical.launchpad.webapp.breadcrumb import Breadcrumb
 
67
from canonical.launchpad.webapp.interfaces import ILaunchBag
 
68
from canonical.launchpad.webapp.menu import structured
 
69
from lp.app.browser.launchpadform import (
 
70
    action,
 
71
    custom_widget,
 
72
    LaunchpadEditFormView,
 
73
    LaunchpadFormView,
 
74
    safe_action,
 
75
    )
 
76
from lp.app.browser.stringformatter import FormattersAPI
 
77
from lp.app.enums import ServiceUsage
 
78
from lp.app.errors import (
 
79
    NotFoundError,
 
80
    UnexpectedFormData,
 
81
    )
 
82
from lp.app.interfaces.launchpad import (
 
83
    ILaunchpadCelebrities,
 
84
    ILaunchpadUsage,
 
85
    )
 
86
from lp.app.validators.name import valid_name_pattern
 
87
from lp.app.widgets.product import (
 
88
    GhostCheckBoxWidget,
 
89
    GhostWidget,
 
90
    ProductBugTrackerWidget,
 
91
    )
 
92
from lp.bugs.browser.bugrole import BugRoleMixin
 
93
from lp.bugs.browser.structuralsubscription import (
 
94
    expose_structural_subscription_data_to_js,
 
95
    )
 
96
from lp.bugs.browser.widgets.bug import (
 
97
    BugTagsWidget,
 
98
    LargeBugTagsWidget,
 
99
    )
 
100
from lp.bugs.interfaces.apportjob import IProcessApportBlobJobSource
 
101
from lp.bugs.interfaces.bug import (
 
102
    CreateBugParams,
 
103
    IBug,
 
104
    IBugAddForm,
 
105
    IBugSet,
 
106
    IProjectGroupBugAddForm,
 
107
    )
 
108
from lp.bugs.interfaces.bugsupervisor import IHasBugSupervisor
 
109
from lp.bugs.interfaces.bugtarget import (
 
110
    IBugTarget,
 
111
    IOfficialBugTagTargetPublic,
 
112
    IOfficialBugTagTargetRestricted,
 
113
    )
 
114
from lp.bugs.interfaces.bugtask import (
 
115
    IBugTaskSet,
 
116
    UNRESOLVED_BUGTASK_STATUSES,
 
117
    )
 
118
from lp.bugs.interfaces.bugtracker import IBugTracker
 
119
from lp.bugs.interfaces.malone import IMaloneApplication
 
120
from lp.bugs.interfaces.securitycontact import IHasSecurityContact
 
121
from lp.bugs.model.bugtask import BugTask
 
122
from lp.bugs.model.structuralsubscription import (
 
123
    get_structural_subscriptions_for_target,
 
124
    )
 
125
from lp.bugs.publisher import BugsLayer
 
126
from lp.bugs.utilities.filebugdataparser import FileBugData
 
127
from lp.hardwaredb.interfaces.hwdb import IHWSubmissionSet
 
128
from lp.registry.browser.product import ProductConfigureBase
 
129
from lp.registry.interfaces.distribution import IDistribution
 
130
from lp.registry.interfaces.distributionsourcepackage import (
 
131
    IDistributionSourcePackage,
 
132
    )
 
133
from lp.registry.interfaces.distroseries import IDistroSeries
 
134
from lp.registry.interfaces.person import IPerson
 
135
from lp.registry.interfaces.product import IProduct
 
136
from lp.registry.interfaces.productseries import IProductSeries
 
137
from lp.registry.interfaces.projectgroup import IProjectGroup
 
138
from lp.registry.interfaces.sourcepackage import ISourcePackage
 
139
from lp.registry.vocabularies import ValidPersonOrTeamVocabulary
 
140
from lp.services.job.interfaces.job import JobStatus
 
141
from lp.services.propertycache import cachedproperty
 
142
 
 
143
# A simple vocabulary for the subscribe_to_existing_bug form field.
 
144
SUBSCRIBE_TO_BUG_VOCABULARY = SimpleVocabulary.fromItems(
 
145
    [('yes', True), ('no', False)])
 
146
 
 
147
 
 
148
class IProductBugConfiguration(Interface):
 
149
    """A composite schema for editing bug app configuration."""
 
150
 
 
151
    bug_supervisor = copy_field(
 
152
        IHasBugSupervisor['bug_supervisor'], readonly=False)
 
153
    security_contact = copy_field(IHasSecurityContact['security_contact'])
 
154
    official_malone = copy_field(ILaunchpadUsage['official_malone'])
 
155
    enable_bug_expiration = copy_field(
 
156
        ILaunchpadUsage['enable_bug_expiration'])
 
157
    bugtracker = copy_field(IProduct['bugtracker'])
 
158
    remote_product = copy_field(IProduct['remote_product'])
 
159
    bug_reporting_guidelines = copy_field(
 
160
        IBugTarget['bug_reporting_guidelines'])
 
161
    bug_reported_acknowledgement = copy_field(
 
162
        IBugTarget['bug_reported_acknowledgement'])
 
163
    enable_bugfiling_duplicate_search = copy_field(
 
164
        IBugTarget['enable_bugfiling_duplicate_search'])
 
165
 
 
166
 
 
167
def product_to_productbugconfiguration(product):
 
168
    """Adapts an `IProduct` into an `IProductBugConfiguration`."""
 
169
    alsoProvides(
 
170
        removeSecurityProxy(product), IProductBugConfiguration)
 
171
    return product
 
172
 
 
173
 
 
174
class ProductConfigureBugTrackerView(BugRoleMixin, ProductConfigureBase):
 
175
    """View class to configure the bug tracker for a project."""
 
176
 
 
177
    label = "Configure bug tracker"
 
178
    schema = IProductBugConfiguration
 
179
    # This ProductBugTrackerWidget renders enable_bug_expiration and
 
180
    # remote_product as subordinate fields, so this view suppresses them.
 
181
    custom_widget('bugtracker', ProductBugTrackerWidget)
 
182
    custom_widget('enable_bug_expiration', GhostCheckBoxWidget)
 
183
    custom_widget('remote_product', GhostWidget)
 
184
 
 
185
    @property
 
186
    def field_names(self):
 
187
        """Return the list of field names to display."""
 
188
        field_names = [
 
189
            "bugtracker",
 
190
            "enable_bug_expiration",
 
191
            "remote_product",
 
192
            "bug_reporting_guidelines",
 
193
            "bug_reported_acknowledgement",
 
194
            "enable_bugfiling_duplicate_search",
 
195
            ]
 
196
        if check_permission("launchpad.Edit", self.context):
 
197
            field_names.extend(["bug_supervisor", "security_contact"])
 
198
 
 
199
        return field_names
 
200
 
 
201
    def validate(self, data):
 
202
        """Constrain bug expiration to Launchpad Bugs tracker."""
 
203
        if check_permission("launchpad.Edit", self.context):
 
204
            self.validateBugSupervisor(data)
 
205
            self.validateSecurityContact(data)
 
206
        # enable_bug_expiration is disabled by JavaScript when bugtracker
 
207
        # is not 'In Launchpad'. The constraint is enforced here in case the
 
208
        # JavaScript fails to activate or run. Note that the bugtracker
 
209
        # name : values are {'In Launchpad' : object, 'Somewhere else' : None
 
210
        # 'In a registered bug tracker' : IBugTracker}.
 
211
        bugtracker = data.get('bugtracker', None)
 
212
        if bugtracker is None or IBugTracker.providedBy(bugtracker):
 
213
            data['enable_bug_expiration'] = False
 
214
 
 
215
    @action("Change", name='change')
 
216
    def change_action(self, action, data):
 
217
        # bug_supervisor and security_contactrequires a transition method,
 
218
        # so it must be handled separately and removed for the
 
219
        # updateContextFromData to work as expected.
 
220
        if check_permission("launchpad.Edit", self.context):
 
221
            self.changeBugSupervisor(data['bug_supervisor'])
 
222
            del data['bug_supervisor']
 
223
            self.changeSecurityContact(data['security_contact'])
 
224
            del data['security_contact']
 
225
        self.updateContextFromData(data)
 
226
 
 
227
 
 
228
class FileBugReportingGuidelines(LaunchpadFormView):
 
229
    """Provides access to common bug reporting attributes.
 
230
 
 
231
    Attributes provided are: security_related and bug_reporting_guidelines.
 
232
 
 
233
    This view is a superclass of `FileBugViewBase` so that non-ajax browsers
 
234
    can load the file bug form, and it is also invoked directly via an XHR
 
235
    request to provide an HTML snippet for Javascript enabled browsers.
 
236
    """
 
237
 
 
238
    schema = IBug
 
239
 
 
240
    @property
 
241
    def field_names(self):
 
242
        """Return the list of field names to display."""
 
243
        return ['security_related']
 
244
 
 
245
    def setUpFields(self):
 
246
        """Set up the form fields. See `LaunchpadFormView`."""
 
247
        super(FileBugReportingGuidelines, self).setUpFields()
 
248
 
 
249
        security_related_field = Bool(
 
250
            __name__='security_related',
 
251
            title=_("This bug is a security vulnerability"),
 
252
            required=False, default=False)
 
253
 
 
254
        self.form_fields = self.form_fields.omit('security_related')
 
255
        self.form_fields += formlib.form.Fields(security_related_field)
 
256
 
 
257
    @property
 
258
    def bug_reporting_guidelines(self):
 
259
        """Guidelines for filing bugs in the current context.
 
260
 
 
261
        Returns a list of dicts, with each dict containing values for
 
262
        "preamble" and "content".
 
263
        """
 
264
 
 
265
        def target_name(target):
 
266
            # IProjectGroup can be considered the target of a bug during
 
267
            # the bug filing process, but does not extend IBugTarget
 
268
            # and ultimately cannot actually be the target of a
 
269
            # bug. Hence this function to determine a suitable
 
270
            # name/title to display. Hurrumph.
 
271
            if IBugTarget.providedBy(target):
 
272
                return target.bugtargetdisplayname
 
273
            else:
 
274
                return target.displayname
 
275
 
 
276
        guidelines = []
 
277
        bugtarget = self.context
 
278
        if bugtarget is not None:
 
279
            content = bugtarget.bug_reporting_guidelines
 
280
            if content is not None and len(content) > 0:
 
281
                guidelines.append({
 
282
                        "source": target_name(bugtarget),
 
283
                        "content": content,
 
284
                        })
 
285
            # Distribution source packages are shown with both their
 
286
            # own reporting guidelines and those of their
 
287
            # distribution.
 
288
            if IDistributionSourcePackage.providedBy(bugtarget):
 
289
                distribution = bugtarget.distribution
 
290
                content = distribution.bug_reporting_guidelines
 
291
                if content is not None and len(content) > 0:
 
292
                    guidelines.append({
 
293
                            "source": target_name(distribution),
 
294
                            "content": content,
 
295
                            })
 
296
        return guidelines
 
297
 
 
298
    def getMainContext(self):
 
299
        if IDistributionSourcePackage.providedBy(self.context):
 
300
            return self.context.distribution
 
301
        else:
 
302
            return self.context
 
303
 
 
304
 
 
305
class FileBugViewBase(FileBugReportingGuidelines, LaunchpadFormView):
 
306
    """Base class for views related to filing a bug."""
 
307
 
 
308
    implements(IBrowserPublisher)
 
309
 
 
310
    extra_data_token = None
 
311
    advanced_form = False
 
312
    frontpage_form = False
 
313
    data_parser = None
 
314
 
 
315
    def __init__(self, context, request):
 
316
        LaunchpadFormView.__init__(self, context, request)
 
317
        self.extra_data = FileBugData()
 
318
 
 
319
    def initialize(self):
 
320
        # redirect_ubuntu_filebug is a cached_property.
 
321
        # Access it first just to compute its value. Because it
 
322
        # makes a DB access to get the bug supervisor, it causes
 
323
        # trouble in tests when form validation errors occur. Because the
 
324
        # transaction is doomed, the storm cache is invalidated and accessing
 
325
        # the property will result in a a LostObjectError, because
 
326
        # the created objects disappeared. Not likely a problem in production
 
327
        # since the objects will still be in the DB, but doesn't hurt there
 
328
        # either. It makes for better diagnosis of failing tests.
 
329
        if self.redirect_ubuntu_filebug:
 
330
            pass
 
331
        LaunchpadFormView.initialize(self)
 
332
        if (not self.redirect_ubuntu_filebug and
 
333
            self.extra_data_token is not None and
 
334
            not self.extra_data_to_process):
 
335
            # self.extra_data has been initialized in publishTraverse().
 
336
            if self.extra_data.initial_summary:
 
337
                self.widgets['title'].setRenderedValue(
 
338
                    self.extra_data.initial_summary)
 
339
            if self.extra_data.initial_tags:
 
340
                self.widgets['tags'].setRenderedValue(
 
341
                    self.extra_data.initial_tags)
 
342
            # XXX: Bjorn Tillenius 2006-01-15:
 
343
            #      We should include more details of what will be added
 
344
            #      to the bug report.
 
345
            self.request.response.addNotification(
 
346
                'Extra debug information will be added to the bug report'
 
347
                ' automatically.')
 
348
 
 
349
    @cachedproperty
 
350
    def redirect_ubuntu_filebug(self):
 
351
        if IDistribution.providedBy(self.context):
 
352
            bug_supervisor = self.context.bug_supervisor
 
353
        elif (IDistributionSourcePackage.providedBy(self.context) or
 
354
              ISourcePackage.providedBy(self.context)):
 
355
            bug_supervisor = self.context.distribution.bug_supervisor
 
356
        else:
 
357
            bug_supervisor = None
 
358
 
 
359
        # Work out whether the redirect should be overidden.
 
360
        do_not_redirect = (
 
361
            self.request.form.get('no-redirect') is not None or
 
362
            [key for key in self.request.form.keys()
 
363
            if 'field.actions' in key] != [] or
 
364
            self.user.inTeam(bug_supervisor))
 
365
 
 
366
        return (
 
367
            config.malone.ubuntu_disable_filebug and
 
368
            self.targetIsUbuntu() and
 
369
            self.extra_data_token is None and
 
370
            not do_not_redirect)
 
371
 
 
372
    @property
 
373
    def field_names(self):
 
374
        """Return the list of field names to display."""
 
375
        context = self.context
 
376
        field_names = ['title', 'comment', 'tags', 'security_related',
 
377
                       'bug_already_reported_as', 'filecontent', 'patch',
 
378
                       'attachment_description', 'subscribe_to_existing_bug']
 
379
        if (IDistribution.providedBy(context) or
 
380
            IDistributionSourcePackage.providedBy(context)):
 
381
            field_names.append('packagename')
 
382
        elif IMaloneApplication.providedBy(context):
 
383
            field_names.append('bugtarget')
 
384
        elif IProjectGroup.providedBy(context):
 
385
            field_names.append('product')
 
386
        elif not IProduct.providedBy(context):
 
387
            raise AssertionError('Unknown context: %r' % context)
 
388
 
 
389
        # If the context is a project group we want to render the optional
 
390
        # fields since they will initially be hidden and later exposed if the
 
391
        # selected project supports them.
 
392
        include_extra_fields = IProjectGroup.providedBy(context)
 
393
        if not include_extra_fields:
 
394
            include_extra_fields = (
 
395
                BugTask.userHasBugSupervisorPrivilegesContext(
 
396
                    context, self.user))
 
397
 
 
398
        if include_extra_fields:
 
399
            field_names.extend(
 
400
                ['assignee', 'importance', 'milestone', 'status'])
 
401
 
 
402
        return field_names
 
403
 
 
404
    @property
 
405
    def initial_values(self):
 
406
        """Give packagename a default value, if applicable."""
 
407
        if not IDistributionSourcePackage.providedBy(self.context):
 
408
            return {}
 
409
 
 
410
        return {'packagename': self.context.name}
 
411
 
 
412
    def isPrivate(self):
 
413
        """Whether bug reports on this target are private by default."""
 
414
        return IProduct.providedBy(self.context) and self.context.private_bugs
 
415
 
 
416
    def contextIsProduct(self):
 
417
        return IProduct.providedBy(self.context)
 
418
 
 
419
    def contextIsProject(self):
 
420
        return IProjectGroup.providedBy(self.context)
 
421
 
 
422
    def targetIsUbuntu(self):
 
423
        ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
 
424
        return (self.context == ubuntu or
 
425
                (IMaloneApplication.providedBy(self.context) and
 
426
                 self.request.form.get('field.bugtarget.distribution') ==
 
427
                 ubuntu.name))
 
428
 
 
429
    def getPackageNameFieldCSSClass(self):
 
430
        """Return the CSS class for the packagename field."""
 
431
        if self.widget_errors.get("packagename"):
 
432
            return 'error'
 
433
        else:
 
434
            return ''
 
435
 
 
436
    def validate(self, data):
 
437
        """Make sure the package name, if provided, exists in the distro."""
 
438
        # The comment field is only required if filing a new bug.
 
439
        if self.submit_bug_action.submitted():
 
440
            comment = data.get('comment')
 
441
            # The widget only exposes the error message. The private
 
442
            # attr contains the real error.
 
443
            widget_error = self.widgets.get('comment')._error
 
444
            if widget_error and isinstance(widget_error.errors, TooLong):
 
445
                self.setFieldError('comment',
 
446
                    'The description is too long. If you have lots '
 
447
                    'text to add, attach a file to the bug instead.')
 
448
            elif not comment or widget_error is not None:
 
449
                self.setFieldError(
 
450
                    'comment', "Provide details about the issue.")
 
451
        # Check a bug has been selected when the user wants to
 
452
        # subscribe to an existing bug.
 
453
        elif self.this_is_my_bug_action.submitted():
 
454
            if not data.get('bug_already_reported_as'):
 
455
                self.setFieldError('bug_already_reported_as',
 
456
                                   "Please choose a bug.")
 
457
        else:
 
458
            # We only care about those two actions.
 
459
            pass
 
460
 
 
461
        # We have to poke at the packagename value directly in the
 
462
        # request, because if validation failed while getting the
 
463
        # widget's data, it won't appear in the data dict.
 
464
        form = self.request.form
 
465
        if form.get("packagename_option") == "choose":
 
466
            packagename = form.get("field.packagename")
 
467
            if packagename:
 
468
                if IDistribution.providedBy(self.context):
 
469
                    distribution = self.context
 
470
                elif 'distribution' in data:
 
471
                    distribution = data['distribution']
 
472
                else:
 
473
                    assert IDistributionSourcePackage.providedBy(self.context)
 
474
                    distribution = self.context.distribution
 
475
 
 
476
                try:
 
477
                    distribution.guessPublishedSourcePackageName(packagename)
 
478
                except NotFoundError:
 
479
                    if distribution.series:
 
480
                        # If a distribution doesn't have any series,
 
481
                        # it won't have any source packages published at
 
482
                        # all, so we set the error only if there are
 
483
                        # series.
 
484
                        packagename_error = (
 
485
                            '"%s" does not exist in %s. Please choose a '
 
486
                            "different package. If you're unsure, please "
 
487
                            'select "I don\'t know"' % (
 
488
                                packagename, distribution.displayname))
 
489
                        self.setFieldError("packagename", packagename_error)
 
490
            else:
 
491
                self.setFieldError("packagename",
 
492
                                   "Please enter a package name")
 
493
 
 
494
        # If we've been called from the frontpage filebug forms we must check
 
495
        # that whatever product or distro is having a bug filed against it
 
496
        # actually uses Malone for its bug tracking.
 
497
        product_or_distro = self.getProductOrDistroFromContext()
 
498
        if (product_or_distro is not None and
 
499
            product_or_distro.bug_tracking_usage != ServiceUsage.LAUNCHPAD):
 
500
            self.setFieldError(
 
501
                'bugtarget',
 
502
                "%s does not use Launchpad as its bug tracker " %
 
503
                product_or_distro.displayname)
 
504
 
 
505
    def setUpWidgets(self):
 
506
        """Customize the onKeyPress event of the package name chooser."""
 
507
        LaunchpadFormView.setUpWidgets(self)
 
508
 
 
509
        if "packagename" in self.field_names:
 
510
            self.widgets["packagename"].onKeyPress = (
 
511
                "selectWidget('choose', event)")
 
512
 
 
513
    def setUpFields(self):
 
514
        """Set up the form fields. See `LaunchpadFormView`."""
 
515
        super(FileBugViewBase, self).setUpFields()
 
516
 
 
517
        # Override the vocabulary for the subscribe_to_existing_bug
 
518
        # field.
 
519
        subscribe_field = Choice(
 
520
            __name__='subscribe_to_existing_bug',
 
521
            title=u'Subscribe to this bug',
 
522
            vocabulary=SUBSCRIBE_TO_BUG_VOCABULARY,
 
523
            required=True, default=False)
 
524
 
 
525
        self.form_fields = self.form_fields.omit('subscribe_to_existing_bug')
 
526
        self.form_fields += formlib.form.Fields(subscribe_field)
 
527
 
 
528
    def contextUsesMalone(self):
 
529
        """Does the context use Malone as its official bugtracker?"""
 
530
        if IProjectGroup.providedBy(self.context):
 
531
            products_using_malone = [
 
532
                product for product in self.context.products
 
533
                if product.bug_tracking_usage == ServiceUsage.LAUNCHPAD]
 
534
            return len(products_using_malone) > 0
 
535
        else:
 
536
            bug_tracking_usage = self.getMainContext().bug_tracking_usage
 
537
            return bug_tracking_usage == ServiceUsage.LAUNCHPAD
 
538
 
 
539
    def shouldSelectPackageName(self):
 
540
        """Should the radio button to select a package be selected?"""
 
541
        return (
 
542
            self.request.form.get("field.packagename") or
 
543
            self.initial_values.get("packagename"))
 
544
 
 
545
    def handleSubmitBugFailure(self, action, data, errors):
 
546
        return self.showFileBugForm()
 
547
 
 
548
    @action("Submit Bug Report", name="submit_bug",
 
549
            failure=handleSubmitBugFailure)
 
550
    def submit_bug_action(self, action, data):
 
551
        """Add a bug to this IBugTarget."""
 
552
        title = data["title"]
 
553
        comment = data["comment"].rstrip()
 
554
        packagename = data.get("packagename")
 
555
        security_related = data.get("security_related", False)
 
556
        distribution = data.get(
 
557
            "distribution", getUtility(ILaunchBag).distribution)
 
558
 
 
559
        context = self.context
 
560
        if distribution is not None:
 
561
            # We're being called from the generic bug filing form, so
 
562
            # manually set the chosen distribution as the context.
 
563
            context = distribution
 
564
        elif IProjectGroup.providedBy(context):
 
565
            context = data['product']
 
566
        elif IMaloneApplication.providedBy(context):
 
567
            context = data['bugtarget']
 
568
 
 
569
        # Ensure that no package information is used, if the user
 
570
        # enters a package name but then selects "I don't know".
 
571
        if self.request.form.get("packagename_option") == "none":
 
572
            packagename = None
 
573
 
 
574
        # Security bugs are always private when filed, but can be disclosed
 
575
        # after they've been reported.
 
576
        if security_related:
 
577
            private = True
 
578
        else:
 
579
            private = False
 
580
 
 
581
        linkified_ack = structured(FormattersAPI(
 
582
            self.getAcknowledgementMessage(self.context)).text_to_html(
 
583
                last_paragraph_class="last"))
 
584
        notifications = [linkified_ack]
 
585
        params = CreateBugParams(
 
586
            title=title, comment=comment, owner=self.user,
 
587
            security_related=security_related, private=private,
 
588
            tags=data.get('tags'))
 
589
        if IDistribution.providedBy(context) and packagename:
 
590
            # We don't know if the package name we got was a source or binary
 
591
            # package name, so let the Soyuz API figure it out for us.
 
592
            packagename = str(packagename.name)
 
593
            try:
 
594
                sourcepackagename = context.guessPublishedSourcePackageName(
 
595
                    packagename)
 
596
            except NotFoundError:
 
597
                notifications.append(
 
598
                    "The package %s is not published in %s; the "
 
599
                    "bug was targeted only to the distribution."
 
600
                    % (packagename, context.displayname))
 
601
                params.comment += (
 
602
                    "\r\n\r\nNote: the original reporter indicated "
 
603
                    "the bug was in package %r; however, that package "
 
604
                    "was not published in %s." % (
 
605
                        packagename, context.displayname))
 
606
            else:
 
607
                context = context.getSourcePackage(sourcepackagename.name)
 
608
 
 
609
        extra_data = self.extra_data
 
610
        if extra_data.extra_description:
 
611
            params.comment = "%s\n\n%s" % (
 
612
                params.comment, extra_data.extra_description)
 
613
            notifications.append(
 
614
                'Additional information was added to the bug description.')
 
615
 
 
616
        if extra_data.private:
 
617
            params.private = extra_data.private
 
618
 
 
619
        # Apply any extra options given by privileged users.
 
620
        if BugTask.userHasBugSupervisorPrivilegesContext(context, self.user):
 
621
            if 'assignee' in data:
 
622
                params.assignee = data['assignee']
 
623
            if 'status' in data:
 
624
                params.status = data['status']
 
625
            if 'importance' in data:
 
626
                params.importance = data['importance']
 
627
            if 'milestone' in data:
 
628
                params.milestone = data['milestone']
 
629
 
 
630
        self.added_bug = bug = context.createBug(params)
 
631
 
 
632
        for comment in extra_data.comments:
 
633
            bug.newMessage(self.user, bug.followup_subject(), comment)
 
634
            notifications.append(
 
635
                'A comment with additional information was added to the'
 
636
                ' bug report.')
 
637
 
 
638
        # XXX 2007-01-19 gmb:
 
639
        #     We need to have a proper FileUpload widget rather than
 
640
        #     this rather hackish solution.
 
641
        attachment = self.request.form.get(self.widgets['filecontent'].name)
 
642
        if attachment or extra_data.attachments:
 
643
            # Attach all the comments to a single empty comment.
 
644
            attachment_comment = bug.newMessage(
 
645
                owner=self.user, subject=bug.followup_subject(), content=None)
 
646
 
 
647
            # Deal with attachments added in the filebug form.
 
648
            if attachment:
 
649
                # We convert slashes in filenames to hyphens to avoid
 
650
                # problems.
 
651
                filename = attachment.filename.replace('/', '-')
 
652
 
 
653
                # If the user hasn't entered a description for the
 
654
                # attachment we use its name.
 
655
                file_description = None
 
656
                if 'attachment_description' in data:
 
657
                    file_description = data['attachment_description']
 
658
                if file_description is None:
 
659
                    file_description = filename
 
660
 
 
661
                bug.addAttachment(
 
662
                    owner=self.user, data=StringIO(data['filecontent']),
 
663
                    filename=filename, description=file_description,
 
664
                    comment=attachment_comment, is_patch=data['patch'])
 
665
 
 
666
                notifications.append(
 
667
                    'The file "%s" was attached to the bug report.' %
 
668
                        cgi.escape(filename))
 
669
 
 
670
            for attachment in extra_data.attachments:
 
671
                bug.linkAttachment(
 
672
                    owner=self.user, file_alias=attachment['file_alias'],
 
673
                    description=attachment['description'],
 
674
                    comment=attachment_comment,
 
675
                    send_notifications=False)
 
676
                notifications.append(
 
677
                    'The file "%s" was attached to the bug report.' %
 
678
                        cgi.escape(attachment['file_alias'].filename))
 
679
 
 
680
        if extra_data.subscribers:
 
681
            # Subscribe additional subscribers to this bug
 
682
            for subscriber in extra_data.subscribers:
 
683
                valid_person_vocabulary = ValidPersonOrTeamVocabulary()
 
684
                try:
 
685
                    person = valid_person_vocabulary.getTermByToken(
 
686
                        subscriber).value
 
687
                except LookupError:
 
688
                    # We cannot currently pass this error up to the user, so
 
689
                    # we'll just ignore it.
 
690
                    pass
 
691
                else:
 
692
                    bug.subscribe(person, self.user)
 
693
                    notifications.append(
 
694
                        '%s has been subscribed to this bug.' %
 
695
                        person.displayname)
 
696
 
 
697
        submission_set = getUtility(IHWSubmissionSet)
 
698
        for submission_key in extra_data.hwdb_submission_keys:
 
699
            submission = submission_set.getBySubmissionKey(
 
700
                submission_key, self.user)
 
701
            if submission is not None:
 
702
                bug.linkHWSubmission(submission)
 
703
 
 
704
        # Give the user some feedback on the bug just opened.
 
705
        for notification in notifications:
 
706
            self.request.response.addNotification(notification)
 
707
        if bug.security_related:
 
708
            self.request.response.addNotification(
 
709
                structured(
 
710
                'Security-related bugs are by default private '
 
711
                '(visible only to their direct subscribers). '
 
712
                'You may choose to <a href="+secrecy">publicly '
 
713
                'disclose</a> this bug.'))
 
714
        if bug.private and not bug.security_related:
 
715
            self.request.response.addNotification(
 
716
                structured(
 
717
                'This bug report has been marked private '
 
718
                '(visible only to its direct subscribers). '
 
719
                'You may choose to <a href="+secrecy">change this</a>.'))
 
720
 
 
721
        self.request.response.redirect(canonical_url(bug.bugtasks[0]))
 
722
 
 
723
    @action("Yes, this is the bug I'm trying to report",
 
724
            name="this_is_my_bug", failure=handleSubmitBugFailure)
 
725
    def this_is_my_bug_action(self, action, data):
 
726
        """Subscribe to the bug suggested."""
 
727
        bug = data.get('bug_already_reported_as')
 
728
        subscribe = data.get('subscribe_to_existing_bug')
 
729
 
 
730
        if bug.isUserAffected(self.user):
 
731
            self.request.response.addNotification(
 
732
                "This bug is already marked as affecting you.")
 
733
        else:
 
734
            bug.markUserAffected(self.user)
 
735
            self.request.response.addNotification(
 
736
                "This bug has been marked as affecting you.")
 
737
 
 
738
        # If the user wants to be subscribed, subscribe them, unless
 
739
        # they're already subscribed.
 
740
        if subscribe:
 
741
            if bug.isSubscribed(self.user):
 
742
                self.request.response.addNotification(
 
743
                    "You are already subscribed to this bug.")
 
744
            else:
 
745
                bug.subscribe(self.user, self.user)
 
746
                self.request.response.addNotification(
 
747
                    "You have subscribed to this bug report.")
 
748
 
 
749
        self.next_url = canonical_url(bug.bugtasks[0])
 
750
 
 
751
    def showFileBugForm(self):
 
752
        """Override this method in base classes to show the filebug form."""
 
753
        raise NotImplementedError
 
754
 
 
755
    @property
 
756
    def inline_filebug_base_url(self):
 
757
        """Return the base URL for the current request.
 
758
 
 
759
        This allows us to build URLs in Javascript without guessing at
 
760
        domains.
 
761
        """
 
762
        return self.request.getRootURL(None)
 
763
 
 
764
    @property
 
765
    def inline_filebug_form_url(self):
 
766
        """Return the URL to the inline filebug form.
 
767
 
 
768
        If a token was passed to this view, it will be be passed through
 
769
        to the inline bug filing form via the returned URL.
 
770
        """
 
771
        url = canonical_url(self.context, view_name='+filebug-inline-form')
 
772
        if self.extra_data_token is not None:
 
773
            url = urlappend(url, self.extra_data_token)
 
774
        return url
 
775
 
 
776
    @property
 
777
    def duplicate_search_url(self):
 
778
        """Return the URL to the inline duplicate search view."""
 
779
        url = canonical_url(self.context, view_name='+filebug-show-similar')
 
780
        if self.extra_data_token is not None:
 
781
            url = urlappend(url, self.extra_data_token)
 
782
        return url
 
783
 
 
784
    def publishTraverse(self, request, name):
 
785
        """See IBrowserPublisher."""
 
786
        if self.extra_data_token is not None:
 
787
            # publishTraverse() has already been called once before,
 
788
            # which means that he URL contains more path components than
 
789
            # expected.
 
790
            raise NotFound(self, name, request=request)
 
791
 
 
792
        self.extra_data_token = name
 
793
        if self.extra_data_processing_job is None:
 
794
            # The URL might be mistyped, or the blob has expired.
 
795
            # XXX: Bjorn Tillenius 2006-01-15:
 
796
            #      We should handle this case better, since a user might
 
797
            #      come to this page when finishing his account
 
798
            #      registration. In that case we should inform the user
 
799
            #      that the blob has expired.
 
800
            raise NotFound(self, name, request=request)
 
801
        else:
 
802
            self.extra_data = self.extra_data_processing_job.getFileBugData()
 
803
 
 
804
        return self
 
805
 
 
806
    def browserDefault(self, request):
 
807
        """See IBrowserPublisher."""
 
808
        return self, ()
 
809
 
 
810
    def getProductOrDistroFromContext(self):
 
811
        """Return the product or distribution relative to the context.
 
812
 
 
813
        For instance, if the context is an IDistroSeries, return the
 
814
        distribution related to it. Will return None if the context is
 
815
        not related to a product or a distro.
 
816
        """
 
817
        context = self.context
 
818
        if IProduct.providedBy(context) or IDistribution.providedBy(context):
 
819
            return context
 
820
        elif IProductSeries.providedBy(context):
 
821
            return context.product
 
822
        elif (IDistroSeries.providedBy(context) or
 
823
              IDistributionSourcePackage.providedBy(context)):
 
824
            return context.distribution
 
825
        else:
 
826
            return None
 
827
 
 
828
    def showOptionalMarker(self, field_name):
 
829
        """See `LaunchpadFormView`."""
 
830
        # The comment field _is_ required, but only when filing the
 
831
        # bug. Since the same form is also used for subscribing to a
 
832
        # bug, the comment field in the schema cannot be marked
 
833
        # required=True. Instead it's validated in
 
834
        # FileBugViewBase.validate. So... we need to suppress the
 
835
        # "(Optional)" marker.
 
836
        if field_name == 'comment':
 
837
            return False
 
838
        else:
 
839
            return LaunchpadFormView.showOptionalMarker(self, field_name)
 
840
 
 
841
    def getRelevantBugTask(self, bug):
 
842
        """Return the first bugtask from this bug that's relevant in the
 
843
        current context.
 
844
 
 
845
        This is a pragmatic function, not general purpose. It tries to
 
846
        find a bugtask that can be used to pretty-up the page, making
 
847
        it more user-friendly and informative. It's not concerned by
 
848
        total accuracy, and will return the first 'relevant' bugtask
 
849
        it finds even if there are other candidates. Be warned!
 
850
        """
 
851
        context = self.context
 
852
 
 
853
        if IProjectGroup.providedBy(context):
 
854
            contexts = set(context.products)
 
855
        else:
 
856
            contexts = [context]
 
857
 
 
858
        for bugtask in bug.bugtasks:
 
859
            if bugtask.target in contexts or bugtask.pillar in contexts:
 
860
                return bugtask
 
861
        return None
 
862
 
 
863
    @property
 
864
    def bugtarget(self):
 
865
        """The bugtarget we're currently assuming.
 
866
 
 
867
        The same as the context.
 
868
        """
 
869
        return self.context
 
870
 
 
871
    default_bug_reported_acknowledgement = "Thank you for your bug report."
 
872
 
 
873
    def getAcknowledgementMessage(self, context):
 
874
        """An acknowlegement message displayed to the user."""
 
875
        # If a given context doesnot have a custom message, we go up in the
 
876
        # "object hierachy" until we find one. If no cusotmized messages
 
877
        # exist for any conext, a default message is returned.
 
878
        #
 
879
        # bug_reported_acknowledgement is defined as a "real" property
 
880
        # for IDistribution, IDistributionSourcePackage, IProduct and
 
881
        # IProjectGroup. Other IBugTarget implementations inherit this
 
882
        # property from their parents. For these classes, we can directly
 
883
        # try to find a custom message farther up in the hierarchy.
 
884
        message = context.bug_reported_acknowledgement
 
885
        if message is not None and len(message.strip()) > 0:
 
886
            return message
 
887
        next_context = None
 
888
        if IProductSeries.providedBy(context):
 
889
            # we don't need to look at
 
890
            # context.product.bug_reported_acknowledgement because a
 
891
            # product series inherits this property from the product.
 
892
            next_context = context.product.project
 
893
        elif IProduct.providedBy(context):
 
894
            next_context = context.project
 
895
        elif IDistributionSourcePackage.providedBy(context):
 
896
            next_context = context.distribution
 
897
        # IDistroseries and ISourcePackage inherit
 
898
        # bug_reported_acknowledgement from their IDistribution, so we
 
899
        # don't need to look up this property in IDistribution.
 
900
        # IDistribution and IProjectGroup don't have any parents.
 
901
        elif (IDistribution.providedBy(context) or
 
902
              IProjectGroup.providedBy(context) or
 
903
              IDistroSeries.providedBy(context) or
 
904
              ISourcePackage.providedBy(context)):
 
905
            pass
 
906
        else:
 
907
            raise TypeError("Unexpected bug target: %r" % context)
 
908
        if next_context is not None:
 
909
            return self.getAcknowledgementMessage(next_context)
 
910
        return self.default_bug_reported_acknowledgement
 
911
 
 
912
    @cachedproperty
 
913
    def extra_data_processing_job(self):
 
914
        """Return the ProcessApportBlobJob for a given BLOB token."""
 
915
        if self.extra_data_token is None:
 
916
            # If there's no extra data token, don't bother looking for a
 
917
            # ProcessApportBlobJob.
 
918
            return None
 
919
 
 
920
        try:
 
921
            return getUtility(IProcessApportBlobJobSource).getByBlobUUID(
 
922
                self.extra_data_token)
 
923
        except SQLObjectNotFound:
 
924
            return None
 
925
 
 
926
    @property
 
927
    def extra_data_to_process(self):
 
928
        """Return True if there is extra data to process."""
 
929
        apport_processing_job = self.extra_data_processing_job
 
930
        if apport_processing_job is None:
 
931
            return False
 
932
        elif apport_processing_job.job.status == JobStatus.COMPLETED:
 
933
            return False
 
934
        else:
 
935
            return True
 
936
 
 
937
 
 
938
class FileBugInlineFormView(FileBugViewBase):
 
939
    """A browser view for displaying the inline filebug form."""
 
940
    schema = IBugAddForm
 
941
 
 
942
 
 
943
class FileBugAdvancedView(FileBugViewBase):
 
944
    """Browser view for filing a bug.
 
945
 
 
946
    This view exists only to redirect from +filebug-advanced to +filebug.
 
947
    """
 
948
 
 
949
    def initialize(self):
 
950
        filebug_url = canonical_url(
 
951
            self.context, rootsite='bugs', view_name='+filebug')
 
952
        self.request.response.redirect(
 
953
            filebug_url, status=httplib.MOVED_PERMANENTLY)
 
954
 
 
955
 
 
956
class FilebugShowSimilarBugsView(FileBugViewBase):
 
957
    """A view for showing possible dupes for a bug.
 
958
 
 
959
    This view will only be used to populate asynchronously-driven parts
 
960
    of a page.
 
961
    """
 
962
    schema = IBugAddForm
 
963
 
 
964
    # XXX: Brad Bollenbach 2006-10-04: This assignment to actions is a
 
965
    # hack to make the action decorator Just Work across inheritance.
 
966
    actions = FileBugViewBase.actions
 
967
    custom_widget('title', TextWidget, displayWidth=40)
 
968
    custom_widget('tags', BugTagsWidget)
 
969
 
 
970
    _MATCHING_BUGS_LIMIT = 10
 
971
    show_summary_in_results = False
 
972
 
 
973
    @property
 
974
    def action_url(self):
 
975
        """Return the +filebug page as the action URL.
 
976
 
 
977
        This enables better validation error handling,
 
978
        since the form is always used inline on the +filebug page.
 
979
        """
 
980
        url = '%s/+filebug' % canonical_url(self.context)
 
981
        if self.extra_data_token is not None:
 
982
            url = urlappend(url, self.extra_data_token)
 
983
        return url
 
984
 
 
985
    @property
 
986
    def search_context(self):
 
987
        """Return the context used to search for similar bugs."""
 
988
        return self.context
 
989
 
 
990
    @property
 
991
    def search_text(self):
 
992
        """Return the search string entered by the user."""
 
993
        return self.request.get('title')
 
994
 
 
995
    @cachedproperty
 
996
    def similar_bugs(self):
 
997
        """Return the similar bugs based on the user search."""
 
998
        title = self.search_text
 
999
        if not title:
 
1000
            return []
 
1001
        search_context = self.search_context
 
1002
        if search_context is None:
 
1003
            return []
 
1004
        elif IProduct.providedBy(search_context):
 
1005
            context_params = {'product': search_context}
 
1006
        elif IDistribution.providedBy(search_context):
 
1007
            context_params = {'distribution': search_context}
 
1008
        else:
 
1009
            assert IDistributionSourcePackage.providedBy(search_context), (
 
1010
                    'Unknown search context: %r' % search_context)
 
1011
            context_params = {
 
1012
                'distribution': search_context.distribution,
 
1013
                'sourcepackagename': search_context.sourcepackagename}
 
1014
 
 
1015
        matching_bugtasks = getUtility(IBugTaskSet).findSimilar(
 
1016
            self.user, title, **context_params)
 
1017
        matching_bugs = getUtility(IBugSet).getDistinctBugsForBugTasks(
 
1018
            matching_bugtasks, self.user, self._MATCHING_BUGS_LIMIT)
 
1019
        return matching_bugs
 
1020
 
 
1021
    @property
 
1022
    def show_duplicate_list(self):
 
1023
        """Return whether or not to show the duplicate list.
 
1024
 
 
1025
        We only show the dupes if:
 
1026
          - The context uses Malone AND
 
1027
          - There are dupes to show AND
 
1028
          - There are no widget errors.
 
1029
        """
 
1030
        return (
 
1031
            self.contextUsesMalone and
 
1032
            len(self.similar_bugs) > 0 and
 
1033
            len(self.widget_errors) == 0)
 
1034
 
 
1035
 
 
1036
class FileBugGuidedView(FilebugShowSimilarBugsView):
 
1037
 
 
1038
    _SEARCH_FOR_DUPES = ViewPageTemplateFile(
 
1039
        "../templates/bugtarget-filebug-search.pt")
 
1040
    _PROJECTGROUP_SEARCH_FOR_DUPES = ViewPageTemplateFile(
 
1041
        "../templates/projectgroup-filebug-search.pt")
 
1042
    _FILEBUG_FORM = ViewPageTemplateFile(
 
1043
        "../templates/bugtarget-filebug-submit-bug.pt")
 
1044
 
 
1045
    # XXX 2009-07-17 Graham Binns
 
1046
    #     As above, this assignment to actions is to make sure that the
 
1047
    #     actions from the ancestor views are preserved, otherwise they
 
1048
    #     get nuked.
 
1049
    actions = FilebugShowSimilarBugsView.actions
 
1050
    template = _SEARCH_FOR_DUPES
 
1051
 
 
1052
    focused_element_id = 'field.title'
 
1053
    show_summary_in_results = True
 
1054
 
 
1055
    def initialize(self):
 
1056
        FilebugShowSimilarBugsView.initialize(self)
 
1057
        if self.redirect_ubuntu_filebug:
 
1058
            # The user is trying to file a new Ubuntu bug via the web
 
1059
            # interface and without using apport. Redirect to a page
 
1060
            # explaining the preferred bug-filing procedure.
 
1061
            self.request.response.redirect(
 
1062
                config.malone.ubuntu_bug_filing_url)
 
1063
 
 
1064
    @property
 
1065
    def page_title(self):
 
1066
        if IMaloneApplication.providedBy(self.context):
 
1067
            return 'Report a bug'
 
1068
        else:
 
1069
            return 'Report a bug about %s' % self.context.title
 
1070
 
 
1071
    @safe_action
 
1072
    @action("Continue", name="projectgroupsearch",
 
1073
            validator="validate_search")
 
1074
    def projectgroup_search_action(self, action, data):
 
1075
        """Search for similar bug reports."""
 
1076
        # Don't give focus to any widget, to ensure that the browser
 
1077
        # won't scroll past the "possible duplicates" list.
 
1078
        self.initial_focus_widget = None
 
1079
        return self._PROJECTGROUP_SEARCH_FOR_DUPES()
 
1080
 
 
1081
    @safe_action
 
1082
    @action("Continue", name="search", validator="validate_search")
 
1083
    def search_action(self, action, data):
 
1084
        """Search for similar bug reports."""
 
1085
        # Don't give focus to any widget, to ensure that the browser
 
1086
        # won't scroll past the "possible duplicates" list.
 
1087
        self.initial_focus_widget = None
 
1088
        return self.showFileBugForm()
 
1089
 
 
1090
    @property
 
1091
    def search_context(self):
 
1092
        """Return the context used to search for similar bugs."""
 
1093
        if IDistributionSourcePackage.providedBy(self.context):
 
1094
            return self.context
 
1095
 
 
1096
        search_context = self.getMainContext()
 
1097
        if IProjectGroup.providedBy(search_context):
 
1098
            assert self.widgets['product'].hasValidInput(), (
 
1099
                "This method should be called only when we know which"
 
1100
                " product the user selected.")
 
1101
            search_context = self.widgets['product'].getInputValue()
 
1102
        elif IMaloneApplication.providedBy(search_context):
 
1103
            if self.widgets['bugtarget'].hasValidInput():
 
1104
                search_context = self.widgets['bugtarget'].getInputValue()
 
1105
            else:
 
1106
                search_context = None
 
1107
 
 
1108
        return search_context
 
1109
 
 
1110
    @property
 
1111
    def search_text(self):
 
1112
        """Return the search string entered by the user."""
 
1113
        try:
 
1114
            return self.widgets['title'].getInputValue()
 
1115
        except InputErrors:
 
1116
            return None
 
1117
 
 
1118
    def validate_search(self, action, data):
 
1119
        """Make sure some keywords are provided."""
 
1120
        try:
 
1121
            data['title'] = self.widgets['title'].getInputValue()
 
1122
        except InputErrors, error:
 
1123
            self.setFieldError("title", "A summary is required.")
 
1124
            return [error]
 
1125
 
 
1126
        # Return an empty list of errors to satisfy the validation API,
 
1127
        # and say "we've handled the validation and found no errors."
 
1128
        return []
 
1129
 
 
1130
    def validate_no_dupe_found(self, action, data):
 
1131
        return ()
 
1132
 
 
1133
    @action("Continue", name="continue",
 
1134
            validator="validate_no_dupe_found")
 
1135
    def continue_action(self, action, data):
 
1136
        """The same action as no-dupe-found, with a different label."""
 
1137
        return self.showFileBugForm()
 
1138
 
 
1139
    def showFileBugForm(self):
 
1140
        return self._FILEBUG_FORM()
 
1141
 
 
1142
 
 
1143
class ProjectFileBugGuidedView(FileBugGuidedView):
 
1144
    """Guided filebug pages for IProjectGroup."""
 
1145
 
 
1146
    # Make inheriting the base class' actions work.
 
1147
    actions = FileBugGuidedView.actions
 
1148
    schema = IProjectGroupBugAddForm
 
1149
 
 
1150
    @cachedproperty
 
1151
    def products_using_malone(self):
 
1152
        return [
 
1153
            product for product in self.context.products
 
1154
            if product.bug_tracking_usage == ServiceUsage.LAUNCHPAD]
 
1155
 
 
1156
    @property
 
1157
    def default_product(self):
 
1158
        if len(self.products_using_malone) > 0:
 
1159
            return self.products_using_malone[0]
 
1160
        else:
 
1161
            return None
 
1162
 
 
1163
    @property
 
1164
    def inline_filebug_form_url(self):
 
1165
        """Return the URL to the inline filebug form.
 
1166
 
 
1167
        If a token was passed to this view, it will be be passed through
 
1168
        to the inline bug filing form via the returned URL.
 
1169
 
 
1170
        The URL returned will be the URL of the first of the current
 
1171
        ProjectGroup's products, since that's the product that will be
 
1172
        selected by default when the view is rendered.
 
1173
        """
 
1174
        url = canonical_url(
 
1175
            self.default_product, view_name='+filebug-inline-form')
 
1176
        if self.extra_data_token is not None:
 
1177
            url = urlappend(url, self.extra_data_token)
 
1178
        return url
 
1179
 
 
1180
    @property
 
1181
    def duplicate_search_url(self):
 
1182
        """Return the URL to the inline duplicate search view.
 
1183
 
 
1184
        The URL returned will be the URL of the first of the current
 
1185
        ProjectGroup's products, since that's the product that will be
 
1186
        selected by default when the view is rendered.
 
1187
        """
 
1188
        url = canonical_url(
 
1189
            self.default_product, view_name='+filebug-show-similar')
 
1190
        if self.extra_data_token is not None:
 
1191
            url = urlappend(url, self.extra_data_token)
 
1192
        return url
 
1193
 
 
1194
 
 
1195
class BugTargetBugListingView(LaunchpadView):
 
1196
    """Helper methods for rendering bug listings."""
 
1197
 
 
1198
    @property
 
1199
    def series_list(self):
 
1200
        if IDistroSeries(self.context, None):
 
1201
            series = self.context.distribution.series
 
1202
        elif IDistribution(self.context, None):
 
1203
            series = self.context.series
 
1204
        elif IProductSeries(self.context, None):
 
1205
            series = self.context.product.series
 
1206
        elif IProduct(self.context, None):
 
1207
            series = self.context.series
 
1208
        else:
 
1209
            raise AssertionError("series_list called with illegal context")
 
1210
        return list(series)
 
1211
 
 
1212
    @property
 
1213
    def milestones_list(self):
 
1214
        if IDistroSeries(self.context, None):
 
1215
            milestone_resultset = self.context.distribution.milestones
 
1216
        elif IDistribution(self.context, None):
 
1217
            milestone_resultset = self.context.milestones
 
1218
        elif IProductSeries(self.context, None):
 
1219
            milestone_resultset = self.context.product.milestones
 
1220
        elif IProduct(self.context, None):
 
1221
            milestone_resultset = self.context.milestones
 
1222
        else:
 
1223
            raise AssertionError(
 
1224
                "milestones_list called with illegal context")
 
1225
        return list(milestone_resultset)
 
1226
 
 
1227
    @property
 
1228
    def series_buglistings(self):
 
1229
        """Return a buglisting for each series.
 
1230
 
 
1231
        The list is sorted newest series to oldest.
 
1232
 
 
1233
        The count only considers bugs that the user would actually be
 
1234
        able to see in a listing.
 
1235
        """
 
1236
        # Circular fail.
 
1237
        from lp.bugs.model.bugsummary import BugSummary
 
1238
        series_buglistings = []
 
1239
        bug_task_set = getUtility(IBugTaskSet)
 
1240
        series_list = self.series_list
 
1241
        if not series_list:
 
1242
            return series_buglistings
 
1243
        # This would be better as delegation not a case statement.
 
1244
        if IDistroSeries(self.context, None):
 
1245
            backlink = BugSummary.distroseries_id
 
1246
        elif IDistribution(self.context, None):
 
1247
            backlink = BugSummary.distroseries_id
 
1248
        elif IProductSeries(self.context, None):
 
1249
            backlink = BugSummary.productseries_id
 
1250
        elif IProduct(self.context, None):
 
1251
            backlink = BugSummary.productseries_id
 
1252
        else:
 
1253
            raise AssertionError("illegal context %r" % self.context)
 
1254
        counts = bug_task_set.countBugs(self.user, series_list, (backlink,))
 
1255
        for series in series_list:
 
1256
            series_bug_count = counts.get((series.id,), 0)
 
1257
            if series_bug_count > 0:
 
1258
                series_buglistings.append(
 
1259
                    dict(
 
1260
                        title=series.name,
 
1261
                        url=canonical_url(series) + "/+bugs",
 
1262
                        count=series_bug_count,
 
1263
                        ))
 
1264
        return series_buglistings
 
1265
 
 
1266
    @property
 
1267
    def milestone_buglistings(self):
 
1268
        """Return a buglisting for each milestone."""
 
1269
        # Circular fail.
 
1270
        from lp.bugs.model.bugsummary import (
 
1271
            BugSummary,
 
1272
            CombineBugSummaryConstraint,
 
1273
            )
 
1274
        milestone_buglistings = []
 
1275
        bug_task_set = getUtility(IBugTaskSet)
 
1276
        milestones = self.milestones_list
 
1277
        if not milestones:
 
1278
            return milestone_buglistings
 
1279
        # Note: this isn't totally optimal as a query, but its the simplest to
 
1280
        # code; we can iterate if needed to provide one complex context to
 
1281
        # countBugs.
 
1282
        query_milestones = map(partial(
 
1283
            CombineBugSummaryConstraint, self.context), milestones)
 
1284
        counts = bug_task_set.countBugs(
 
1285
            self.user, query_milestones, (BugSummary.milestone_id,))
 
1286
        for milestone in milestones:
 
1287
            milestone_bug_count = counts.get((milestone.id,), 0)
 
1288
            if milestone_bug_count > 0:
 
1289
                milestone_buglistings.append(
 
1290
                    dict(
 
1291
                        title=milestone.name,
 
1292
                        url=canonical_url(milestone),
 
1293
                        count=milestone_bug_count,
 
1294
                        ))
 
1295
        return milestone_buglistings
 
1296
 
 
1297
 
 
1298
class BugCountDataItem:
 
1299
    """Data about bug count for a status."""
 
1300
 
 
1301
    def __init__(self, label, count, color):
 
1302
        self.label = label
 
1303
        self.count = count
 
1304
        if color.startswith('#'):
 
1305
            self.color = 'MochiKit.Color.Color.fromHexString("%s")' % color
 
1306
        else:
 
1307
            self.color = 'MochiKit.Color.Color["%sColor"]()' % color
 
1308
 
 
1309
 
 
1310
class BugTargetBugTagsView(LaunchpadView):
 
1311
    """Helper methods for rendering the bug tags portlet."""
 
1312
 
 
1313
    def _getSearchURL(self, tag):
 
1314
        """Return the search URL for the tag."""
 
1315
        # Use path_only here to reduce the size of the rendered page.
 
1316
        return "+bugs?field.tag=%s" % urllib.quote(tag)
 
1317
 
 
1318
    @property
 
1319
    def tags_cloud_data(self):
 
1320
        """The data for rendering a tags cloud"""
 
1321
        official_tags = self.context.official_bug_tags
 
1322
        tags = self.context.getUsedBugTagsWithOpenCounts(
 
1323
            self.user, 10, official_tags)
 
1324
 
 
1325
        return sorted(
 
1326
            [dict(
 
1327
                tag=tag,
 
1328
                count=count,
 
1329
                url=self._getSearchURL(tag),
 
1330
                )
 
1331
            for (tag, count) in tags.iteritems()],
 
1332
            key=itemgetter('count'), reverse=True)
 
1333
 
 
1334
    @property
 
1335
    def show_manage_tags_link(self):
 
1336
        """Should a link to a "manage official tags" page be shown?"""
 
1337
        return (IOfficialBugTagTargetRestricted.providedBy(self.context) and
 
1338
                check_permission('launchpad.BugSupervisor', self.context))
 
1339
 
 
1340
 
 
1341
class OfficialBugTagsManageView(LaunchpadEditFormView):
 
1342
    """View class for management of official bug tags."""
 
1343
 
 
1344
    schema = IOfficialBugTagTargetPublic
 
1345
    custom_widget('official_bug_tags', LargeBugTagsWidget)
 
1346
 
 
1347
    @property
 
1348
    def label(self):
 
1349
        """The form label."""
 
1350
        return 'Manage official bug tags for %s' % self.context.title
 
1351
 
 
1352
    @property
 
1353
    def page_title(self):
 
1354
        """The page title."""
 
1355
        return self.label
 
1356
 
 
1357
    @action('Save', name='save')
 
1358
    def save_action(self, action, data):
 
1359
        """Action for saving new official bug tags."""
 
1360
        self.context.official_bug_tags = data['official_bug_tags']
 
1361
        self.next_url = canonical_url(self.context)
 
1362
 
 
1363
    @property
 
1364
    def tags_js_data(self):
 
1365
        """Return the JSON representation of the bug tags."""
 
1366
        # The model returns dict and list respectively but dumps blows up on
 
1367
        # security proxied objects.
 
1368
        used_tags = removeSecurityProxy(
 
1369
            self.context.getUsedBugTagsWithOpenCounts(self.user))
 
1370
        official_tags = removeSecurityProxy(self.context.official_bug_tags)
 
1371
        return """<script type="text/javascript">
 
1372
                      var used_bug_tags = %s;
 
1373
                      var official_bug_tags = %s;
 
1374
                      var valid_name_pattern = %s;
 
1375
                  </script>
 
1376
               """ % (
 
1377
               dumps(used_tags),
 
1378
               dumps(official_tags),
 
1379
               dumps(valid_name_pattern.pattern))
 
1380
 
 
1381
    @property
 
1382
    def cancel_url(self):
 
1383
        """The URL the user is sent to when clicking the "cancel" link."""
 
1384
        return canonical_url(self.context)
 
1385
 
 
1386
 
 
1387
class BugsVHostBreadcrumb(Breadcrumb):
 
1388
    rootsite = 'bugs'
 
1389
    text = 'Bugs'
 
1390
 
 
1391
 
 
1392
class BugsPatchesView(LaunchpadView):
 
1393
    """View list of patch attachments associated with bugs."""
 
1394
 
 
1395
    @property
 
1396
    def label(self):
 
1397
        """The display label for the view."""
 
1398
        if IPerson.providedBy(self.context):
 
1399
            return 'Patch attachments for %s' % self.context.displayname
 
1400
        else:
 
1401
            return 'Patch attachments in %s' % self.context.displayname
 
1402
 
 
1403
    @property
 
1404
    def patch_task_orderings(self):
 
1405
        """The list of possible sort orderings for the patches view.
 
1406
 
 
1407
        The orderings are a list of tuples of the form:
 
1408
          [(DisplayName, InternalOrderingName), ...]
 
1409
        For example:
 
1410
          [("Patch age", "-latest_patch_uploaded"),
 
1411
           ("Importance", "-importance"),
 
1412
           ...]
 
1413
        """
 
1414
        orderings = [("patch age", "-latest_patch_uploaded"),
 
1415
                     ("importance", "-importance"),
 
1416
                     ("status", "status"),
 
1417
                     ("oldest first", "datecreated"),
 
1418
                     ("newest first", "-datecreated")]
 
1419
        targetname = self.targetName()
 
1420
        if targetname is not None:
 
1421
            # Lower case for consistency with the other orderings.
 
1422
            orderings.append((targetname.lower(), "targetname"))
 
1423
        return orderings
 
1424
 
 
1425
    def batchedPatchTasks(self):
 
1426
        """Return a BatchNavigator for bug tasks with patch attachments."""
 
1427
        orderby = self.request.get("orderby", "-latest_patch_uploaded")
 
1428
        if orderby not in [x[1] for x in self.patch_task_orderings]:
 
1429
            raise UnexpectedFormData(
 
1430
                "Unexpected value for field 'orderby': '%s'" % orderby)
 
1431
        return BatchNavigator(
 
1432
            self.context.searchTasks(
 
1433
                None, user=self.user, order_by=orderby,
 
1434
                status=UNRESOLVED_BUGTASK_STATUSES,
 
1435
                omit_duplicates=True, has_patch=True),
 
1436
            self.request)
 
1437
 
 
1438
    def targetName(self):
 
1439
        """Return the name of the current context's target type, or None.
 
1440
 
 
1441
        The name is something like "Package" or "Project" (meaning
 
1442
        Product); it is intended to be appropriate to use as a column
 
1443
        name in a web page, for example.  If no target type is
 
1444
        appropriate for the current context, then return None.
 
1445
        """
 
1446
        if (IDistribution.providedBy(self.context) or
 
1447
            IDistroSeries.providedBy(self.context)):
 
1448
            return "Package"
 
1449
        elif (IProjectGroup.providedBy(self.context) or
 
1450
              IPerson.providedBy(self.context)):
 
1451
            # In the case of an IPerson, the target column can vary
 
1452
            # row-by-row, showing both packages and products.  We
 
1453
            # decided to go with the table header "Project" for both,
 
1454
            # as its meaning is broad and could conceivably cover
 
1455
            # packages too.  We also considered "Target", but rejected
 
1456
            # it because it's used as a verb elsewhere in Launchpad's
 
1457
            # UI, with a totally different meaning.  If anyone can
 
1458
            # think of a better term than "Project", please JFDI here.
 
1459
            return "Project"  # "Project" meaning Product, of course
 
1460
        else:
 
1461
            return None
 
1462
 
 
1463
    def patchAge(self, patch):
 
1464
        """Return a timedelta object for the age of a patch attachment."""
 
1465
        now = datetime.now(timezone('UTC'))
 
1466
        return now - patch.message.datecreated
 
1467
 
 
1468
    def proxiedUrlForLibraryFile(self, patch):
 
1469
        """Return the proxied download URL for a Librarian file."""
 
1470
        return ProxiedLibraryFileAlias(patch.libraryfile, patch).http_url
 
1471
 
 
1472
 
 
1473
class TargetSubscriptionView(LaunchpadView):
 
1474
    """A view to show all a person's structural subscriptions to a target."""
 
1475
 
 
1476
    def initialize(self):
 
1477
        super(TargetSubscriptionView, self).initialize()
 
1478
        # Some resources such as help files are only provided on the bugs
 
1479
        # rootsite.  So if we got here via another, possibly hand-crafted, URL
 
1480
        # redirect to the equivalent URL on the bugs rootsite.
 
1481
        if not BugsLayer.providedBy(self.request):
 
1482
            new_url = urljoin(
 
1483
                self.request.getRootURL('bugs'), self.request['PATH_INFO'])
 
1484
            self.request.response.redirect(new_url)
 
1485
            return
 
1486
        expose_structural_subscription_data_to_js(
 
1487
            self.context, self.request, self.user, self.subscriptions)
 
1488
 
 
1489
    @property
 
1490
    def subscriptions(self):
 
1491
        return get_structural_subscriptions_for_target(
 
1492
            self.context, self.user)
 
1493
 
 
1494
    @property
 
1495
    def label(self):
 
1496
        return "Your subscriptions to %s" % (self.context.displayname,)
 
1497
 
 
1498
    page_title = label