~launchpad-pqm/launchpad/devel

« back to all changes in this revision

Viewing changes to lib/lp/app/browser/root.py

  • Committer: Canonical.com Patch Queue Manager
  • Date: 2004-08-03 09:17:25 UTC
  • mfrom: (unknown (missing))
  • Revision ID: Arch-1:rocketfuel@canonical.com%launchpad--devel--0--patch-19
Removed defaultSkin directive to make launchpad work with the latest zope.
Patches applied:

 * steve.alexander@canonical.com/launchpad--devel--0--patch-16
   merge from rocketfuel

 * steve.alexander@canonical.com/launchpad--devel--0--patch-17
   removed use of defaultSkin directive, which has been removed from zope3

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
# Copyright 2009-2011 Canonical Ltd.  This software is licensed under the
2
 
# GNU Affero General Public License version 3 (see the file LICENSE).
3
 
"""Browser code for the Launchpad root page."""
4
 
 
5
 
__metaclass__ = type
6
 
__all__ = [
7
 
    'LaunchpadRootIndexView',
8
 
    'LaunchpadSearchView',
9
 
    ]
10
 
 
11
 
 
12
 
import re
13
 
import time
14
 
 
15
 
import feedparser
16
 
from lazr.batchnavigator.z3batching import batch
17
 
from zope.app.form.interfaces import ConversionError
18
 
from zope.component import getUtility
19
 
from zope.interface import Interface
20
 
from zope.schema import TextLine
21
 
from zope.schema.interfaces import TooLong
22
 
from zope.schema.vocabulary import getVocabularyRegistry
23
 
 
24
 
from canonical.config import config
25
 
from canonical.launchpad import _
26
 
from lp.services.statistics.interfaces.statistic import (
27
 
    ILaunchpadStatisticSet,
28
 
    )
29
 
from canonical.launchpad.webapp import LaunchpadView
30
 
from canonical.launchpad.webapp.authorization import check_permission
31
 
from canonical.launchpad.webapp.batching import BatchNavigator
32
 
from canonical.launchpad.webapp.publisher import canonical_url
33
 
from canonical.launchpad.webapp.vhosts import allvhosts
34
 
from canonical.lazr.timeout import urlfetch
35
 
from lp.answers.interfaces.questioncollection import IQuestionSet
36
 
from lp.app.browser.launchpadform import (
37
 
    action,
38
 
    LaunchpadFormView,
39
 
    safe_action,
40
 
    )
41
 
from lp.app.errors import NotFoundError
42
 
from lp.app.interfaces.launchpad import ILaunchpadCelebrities
43
 
from lp.app.validators.name import sanitize_name
44
 
from lp.blueprints.interfaces.specification import ISpecificationSet
45
 
from lp.bugs.interfaces.bug import IBugSet
46
 
from lp.code.interfaces.branchcollection import IAllBranches
47
 
from lp.registry.browser.announcement import HasAnnouncementsView
48
 
from lp.registry.interfaces.person import IPersonSet
49
 
from lp.registry.interfaces.pillar import IPillarNameSet
50
 
from lp.registry.interfaces.product import IProductSet
51
 
from lp.services.googlesearch.interfaces import (
52
 
    GoogleResponseError,
53
 
    ISearchService,
54
 
    )
55
 
from lp.services.propertycache import cachedproperty
56
 
 
57
 
 
58
 
shipit_faq_url = 'http://www.ubuntu.com/getubuntu/shipit-faq'
59
 
 
60
 
 
61
 
class LaunchpadRootIndexView(HasAnnouncementsView, LaunchpadView):
62
 
    """An view for the default view of the LaunchpadRoot."""
63
 
 
64
 
    page_title = 'Launchpad'
65
 
    featured_projects = []
66
 
    featured_projects_top = None
67
 
 
68
 
    # Used by the footer to display the lp-arcana section.
69
 
    is_root_page = True
70
 
 
71
 
    @staticmethod
72
 
    def _get_day_of_year():
73
 
        """Calculate the number of the current day.
74
 
 
75
 
        This method gets overridden in tests to make the selection of the
76
 
        top featured project deterministic.
77
 
        """
78
 
        return time.gmtime()[7]
