~launchpad-pqm/launchpad/devel

« back to all changes in this revision

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

Merged db-devel

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
# Copyright 2009 Canonical Ltd.  This software is licensed under the
 
1
# Copyright 2010 Canonical Ltd.  This software is licensed under the
2
2
# GNU Affero General Public License version 3 (see the file LICENSE).
3
3
 
4
4
"""IBugTarget-related browser views."""
7
7
 
8
8
__all__ = [
9
9
    "BugsVHostBreadcrumb",
 
10
    "BugsPatchesView",
10
11
    "BugTargetBugListingView",
11
12
    "BugTargetBugTagsView",
12
13
    "BugTargetBugsView",
19
20
 
20
21
import cgi
21
22
from cStringIO import StringIO
22
 
from email import message_from_string
 
23
from datetime import datetime
23
24
from operator import itemgetter
 
25
from pytz import timezone
24
26
from simplejson import dumps
25
 
import tempfile
26
27
import urllib
27
28
 
 
29
from sqlobject import SQLObjectNotFound
 
30
 
28
31
from z3c.ptcompat import ViewPageTemplateFile
29
32
from zope.app.form.browser import TextWidget
30
33
from zope.app.form.interfaces import InputErrors
39
42
from canonical.cachedproperty import cachedproperty
40
43
from canonical.config import config
41
44
from lp.bugs.browser.bugtask import BugTaskSearchListingView
 
45
from lp.bugs.interfaces.apportjob import IProcessApportBlobJobSource
42
46
from lp.bugs.interfaces.bug import IBug
43
47
from lp.bugs.interfaces.bugtask import BugTaskSearchParams
44
48
from canonical.launchpad.browser.feeds import (
48
52
    IBugTarget, IOfficialBugTagTargetPublic, IOfficialBugTagTargetRestricted)
49
53
from lp.bugs.interfaces.bug import IBugSet
50
54
from lp.bugs.interfaces.bugtask import (
51
 
    BugTaskStatus, IBugTaskSet, UNRESOLVED_BUGTASK_STATUSES)
 
55
    BugTaskStatus, IBugTaskSet, UNRESOLVED_BUGTASK_STATUSES,
 
56
    UNRESOLVED_PLUS_FIXRELEASED_BUGTASK_STATUSES)
52
57
from canonical.launchpad.interfaces.launchpad import (
53
58
    IHasExternalBugTracker, ILaunchpadUsage)
54
59
from lp.hardwaredb.interfaces.hwdb import IHWSubmissionSet
55
60
from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
56
 
from canonical.launchpad.interfaces.temporaryblobstorage import (
57
 
    ITemporaryStorageManager)
58
61
from canonical.launchpad.searchbuilder import any
59
62
from canonical.launchpad.webapp import urlappend
60
63
from canonical.launchpad.webapp.breadcrumb import Breadcrumb
61
 
from canonical.launchpad.webapp.interfaces import ILaunchBag, NotFoundError
 
64
from canonical.launchpad.webapp.interfaces import (
 
65
    ILaunchBag, NotFoundError, UnexpectedFormData)
62
66
from lp.bugs.interfaces.bug import (
63
67
    CreateBugParams, IBugAddForm, IProjectGroupBugAddForm)
64
68
from lp.bugs.interfaces.malone import IMaloneApplication
 
69
from lp.bugs.utilities.filebugdataparser import FileBugData
65
70
from lp.registry.interfaces.distribution import IDistribution
66
71
from lp.registry.interfaces.distributionsourcepackage import (
67
72
    IDistributionSourcePackage)
68
73
from lp.registry.interfaces.distroseries import IDistroSeries
 
74
from lp.registry.interfaces.person import IPerson
69
75
from lp.registry.interfaces.product import IProduct
70
76
from lp.registry.interfaces.productseries import IProductSeries
71
77
from lp.registry.interfaces.projectgroup import IProjectGroup
72
78
from lp.registry.interfaces.sourcepackage import ISourcePackage
 
