1
# Copyright 2010-2011 Canonical Ltd. This software is licensed under the
2
# GNU Affero General Public License version 3 (see the file LICENSE).
4
"""IBugTarget-related browser views."""
11
"BugTargetBugListingView",
12
"BugTargetBugTagsView",
13
"FileBugAdvancedView",
16
"IProductBugConfiguration",
17
"OfficialBugTagsManageView",
18
"ProductConfigureBugTrackerView",
19
"ProjectFileBugGuidedView",
20
"product_to_productbugconfiguration",
24
from cStringIO import StringIO
25
from datetime import datetime
26
from functools import partial
28
from operator import itemgetter
30
from urlparse import urljoin
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 (
46
from zope.publisher.interfaces import NotFound
47
from zope.publisher.interfaces.browser import IBrowserPublisher
48
from zope.schema import (
52
from zope.schema.interfaces import TooLong
53
from zope.schema.vocabulary import SimpleVocabulary
54
from zope.security.proxy import removeSecurityProxy
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 (
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 (
72
LaunchpadEditFormView,
76
from lp.app.browser.stringformatter import FormattersAPI
77
from lp.app.enums import ServiceUsage
78
from lp.app.errors import (
82
from lp.app.interfaces.launchpad import (
83
ILaunchpadCelebrities,
86
from lp.app.validators.name import valid_name_pattern
87
from lp.app.widgets.product import (
90
ProductBugTrackerWidget,
92
from lp.bugs.browser.bugrole import BugRoleMixin
93
from lp.bugs.browser.structuralsubscription import (
94
expose_structural_subscription_data_to_js,
96
from lp.bugs.browser.widgets.bug import (
100
from lp.bugs.interfaces.apportjob import IProcessApportBlobJobSource
101
from lp.bugs.interfaces.bug import (
106
IProjectGroupBugAddForm,
108
from lp.bugs.interfaces.bugsupervisor import IHasBugSupervisor
109
from lp.bugs.interfaces.bugtarget import (
111
IOfficialBugTagTargetPublic,
112
IOfficialBugTagTargetRestricted,
114
from lp.bugs.interfaces.bugtask import (
116
UNRESOLVED_BUGTASK_STATUSES,
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,
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,
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
143
# A simple vocabulary for the subscribe_to_existing_bug form field.
144
SUBSCRIBE_TO_BUG_VOCABULARY = SimpleVocabulary.fromItems(
145
[('yes', True), ('no', False)])
148
class IProductBugConfiguration(Interface):
149
"""A composite schema for editing bug app configuration."""
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'])
167
def product_to_productbugconfiguration(product):
168
"""Adapts an `IProduct` into an `IProductBugConfiguration`."""
170
removeSecurityProxy(product), IProductBugConfiguration)
174
class ProductConfigureBugTrackerView(BugRoleMixin, ProductConfigureBase):
175
"""View class to configure the bug tracker for a project."""
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)
186
def field_names(self):
187
"""Return the list of field names to display."""
190
"enable_bug_expiration",
192
"bug_reporting_guidelines",
193
"bug_reported_acknowledgement",
194
"enable_bugfiling_duplicate_search",
196
if check_permission("launchpad.Edit", self.context):
197
field_names.extend(["bug_supervisor", "security_contact"])
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
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)
228
class FileBugReportingGuidelines(LaunchpadFormView):
229
"""Provides access to common bug reporting attributes.
231
Attributes provided are: security_related and bug_reporting_guidelines.
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.
241
def field_names(self):
242
"""Return the list of field names to display."""
243
return ['security_related']
245
def setUpFields(self):
246
"""Set up the form fields. See `LaunchpadFormView`."""
247
super(FileBugReportingGuidelines, self).setUpFields()
249
security_related_field = Bool(
250
__name__='security_related',
251
title=_("This bug is a security vulnerability"),
252
required=False, default=False)
254
self.form_fields = self.form_fields.omit('security_related')
255
self.form_fields += formlib.form.Fields(security_related_field)
258
def bug_reporting_guidelines(self):
259
"""Guidelines for filing bugs in the current context.
261
Returns a list of dicts, with each dict containing values for
262
"preamble" and "content".
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
274
return target.displayname
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:
282
"source": target_name(bugtarget),
285
# Distribution source packages are shown with both their
286
# own reporting guidelines and those of their
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:
293
"source": target_name(distribution),
298
def getMainContext(self):
299
if IDistributionSourcePackage.providedBy(self.context):
300
return self.context.distribution
305
class FileBugViewBase(FileBugReportingGuidelines, LaunchpadFormView):
306
"""Base class for views related to filing a bug."""
308
implements(IBrowserPublisher)
310
extra_data_token = None
311
advanced_form = False
312
frontpage_form = False
315
def __init__(self, context, request):
316
LaunchpadFormView.__init__(self, context, request)
317
self.extra_data = FileBugData()
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:
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
345
self.request.response.addNotification(
346
'Extra debug information will be added to the bug report'
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
357
bug_supervisor = None
359
# Work out whether the redirect should be overidden.
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))
367
config.malone.ubuntu_disable_filebug and
368
self.targetIsUbuntu() and
369
self.extra_data_token is None and
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)
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(
398
if include_extra_fields:
400
['assignee', 'importance', 'milestone', 'status'])
405
def initial_values(self):
406
"""Give packagename a default value, if applicable."""
407
if not IDistributionSourcePackage.providedBy(self.context):
410
return {'packagename': self.context.name}
413
"""Whether bug reports on this target are private by default."""
414
return IProduct.providedBy(self.context) and self.context.private_bugs
416
def contextIsProduct(self):
417
return IProduct.providedBy(self.context)
419
def contextIsProject(self):
420
return IProjectGroup.providedBy(self.context)
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') ==
429
def getPackageNameFieldCSSClass(self):
430
"""Return the CSS class for the packagename field."""
431
if self.widget_errors.get("packagename"):
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:
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.")
458
# We only care about those two actions.
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")
468
if IDistribution.providedBy(self.context):
469
distribution = self.context
470
elif 'distribution' in data:
471
distribution = data['distribution']
473
assert IDistributionSourcePackage.providedBy(self.context)
474
distribution = self.context.distribution
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
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)
491
self.setFieldError("packagename",
492
"Please enter a package name")
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):
502
"%s does not use Launchpad as its bug tracker " %
503
product_or_distro.displayname)
505
def setUpWidgets(self):
506
"""Customize the onKeyPress event of the package name chooser."""
507
LaunchpadFormView.setUpWidgets(self)
509
if "packagename" in self.field_names:
510
self.widgets["packagename"].onKeyPress = (
511
"selectWidget('choose', event)")
513
def setUpFields(self):
514
"""Set up the form fields. See `LaunchpadFormView`."""
515
super(FileBugViewBase, self).setUpFields()
517
# Override the vocabulary for the subscribe_to_existing_bug
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)
525
self.form_fields = self.form_fields.omit('subscribe_to_existing_bug')
526
self.form_fields += formlib.form.Fields(subscribe_field)
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
536
bug_tracking_usage = self.getMainContext().bug_tracking_usage
537
return bug_tracking_usage == ServiceUsage.LAUNCHPAD
539
def shouldSelectPackageName(self):
540
"""Should the radio button to select a package be selected?"""
542
self.request.form.get("field.packagename") or
543
self.initial_values.get("packagename"))
545
def handleSubmitBugFailure(self, action, data, errors):
546
return self.showFileBugForm()
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)
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']
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":
574
# Security bugs are always private when filed, but can be disclosed
575
# after they've been reported.
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)
594
sourcepackagename = context.guessPublishedSourcePackageName(
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))
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))
607
context = context.getSourcePackage(sourcepackagename.name)
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.')
616
if extra_data.private:
617
params.private = extra_data.private
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']
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']
630
self.added_bug = bug = context.createBug(params)
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'
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)
647
# Deal with attachments added in the filebug form.
649
# We convert slashes in filenames to hyphens to avoid
651
filename = attachment.filename.replace('/', '-')
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
662
owner=self.user, data=StringIO(data['filecontent']),
663
filename=filename, description=file_description,
664
comment=attachment_comment, is_patch=data['patch'])
666
notifications.append(
667
'The file "%s" was attached to the bug report.' %
668
cgi.escape(filename))
670
for attachment in extra_data.attachments:
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))
680
if extra_data.subscribers:
681
# Subscribe additional subscribers to this bug
682
for subscriber in extra_data.subscribers:
683
valid_person_vocabulary = ValidPersonOrTeamVocabulary()
685
person = valid_person_vocabulary.getTermByToken(
688
# We cannot currently pass this error up to the user, so
689
# we'll just ignore it.
692
bug.subscribe(person, self.user)
693
notifications.append(
694
'%s has been subscribed to this bug.' %
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)
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(
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(
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>.'))
721
self.request.response.redirect(canonical_url(bug.bugtasks[0]))
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')
730
if bug.isUserAffected(self.user):
731
self.request.response.addNotification(
732
"This bug is already marked as affecting you.")
734
bug.markUserAffected(self.user)
735
self.request.response.addNotification(
736
"This bug has been marked as affecting you.")
738
# If the user wants to be subscribed, subscribe them, unless
739
# they're already subscribed.
741
if bug.isSubscribed(self.user):
742
self.request.response.addNotification(
743
"You are already subscribed to this bug.")
745
bug.subscribe(self.user, self.user)
746
self.request.response.addNotification(
747
"You have subscribed to this bug report.")
749
self.next_url = canonical_url(bug.bugtasks[0])
751
def showFileBugForm(self):
752
"""Override this method in base classes to show the filebug form."""
753
raise NotImplementedError
756
def inline_filebug_base_url(self):
757
"""Return the base URL for the current request.
759
This allows us to build URLs in Javascript without guessing at
762
return self.request.getRootURL(None)
765
def inline_filebug_form_url(self):
766
"""Return the URL to the inline filebug form.
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.
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)
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)
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
790
raise NotFound(self, name, request=request)
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)
802
self.extra_data = self.extra_data_processing_job.getFileBugData()
806
def browserDefault(self, request):
807
"""See IBrowserPublisher."""
810
def getProductOrDistroFromContext(self):
811
"""Return the product or distribution relative to the context.
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.
817
context = self.context
818
if IProduct.providedBy(context) or IDistribution.providedBy(context):
820
elif IProductSeries.providedBy(context):
821
return context.product
822
elif (IDistroSeries.providedBy(context) or
823
IDistributionSourcePackage.providedBy(context)):
824
return context.distribution
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':
839
return LaunchpadFormView.showOptionalMarker(self, field_name)
841
def getRelevantBugTask(self, bug):
842
"""Return the first bugtask from this bug that's relevant in the
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!
851
context = self.context
853
if IProjectGroup.providedBy(context):
854
contexts = set(context.products)
858
for bugtask in bug.bugtasks:
859
if bugtask.target in contexts or bugtask.pillar in contexts:
865
"""The bugtarget we're currently assuming.
867
The same as the context.
871
default_bug_reported_acknowledgement = "Thank you for your bug report."
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.
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:
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)):
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
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.
921
return getUtility(IProcessApportBlobJobSource).getByBlobUUID(
922
self.extra_data_token)
923
except SQLObjectNotFound:
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:
932
elif apport_processing_job.job.status == JobStatus.COMPLETED:
938
class FileBugInlineFormView(FileBugViewBase):
939
"""A browser view for displaying the inline filebug form."""
943
class FileBugAdvancedView(FileBugViewBase):
944
"""Browser view for filing a bug.
946
This view exists only to redirect from +filebug-advanced to +filebug.
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)
956
class FilebugShowSimilarBugsView(FileBugViewBase):
957
"""A view for showing possible dupes for a bug.
959
This view will only be used to populate asynchronously-driven parts
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)
970
_MATCHING_BUGS_LIMIT = 10
971
show_summary_in_results = False
974
def action_url(self):
975
"""Return the +filebug page as the action URL.
977
This enables better validation error handling,
978
since the form is always used inline on the +filebug page.
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)
986
def search_context(self):
987
"""Return the context used to search for similar bugs."""
991
def search_text(self):
992
"""Return the search string entered by the user."""
993
return self.request.get('title')
996
def similar_bugs(self):
997
"""Return the similar bugs based on the user search."""
998
title = self.search_text
1001
search_context = self.search_context
1002
if search_context is None:
1004
elif IProduct.providedBy(search_context):
1005
context_params = {'product': search_context}
1006
elif IDistribution.providedBy(search_context):
1007
context_params = {'distribution': search_context}
1009
assert IDistributionSourcePackage.providedBy(search_context), (
1010
'Unknown search context: %r' % search_context)
1012
'distribution': search_context.distribution,
1013
'sourcepackagename': search_context.sourcepackagename}
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
1022
def show_duplicate_list(self):
1023
"""Return whether or not to show the duplicate list.
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.
1031
self.contextUsesMalone and
1032
len(self.similar_bugs) > 0 and
1033
len(self.widget_errors) == 0)
1036
class FileBugGuidedView(FilebugShowSimilarBugsView):
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")
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
1049
actions = FilebugShowSimilarBugsView.actions
1050
template = _SEARCH_FOR_DUPES
1052
focused_element_id = 'field.title'
1053
show_summary_in_results = True
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)
1065
def page_title(self):
1066
if IMaloneApplication.providedBy(self.context):
1067
return 'Report a bug'
1069
return 'Report a bug about %s' % self.context.title
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()
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()
1091
def search_context(self):
1092
"""Return the context used to search for similar bugs."""
1093
if IDistributionSourcePackage.providedBy(self.context):
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()
1106
search_context = None
1108
return search_context
1111
def search_text(self):
1112
"""Return the search string entered by the user."""
1114
return self.widgets['title'].getInputValue()
1118
def validate_search(self, action, data):
1119
"""Make sure some keywords are provided."""
1121
data['title'] = self.widgets['title'].getInputValue()
1122
except InputErrors, error:
1123
self.setFieldError("title", "A summary is required.")
1126
# Return an empty list of errors to satisfy the validation API,
1127
# and say "we've handled the validation and found no errors."
1130
def validate_no_dupe_found(self, action, data):
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()
1139
def showFileBugForm(self):
1140
return self._FILEBUG_FORM()
1143
class ProjectFileBugGuidedView(FileBugGuidedView):
1144
"""Guided filebug pages for IProjectGroup."""
1146
# Make inheriting the base class' actions work.
1147
actions = FileBugGuidedView.actions
1148
schema = IProjectGroupBugAddForm
1151
def products_using_malone(self):
1153
product for product in self.context.products
1154
if product.bug_tracking_usage == ServiceUsage.LAUNCHPAD]
1157
def default_product(self):
1158
if len(self.products_using_malone) > 0:
1159
return self.products_using_malone[0]
1164
def inline_filebug_form_url(self):
1165
"""Return the URL to the inline filebug form.
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.
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.
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)
1181
def duplicate_search_url(self):
1182
"""Return the URL to the inline duplicate search view.
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.
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)
1195
class BugTargetBugListingView(LaunchpadView):
1196
"""Helper methods for rendering bug listings."""
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
1209
raise AssertionError("series_list called with illegal context")
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
1223
raise AssertionError(
1224
"milestones_list called with illegal context")
1225
return list(milestone_resultset)
1228
def series_buglistings(self):
1229
"""Return a buglisting for each series.
1231
The list is sorted newest series to oldest.
1233
The count only considers bugs that the user would actually be
1234
able to see in a listing.
1237
from lp.bugs.model.bugsummary import BugSummary
1238
series_buglistings = []
1239
bug_task_set = getUtility(IBugTaskSet)
1240
series_list = self.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
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(
1261
url=canonical_url(series) + "/+bugs",
1262
count=series_bug_count,
1264
return series_buglistings
1267
def milestone_buglistings(self):
1268
"""Return a buglisting for each milestone."""
1270
from lp.bugs.model.bugsummary import (
1272
CombineBugSummaryConstraint,
1274
milestone_buglistings = []
1275
bug_task_set = getUtility(IBugTaskSet)
1276
milestones = self.milestones_list
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
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(
1291
title=milestone.name,
1292
url=canonical_url(milestone),
1293
count=milestone_bug_count,
1295
return milestone_buglistings
1298
class BugCountDataItem:
1299
"""Data about bug count for a status."""
1301
def __init__(self, label, count, color):
1304
if color.startswith('#'):
1305
self.color = 'MochiKit.Color.Color.fromHexString("%s")' % color
1307
self.color = 'MochiKit.Color.Color["%sColor"]()' % color
1310
class BugTargetBugTagsView(LaunchpadView):
1311
"""Helper methods for rendering the bug tags portlet."""
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)
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)
1329
url=self._getSearchURL(tag),
1331
for (tag, count) in tags.iteritems()],
1332
key=itemgetter('count'), reverse=True)
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))
1341
class OfficialBugTagsManageView(LaunchpadEditFormView):
1342
"""View class for management of official bug tags."""
1344
schema = IOfficialBugTagTargetPublic
1345
custom_widget('official_bug_tags', LargeBugTagsWidget)
1349
"""The form label."""
1350
return 'Manage official bug tags for %s' % self.context.title
1353
def page_title(self):
1354
"""The page title."""
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)
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;
1378
dumps(official_tags),
1379
dumps(valid_name_pattern.pattern))
1382
def cancel_url(self):
1383
"""The URL the user is sent to when clicking the "cancel" link."""
1384
return canonical_url(self.context)
1387
class BugsVHostBreadcrumb(Breadcrumb):
1392
class BugsPatchesView(LaunchpadView):
1393
"""View list of patch attachments associated with bugs."""
1397
"""The display label for the view."""
1398
if IPerson.providedBy(self.context):
1399
return 'Patch attachments for %s' % self.context.displayname
1401
return 'Patch attachments in %s' % self.context.displayname
1404
def patch_task_orderings(self):
1405
"""The list of possible sort orderings for the patches view.
1407
The orderings are a list of tuples of the form:
1408
[(DisplayName, InternalOrderingName), ...]
1410
[("Patch age", "-latest_patch_uploaded"),
1411
("Importance", "-importance"),
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"))
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),
1438
def targetName(self):
1439
"""Return the name of the current context's target type, or None.
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.
1446
if (IDistribution.providedBy(self.context) or
1447
IDistroSeries.providedBy(self.context)):
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
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
1468
def proxiedUrlForLibraryFile(self, patch):
1469
"""Return the proxied download URL for a Librarian file."""
1470
return ProxiedLibraryFileAlias(patch.libraryfile, patch).http_url
1473
class TargetSubscriptionView(LaunchpadView):
1474
"""A view to show all a person's structural subscriptions to a target."""
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):
1483
self.request.getRootURL('bugs'), self.request['PATH_INFO'])
1484
self.request.response.redirect(new_url)
1486
expose_structural_subscription_data_to_js(
1487
self.context, self.request, self.user, self.subscriptions)
1490
def subscriptions(self):
1491
return get_structural_subscriptions_for_target(
1492
self.context, self.user)
1496
return "Your subscriptions to %s" % (self.context.displayname,)