79
 
 
80
 
    def initialize(self):
81
 
        """Set up featured projects list and the top featured project."""
82
 
        super(LaunchpadRootIndexView, self).initialize()
83
 
        # The maximum number of projects to be displayed as defined by the
84
 
        # number of items plus one top featured project.
85
 
        self.featured_projects = list(
86
 
            getUtility(IPillarNameSet).featured_projects)
87
 
        self._setFeaturedProjectsTop()
88
 
 
89
 
    def _setFeaturedProjectsTop(self):
90
 
        """Set the top featured project and remove it from the list."""
91
 
        project_count = len(self.featured_projects)
92
 
        if project_count > 0:
93
 
            top_project = self._get_day_of_year() % project_count
94
 
            self.featured_projects_top = self.featured_projects.pop(
95
 
                top_project)
96
 
 
97
 
    @cachedproperty
98
 
    def apphomes(self):
99
 
        return {
100
 
            'answers': canonical_url(self.context, rootsite='answers'),
101
 
            'blueprints': canonical_url(self.context, rootsite='blueprints'),
102
 
            'bugs': canonical_url(self.context, rootsite='bugs'),
103
 
            'code': canonical_url(self.context, rootsite='code'),
104
 
            'translations': canonical_url(self.context,
105
 
                                          rootsite='translations'),
106
 
            'ubuntu': canonical_url(
107
 
                getUtility(ILaunchpadCelebrities).ubuntu),
108
 
            }
109
 
 
110
 
    @property
111
 
    def branch_count(self):
112
 
        """The total branch count of public branches in all of Launchpad."""
113
 
        return getUtility(IAllBranches).visibleByUser(None).count()
114
 
 
115
 
    @property
116
 
    def bug_count(self):
117
 
        """The total bug count in all of Launchpad."""
118
 
        return getUtility(ILaunchpadStatisticSet).value('bug_count')
119
 
 
120
 
    @property
121
 
    def project_count(self):
122
 
        """The total project count in all of Launchpad."""
123
 
        return getUtility(IProductSet).count_all()
124
 
 
125
 
    @property
126
 
    def translation_count(self):
127
 
        """The total count of translatable strings in all of Launchpad """
128
 
        return getUtility(ILaunchpadStatisticSet).value('pomsgid_count')
129
 
 
130
 
    @property
131
 
    def blueprint_count(self):
132
 
        """The total blueprint count in all of Launchpad."""
133
 
        return getUtility(ISpecificationSet).specification_count
134
 
 
135
 
    @property
136
 
    def answer_count(self):
137
 
        """The total blueprint count in all of Launchpad."""
138
 
        return getUtility(ILaunchpadStatisticSet).value('question_count')
139
 
 
140
 
    def getRecentBlogPosts(self):
141
 
        """Return the parsed feed of the most recent blog posts.
142
 
 
143
 
        It returns a list of dict with keys title, description, link and date.
144
 
 
145
 
        The date is formatted and the description which may contain HTML is
146
 
        sanitized.
147
 
 
148
 
        The number of blog posts to display is controlled through
149
 
        launchpad.homepage_recent_posts_count. The posts are fetched
150
 
        from the feed specified in launchpad.homepage_recent_posts_feed.
151
 
 
152
 
        Since the feed is parsed everytime, the template should cache this
153
 
        through memcached.
154
 
 
155
 
        FeedParser takes care of sanitizing the HTML contained in the feed.
156
 
        """
157
 
        # Use urlfetch which supports timeout
158
 
        try:
159
 
            data = urlfetch(config.launchpad.homepage_recent_posts_feed)
160
 
        except IOError:
161
 
            return []
162
 
        feed = feedparser.parse(data)
163
 
        posts = []
164
 
        max_count = config.launchpad.homepage_recent_posts_count
165
 
        # FeedParser takes care of HTML sanitisation.
166
 
        for entry in feed.entries[:max_count]:
167
 
            posts.append({
168
 
                'title': entry.title,
169
 
                'description': entry.description,
170
 
                'link': entry.link,
171
 
                'date': time.strftime('%d %b %Y', entry.updated_parsed),
172
 
                })
173
 
        return posts
174
 
 
175
 
 
176
 