79
from lp.services.job.interfaces.job import JobStatus
73
80
from canonical.launchpad.webapp import (
74
81
    LaunchpadEditFormView, LaunchpadFormView, LaunchpadView, action,
75
82
    canonical_url, custom_widget, safe_action)
76
83
from canonical.launchpad.webapp.authorization import check_permission
 
84
from canonical.launchpad.webapp.batching import BatchNavigator
77
85
from canonical.launchpad.webapp.tales import BugTrackerFormatterAPI
78
86
from canonical.launchpad.validators.name import valid_name_pattern
79
87
from canonical.launchpad.webapp.menu import structured
84
92
from lp.registry.vocabularies import ValidPersonOrTeamVocabulary
85
93
 
86
94
 
87
 
class FileBugDataParser:
88
 
    """Parser for a message containing extra bug information.
89
 
 
90
 
    Applications like Apport upload such messages, before filing the
91
 
    bug.
92
 
    """
93
 
 
94
 
    def __init__(self, blob_file):
95
 
        self.blob_file = blob_file
96
 
        self.headers = {}
97
 
        self._buffer = ''
98
 
        self.extra_description = None
99
 
        self.comments = []
100
 
        self.attachments = []
101
 
        self.BUFFER_SIZE = 8192
102
 
 
103
 
    def _consumeBytes(self, end_string):
104
 
        """Read bytes from the message up to the end_string.
105
 
 
106
 
        The end_string is included in the output.
107
 
 
108
 
        If end-of-file is reached, '' is returned.
109
 
        """
110
 
        while end_string not in self._buffer:
111
 
            data = self.blob_file.read(self.BUFFER_SIZE)
112
 
            self._buffer += data
113
 
            if len(data) < self.BUFFER_SIZE:
114
 
                # End of file.
115
 
                if end_string not in self._buffer:
116
 
                    # If the end string isn't present, we return
117
 
                    # everything.
118
 
                    buffer = self._buffer
119
 
                    self._buffer = ''
120
 
                    return buffer
121
 
                break
122
 
        end_index = self._buffer.index(end_string)
123
 
        bytes = self._buffer[:end_index+len(end_string)]
124
 
        self._buffer = self._buffer[end_index+len(end_string):]
125
 
        return bytes
126
 
 
127
 
    def readHeaders(self):
128
 
        """Read the next set of headers of the message."""
129
 
        header_text = self._consumeBytes('\n\n')
130
 
        # Use the email package to return a dict-like object of the
131
 
        # headers, so we don't have to parse the text ourselves.
132
 
        return message_from_string(header_text)
133
 
 
134
 
    def readLine(self):
135
 
        """Read a line of the message."""
136
 
        data = self._consumeBytes('\n')
137
 
        if data == '':
138
 
            raise AssertionError('End of file reached.')
139
 
        return data
140
 
 
141
 
    def _setDataFromHeaders(self, data, headers):
142
 
        """Set the data attributes from the message headers."""
143
 
        if 'Subject' in headers:
144
 
            data.initial_summary = unicode(headers['Subject'])
145
 
        if 'Tags' in headers:
146
 
            tags_string = unicode(headers['Tags'])
147
 
            data.initial_tags = tags_string.lower().split()
148
 
        if 'Private' in headers:
149
 
            private = headers['Private']
150
 
            if private.lower() == 'yes':
151
 
                data.private = True
152
 
            elif private.lower() == 'no':
153
 
                data.private = False
154
 
            else:
155
 
                # If the value is anything other than yes or no we just
156
 
                # ignore it as we cannot currently give the user an error
157
 
                pass
158
 
        if 'Subscribers' in headers:
159
 
            subscribers_string = unicode(headers['Subscribers'])
160
 
            data.subscribers = subscribers_string.lower().split()
161
 
        if 'HWDB-Submission' in headers:
162
 
            submission_string = unicode(headers['HWDB-Submission'])
