summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/mailman/rest/adapters.py2
-rw-r--r--src/mailman/rest/configuration.py260
-rw-r--r--src/mailman/rest/docs/basic.txt2
-rw-r--r--src/mailman/rest/docs/configuration.txt53
-rw-r--r--src/mailman/rest/docs/domains.txt2
-rw-r--r--src/mailman/rest/docs/helpers.txt54
-rw-r--r--src/mailman/rest/docs/lists.txt8
-rw-r--r--src/mailman/rest/docs/membership.txt12
-rw-r--r--src/mailman/rest/helpers.py36
-rw-r--r--src/mailman/rest/lists.py229
-rw-r--r--src/mailman/rest/members.py5
-rw-r--r--src/mailman/tests/test_documentation.py4
12 files changed, 401 insertions, 266 deletions
diff --git a/src/mailman/rest/adapters.py b/src/mailman/rest/adapters.py
index 1edac3a34..a98798fcb 100644
--- a/src/mailman/rest/adapters.py
+++ b/src/mailman/rest/adapters.py
@@ -76,7 +76,7 @@ class SubscriptionService:
# Convert from string to enum.
mode = (DeliveryMode.regular
if delivery_mode is None
- else DeliveryMode(delivery_mode))
+ else delivery_mode)
if real_name is None:
real_name, at, domain = address.partition('@')
if len(at) == 0:
diff --git a/src/mailman/rest/configuration.py b/src/mailman/rest/configuration.py
new file mode 100644
index 000000000..d2653fea4
--- /dev/null
+++ b/src/mailman/rest/configuration.py
@@ -0,0 +1,260 @@
+# Copyright (C) 2010 by the Free Software Foundation, Inc.
+#
+# This file is part of GNU Mailman.
+#
+# GNU Mailman is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option)
+# any later version.
+#
+# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
+
+"""Mailing list configuration via REST API."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'ListConfiguration',
+ ]
+
+
+from lazr.config import as_boolean, as_timedelta
+from restish import http, resource
+
+from mailman.config import config
+from mailman.interfaces.autorespond import ResponseAction
+from mailman.interfaces.mailinglist import IAcceptableAliasSet
+from mailman.rest.helpers import Validator, enum_validator, etag
+
+
+
+class GetterSetter:
+ """Get and set attributes on mailing lists.
+
+ Most attributes are fairly simple - a getattr() or setattr() on the
+ mailing list does the trick, with the appropriate encoding or decoding on
+ the way in and out. Encoding doesn't happen here though; the standard
+ JSON library handles most types, but see ExtendedEncoder in
+ mailman.rest.helpers for additional support.
+
+ Others are more complicated since they aren't kept in the model as direct
+ columns in the database. These will use subclasses of this base class.
+ Read-only attributes will have a decoder which always raises ValueError.
+ """
+
+ def __init__(self, decoder=None):
+ """Create a getter/setter for a specific list attribute.
+
+ :param decoder: The callable for decoding a web request value string
+ into the specific data type needed by the `IMailingList`
+ attribute. Use None to indicate a read-only attribute. The
+ callable should raise ValueError when the web request value cannot
+ be converted.
+ :type decoder: callable
+ """
+ self.decoder = decoder
+
+ def get(self, mlist, attribute):
+ """Return the named mailing list attribute value.
+
+ :param mlist: The mailing list.
+ :type mlist: `IMailingList`
+ :param attribute: The attribute name.
+ :type attribute: string
+ :return: The attribute value, ready for JSON encoding.
+ :rtype: object
+ """
+ return getattr(mlist, attribute)
+
+ def put(self, mlist, attribute, value):
+ """Set the named mailing list attribute value.
+
+ :param mlist: The mailing list.
+ :type mlist: `IMailingList`
+ :param attribute: The attribute name.
+ :type attribute: string
+ :param value: The new value for the attribute.
+ :type request_value: object
+ """
+ setattr(mlist, attribute, value)
+
+ def __call__(self, value):
+ """Convert the value to its internal format.
+
+ :param value: The web request value to convert.
+ :type value: string
+ :return: The converted value.
+ :rtype: object
+ """
+ if self.decoder is None:
+ return value
+ return self.decoder(value)
+
+
+class AcceptableAliases(GetterSetter):
+ """Resource for the acceptable aliases of a mailing list."""
+
+ def get(self, mlist, attribute):
+ """Return the mailing list's acceptable aliases."""
+ assert attribute == 'acceptable_aliases', (
+ 'Unexpected attribute: {0}'.format(attribute))
+ aliases = IAcceptableAliasSet(mlist)
+ return sorted(aliases.aliases)
+
+ def put(self, mlist, attribute, value):
+ """Change the acceptable aliases.
+
+ Because this is a PUT operation, all previous aliases are cleared
+ first. Thus, this is an overwrite. The keys in the request are
+ ignored.
+ """
+ assert attribute == 'acceptable_aliases', (
+ 'Unexpected attribute: {0}'.format(attribute))
+ alias_set = IAcceptableAliasSet(mlist)
+ alias_set.clear()
+ for alias in value:
+ alias_set.add(unicode(alias))
+
+
+
+# Additional validators for converting from web request strings to internal
+# data types. See below for details.
+
+def pipeline_validator(pipeline_name):
+ """Convert the pipeline name to a string, but only if it's known."""
+ if pipeline_name in config.pipelines:
+ return unicode(pipeline_name)
+ raise ValueError('Unknown pipeline: {0}'.format(pipeline_name))
+
+
+def list_of_unicode(values):
+ """Turn a list of things into a list of unicodes."""
+ return [unicode(value) for value in values]
+
+
+
+# This is the list of IMailingList attributes that are exposed through the
+# REST API. The values of the keys are the GetterSetter instance holding the
+# decoder used to convert the web request string to an internally valid value.
+# The instance also contains the get() and put() methods used to retrieve and
+# set the attribute values. Its .decoder attribute will be None for read-only
+# attributes.
+#
+# The decoder must either return the internal value or raise a ValueError if
+# the conversion failed (e.g. trying to turn 'Nope' into a boolean).
+#
+# Many internal value types can be automatically JSON encoded, but see
+# mailman.rest.helpers.ExtendedEncoder for specializations of certain types
+# (e.g. datetimes, timedeltas, enums).
+
+ATTRIBUTES = dict(
+ acceptable_aliases=AcceptableAliases(list_of_unicode),
+ admin_immed_notify=GetterSetter(as_boolean),
+ admin_notify_mchanges=GetterSetter(as_boolean),
+ administrivia=GetterSetter(as_boolean),
+ advertised=GetterSetter(as_boolean),
+ anonymous_list=GetterSetter(as_boolean),
+ autorespond_owner=GetterSetter(enum_validator(ResponseAction)),
+ autorespond_postings=GetterSetter(enum_validator(ResponseAction)),
+ autorespond_requests=GetterSetter(enum_validator(ResponseAction)),
+ autoresponse_grace_period=GetterSetter(as_timedelta),
+ autoresponse_owner_text=GetterSetter(unicode),
+ autoresponse_postings_text=GetterSetter(unicode),
+ autoresponse_request_text=GetterSetter(unicode),
+ bounces_address=GetterSetter(None),
+ collapse_alternatives=GetterSetter(as_boolean),
+ convert_html_to_plaintext=GetterSetter(as_boolean),
+ created_at=GetterSetter(None),
+ description=GetterSetter(unicode),
+ digest_last_sent_at=GetterSetter(None),
+ digest_size_threshold=GetterSetter(float),
+ filter_content=GetterSetter(as_boolean),
+ fqdn_listname=GetterSetter(None),
+ host_name=GetterSetter(None),
+ include_list_post_header=GetterSetter(as_boolean),
+ include_rfc2369_headers=GetterSetter(as_boolean),
+ join_address=GetterSetter(None),
+ last_post_at=GetterSetter(None),
+ leave_address=GetterSetter(None),
+ list_id=GetterSetter(None),
+ list_name=GetterSetter(None),
+ next_digest_number=GetterSetter(None),
+ no_reply_address=GetterSetter(None),
+ owner_address=GetterSetter(None),
+ pipeline=GetterSetter(pipeline_validator),
+ post_id=GetterSetter(None),
+ posting_address=GetterSetter(None),
+ real_name=GetterSetter(unicode),
+ request_address=GetterSetter(None),
+ scheme=GetterSetter(None),
+ volume=GetterSetter(None),
+ web_host=GetterSetter(None),
+ )
+
+
+VALIDATORS = ATTRIBUTES.copy()
+for attribute, gettersetter in VALIDATORS.items():
+ if gettersetter.decoder is None:
+ del VALIDATORS[attribute]
+
+
+
+class ListConfiguration(resource.Resource):
+ """A mailing list configuration resource."""
+
+ def __init__(self, mailing_list, attribute):
+ self._mlist = mailing_list
+ self._attribute = attribute
+
+ @resource.GET()
+ def get_configuration(self, request):
+ """Get a mailing list configuration."""
+ resource = {}
+ if self._attribute is None:
+ # Return all readable attributes.
+ for attribute in ATTRIBUTES:
+ value = ATTRIBUTES[attribute].get(self._mlist, attribute)
+ resource[attribute] = value
+ elif self._attribute not in ATTRIBUTES:
+ return http.bad_request(
+ [], b'Unknown attribute: {0}'.format(self._attribute))
+ else:
+ attribute = self._attribute
+ value = ATTRIBUTES[attribute].get(self._mlist, attribute)
+ resource[attribute] = value
+ return http.ok([], etag(resource))
+
+ @resource.PUT()
+ def put_configuration(self, request):
+ """Set a mailing list configuration."""
+ attribute = self._attribute
+ if attribute is None:
+ # Set all writable attributes.
+ try:
+ converted = Validator(**VALIDATORS)(request)
+ except ValueError as error:
+ return http.bad_request([], str(error))
+ for key, value in converted.items():
+ ATTRIBUTES[key].put(self._mlist, key, value)
+ elif attribute not in ATTRIBUTES:
+ return http.bad_request(
+ [], b'Unknown attribute: {0}'.format(attribute))
+ elif ATTRIBUTES[attribute].decoder is None:
+ return http.bad_request(
+ [], b'Read-only attribute: {0}'.format(attribute))
+ else:
+ validator = Validator(**{attribute: VALIDATORS[attribute]})
+ try:
+ values = validator(request)
+ except ValueError as error:
+ return http.bad_request([], str(error))
+ ATTRIBUTES[attribute].put(
+ self._mlist, attribute, values[attribute])
+ return http.ok([], '')
diff --git a/src/mailman/rest/docs/basic.txt b/src/mailman/rest/docs/basic.txt
index 643d6d906..94d80e5a2 100644
--- a/src/mailman/rest/docs/basic.txt
+++ b/src/mailman/rest/docs/basic.txt
@@ -28,4 +28,4 @@ appropriate HTTP 404 Not Found error.
>>> dump_json('http://localhost:8001/3.0/does-not-exist')
Traceback (most recent call last):
...
- HTTPError: HTTP Error 404: Not Found
+ HTTPError: HTTP Error 404: 404 Not Found
diff --git a/src/mailman/rest/docs/configuration.txt b/src/mailman/rest/docs/configuration.txt
index 9cb30f045..1ad374d32 100644
--- a/src/mailman/rest/docs/configuration.txt
+++ b/src/mailman/rest/docs/configuration.txt
@@ -15,6 +15,7 @@ All readable attributes for a list are available on a sub-resource.
>>> dump_json('http://localhost:8001/3.0/lists/'
... 'test-one@example.com/config')
+ acceptable_aliases: []
admin_immed_notify: True
admin_notify_mchanges: False
administrivia: True
@@ -68,6 +69,7 @@ writable attributes in one request.
>>> dump_json('http://localhost:8001/3.0/lists/'
... 'test-one@example.com/config',
... dict(
+ ... acceptable_aliases=['one@example.com', 'two@example.com'],
... admin_immed_notify=False,
... admin_notify_mchanges=True,
... administrivia=False,
@@ -100,6 +102,7 @@ These values are changed permanently.
>>> dump_json('http://localhost:8001/3.0/lists/'
... 'test-one@example.com/config')
+ acceptable_aliases: [u'one@example.com', u'two@example.com']
admin_immed_notify: False
admin_notify_mchanges: True
administrivia: False
@@ -130,12 +133,13 @@ These values are changed permanently.
...
If you use PUT to change a list's configuration, all writable attributes must
-be included. It is an error to leave one out (e.g. `pipeline`)...
+be included. It is an error to leave one or more out...
>>> dump_json('http://localhost:8001/3.0/lists/'
... 'test-one@example.com/config',
... dict(
- ... #admin_immed_notify=False,
+ ... #acceptable_aliases=['one', 'two'],
+ ... admin_immed_notify=False,
... admin_notify_mchanges=True,
... administrivia=False,
... advertised=False,
@@ -160,7 +164,7 @@ be included. It is an error to leave one out (e.g. `pipeline`)...
... 'PUT')
Traceback (most recent call last):
...
- HTTPError: HTTP Error 400: Bad Request
+ HTTPError: HTTP Error 400: Missing parameters: acceptable_aliases
...or to add an unknown one.
@@ -168,6 +172,7 @@ be included. It is an error to leave one out (e.g. `pipeline`)...
... 'test-one@example.com/config',
... dict(
... a_mailing_list_attribute=False,
+ ... acceptable_aliases=['one', 'two'],
... admin_immed_notify=False,
... admin_notify_mchanges=True,
... administrivia=False,
@@ -193,7 +198,7 @@ be included. It is an error to leave one out (e.g. `pipeline`)...
... 'PUT')
Traceback (most recent call last):
...
- HTTPError: HTTP Error 400: Bad Request
+ HTTPError: HTTP Error 400: Unexpected parameters: a_mailing_list_attribute
It is also an error to spell an attribute value incorrectly...
@@ -201,6 +206,7 @@ It is also an error to spell an attribute value incorrectly...
... 'test-one@example.com/config',
... dict(
... admin_immed_notify='Nope',
+ ... acceptable_aliases=['one', 'two'],
... admin_notify_mchanges=True,
... administrivia=False,
... advertised=False,
@@ -225,13 +231,14 @@ It is also an error to spell an attribute value incorrectly...
... 'PUT')
Traceback (most recent call last):
...
- HTTPError: HTTP Error 400: Bad Request
+ HTTPError: HTTP Error 400: Cannot convert parameters: admin_immed_notify
...or to name a pipeline that doesn't exist...
>>> dump_json('http://localhost:8001/3.0/lists/'
... 'test-one@example.com/config',
... dict(
+ ... acceptable_aliases=['one', 'two'],
... admin_immed_notify=False,
... admin_notify_mchanges=True,
... advertised=False,
@@ -256,13 +263,14 @@ It is also an error to spell an attribute value incorrectly...
... 'PUT')
Traceback (most recent call last):
...
- HTTPError: HTTP Error 400: Bad Request
+ HTTPError: HTTP Error 400: Cannot convert parameters: pipeline
...or to name an invalid auto-response enumeration value.
>>> dump_json('http://localhost:8001/3.0/lists/'
... 'test-one@example.com/config',
... dict(
+ ... acceptable_aliases=['one', 'two'],
... admin_immed_notify=False,
... admin_notify_mchanges=True,
... advertised=False,
@@ -279,7 +287,7 @@ It is also an error to spell an attribute value incorrectly...
... include_rfc2369_headers=False,
... include_list_post_header=False,
... digest_size_threshold=10.5,
- ... pipeline='dummy',
+ ... pipeline='virgin',
... filter_content=True,
... convert_html_to_plaintext=True,
... collapse_alternatives=False,
@@ -287,7 +295,7 @@ It is also an error to spell an attribute value incorrectly...
... 'PUT')
Traceback (most recent call last):
...
- HTTPError: HTTP Error 400: Bad Request
+ HTTPError: HTTP Error 400: Cannot convert parameters: autorespond_owner
Changing a partial configuration
@@ -295,18 +303,14 @@ Changing a partial configuration
Using PATCH, you can change just one attribute.
- >>> dump_json('http://localhost:8001/3.0/lists/'
- ... 'test-one@example.com/config',
- ... dict(real_name='My List'),
- ... 'PATCH')
- content-length: 0
- date: ...
- server: WSGIServer/...
- status: 200
+XXX WebOb does not currently support PATCH, so neither does restish.
-These values are changed permanently.
+# >>> dump_json('http://localhost:8001/3.0/lists/'
+# ... 'test-one@example.com/config',
+# ... dict(real_name='My List'),
+# ... 'PATCH')
-XXX WebOb does not currently support PATCH, so neither does restish.
+These values are changed permanently.
Sub-resources
@@ -325,18 +329,21 @@ These are recipient aliases that can be used in the To and CC headers instead
of the posting address. They are often used in forwarded emails. By default,
a mailing list has no acceptable aliases.
+ >>> from mailman.interfaces.mailinglist import IAcceptableAliasSet
+ >>> IAcceptableAliasSet(mlist).clear()
+ >>> transaction.commit()
>>> dump_json('http://localhost:8001/3.0/lists/'
... 'test-one@example.com/config/acceptable_aliases')
- aliases: []
- http_etag: "c883ba7e4f62819da3c087f086feb5fe524c10b4"
+ acceptable_aliases: []
+ http_etag: "..."
We can add a few by PUTting them on the sub-resource. The keys in the
dictionary are ignored.
>>> dump_json('http://localhost:8001/3.0/lists/'
... 'test-one@example.com/config/acceptable_aliases',
- ... dict(one='foo@example.com',
- ... two='bar@example.net'),
+ ... dict(acceptable_aliases=['foo@example.com',
+ ... 'bar@example.net']),
... 'PUT')
content-length: 0
date: ...
@@ -348,7 +355,7 @@ Aliases are returned as a list on the 'aliases' key.
>>> response = call_http(
... 'http://localhost:8001/3.0/lists/'
... 'test-one@example.com/config/acceptable_aliases')
- >>> for alias in response['aliases']:
+ >>> for alias in response['acceptable_aliases']:
... print alias
bar@example.net
foo@example.com
diff --git a/src/mailman/rest/docs/domains.txt b/src/mailman/rest/docs/domains.txt
index b8e0170b0..a3a6f1227 100644
--- a/src/mailman/rest/docs/domains.txt
+++ b/src/mailman/rest/docs/domains.txt
@@ -110,7 +110,7 @@ But we get a 404 for a non-existent domain.
>>> dump_json('http://localhost:8001/3.0/domains/does-not-exist')
Traceback (most recent call last):
...
- HTTPError: HTTP Error 404: Not Found
+ HTTPError: HTTP Error 404: 404 Not Found
Creating new domains
diff --git a/src/mailman/rest/docs/helpers.txt b/src/mailman/rest/docs/helpers.txt
index 7b9aa9863..23cd2f162 100644
--- a/src/mailman/rest/docs/helpers.txt
+++ b/src/mailman/rest/docs/helpers.txt
@@ -141,3 +141,57 @@ But if the optional values are present, they must of course also be valid.
Traceback (most recent call last):
...
ValueError: Cannot convert parameters: five, four
+
+
+Arrays
+------
+
+Some POST forms include more than one value for a particular key. This is how
+lists and arrays are modeled. The Validator does the right thing with such
+form data. Specifically, when a key shows up multiple times in the form data,
+a list is given to the validator.
+
+ # Of course we can't use a normal dictionary, but webob has a useful data
+ # type we can use.
+ >>> from webob.multidict import MultiDict
+ >>> form_data = MultiDict(one='1', many='3')
+ >>> form_data.add('many', '4')
+ >>> form_data.add('many', '5')
+
+This is a validation function that ensures the value is a list.
+
+ >>> def must_be_list(value):
+ ... if not isinstance(value, list):
+ ... raise ValueError('not a list')
+ ... return [int(item) for item in value]
+
+This is a validation function that ensure the value is *not* a list.
+
+ >>> def must_be_scalar(value):
+ ... if isinstance(value, list):
+ ... raise ValueError('is a list')
+ ... return int(value)
+
+And a validator to pull it all together.
+
+ >>> validator = Validator(one=must_be_scalar, many=must_be_list)
+ >>> FakeRequest.POST = form_data
+ >>> values = validator(FakeRequest)
+ >>> print values['one']
+ 1
+ >>> print values['many']
+ [3, 4, 5]
+
+The list values are guaranteed to be in the same order they show up in the
+form data.
+
+ >>> from webob.multidict import MultiDict
+ >>> form_data = MultiDict(one='1', many='3')
+ >>> form_data.add('many', '5')
+ >>> form_data.add('many', '4')
+ >>> FakeRequest.POST = form_data
+ >>> values = validator(FakeRequest)
+ >>> print values['one']
+ 1
+ >>> print values['many']
+ [3, 5, 4]
diff --git a/src/mailman/rest/docs/lists.txt b/src/mailman/rest/docs/lists.txt
index 504d16feb..3cb7e1481 100644
--- a/src/mailman/rest/docs/lists.txt
+++ b/src/mailman/rest/docs/lists.txt
@@ -75,7 +75,7 @@ not exist.
... })
Traceback (most recent call last):
...
- HTTPError: HTTP Error 400: Bad Request
+ HTTPError: HTTP Error 400: Domain does not exist example.org
Nor can you create a mailing list that already exists.
@@ -84,7 +84,7 @@ Nor can you create a mailing list that already exists.
... })
Traceback (most recent call last):
...
- HTTPError: HTTP Error 400: Bad Request
+ HTTPError: HTTP Error 400: Mailing list exists
Deleting lists via the API
@@ -116,10 +116,10 @@ deleted.
... method='DELETE')
Traceback (most recent call last):
...
- HTTPError: HTTP Error 404: Not Found
+ HTTPError: HTTP Error 404: 404 Not Found
>>> dump_json('http://localhost:8001/3.0/lists/test-ten@example.com',
... method='DELETE')
Traceback (most recent call last):
...
- HTTPError: HTTP Error 404: Not Found
+ HTTPError: HTTP Error 404: 404 Not Found
diff --git a/src/mailman/rest/docs/membership.txt b/src/mailman/rest/docs/membership.txt
index 59af8f2f7..0b5eb063a 100644
--- a/src/mailman/rest/docs/membership.txt
+++ b/src/mailman/rest/docs/membership.txt
@@ -257,7 +257,7 @@ For some reason Elly tries to join a mailing list that does not exist.
... })
Traceback (most recent call last):
...
- HTTPError: HTTP Error 400: Bad Request
+ HTTPError: HTTP Error 400: No such list
Then, she tries to leave a mailing list that does not exist.
@@ -266,7 +266,7 @@ Then, she tries to leave a mailing list that does not exist.
... method='DELETE')
Traceback (most recent call last):
...
- HTTPError: HTTP Error 404: Not Found
+ HTTPError: HTTP Error 404: 404 Not Found
She then tries to leave a mailing list with a bogus address.
@@ -275,7 +275,7 @@ She then tries to leave a mailing list with a bogus address.
... method='DELETE')
Traceback (most recent call last):
...
- HTTPError: HTTP Error 404: Not Found
+ HTTPError: HTTP Error 404: 404 Not Found
For some reason, Elly tries to leave the mailing list again, but she's already
been unsubscribed.
@@ -285,7 +285,7 @@ been unsubscribed.
... method='DELETE')
Traceback (most recent call last):
...
- HTTPError: HTTP Error 404: Not Found
+ HTTPError: HTTP Error 404: 404 Not Found
Anna tries to join a mailing list she's already a member of.
@@ -295,7 +295,7 @@ Anna tries to join a mailing list she's already a member of.
... })
Traceback (most recent call last):
...
- HTTPError: HTTP Error 409: Conflict
+ HTTPError: HTTP Error 409: Member already subscribed
Gwen tries to join the alpha mailing list using an invalid delivery mode.
@@ -307,7 +307,7 @@ Gwen tries to join the alpha mailing list using an invalid delivery mode.
... })
Traceback (most recent call last):
...
- HTTPError: HTTP Error 400: Bad Request
+ HTTPError: HTTP Error 400: Cannot convert parameters: delivery_mode
Even using an address with "funny" characters Hugh can join the mailing list.
diff --git a/src/mailman/rest/helpers.py b/src/mailman/rest/helpers.py
index c6a020f61..8c4f0bae9 100644
--- a/src/mailman/rest/helpers.py
+++ b/src/mailman/rest/helpers.py
@@ -22,6 +22,7 @@ from __future__ import absolute_import, unicode_literals
__metaclass__ = type
__all__ = [
'ContainerMixin',
+ 'enum_validator',
'etag',
'no_content',
'path_to',
@@ -36,7 +37,6 @@ from datetime import datetime, timedelta
from flufl.enum import Enum
from lazr.config import as_boolean
from restish.http import Response
-from restish.resource import MethodDecorator
from mailman.config import config
@@ -154,6 +154,18 @@ class CollectionMixin:
+class enum_validator:
+ """Convert an enum value name into an enum value."""
+
+ def __init__(self, enum_class):
+ self._enum_class = enum_class
+
+ def __call__(self, enum_value):
+ # This will raise a ValueError if the enum value is unknown. Let that
+ # percolate up.
+ return self._enum_class[enum_value]
+
+
class Validator:
"""A validator of parameter input."""
@@ -168,7 +180,21 @@ class Validator:
values = {}
extras = set()
cannot_convert = set()
- for key, value in request.POST.items():
+ form_data = {}
+ # All keys which show up only once in the form data get a scalar value
+ # in the pre-converted dictionary. All keys which show up more than
+ # once get a list value.
+ missing = object()
+ for key, new_value in request.POST.items():
+ old_value = form_data.get(key, missing)
+ if old_value is missing:
+ form_data[key] = new_value
+ elif isinstance(old_value, list):
+ old_value.append(new_value)
+ else:
+ form_data[key] = [old_value, new_value]
+ # Now do all the conversions.
+ for key, value in form_data.items():
try:
values[key] = self._converters[key](value)
except KeyError:
@@ -206,9 +232,3 @@ def restish_matcher(function):
def no_content():
"""204 No Content."""
return Response('204 No Content', [], None)
-
-
-# restish doesn't support HTTP PATCH (it's not standard).
-class PATCH(MethodDecorator):
- """ http PATCH method """
- method = 'PATCH'
diff --git a/src/mailman/rest/lists.py b/src/mailman/rest/lists.py
index 31c5dcfb0..af5b3ced5 100644
--- a/src/mailman/rest/lists.py
+++ b/src/mailman/rest/lists.py
@@ -27,25 +27,17 @@ __all__ = [
]
-import os
-import sys
-
-from lazr.config import as_boolean, as_timedelta
-from pkg_resources import resource_listdir
from restish import http, resource
from zope.component import getUtility
from mailman.app.lifecycle import create_list, remove_list
-from mailman.config import config
-from mailman.interfaces.autorespond import ResponseAction
from mailman.interfaces.domain import BadDomainSpecificationError
from mailman.interfaces.listmanager import (
IListManager, ListAlreadyExistsError)
-from mailman.interfaces.mailinglist import IAcceptableAliasSet
from mailman.interfaces.member import MemberRole
+from mailman.rest.configuration import ListConfiguration
from mailman.rest.helpers import (
- CollectionMixin, PATCH, Validator, etag, no_content, path_to,
- restish_matcher)
+ CollectionMixin, Validator, etag, no_content, path_to, restish_matcher)
from mailman.rest.members import AMember, MembersOfList
@@ -95,26 +87,18 @@ def config_matcher(request, segments):
"""A matcher for a mailing list's configuration resource.
e.g. /config
+ e.g. /config/description
"""
- if len(segments) == 1 and segments[0] == 'config':
+ if len(segments) < 1 or segments[0] != 'config':
+ return None
+ if len(segments) == 1:
return (), {}, ()
- # It's something else.
+ if len(segments) == 2:
+ return (), dict(attribute=segments[1]), ()
+ # More segments are not allowed.
return None
-@restish_matcher
-def subresource_config_matcher(request, segments):
- """A matcher for configuration sub-resources.
-
- e.g. /config/acceptable_aliases
- """
- if len(segments) != 2 or segments[0] != 'config':
- return None
- # Don't check here whether it's a known subresource or not. Let that be
- # done in subresource_config() method below.
- return (), dict(attribute=segments[1]), ()
-
-
class _ListBase(resource.Resource, CollectionMixin):
"""Shared base class for mailing list representations."""
@@ -169,22 +153,9 @@ class AList(_ListBase):
return MembersOfList(self._mlist, role)
@resource.child(config_matcher)
- def config(self, request, segments):
+ def config(self, request, segments, attribute=None):
"""Return a mailing list configuration object."""
- return ListConfiguration(self._mlist)
-
- @resource.child(subresource_config_matcher)
- def subresource_config(self, request, segments, attribute):
- """Return the subresource configuration object.
-
- This will return a Bad Request if it isn't a known subresource.
- """
- missing = object()
- subresource_class = SUBRESOURCES.get(attribute, missing)
- if subresource_class is missing:
- return http.bad_request(
- [], 'Unknown attribute {0}'.format(attribute))
- return subresource_class(self._mlist, attribute)
+ return ListConfiguration(self._mlist, attribute)
@@ -214,181 +185,3 @@ class AllLists(_ListBase):
"""/lists"""
resource = self._make_collection(request)
return http.ok([], etag(resource))
-
-
-
-# The set of readable IMailingList attributes.
-READABLE = (
- # Identity.
- 'created_at',
- 'list_name',
- 'host_name',
- 'fqdn_listname',
- 'real_name',
- 'description',
- 'list_id',
- 'include_list_post_header',
- 'include_rfc2369_headers',
- 'advertised',
- 'anonymous_list',
- # Contact addresses.
- 'posting_address',
- 'no_reply_address',
- 'owner_address',
- 'request_address',
- 'bounces_address',
- 'join_address',
- 'leave_address',
- # Posting history.
- 'last_post_at',
- 'post_id',
- # Digests.
- 'digest_last_sent_at',
- 'volume',
- 'next_digest_number',
- 'digest_size_threshold',
- # Web access.
- 'scheme',
- 'web_host',
- # Notifications.
- 'admin_immed_notify',
- 'admin_notify_mchanges',
- # Automatic responses.
- 'autoresponse_grace_period',
- 'autorespond_owner',
- 'autoresponse_owner_text',
- 'autorespond_postings',
- 'autoresponse_postings_text',
- 'autorespond_requests',
- 'autoresponse_request_text',
- # Processing.
- 'pipeline',
- 'administrivia',
- 'filter_content',
- 'convert_html_to_plaintext',
- 'collapse_alternatives',
- )
-
-
-def pipeline_validator(pipeline_name):
- """Convert the pipeline name to a string, but only if it's known."""
- if pipeline_name in config.pipelines:
- return unicode(pipeline_name)
- raise ValueError('Unknown pipeline: {0}'.format(pipeline_name))
-
-
-class enum_validator:
- """Convert an enum value name into an enum value."""
-
- def __init__(self, enum_class):
- self._enum_class = enum_class
-
- def __call__(self, enum_value):
- # This will raise a ValueError if the enum value is unknown. Let that
- # percolate up.
- return self._enum_class[enum_value]
-
-
-VALIDATORS = {
- # Identity.
- 'real_name': unicode,
- 'description': unicode,
- 'include_list_post_header': as_boolean,
- 'include_rfc2369_headers': as_boolean,
- 'advertised': as_boolean,
- 'anonymous_list': as_boolean,
- # Digests.
- 'digest_size_threshold': float,
- # Notifications.
- 'admin_immed_notify': as_boolean,
- 'admin_notify_mchanges': as_boolean,
- # Automatic responses.
- 'autoresponse_grace_period': as_timedelta,
- 'autorespond_owner': enum_validator(ResponseAction),
- 'autoresponse_owner_text': unicode,
- 'autorespond_postings': enum_validator(ResponseAction),
- 'autoresponse_postings_text': unicode,
- 'autorespond_requests': enum_validator(ResponseAction),
- 'autoresponse_request_text': unicode,
- # Processing.
- 'pipeline': pipeline_validator,
- 'administrivia': as_boolean,
- 'filter_content': as_boolean,
- 'convert_html_to_plaintext': as_boolean,
- 'collapse_alternatives': as_boolean,
- }
-
-
-class ListConfiguration(resource.Resource):
- """A mailing list configuration resource."""
-
- def __init__(self, mailing_list):
- self._mlist = mailing_list
-
- @resource.GET()
- def get_configuration(self, request):
- """Return a mailing list's readable configuration."""
- resource = {}
- for attribute in READABLE:
- resource[attribute] = getattr(self._mlist, attribute)
- return http.ok([], etag(resource))
-
- @resource.PUT()
- def put_configuration(self, request):
- """Set all of a mailing list's configuration."""
- # Use PATCH to change just one or a few of the attributes.
- validator = Validator(**VALIDATORS)
- try:
- for key, value in validator(request).items():
- setattr(self._mlist, key, value)
- except ValueError as error:
- return http.bad_request([], str(error))
- return http.ok([], '')
-
- @PATCH()
- def patch_configuration(self, request):
- """Set a subset of the mailing list's configuration."""
- validator = Validator(_optional=VALIDATORS.keys(), **VALIDATORS)
- try:
- for key, value in validator(request).items():
- setattr(self._mlist, key, value)
- except ValueError as error:
- return http.bad_request([], str(error))
- return http.ok([], '')
-
-
-
-class AcceptableAliases(resource.Resource):
- """Resource for the acceptable aliases of a mailing list."""
-
- def __init__(self, mailing_list, attribute):
- assert attribute == 'acceptable_aliases', (
- 'unexpected attribute: {0}'.format(attribute))
- self._mlist = mailing_list
-
- @resource.GET()
- def aliases(self, request):
- """Return the mailing list's acceptable aliases."""
- aliases = IAcceptableAliasSet(self._mlist)
- resource = dict(aliases=sorted(aliases.aliases))
- return http.ok([], etag(resource))
-
- @resource.PUT()
- def put_configuration(self, request):
- """Change the acceptable aliases.
-
- Because this is a PUT operation, all previous aliases are cleared
- first. Thus, this is an overwrite. The keys in the request are
- ignored.
- """
- aliases = IAcceptableAliasSet(self._mlist)
- aliases.clear()
- for alias in request.POST.values():
- aliases.add(unicode(alias))
- return http.ok([], '')
-
-
-
-SUBRESOURCES = dict(
- acceptable_aliases=AcceptableAliases,
- )
diff --git a/src/mailman/rest/members.py b/src/mailman/rest/members.py
index abd0eff67..67dfb6533 100644
--- a/src/mailman/rest/members.py
+++ b/src/mailman/rest/members.py
@@ -38,7 +38,8 @@ from mailman.interfaces.listmanager import NoSuchListError
from mailman.interfaces.member import (
AlreadySubscribedError, DeliveryMode, MemberRole)
from mailman.interfaces.membership import ISubscriptionService
-from mailman.rest.helpers import CollectionMixin, Validator, etag, path_to
+from mailman.rest.helpers import (
+ CollectionMixin, Validator, enum_validator, etag, path_to)
@@ -97,7 +98,7 @@ class AllMembers(_MemberBase):
validator = Validator(fqdn_listname=unicode,
address=unicode,
real_name=unicode,
- delivery_mode=unicode,
+ delivery_mode=enum_validator(DeliveryMode),
_optional=('real_name', 'delivery_mode'))
member = service.join(**validator(request))
except AlreadySubscribedError:
diff --git a/src/mailman/tests/test_documentation.py b/src/mailman/tests/test_documentation.py
index a914127e7..c0f8dca20 100644
--- a/src/mailman/tests/test_documentation.py
+++ b/src/mailman/tests/test_documentation.py
@@ -123,7 +123,7 @@ def call_http(url, data=None, method=None):
"""
headers = {}
if data is not None:
- data = urlencode(data)
+ data = urlencode(data, doseq=True)
headers['Content-Type'] = 'application/x-www-form-urlencoded'
if method is None:
if data is None:
@@ -135,7 +135,7 @@ def call_http(url, data=None, method=None):
# If we did not get a 2xx status code, make this look like a urllib2
# exception, for backward compatibility with existing doctests.
if response.status // 100 != 2:
- raise HTTPError(url, response.status, response.reason, response, None)
+ raise HTTPError(url, response.status, content, response, None)
if len(content) == 0:
for header in sorted(response):
print '{0}: {1}'.format(header, response[header])