class LaunchpadSearchFormView(LaunchpadView):
177
 
    """A view to display the global search form in any page."""
178
 
    id_suffix = '-secondary'
179
 
    text = None
180
 
    focusedElementScript = None
181
 
    form_wide_errors = None
182
 
    errors = None
183
 
    error_count = None
184
 
    error = None
185
 
    error_class = None
186
 
 
187
 
    @property
188
 
    def rooturl(self):
189
 
        """Return the site's root url."""
190
 
        return allvhosts.configs['mainsite'].rooturl
191
 
 
192
 
 
193
 
class LaunchpadPrimarySearchFormView(LaunchpadSearchFormView):
194
 
    """A view to display the global search form in the page."""
195
 
    id_suffix = ''
196
 
 
197
 
    @property
198
 
    def text(self):
199
 
        """The search text submitted to the context view."""
200
 
        return self.context.text
201
 
 
202
 
    @property
203
 
    def focusedElementScript(self):
204
 
        """The context view's focusedElementScript."""
205
 
        return self.context.focusedElementScript
206
 
 
207
 
    @property
208
 
    def form_wide_errors(self):
209
 
        """The context view's form_wide_errors."""
210
 
        return self.context.form_wide_errors
211
 
 
212
 
    @property
213
 
    def errors(self):
214
 
        """The context view's errors."""
215
 
        return self.context.errors
216
 
 
217
 
    @property
218
 
    def error_count(self):
219
 
        """The context view's error_count."""
220
 
        return self.context.error_count
221
 
 
222
 
    @property
223
 
    def error(self):
224
 
        """The context view's text field error."""
225
 
        return self.context.getFieldError('text')
226
 
 
227
 
    @property
228
 
    def error_class(self):
229
 
        """Return the 'error' if there is an error, or None."""
230
 
        if self.error:
231
 
            return 'error'
232
 
        return None
233
 
 
234
 
 
235
 
class ILaunchpadSearch(Interface):
236
 
    """The Schema for performing searches across all Launchpad."""
237
 
 
238
 
    text = TextLine(
239
 
        title=_('Search text'), required=False, max_length=250)
240
 
 
241
 
 
242
 
class LaunchpadSearchView(LaunchpadFormView):
243
 
    """A view to search for Launchpad pages and objects."""
244
 
    schema = ILaunchpadSearch
245
 
    field_names = ['text']
246
 
 
247
 
    shipit_keywords = set([
248
 
        'ubuntu', 'kubuntu', 'edubuntu',
249
 
        'ship', 'shipit', 'send', 'get', 'mail', 'free',
250
 
        'cd', 'cds', 'dvd', 'dvds', 'disc'])
251
 
    shipit_anti_keywords = set([
252
 
        'burn', 'burning', 'enable', 'error', 'errors', 'image', 'iso',
253
 
        'read', 'rip', 'write'])
254
 
 
255
 
    def __init__(self, context, request):
256
 
        """Initialize the view.
257
 
 
258
 
        Set the state of the search_params and matches.
259
 
        """
260
 
        super(LaunchpadSearchView, self).__init__(context, request)
261
 
        self.has_page_service = True
262
 
        self._bug = None
263
 
        self._question = None
264
 
        self._person_or_team = None
265
 
        self._pillar = None
266
 
        self._pages = None
267
 
        self.search_params = self._getDefaultSearchParams()
268
 
        # The Search Action should always run.
269
 
        self.request.form['field.actions.search'] = 'Search'
270
 
 
271
 
    def _getDefaultSearchParams(self):
272
 
        """Return a dict of the search param set to their default state."""
273
 
        return {
274
 
            'text': None,
275
 
            'start': 0,
276
 
            }
277
 
 
278
 
    def _updateSearchParams(self):
279
 
        """Sanitize the search_params and add the BatchNavigator params."""
280
 
        if self.search_params['text'] is not None:
281
 
            text = self.search_params['text'].strip()
282
 
            if text == '':
283
 
                self.search_params['text'] = None
284
 
            else:
285
 
                self.search_params['text'] = text
286
 
        request_start = self.request.get('start', self.search_params['start'])
287
 
        try:
288
 
            start = int(request_start)