163
 
            data.hwdb_submission_keys = (
164
 
                part.strip() for part in submission_string.split(','))
165
 
 
166
 
    def parse(self):
167
 
        """Parse the message and  return a FileBugData instance.
168
 
 
169
 
            * The Subject header is the initial bug summary.
170
 
            * The Tags header specifies the initial bug tags.
171
 
            * The Private header sets the visibility of the bug.
172
 
            * The Subscribers header specifies additional initial subscribers
173
 
            * The first inline part will be added to the description.
174
 
            * All other inline parts will be added as separate comments.
175
 
            * All attachment parts will be added as attachment.
176
 
 
177
 
        When parsing each part of the message is stored in a temporary
178
 
        file on the file system. After using the returned data,
179
 
        removeTemporaryFiles() must be called.
180
 
        """
181
 
        headers = self.readHeaders()
182
 
        data = FileBugData()
183
 
        self._setDataFromHeaders(data, headers)
184
 
 
185
 
        # The headers is a Message instance.
186
 
        boundary = "--" + headers.get_param("boundary")
187
 
        line = self.readLine()
188
 
        while not line.startswith(boundary + '--'):
189
 
            part_file = tempfile.TemporaryFile()
190
 
            part_headers = self.readHeaders()
191
 
            content_encoding = part_headers.get('Content-Transfer-Encoding')
192
 
            if content_encoding is not None and content_encoding != 'base64':
193
 
                raise AssertionError(
194
 
                    "Unknown encoding: %r." % content_encoding)
195
 
            line = self.readLine()
196
 
            while not line.startswith(boundary):
197
 
                # Decode the file.
198
 
                if content_encoding is not None:
199
 
                    line = line.decode(content_encoding)
200
 
                part_file.write(line)
201
 
                line = self.readLine()
202
 
            # Prepare the file for reading.
203
 
            part_file.seek(0)
204
 
            disposition = part_headers['Content-Disposition']
205
 
            disposition = disposition.split(';')[0]
206
 
            disposition = disposition.strip()
207
 
            if disposition == 'inline':
208
 
                assert part_headers.get_content_type() == 'text/plain', (
209
 
                    "Inline parts have to be plain text.")
210
 
                charset = part_headers.get_content_charset()
211
 
                assert charset, (
212
 
                    "A charset has to be specified for text parts.")
213
 
                inline_content = part_file.read().rstrip()
214
 
                part_file.close()
215
 
                inline_content = inline_content.decode(charset)
216
 
 
217
 
                if data.extra_description is None:
218
 
                    # The first inline part is extra description.
219
 
                    data.extra_description = inline_content
220
 
                else:
221
 
                    data.comments.append(inline_content)
222
 
            elif disposition == 'attachment':
223
 
                attachment = dict(
224
 
                    filename=unicode(part_headers.get_filename().strip("'")),
225
 
                    content_type=unicode(part_headers['Content-type']),
226
 
                    content=part_file)
227
 
                if 'Content-Description' in part_headers:
228
 
                    attachment['description'] = unicode(
229
 
                        part_headers['Content-Description'])
230
 
                else:
231
 
                    attachment['description'] = attachment['filename']
232
 
                data.attachments.append(attachment)
233
 
            else:
234
 
                # If the message include other disposition types,
235
 
                # simply ignore them. We don't want to break just
236
 
                # because some extra information is included.
237
 
                continue
238
 
        return data
239
 
 
240
 
 
241
 
class FileBugData:
242
 
    """Extra data to be added to the bug."""
243
 
 
244
 
    def __init__(self):
245
 
        self.initial_summary = None
246
 
        self.initial_summary = None
247
 
        self.initial_tags = []
248
 
        self.private = None
249
 
        self.subscribers = []
250
 
        self.extra_description = None
251
 
        self.comments = []
252
 
        self.attachments = []
253
 
        self.hwdb_submission_keys = []
