launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #02898
[Merge] lp:~julian-edwards/launchpad/publisher-config-db-ui into lp:launchpad/db-devel
Julian Edwards has proposed merging lp:~julian-edwards/launchpad/publisher-config-db-ui into lp:launchpad/db-devel with lp:~julian-edwards/launchpad/publisher-config-db-schema as a prerequisite.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~julian-edwards/launchpad/publisher-config-db-ui/+merge/52584
This branch builds on a previous one that adds a new database table, PublisherConfig, by letting you edit its values in a simple form. See the pre-requisite branch merge proposal for more details.
The long-term goal of this page is to also support the admin setting publishing schedules instead of having to do it via a crontab entry, but that will come later of course.
For now, only admins are allowed to edit this page and its settings. I also added a link to the new page on the Distribution:+index page which is only presented to admins. I've not bothered hiding anything behind a feature flag because of this.
Note that I could have used a LaunchpadEditFormView, but this depends on the object being edited already present in the database. Instead you can just do a custom initial_values property on the view that DTRT. The save_action also DTRT depending on whether it's a new entry or an edit.
--
https://code.launchpad.net/~julian-edwards/launchpad/publisher-config-db-ui/+merge/52584
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~julian-edwards/launchpad/publisher-config-db-ui into lp:launchpad/db-devel.
=== modified file 'lib/lp/registry/adapters.py'
--- lib/lp/registry/adapters.py 2011-01-04 16:08:57 +0000
+++ lib/lp/registry/adapters.py 2011-03-08 17:19:32 +0000
@@ -18,6 +18,9 @@
from zope.interface import implements
from canonical.launchpad.webapp.interfaces import ILaunchpadPrincipal
+from lp.archivepublisher.interfaces.publisherconfig import (
+ IPublisherConfigSet,
+ )
from lp.registry.interfaces.poll import (
IPollSet,
IPollSubset,
@@ -112,3 +115,10 @@
or `ILaunchpadUsage`.
"""
return productseries.product
+
+
+def distribution_to_publisherconfig(distro):
+ """Adapts `IDistribution` to `IPublisherConfig`."""
+ # Used for traversal from distro to +pubconf.
+ config = getUtility(IPublisherConfigSet).getByDistribution(distro)
+ return config
=== modified file 'lib/lp/registry/browser/configure.zcml'
--- lib/lp/registry/browser/configure.zcml 2011-01-24 20:53:10 +0000
+++ lib/lp/registry/browser/configure.zcml 2011-03-08 17:19:32 +0000
@@ -1992,6 +1992,13 @@
facet="overview"
permission="launchpad.Edit"
template="../../app/templates/generic-edit.pt"/>
+ <browser:page
+ name="+pubconf"
+ for="lp.registry.interfaces.distribution.IDistribution"
+ class="lp.registry.browser.distribution.DistributionPublisherConfigView"
+ template="../../app/templates/generic-edit.pt"
+ facet="overview"
+ permission="launchpad.Admin"/>
<browser:defaultView
for="lp.registry.interfaces.distribution.IDistributionSet"
name="+index"/>
=== modified file 'lib/lp/registry/browser/distribution.py'
--- lib/lp/registry/browser/distribution.py 2011-02-03 10:35:36 +0000
+++ lib/lp/registry/browser/distribution.py 2011-03-08 17:19:32 +0000
@@ -21,6 +21,7 @@
'DistributionPPASearchView',
'DistributionPackageSearchView',
'DistributionPendingReviewMirrorsView',
+ 'DistributionPublisherConfigView',
'DistributionSeriesView',
'DistributionSeriesMirrorsRSSView',
'DistributionSeriesMirrorsView',
@@ -40,6 +41,7 @@
from zope.component import getUtility
from zope.event import notify
+from zope.formlib import form
from zope.interface import implements
from zope.lifecycleevent import ObjectCreatedEvent
from zope.security.interfaces import Unauthorized
@@ -78,6 +80,10 @@
)
from lp.app.errors import NotFoundError
from lp.app.widgets.image import ImageChangeWidget
+from lp.archivepublisher.interfaces.publisherconfig import (
+ IPublisherConfig,
+ IPublisherConfigSet,
+ )
from lp.blueprints.browser.specificationtarget import (
HasSpecificationsMenuMixin,
)
@@ -278,7 +284,12 @@
"""A menu of context actions."""
usedfor = IDistribution
facet = 'overview'
- links = ['edit']
+ links = ['edit', 'pubconf']
+
+ @enabled_with_permission("launchpad.Admin")
+ def pubconf(self):
+ text = "Configure publisher"
+ return Link("+pubconf", text, icon="edit")
class DistributionOverviewMenu(ApplicationMenu, DistributionLinksMixin):
@@ -1079,3 +1090,57 @@
@cachedproperty
def mirrors(self):
return self.context.disabled_mirrors
+
+
+class DistributionPublisherConfigView(LaunchpadFormView):
+ """View class for configuring publisher options for a DistroSeries.
+
+ It redirects to the main distroseries page after a successful edit.
+ """
+ schema = IPublisherConfig
+ field_names = ['root_dir', 'base_url', 'copy_base_url']
+
+ @property
+ def label(self):
+ """See `LaunchpadFormView`."""
+ return 'Publisher configuration for %s' % self.context.title
+
+ @property
+ def page_title(self):
+ """The page title."""
+ return self.label
+
+ @property
+ def cancel_url(self):
+ """See `LaunchpadFormView`."""
+ return canonical_url(self.context)
+
+ @property
+ def initial_values(self):
+ """If the config already exists, set up the fields with data."""
+ config = getUtility(
+ IPublisherConfigSet).getByDistribution(self.context)
+ values = {}
+ if config is not None:
+ for name in self.field_names:
+ values[name] = getattr(config, name)
+
+ return values
+
+ @action("Save")
+ def save_action(self, action, data):
+ """Update the context and redirect to its overview page."""
+ config = getUtility(IPublisherConfigSet).getByDistribution(
+ self.context)
+ if config is None:
+ config = getUtility(IPublisherConfigSet).new(
+ distribution=self.context,
+ root_dir=data['root_dir'],
+ base_url=data['base_url'],
+ copy_base_url=data['copy_base_url'])
+ else:
+ form.applyChanges(config, self.form_fields, data, self.adapters)
+
+ self.request.response.addInfoNotification(
+ 'Your changes have been applied.')
+ self.next_url = canonical_url(self.context)
=== added file 'lib/lp/registry/browser/tests/test_distribution.py'
--- lib/lp/registry/browser/tests/test_distribution.py 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/browser/tests/test_distribution.py 2011-03-08 17:19:32 +0000
@@ -0,0 +1,90 @@
+# Copyright 2009 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+
+from zope.component import getUtility
+
+from canonical.testing.layers import DatabaseFunctionalLayer
+from canonical.launchpad.ftests import login
+from canonical.launchpad.webapp.servers import LaunchpadTestRequest
+from lp.archivepublisher.interfaces.publisherconfig import IPublisherConfigSet
+from lp.registry.browser.distribution import DistributionPublisherConfigView
+from lp.testing import TestCaseWithFactory
+from lp.testing.sampledata import LAUNCHPAD_ADMIN
+
+
+class TestDistributionPublisherConfigView(TestCaseWithFactory):
+ """Test `DistributionPublisherConfigView`."""
+
+ layer = DatabaseFunctionalLayer
+
+ def setUp(self):
+ # Create a test distribution.
+ super(TestDistributionPublisherConfigView, self).setUp()
+ self.distro = self.factory.makeDistribution()
+ login(LAUNCHPAD_ADMIN)
+
+ self.ROOT_DIR = u"rootdir/test"
+ self.BASE_URL = u"http://base.url"
+ self.COPY_BASE_URL = u"http://copybase.url"
+
+ def test_empty_initial_values(self):
+ # Test that the page will display empty field values with no
+ # existing config set up.
+ view = DistributionPublisherConfigView(
+ self.distro, LaunchpadTestRequest())
+
+ for value in view.initial_values:
+ self.assertEqual(u"", value)
+
+ def test_previous_initial_values(self):
+ # Test that the initial values are the same as the ones in the
+ # existing database record.
+ pubconf = self.factory.makePublisherConfig(
+ distribution=self.distro,
+ root_dir=self.ROOT_DIR,
+ base_url=self.BASE_URL,
+ copy_base_url=self.COPY_BASE_URL,
+ )
+
+ view = DistributionPublisherConfigView(
+ self.distro, LaunchpadTestRequest())
+
+ self.assertEqual(self.ROOT_DIR, view.initial_values["root_dir"])
+ self.assertEqual(self.BASE_URL, view.initial_values["base_url"])
+ self.assertEqual(
+ self.COPY_BASE_URL, view.initial_values["copy_base_url"])
+
+ def _change_and_test_config(self):
+ form = {
+ 'field.actions.save': 'save',
+ 'field.root_dir': self.ROOT_DIR,
+ 'field.base_url': self.BASE_URL,
+ 'field.copy_base_url': self.COPY_BASE_URL,
+ }
+
+ view = DistributionPublisherConfigView(
+ self.distro, LaunchpadTestRequest(method='POST', form=form))
+ view.initialize()
+
+ config = getUtility(
+ IPublisherConfigSet).getByDistribution(self.distro)
+
+ self.assertEqual(self.ROOT_DIR, config.root_dir)
+ self.assertEqual(self.BASE_URL, config.base_url)
+ self.assertEqual(self.COPY_BASE_URL, config.copy_base_url)
+
+ def test_add_new_config(self):
+ # Test POSTing a new config.
+ self._change_and_test_config()
+
+ def test_change_existing_config(self):
+ # Test POSTing to change existing config.
+ pubconf = self.factory.makePublisherConfig(
+ distribution=self.distro,
+ root_dir=u"random",
+ base_url=u"blah",
+ copy_base_url=u"foo",
+ )
+ self._change_and_test_config()
=== modified file 'lib/lp/registry/configure.zcml'
--- lib/lp/registry/configure.zcml 2011-02-16 21:34:13 +0000
+++ lib/lp/registry/configure.zcml 2011-03-08 17:19:32 +0000
@@ -1535,6 +1535,7 @@
addAnswerContact
removeAnswerContact"/>
+
<!-- IFAQTarget -->
<allow
@@ -1578,6 +1579,12 @@
for="lp.registry.interfaces.distribution.IDistributionSet"
factory="lp.registry.browser.distribution.DistributionSetBreadcrumb"
permission="zope.Public"/>
+ <adapter
+ provides="lp.archivepublisher.interfaces.publisherconfig.IPublisherConfig"
+ for="lp.registry.interfaces.distribution.IDistribution"
+ factory="lp.registry.adapters.distribution_to_publisherconfig"
+ permission="zope.Public"/>
+
<!-- DistributionSet -->
=== modified file 'lib/lp/registry/stories/distribution/xx-distribution-launchpad-usage.txt'
--- lib/lp/registry/stories/distribution/xx-distribution-launchpad-usage.txt 2010-11-30 15:11:48 +0000
+++ lib/lp/registry/stories/distribution/xx-distribution-launchpad-usage.txt 2011-03-08 17:19:32 +0000
@@ -12,6 +12,10 @@
Traceback (most recent call last):
...
LinkNotFound...
+ >>> user_browser.getLink('Configure publisher')
+ Traceback (most recent call last):
+ ...
+ LinkNotFound...
>>> user_browser.open('http://launchpad.dev/ubuntu/+edit')
Traceback (most recent call last):
...
@@ -62,12 +66,31 @@
>>> admin_browser.getControl(name='field.official_malone').value = False
>>> admin_browser.getControl(name='field.blueprints_usage').value = (
... ['UNKNOWN'])
- >>> admin_browser.getControl(name='field.answers_usage').value = ['UNKNOWN']
+ >>> admin_browser.getControl(
+ ... name='field.answers_usage').value = ['UNKNOWN']
>>> admin_browser.getControl('Change', index=3).click()
>>> print admin_browser.url
http://launchpad.dev/ubuntu
+Only administators can configure the publisher for the distribution:
+
+ >>> admin_browser.getLink('Configure publisher').click()
+ >>> print admin_browser.title
+ Publisher configuration for...
+
+ >>> admin_browser.getControl(
+ ... name='field.root_dir').value = "/tmp/root_dir"
+ >>> admin_browser.getControl(
+ ... name='field.base_url').value = "http://base.url/"
+ >>> admin_browser.getControl(
+ ... name='field.copy_base_url').value = "http://copy.base.url/"
+ >>> admin_browser.getControl('Save').click()
+
+ >>> print admin_browser.url
+ http://launchpad.dev/ubuntu
+
+
enable_bug_expiration and JavaScript
====================================
=== removed file 'lib/lp/services/googlesearch/__init__.py'
--- lib/lp/services/googlesearch/__init__.py 2011-03-07 16:32:12 +0000
+++ lib/lp/services/googlesearch/__init__.py 1970-01-01 00:00:00 +0000
@@ -1,326 +0,0 @@
-# Copyright 2009 Canonical Ltd. This software is licensed under the
-# GNU Affero General Public License version 3 (see the file LICENSE).
-
-# pylint: disable-msg=E0211,E0213
-
-"""Interfaces for searching and working with results."""
-
-__metaclass__ = type
-
-__all__ = [
- 'GoogleSearchService',
- 'PageMatch',
- 'PageMatches',
- ]
-
-import xml.etree.cElementTree as ET
-import urllib
-import urllib2
-from urlparse import urlunparse
-
-from lazr.restful.utils import get_current_browser_request
-from lazr.uri import URI
-from zope.interface import implements
-
-from canonical.config import config
-from canonical.lazr.timeout import TimeoutError
-from lp.services.googlesearch.interfaces import (
- GoogleResponseError,
- GoogleWrongGSPVersion,
- ISearchResult,
- ISearchResults,
- ISearchService,
- )
-from canonical.launchpad.webapp import urlparse
-from lp.services.timeline.requesttimeline import get_request_timeline
-
-
-class PageMatch:
- """See `ISearchResult`.
-
- A search result that represents a web page.
- """
- implements(ISearchResult)
-
- @property
- def url_rewrite_exceptions(self):
- """A list of launchpad.net URLs that must not be rewritten.
-
- Configured in config.google.url_rewrite_exceptions.
- """
- return config.google.url_rewrite_exceptions.split()
-
- @property
- def url_rewrite_scheme(self):
- """The URL scheme used in rewritten URLs.
-
- Configured in config.vhosts.use_https.
- """
- if config.vhosts.use_https:
- return 'https'
- else:
- return 'http'
-
- @property
- def url_rewrite_hostname(self):
- """The network location used in rewritten URLs.
-
- Configured in config.vhost.mainsite.hostname.
- """
- return config.vhost.mainsite.hostname
-
- def __init__(self, title, url, summary):
- """initialize a PageMatch.
-
- :param title: A string. The title of the item.
- :param url: A string. The full URL of the item.
- :param summary: A string. A summary of the item.
- """
- self.title = title
- self.summary = summary
- self.url = self._rewrite_url(url)
-
- def _strip_trailing_slash(self, url):
- """Return the url without a trailing slash."""
- uri = URI(url).ensureNoSlash()
- return str(uri)
-
- def _rewrite_url(self, url):
- """Rewrite the url to the local environment.
-
- Links with launchpad.net are rewritten to the local hostname,
- except if the domain matches a domain in the url_rewrite_exceptions.
- property.
-
- :param url: A URL str that may be rewritten to the local
- launchpad environment.
- :return: A URL str.
- """
- if self.url_rewrite_hostname == 'launchpad.net':
- # Do not rewrite the url is the hostname is the public hostname.
- return self._strip_trailing_slash(url)
- parts = urlparse(url)
- for netloc in self.url_rewrite_exceptions:
- # The network location is parts[1] in the tuple.
- if netloc in parts[1]:
- return url
- local_scheme = self.url_rewrite_scheme
- local_hostname = parts[1].replace(
- 'launchpad.net', self.url_rewrite_hostname)
- local_parts = tuple(
- [local_scheme] + [local_hostname] + list(parts[2:]))
- url = urlunparse(local_parts)
- return self._strip_trailing_slash(url)
-
-
-class PageMatches:
- """See `ISearchResults`.
-
- A collection of PageMatches.
- """
- implements(ISearchResults)
-
- def __init__(self, matches, start, total):
- """initialize a PageMatches.
-
- :param matches: A list of `PageMatch` objects.
- :param start: The index of the first item in the collection relative
- to the total number of items.
- :param total: The total number of items that matched a search.
- """
- self._matches = matches
- self.start = start
- self.total = total
-
- def __len__(self):
- """See `ISearchResults`."""
- return len(self._matches)
-
- def __getitem__(self, index):
- """See `ISearchResults`."""
- return self._matches[index]
-
- def __iter__(self):
- """See `ISearchResults`."""
- return iter(self._matches)
-
-
-class GoogleSearchService:
- """See `ISearchService`.
-
- A search service that search Google for launchpad.net pages.
- """
- implements(ISearchService)
-
- _default_values = {
- 'client': 'google-csbe',
- 'cx': None,
- 'ie': 'utf8',
- 'num': 20,
- 'oe': 'utf8',
- 'output': 'xml_no_dtd',
- 'start': 0,
- 'q': None,
- }
-
- @property
- def client_id(self):
- """The client-id issued by Google.
-
- Google requires that each client of the Google Search Engine
- service to pass its id as a parameter in the request URL.
- """
- return config.google.client_id
-
- @property
- def site(self):
- """The URL to the Google Search Engine service.
-
- The URL is probably http://www.google.com/search.
- """
- return config.google.site
-
- def search(self, terms, start=0):
- """See `ISearchService`.
-
- The config.google.client_id is used as Google client-id in the
- search request. Search returns 20 or fewer results for each query.
- For terms that match more than 20 results, the start param can be
- used over multiple queries to get successive sets of results.
-
- :return: `ISearchResults` (PageMatches).
- :raise: `GoogleWrongGSPVersion` if the xml cannot be parsed.
- """
- search_url = self.create_search_url(terms, start=start)
- from canonical.lazr.timeout import urlfetch
- request = get_current_browser_request()
- timeline = get_request_timeline(request)
- action = timeline.start("google-search-api", search_url)
- try:
- gsp_xml = urlfetch(search_url)
- except (TimeoutError, urllib2.HTTPError, urllib2.URLError), error:
- # Google search service errors are not code errors. Let the
- # call site choose to handle the unavailable service.
- raise GoogleResponseError(
- "The response errored: %s" % str(error))
- finally:
- action.finish()
- page_matches = self._parse_google_search_protocol(gsp_xml)
- return page_matches
-
- def _checkParameter(self, name, value, is_int=False):
- """Check that a parameter value is not None or an empty string."""
- if value in (None, ''):
- raise AssertionError("Missing value for parameter '%s'." % name)
- if is_int:
- try:
- int(value)
- except ValueError:
- raise AssertionError(
- "Value for parameter '%s' is not an int." % name)
-
- def create_search_url(self, terms, start=0):
- """Return a Google search url."""
- self._checkParameter('q', terms)
- self._checkParameter('start', start, is_int=True)
- self._checkParameter('cx', self.client_id)
- safe_terms = urllib.quote_plus(terms.encode('utf8'))
- search_params = dict(self._default_values)
- search_params['q'] = safe_terms
- search_params['start'] = start
- search_params['cx'] = self.client_id
- search_param_list = []
- for name in sorted(search_params):
- value = search_params[name]
- search_param_list.append('%s=%s' % (name, value))
- query_string = '&'.join(search_param_list)
- return self.site + '?' + query_string
-
- def _getElementsByAttributeValue(self, doc, path, name, value):
- """Return a list of elements whose named attribute matches the value.
-
- The cElementTree implementation does not support attribute selection
- (@) or conditional expressions (./PARAM[@name = 'start']).
-
- :param doc: An ElementTree of an XML document.
- :param path: A string path to match the first element.
- :param name: The attribute name to check.
- :param value: The string value of the named attribute.
- """
- elements = doc.findall(path)
- return [element for element in elements
- if element.get(name) == value]
-
- def _getElementByAttributeValue(self, doc, path, name, value):
- """Return the first element whose named attribute matches the value.
-
- :param doc: An ElementTree of an XML document.
- :param path: A string path to match an element.
- :param name: The attribute name to check.
- :param value: The string value of the named attribute.
- """
- return self._getElementsByAttributeValue(doc, path, name, value)[0]
-
- def _parse_google_search_protocol(self, gsp_xml):
- """Return a `PageMatches` object.
-
- :param gsp_xml: A string that should be Google Search Protocol
- version 3.2 XML. There is no guarantee that other GSP versions
- can be parsed.
- :return: `ISearchResults` (PageMatches).
- :raise: `GoogleResponseError` if the xml is incomplete.
- :raise: `GoogleWrongGSPVersion` if the xml cannot be parsed.
- """
- try:
- gsp_doc = ET.fromstring(gsp_xml)
- start_param = self._getElementByAttributeValue(
- gsp_doc, './PARAM', 'name', 'start')
- except (SyntaxError, IndexError):
- raise GoogleResponseError("The response was incomplete, no xml.")
- try:
- start = int(start_param.get('value'))
- except (AttributeError, ValueError):
- # The datatype is not what PageMatches requires.
- raise GoogleWrongGSPVersion(
- "Could not get the 'start' from the GSP XML response.")
- page_matches = []
- total = 0
- results = gsp_doc.find('RES')
- if results is None:
- # Google did not match any pages. Return an empty PageMatches.
- return PageMatches(page_matches, start, total)
-
- try:
- total = int(results.find('M').text)
- except (AttributeError, ValueError):
- # The datatype is not what PageMatches requires.
- raise GoogleWrongGSPVersion(
- "Could not get the 'total' from the GSP XML response.")
- if total < 0:
- # See bug 683115.
- total = 0
- for result in results.findall('R'):
- url_tag = result.find('U')
- title_tag = result.find('T')
- summary_tag = result.find('S')
- if None in (url_tag, title_tag, summary_tag):
- # Google indexed a bad page, or the page may be marked for
- # removal from the index. We should not include this.
- continue
- title = title_tag.text
- url = url_tag.text
- summary = summary_tag.text
- if None in (url, title, summary):
- # There is not enough data to create a PageMatch object.
- # This can be caused by an empty title or summary which
- # has been observed for pages that are from vhosts that
- # should not be indexed.
- continue
- summary = summary.replace('<br>', '')
- page_matches.append(PageMatch(title, url, summary))
- if len(page_matches) == 0 and total > 20:
- # No viable page matches could be found in the set and there
- # are more possible matches; the XML may be the wrong version.
- raise GoogleWrongGSPVersion(
- "Could not get any PageMatches from the GSP XML response.")
- return PageMatches(page_matches, start, total)
Follow ups