289
 
        except (ValueError, TypeError):
290
 
            return
291
 
        self.search_params['start'] = start
292
 
 
293
 
    @property
294
 
    def text(self):
295
 
        """Return the text or None."""
296
 
        return self.search_params['text']
297
 
 
298
 
    @property
299
 
    def start(self):
300
 
        """Return the start index of the batch."""
301
 
        return self.search_params['start']
302
 
 
303
 
    @property
304
 
    def page_title(self):
305
 
        """Page title."""
306
 
        return self.page_heading
307
 
 
308
 
    @property
309
 
    def page_heading(self):
310
 
        """Heading to display above the search results."""
311
 
        if self.text is None:
312
 
            return 'Search Launchpad'
313
 
        else:
314
 
            return 'Pages matching "%s" in Launchpad' % self.text
315
 
 
316
 
    @property
317
 
    def batch_heading(self):
318
 
        """Heading to display in the batch navigation."""
319
 
        if self.has_exact_matches:
320
 
            return ('other page matching "%s"' % self.text,
321
 
                    'other pages matching "%s"' % self.text)
322
 
        else:
323
 
            return ('page matching "%s"' % self.text,
324
 
                    'pages matching "%s"' % self.text)
325
 
 
326
 
    @property
327
 
    def focusedElementScript(self):
328
 
        """Focus the first widget when there are no matches."""
329
 
        if self.has_matches:
330
 
            return None
331
 
        return super(LaunchpadSearchView, self).focusedElementScript()
332
 
 
333
 
    @property
334
 
    def bug(self):
335
 
        """Return the bug that matched the terms, or None."""
336
 
        return self._bug
337
 
 
338
 
    @property
339
 
    def question(self):
340
 
        """Return the question that matched the terms, or None."""
341
 
        return self._question
342
 
 
343
 
    @property
344
 
    def pillar(self):
345
 
        """Return the project that matched the terms, or None."""
346
 
        return self._pillar
347
 
 
348
 
    @property
349
 
    def person_or_team(self):
350
 
        """Return the person or team that matched the terms, or None."""
351
 
        return self._person_or_team
352
 
 
353
 
    @property
354
 
    def pages(self):
355
 
        """Return the pages that matched the terms, or None."""
356
 
        return self._pages
357
 
 
358
 
    @property
359
 
    def has_shipit(self):
360
 
        """Return True is the search text contains shipit keywords."""
361
 
        if self.text is None:
362
 
            return False
363
 
        terms = set(self.text.lower().split())
364
 
        anti_matches = self.shipit_anti_keywords.intersection(terms)
365
 
        if len(anti_matches) >= 1:
366
 
            return False
367
 
        matches = self.shipit_keywords.intersection(terms)
368
 
        return len(matches) >= 2
369
 
 
370
 
    @property
371
 
    def has_exact_matches(self):
372
 
        """Return True if something exactly matched the search terms."""
373
 
        kinds = (self.bug, self.question, self.pillar,
374
 
                 self.person_or_team, self.has_shipit)
375
 
        return self.containsMatchingKind(kinds)
376
 
 
377
 
    @property
378
 
    def shipit_faq_url(self):
379
 
        """The shipit FAQ URL."""
380
 
        return shipit_faq_url
381
 
 
382
 
    @property
383
 
    def has_matches(self):
384
 
        """Return True if something matched the search terms, or False."""
385
 
        kinds = (self.bug, self.question, self.pillar,
386
 
                 self.person_or_team, self.has_shipit, self.pages)
387
 
        return self.containsMatchingKind(kinds)
388
 
 
389
 
    @property
390
 
    def url(self):
391
 
        """Return the requested URL."""
392
 
        if 'QUERY_STRING' in self.request:
393
 
            query_string = self.request['QUERY_STRING']
394
 
        else:
395
 
            query_string = ''
396
 
        return self.request.getURL() + '?' + query_string
397
 
 
398
 
    def containsMatchingKind(self, kinds):
399
 
        """Return True if one of the items in kinds is not None, or False."""
400
 
        for kind in kinds:
401
 
            if kind is not None and kind is not False:
402
 
                return True
403
 
        return False
404
 
 
405
 
    def validate(self, data):