254
 
 
255
 
 
256
95
# A simple vocabulary for the subscribe_to_existing_bug form field.
257
96
SUBSCRIBE_TO_BUG_VOCABULARY = SimpleVocabulary.fromItems(
258
97
    [('yes', True), ('no', False)])
275
114
    def initialize(self):
276
115
        LaunchpadFormView.initialize(self)
277
116
        if (not self.redirect_ubuntu_filebug and
278
 
            self.extra_data_token is not None):
 
117
            self.extra_data_token is not None and
 
118
            not self.extra_data_to_process):
279
119
            # self.extra_data has been initialized in publishTraverse().
280
120
            if self.extra_data.initial_summary:
281
121
                self.widgets['title'].setRenderedValue(
617
457
                        cgi.escape(filename))
618
458
 
619
459
            for attachment in extra_data.attachments:
620
 
                bug.addAttachment(
621
 
                    owner=self.user, data=attachment['content'],
 
460
                bug.linkAttachment(
 
461
                    owner=self.user, file_alias=attachment['file_alias'],
622
462
                    description=attachment['description'],
623
 
                    comment=attachment_comment,
624
 
                    filename=attachment['filename'],
625
 
                    content_type=attachment['content_type'])
 
463
                    comment=attachment_comment)
626
464
                notifications.append(
627
465
                    'The file "%s" was attached to the bug report.' %
628
 
                        cgi.escape(attachment['filename']))
 
466
                        cgi.escape(attachment['file_alias'].filename))
629
467
 
630
468
        if extra_data.subscribers:
631
469
            # Subscribe additional subscribers to this bug
739
577
            # expected.
740
578
            raise NotFound(self, name, request=request)
741
579
 
742
 
        extra_bug_data = getUtility(ITemporaryStorageManager).fetch(name)
743
 
        if extra_bug_data is not None:
744
 
            self.extra_data_token = name
745
 
            extra_bug_data.file_alias.open()
746
 
            self.data_parser = FileBugDataParser(extra_bug_data.file_alias)
747
 
            self.extra_data = self.data_parser.parse()
748
 
            extra_bug_data.file_alias.close()
749
 
        else:
 
580
        self.extra_data_token = name
 
581
        if self.extra_data_processing_job is None:
750
582
            # The URL might be mistyped, or the blob has expired.
751
583
            # XXX: Bjorn Tillenius 2006-01-15:
752
584
            #      We should handle this case better, since a user might
754
586
            #      registration. In that case we should inform the user
755
587
            #      that the blob has expired.
756
588
            raise NotFound(self, name, request=request)
 
589
        else:
 
590
            self.extra_data = self.extra_data_processing_job.getFileBugData()
 
591
 
757
592
        return self
758
593
 
759
594
    def browserDefault(self, request):
862
697
                            })
863
698
        return guidelines
864
699
 
 
700
    @cachedproperty
 
701
    def extra_data_processing_job(self):
 
702
        """Return the ProcessApportBlobJob for a given BLOB token."""
 
703
        if self.extra_data_token is None:
 
704
            # If there's no extra data token, don't bother looking for a
 
705
            # ProcessApportBlobJob.
 
706
            return None
 
707
 
 
708
        try:
 
709
            return getUtility(IProcessApportBlobJobSource).getByBlobUUID(
 
710
                self.extra_data_token)
 
711
        except SQLObjectNotFound:
 
712
            return None
 
713
 
 
714
    @property
 
715
    def extra_data_to_process(self):
 
716
        """Return True if there is extra data to process."""
 
717
        apport_processing_job = self.extra_data_processing_job
 
718
        if apport_processing_job is None:
 
719
            return False
 
720
        elif apport_processing_job.job.status == JobStatus.COMPLETED:
 
721
            return False
 
722
        else:
 
723
            return True
 
724
 
865
725
 
866
726
class FileBugInlineFormView(FileBugViewBase):
867
727
    """A browser view for displaying the inline filebug form."""
