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."""
7
'LaunchpadRootIndexView',
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
24
from canonical.config import config
25
from canonical.launchpad import _
26
from lp.services.statistics.interfaces.statistic import (
27
ILaunchpadStatisticSet,
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 (
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 (
55
from lp.services.propertycache import cachedproperty
58
shipit_faq_url = 'http://www.ubuntu.com/getubuntu/shipit-faq'
61
class LaunchpadRootIndexView(HasAnnouncementsView, LaunchpadView):
62
"""An view for the default view of the LaunchpadRoot."""
64
page_title = 'Launchpad'
65
featured_projects = []
66
featured_projects_top = None
68
# Used by the footer to display the lp-arcana section.
72
def _get_day_of_year():
73
"""Calculate the number of the current day.
75
This method gets overridden in tests to make the selection of the
76
top featured project deterministic.
78
return time.gmtime()[7]
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()
89
def _setFeaturedProjectsTop(self):
90
"""Set the top featured project and remove it from the list."""
91
project_count = len(self.featured_projects)
93
top_project = self._get_day_of_year() % project_count
94
self.featured_projects_top = self.featured_projects.pop(
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),
111
def branch_count(self):
112
"""The total branch count of public branches in all of Launchpad."""
113
return getUtility(IAllBranches).visibleByUser(None).count()
117
"""The total bug count in all of Launchpad."""
118
return getUtility(ILaunchpadStatisticSet).value('bug_count')
121
def project_count(self):
122
"""The total project count in all of Launchpad."""
123
return getUtility(IProductSet).count_all()
126
def translation_count(self):
127
"""The total count of translatable strings in all of Launchpad """
128
return getUtility(ILaunchpadStatisticSet).value('pomsgid_count')
131
def blueprint_count(self):
132
"""The total blueprint count in all of Launchpad."""
133
return getUtility(ISpecificationSet).specification_count
136
def answer_count(self):
137
"""The total blueprint count in all of Launchpad."""
138
return getUtility(ILaunchpadStatisticSet).value('question_count')
140
def getRecentBlogPosts(self):
141
"""Return the parsed feed of the most recent blog posts.
143
It returns a list of dict with keys title, description, link and date.
145
The date is formatted and the description which may contain HTML is
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.
152
Since the feed is parsed everytime, the template should cache this
155
FeedParser takes care of sanitizing the HTML contained in the feed.
157
# Use urlfetch which supports timeout
159
data = urlfetch(config.launchpad.homepage_recent_posts_feed)
162
feed = feedparser.parse(data)
164
max_count = config.launchpad.homepage_recent_posts_count
165
# FeedParser takes care of HTML sanitisation.
166
for entry in feed.entries[:max_count]:
168
'title': entry.title,
169
'description': entry.description,
171
'date': time.strftime('%d %b %Y', entry.updated_parsed),
176
class LaunchpadSearchFormView(LaunchpadView):
177
"""A view to display the global search form in any page."""
178
id_suffix = '-secondary'
180
focusedElementScript = None
181
form_wide_errors = None
189
"""Return the site's root url."""
190
return allvhosts.configs['mainsite'].rooturl
193
class LaunchpadPrimarySearchFormView(LaunchpadSearchFormView):
194
"""A view to display the global search form in the page."""
199
"""The search text submitted to the context view."""
200
return self.context.text
203
def focusedElementScript(self):
204
"""The context view's focusedElementScript."""
205
return self.context.focusedElementScript
208
def form_wide_errors(self):
209
"""The context view's form_wide_errors."""
210
return self.context.form_wide_errors
214
"""The context view's errors."""
215
return self.context.errors
218
def error_count(self):
219
"""The context view's error_count."""
220
return self.context.error_count
224
"""The context view's text field error."""
225
return self.context.getFieldError('text')
228
def error_class(self):
229
"""Return the 'error' if there is an error, or None."""
235
class ILaunchpadSearch(Interface):
236
"""The Schema for performing searches across all Launchpad."""
239
title=_('Search text'), required=False, max_length=250)
242
class LaunchpadSearchView(LaunchpadFormView):
243
"""A view to search for Launchpad pages and objects."""
244
schema = ILaunchpadSearch
245
field_names = ['text']
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'])
255
def __init__(self, context, request):
256
"""Initialize the view.
258
Set the state of the search_params and matches.
260
super(LaunchpadSearchView, self).__init__(context, request)
261
self.has_page_service = True
263
self._question = None
264
self._person_or_team = None
267
self.search_params = self._getDefaultSearchParams()
268
# The Search Action should always run.
269
self.request.form['field.actions.search'] = 'Search'
271
def _getDefaultSearchParams(self):
272
"""Return a dict of the search param set to their default state."""
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()
283
self.search_params['text'] = None
285
self.search_params['text'] = text
286
request_start = self.request.get('start', self.search_params['start'])
288
start = int(request_start)
289
except (ValueError, TypeError):
291
self.search_params['start'] = start
295
"""Return the text or None."""
296
return self.search_params['text']
300
"""Return the start index of the batch."""
301
return self.search_params['start']
304
def page_title(self):
306
return self.page_heading
309
def page_heading(self):
310
"""Heading to display above the search results."""
311
if self.text is None:
312
return 'Search Launchpad'
314
return 'Pages matching "%s" in Launchpad' % self.text
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)
323
return ('page matching "%s"' % self.text,
324
'pages matching "%s"' % self.text)
327
def focusedElementScript(self):
328
"""Focus the first widget when there are no matches."""
331
return super(LaunchpadSearchView, self).focusedElementScript()
335
"""Return the bug that matched the terms, or None."""
340
"""Return the question that matched the terms, or None."""
341
return self._question
345
"""Return the project that matched the terms, or None."""
349
def person_or_team(self):
350
"""Return the person or team that matched the terms, or None."""
351
return self._person_or_team
355
"""Return the pages that matched the terms, or None."""
359
def has_shipit(self):
360
"""Return True is the search text contains shipit keywords."""
361
if self.text is None:
363
terms = set(self.text.lower().split())
364
anti_matches = self.shipit_anti_keywords.intersection(terms)
365
if len(anti_matches) >= 1:
367
matches = self.shipit_keywords.intersection(terms)
368
return len(matches) >= 2
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)
378
def shipit_faq_url(self):
379
"""The shipit FAQ URL."""
380
return shipit_faq_url
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)
391
"""Return the requested URL."""
392
if 'QUERY_STRING' in self.request:
393
query_string = self.request['QUERY_STRING']
396
return self.request.getURL() + '?' + query_string
398
def containsMatchingKind(self, kinds):
399
"""Return True if one of the items in kinds is not None, or False."""
401
if kind is not None and kind is not False:
405
def validate(self, data):
406
"""See `LaunchpadFormView`"""
407
errors = list(self.errors)
409
if isinstance(error, ConversionError):
411
'text', 'Can not convert your search term.')
412
elif isinstance(error, unicode):
414
elif (error.field_name == 'text'
415
and isinstance(error.errors, TooLong)):
417
'text', 'The search text cannot exceed 250 characters.')
420
@action(u'Search', name='search')
421
def search_action(self, action, data):
422
"""The Action executed when the user uses the search button.
424
Saves the user submitted search parameters in an instance
427
self.search_params.update(**data)
428
self._updateSearchParams()
429
if self.text is None:
433
numeric_token = self._getNumericToken(self.text)
434
if numeric_token is not None:
436
bug = getUtility(IBugSet).get(numeric_token)
437
if check_permission("launchpad.View", bug):
439
except NotFoundError:
440
# Let self._bug remain None.
442
self._question = getUtility(IQuestionSet).get(numeric_token)
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(
450
self._pages = self.searchPages(self.text, start=self.start)
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)
458
return match.group(1)
460
def _getNameToken(self, text):
461
"""Return the search text as a Launchpad name.
463
Launchpad names may contain ^[a-z0-9][a-z0-9\+\.\-]+$.
464
See `valid_name_pattern`.
466
hypen_pattern = re.compile(r'[ _]')
467
name = hypen_pattern.sub('-', text.strip().lower())
468
return sanitize_name(name)
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
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')
485
return vocab.getTermByToken(name).value
489
def searchPages(self, query_terms, start=0):
490
"""Return the up to 20 pages that match the query_terms, or None.
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.
496
if query_terms in [None, '']:
498
google_search = getUtility(ISearchService)
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
507
if len(page_matches) == 0:
509
navigator = GoogleBatchNavigator(
510
page_matches, self.request, start=start)
511
navigator.setHeadings(*self.batch_heading)
516
"""A list that contains a subset of items (a window) of a virtual list."""
518
def __init__(self, window, start, total):
519
"""Create a WindowedList from a smaller list.
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.
525
self._window = window
528
self._end = start + len(window)
531
"""Return the length of the virtual list."""
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))
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.
551
"""Yield each item, or None if the index is virtual."""
552
for index in range(0, self._total):
556
class WindowedListBatch(batch._Batch):
557
"""A batch class that does not include None objects when iterating."""
560
"""Iterate over objects that are not None."""
561
for item in super(WindowedListBatch, self).__iter__():
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)
573
class GoogleBatchNavigator(BatchNavigator):
574
"""A batch navigator with a fixed size of 20 items per batch."""
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
581
singular_heading = 'page'
582
plural_heading = 'pages'
584
def __init__(self, results, request, start=0, size=20, callback=None,
585
transient_parameters=None, force_start=False,
587
"""See `BatchNavigator`.
589
:param results: A `PageMatches` object that contains the matching
590
pages to iterate over.
591
:param request: An `IBrowserRequest` that contains the form
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.
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)
603
def determineSize(self, size, batch_params_source):
604
# Force the default and users requested sizes to 20.
605
self.default_size = 20