406
 
        """See `LaunchpadFormView`"""
407
 
        errors = list(self.errors)
408
 
        for error in errors:
409
 
            if isinstance(error, ConversionError):
410
 
                self.setFieldError(
411
 
                    'text', 'Can not convert your search term.')
412
 
            elif isinstance(error, unicode):
413
 
                continue
414
 
            elif (error.field_name == 'text'
415
 
                and isinstance(error.errors, TooLong)):
416
 
                self.setFieldError(
417
 
                    'text', 'The search text cannot exceed 250 characters.')
418
 
 
419
 
    @safe_action
420
 
    @action(u'Search', name='search')
421
 
    def search_action(self, action, data):
422
 
        """The Action executed when the user uses the search button.
423
 
 
424
 
        Saves the user submitted search parameters in an instance
425
 
        attribute.
426
 
        """
427
 
        self.search_params.update(**data)
428
 
        self._updateSearchParams()
429
 
        if self.text is None:
430
 
            return
431
 
 
432
 
        if self.start == 0:
433
 
            numeric_token = self._getNumericToken(self.text)
434
 
            if numeric_token is not None:
435
 
                try:
436
 
                    bug = getUtility(IBugSet).get(numeric_token)
437
 
                    if check_permission("launchpad.View", bug):
438
 
                        self._bug = bug
439
 
                except NotFoundError:
440
 
                    # Let self._bug remain None.
441
 
                    pass
442
 
                self._question = getUtility(IQuestionSet).get(numeric_token)
443
 
 
444
 
            name_token = self._getNameToken(self.text)
445
 
            if name_token is not None:
446
 
                self._person_or_team = self._getPersonOrTeam(name_token)
447
 
                self._pillar = self._getDistributionOrProductOrProjectGroup(
448
 
                    name_token)
449
 
 
450
 
        self._pages = self.searchPages(self.text, start=self.start)
451
 
 
452
 
    def _getNumericToken(self, text):
453
 
        """Return the first group of numbers in the search text, or None."""
454
 
        numeric_pattern = re.compile(r'(\d+)')
455
 
        match = numeric_pattern.search(text)
456
 
        if match is None:
457
 
            return None
458
 
        return match.group(1)
459
 
 
460
 
    def _getNameToken(self, text):
461
 
        """Return the search text as a Launchpad name.
462
 
 
463
 
        Launchpad names may contain ^[a-z0-9][a-z0-9\+\.\-]+$.
464
 
        See `valid_name_pattern`.
465
 
        """
466
 
        hypen_pattern = re.compile(r'[ _]')
467
 
        name = hypen_pattern.sub('-', text.strip().lower())
468
 
        return sanitize_name(name)
469
 
 
470
 
    def _getPersonOrTeam(self, name):
471
 
        """Return the matching active person or team."""
472
 
        person_or_team = getUtility(IPersonSet).getByName(name)
473
 
        if (person_or_team is not None
474
 
            and person_or_team.is_valid_person_or_team
475
 
            and check_permission('launchpad.View', person_or_team)):
476
 
            return person_or_team
477
 
        return None
478
 
 
479
 
    def _getDistributionOrProductOrProjectGroup(self, name):
480
 
        """Return the matching distribution, product or project, or None."""
481
 
        vocabulary_registry = getVocabularyRegistry()
482
 
        vocab = vocabulary_registry.get(
483
 
            None, 'DistributionOrProductOrProjectGroup')
484
 
        try:
485
 
            return vocab.getTermByToken(name).value
486
 
        except LookupError:
487
 
            return None
488
 
 
489
 
    def searchPages(self, query_terms, start=0):
490
 
        """Return the up to 20 pages that match the query_terms, or None.
491
 
 
492
 
        :param query_terms: The unescaped terms to query Google.
493
 
        :param start: The index of the page that starts the set of pages.
494
 
        :return: A GooglBatchNavigator or None.
495
 
        """
496
 
        if query_terms in [None, '']:
497
 
            return None
498
 
        google_search = getUtility(ISearchService)
499
 
        try:
500
 
            page_matches = google_search.search(
501
 
                terms=query_terms, start=start)
502
 
        except GoogleResponseError:
503
 
            # There was a connectivity or Google service issue that means