1408
1268
class BugsVHostBreadcrumb(Breadcrumb):
1409
1269
    rootsite = 'bugs'
1410
1270
    text = 'Bugs'
 
1271
 
 
1272
 
 
1273
class BugsPatchesView(LaunchpadView):
 
1274
    """View list of patch attachments associated with bugs."""
 
1275
 
 
1276
    @property
 
1277
    def label(self):
 
1278
        """The display label for the view."""
 
1279
        if IPerson.providedBy(self.context):
 
1280
            return 'Patch attachments for %s' % self.context.displayname
 
1281
        else:
 
1282
            return 'Patch attachments in %s' % self.context.displayname
 
1283
 
 
1284
    @property
 
1285
    def patch_task_orderings(self):
 
1286
        """The list of possible sort orderings for the patches view.
 
1287
 
 
1288
        The orderings are a list of tuples of the form:
 
1289
          [(DisplayName, InternalOrderingName), ...]
 
1290
        For example:
 
1291
          [("Patch age", "-latest_patch_uploaded"),
 
1292
           ("Importance", "-importance"),
 
1293
           ...]
 
1294
        """
 
1295
        orderings = [("patch age", "-latest_patch_uploaded"),
 
1296
                     ("importance", "-importance"),
 
1297
                     ("status", "status"),
 
1298
                     ("oldest first", "datecreated"),
 
1299
                     ("newest first", "-datecreated")]
 
1300
        targetname = self.targetName()
 
1301
        if targetname is not None:
 
1302
            # Lower case for consistency with the other orderings.
 
1303
            orderings.append((targetname.lower(), "targetname"))
 
1304
        return orderings
 
1305
 
 
1306
 
 
1307
    def batchedPatchTasks(self):
 
1308
        """Return a BatchNavigator for bug tasks with patch attachments."""
 
1309
        orderby = self.request.get("orderby", "-latest_patch_uploaded")
 
1310
        if orderby not in [x[1] for x in self.patch_task_orderings]:
 
1311
            raise UnexpectedFormData(
 
1312
                "Unexpected value for field 'orderby': '%s'" % orderby)
 
1313
        return BatchNavigator(
 
1314
            self.context.searchTasks(
 
1315
                None, user=self.user, order_by=orderby,
 
1316
                status=UNRESOLVED_PLUS_FIXRELEASED_BUGTASK_STATUSES,
 
1317
                omit_duplicates=True, has_patch=True),
 
1318
            self.request)
 
1319
 
 
1320
    def targetName(self):
 
1321
        """Return the name of the current context's target type, or None.
 
1322
 
 
1323
        The name is something like "Package" or "Project" (meaning
 
1324
        Product); it is intended to be appropriate to use as a column
 
1325
        name in a web page, for example.  If no target type is
 
1326
        appropriate for the current context, then return None.
 
1327
        """
 
1328
        if (IDistribution.providedBy(self.context) or
 
1329
            IDistroSeries.providedBy(self.context)):
 
1330
            return "Package"
 
1331
        elif (IProjectGroup.providedBy(self.context) or
 
1332
              IPerson.providedBy(self.context)):
 
1333
            # In the case of an IPerson, the target column can vary
 
1334
            # row-by-row, showing both packages and products.  We
 
1335
            # decided to go with the table header "Project" for both,
 
1336
            # as its meaning is broad and could conceivably cover
 
1337
            # packages too.  We also considered "Target", but rejected
 
1338
            # it because it's used as a verb elsewhere in Launchpad's
 
1339
            # UI, with a totally different meaning.  If anyone can
 
1340
            # think of a better term than "Project", please JFDI here.
 
1341
            return "Project"  # "Project" meaning Product, of course
 
1342
        else:
 
1343
            return None
 
1344
 
 
1345
    def patchAge(self, patch):
 
1346
        """Return a timedelta object for the age of a patch attachment."""
 
1347
        now = datetime.now(timezone('UTC'))
 
1348
        return now - patch.message.datecreated