504
 
            # there is no data available at this moment.
505
 
            self.has_page_service = False
506
 
            return None
507
 
        if len(page_matches) == 0:
508
 
            return None
509
 
        navigator = GoogleBatchNavigator(
510
 
            page_matches, self.request, start=start)
511
 
        navigator.setHeadings(*self.batch_heading)
512
 
        return navigator
513
 
 
514
 
 
515
 
class WindowedList:
516
 
    """A list that contains a subset of items (a window) of a virtual list."""
517
 
 
518
 
    def __init__(self, window, start, total):
519
 
        """Create a WindowedList from a smaller list.
520
 
 
521
 
        :param window: The list with real items.
522
 
        :param start: An int, the list's starting index in the virtual list.
523
 
        :param total: An int, the total number of items in the virtual list.
524
 
        """
525
 
        self._window = window
526
 
        self._start = start
527
 
        self._total = total
528
 
        self._end = start + len(window)
529
 
 
530
 
    def __len__(self):
531
 
        """Return the length of the virtual list."""
532
 
        return self._total
533
 
 
534
 
    def __getitem__(self, key):
535
 
        """Return the key item or None if key belongs to the virtual list."""
536
 
        # When the key is a slice, return a list of items.
537
 
        if isinstance(key, (tuple, slice)):
538
 
            if isinstance(key, (slice)):
539
 
                indices = key.indices(len(self))
540
 
            else:
541
 
                indices = key
542
 
            return [self[index] for index in range(*indices)]
543
 
        # If the index belongs to the window return a real item.
544
 
        if key >= self._start and key < self._end:
545
 
            window_index = key - self._start
546
 
            return self._window[window_index]
547
 
        # Otherwise the index belongs to the virtual list.
548
 
        return None
549
 
 
550
 
    def __iter__(self):
551
 
        """Yield each item, or None if the index is virtual."""
552
 
        for index in range(0, self._total):
553
 
            yield self[index]
554
 
 
555
 
 
556
 
class WindowedListBatch(batch._Batch):
557
 
    """A batch class that does not include None objects when iterating."""
558
 
 
559
 
    def __iter__(self):
560
 
        """Iterate over objects that are not None."""
561
 
        for item in super(WindowedListBatch, self).__iter__():
562
 
            if item is not None:
563
 
                # Never yield None
564
 
                yield item
565
 
 
566
 
    def endNumber(self):
567
 
        """Return the end index of the batch, not including None objects."""
568
 
        # This class should know about the private _window attribute.
569
 
        # pylint: disable-msg=W0212
570
 
        return self.start + len(self.list._window)
571
 
 
572
 
 
573
 
class GoogleBatchNavigator(BatchNavigator):
574
 
    """A batch navigator with a fixed size of 20 items per batch."""
575
 
 
576
 
    _batch_factory = WindowedListBatch
577
 
    # Searches generally don't show the 'Last' link when there is a
578
 
    # good chance of getting over 100,000 results.
579
 
    show_last_link = False
580
 
 
581
 
    singular_heading = 'page'
582
 
    plural_heading = 'pages'
583
 
 
584
 
    def __init__(self, results, request, start=0, size=20, callback=None,
585
 
                 transient_parameters=None, force_start=False,
586
 
                 range_factory=None):
587
 
        """See `BatchNavigator`.
588
 
 
589
 
        :param results: A `PageMatches` object that contains the matching
590
 
            pages to iterate over.
591
 
        :param request: An `IBrowserRequest` that contains the form
592
 
            parameters.
593
 
        :param start: an int that represents the start of the current batch.
594
 
        :param size: The batch size is fixed to 20, The param is not used.
595
 
        :param callback: Not used.
596
 
        """
597
 
        results = WindowedList(results, start, results.total)
598
 
        super(GoogleBatchNavigator, self).__init__(results, request,
599
 
            start=start, size=size, callback=callback,
600
 
            transient_parameters=transient_parameters,
601
 
            force_start=force_start, range_factory=range_factory)
602
 
 
603
 
    def determineSize(self, size, batch_params_source):
604
 
        # Force the default and users requested sizes to 20.
605
 
        self.default_size = 20
606
 
        return 20