summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorJimmy Bergman2012-09-24 08:26:50 +0200
committerJimmy Bergman2012-09-24 08:26:50 +0200
commita3516893922c74b88f71ce62227adf5a683baa00 (patch)
tree838f38fd2a6d571da443a6c0335c17044ff86371 /src
parent8271738ba287c4688173ff760118996c1590b84f (diff)
parent12b9839a5e7f1e9fda477c5e40ed190e08292da7 (diff)
downloadmailman-a3516893922c74b88f71ce62227adf5a683baa00.tar.gz
mailman-a3516893922c74b88f71ce62227adf5a683baa00.tar.zst
mailman-a3516893922c74b88f71ce62227adf5a683baa00.zip
Merge with trunk
Diffstat (limited to 'src')
-rw-r--r--src/mailman/core/errors.py22
-rw-r--r--src/mailman/docs/MTA.rst11
-rw-r--r--src/mailman/docs/NEWS.rst17
-rw-r--r--src/mailman/mta/postfix.py60
-rw-r--r--src/mailman/rest/addresses.py41
-rw-r--r--src/mailman/rest/configuration.py108
-rw-r--r--src/mailman/rest/docs/addresses.rst42
-rw-r--r--src/mailman/rest/docs/domains.rst4
-rw-r--r--src/mailman/rest/docs/lists.rst52
-rw-r--r--src/mailman/rest/docs/users.rst101
-rw-r--r--src/mailman/rest/helpers.py61
-rw-r--r--src/mailman/rest/lists.py15
-rw-r--r--src/mailman/rest/preferences.py21
-rw-r--r--src/mailman/rest/root.py6
-rw-r--r--src/mailman/rest/tests/test_addresses.py74
-rw-r--r--src/mailman/rest/users.py60
-rw-r--r--src/mailman/rest/validator.py50
17 files changed, 551 insertions, 194 deletions
diff --git a/src/mailman/core/errors.py b/src/mailman/core/errors.py
index 529ac86fe..95a318ca6 100644
--- a/src/mailman/core/errors.py
+++ b/src/mailman/core/errors.py
@@ -43,7 +43,10 @@ __all__ = [
'MemberError',
'MustDigestError',
'PasswordError',
+ 'RESTError',
+ 'ReadOnlyPATCHRequestError',
'RejectMessage',
+ 'UnknownPATCHRequestError',
]
@@ -126,3 +129,22 @@ class BadPasswordSchemeError(PasswordError):
def __str__(self):
return 'A bad password scheme was given: %s' % self.scheme_name
+
+
+
+class RESTError(MailmanError):
+ """Base class for REST API errors."""
+
+
+class UnknownPATCHRequestError(RESTError):
+ """A PATCH request contained an unknown attribute."""
+
+ def __init__(self, attribute):
+ self.attribute = attribute
+
+
+class ReadOnlyPATCHRequestError(RESTError):
+ """A PATCH request contained a read-only attribute."""
+
+ def __init__(self, attribute):
+ self.attribute = attribute
diff --git a/src/mailman/docs/MTA.rst b/src/mailman/docs/MTA.rst
index 04bfc10e1..c6d2230c4 100644
--- a/src/mailman/docs/MTA.rst
+++ b/src/mailman/docs/MTA.rst
@@ -102,14 +102,17 @@ file::
hash:/path-to-mailman/var/data/postfix_lmtp
local_recipient_maps =
hash:/path-to-mailman/var/data/postfix_lmtp
- relay_domains =
- hash:/path-to-mailman/var/data/postfix_domains
where `path-to-mailman` is replaced with the actual path that you're running
Mailman from. Setting `local_recipient_maps` as well as `transport_maps`
allows Postfix to properly reject all messages destined for non-existent local
-users. Setting `relay_domains` means postfix will start to accept mails for
-newly added domains even if they are not part of `mydestination`.
+users.
+
+
+Virtual domains
+---------------
+
+TBD: figure out how virtual domains interact with the transport maps.
Sendmail
diff --git a/src/mailman/docs/NEWS.rst b/src/mailman/docs/NEWS.rst
index 329264e31..8fec45957 100644
--- a/src/mailman/docs/NEWS.rst
+++ b/src/mailman/docs/NEWS.rst
@@ -12,6 +12,23 @@ Here is a history of user visible changes to Mailman.
==========================
(2012-XX-XX)
+REST
+----
+ * Add list_id to JSON representation for a mailing list (given by Jimmy
+ Bergman).
+ * The canonical resource for a mailing list (and thus its self_link) is now
+ the URL with the list-id. To reference a mailing list, the list-id url is
+ preferred, but for backward compatibility, the posting address is still
+ accepted.
+ * You can now PUT and PATCH on user resources to change the user's display
+ name or password. For passwords, you pass in the clear text password and
+ Mailman will hash it before storing.
+ * You can now verify and unverify an email address through the REST API.
+ POST to .../addresses/<email>/verify and .../addresses/<email>/unverify
+ respectively. The POST data is ignored. It is not an error to verify or
+ unverify an address more than once, but verifying an already verified
+ address does not change its `.verified_on` date. (LP: #1054730)
+
3.0 beta 2 -- "Freeze"
======================
diff --git a/src/mailman/mta/postfix.py b/src/mailman/mta/postfix.py
index 4fc097ffc..c04e38f02 100644
--- a/src/mailman/mta/postfix.py
+++ b/src/mailman/mta/postfix.py
@@ -64,7 +64,6 @@ class LMTP:
# We can ignore the mlist argument because for LMTP delivery, we just
# generate the entire file every time.
self.regenerate()
- self.regenerate_domain()
delete = create
@@ -108,46 +107,6 @@ class LMTP:
log.error(msg, command, status, errstr)
raise RuntimeError(msg % (command, status, errstr))
- def regenerate_domain(self, output=None):
- """The map for all list domains
-
- The format for Postfix's LMTP transport map is defined here:
- http://www.postfix.org/transport.5.html
- """
- # Acquire a lock file to prevent other processes from racing us here.
- lock_file = os.path.join(config.LOCK_DIR, 'mta')
- with Lock(lock_file):
- # If output is a filename, open up a backing file and write the
- # output there, then do the atomic rename dance. First though, if
- # it's None, we use a calculated path.
- if output is None:
- path = os.path.join(config.DATA_DIR, 'postfix_domains')
- path_new = path + '.new'
- elif isinstance(output, basestring):
- path = output
- path_new = output + '.new'
- else:
- path = path_new = None
- if path_new is None:
- self._do_write_file_domains(output)
- # There's nothing to rename, and we can't generate the .db
- # file, so we're done.
- return
- # Write the file.
- with open(path_new, 'w') as fp:
- self._do_write_file_domains(fp)
- # Atomically rename to the intended path.
- os.rename(path + '.new', path)
- # Now that the new file is in place, we must tell Postfix to
- # generate a new .db file.
- command = config.mta.postfix_map_cmd + ' ' + path
- status = (os.system(command) >> 8) & 0xff
- if status:
- msg = 'command failure: %s, %s, %s'
- errstr = os.strerror(status)
- log.error(msg, command, status, errstr)
- raise RuntimeError(msg % (command, status, errstr))
-
def _do_write_file(self, fp):
"""Do the actual file writes for list creation."""
# Sort all existing mailing list names first by domain, then by local
@@ -178,22 +137,3 @@ class LMTP:
for alias in aliases:
print(ALIASTMPL.format(alias, config, width), file=fp)
print(file=fp)
-
- def _do_write_file_domains(self, fp):
- """Do the actual file writes of the domain map for list creation."""
- # Sort all existing mailing list names first by domain, then my local
- # part. For postfix we need a dummy entry for the domain.
- by_domain = []
- for list_name, mail_host in getUtility(IListManager).name_components:
- by_domain.append(mail_host)
- print("""\
-# AUTOMATICALLY GENERATED BY MAILMAN ON {0}
-#
-# This file is generated by Mailman, and is kept in sync with the binary hash
-# file. YOU SHOULD NOT MANUALLY EDIT THIS FILE unless you know what you're
-# doing, and can keep the two files properly in sync. If you screw it up,
-# you're on your own.
-""".format(now().replace(microsecond=0)), file=fp)
- for domain in sorted(by_domain):
- print("""{0} {0}""".format(domain), file=fp)
-
diff --git a/src/mailman/rest/addresses.py b/src/mailman/rest/addresses.py
index 2e81cb030..1e043c2c7 100644
--- a/src/mailman/rest/addresses.py
+++ b/src/mailman/rest/addresses.py
@@ -31,10 +31,11 @@ from operator import attrgetter
from restish import http, resource
from zope.component import getUtility
-from mailman.rest.helpers import CollectionMixin, etag, path_to
+from mailman.rest.helpers import CollectionMixin, etag, no_content, path_to
from mailman.rest.members import MemberCollection
from mailman.rest.preferences import Preferences
from mailman.interfaces.usermanager import IUserManager
+from mailman.utilities.datetime import now
@@ -76,6 +77,24 @@ class AllAddresses(_AddressBase):
+class _VerifyResource(resource.Resource):
+ """A helper resource for verify/unverify POSTS."""
+
+ def __init__(self, address, action):
+ self._address = address
+ self._action = action
+ assert action in ('verify', 'unverify')
+
+ @resource.POST()
+ def verify(self, request):
+ # We don't care about the POST data, just do the action.
+ if self._action == 'verify' and self._address.verified_on is None:
+ self._address.verified_on = now()
+ elif self._action == 'unverify':
+ self._address.verified_on = None
+ return no_content()
+
+
class AnAddress(_AddressBase):
"""An address."""
@@ -115,6 +134,26 @@ class AnAddress(_AddressBase):
'addresses/{0}'.format(self._address.email))
return child, []
+ @resource.child()
+ def verify(self, request, segments):
+ """/addresses/<email>/verify"""
+ if len(segments) != 0:
+ return http.bad_request()
+ if self._address is None:
+ return http.not_found()
+ child = _VerifyResource(self._address, 'verify')
+ return child, []
+
+ @resource.child()
+ def unverify(self, request, segments):
+ """/addresses/<email>/verify"""
+ if len(segments) != 0:
+ return http.bad_request()
+ if self._address is None:
+ return http.not_found()
+ child = _VerifyResource(self._address, 'unverify')
+ return child, []
+
class UserAddresses(_AddressBase):
diff --git a/src/mailman/rest/configuration.py b/src/mailman/rest/configuration.py
index 83d4c74f6..8db23136a 100644
--- a/src/mailman/rest/configuration.py
+++ b/src/mailman/rest/configuration.py
@@ -29,78 +29,17 @@ from lazr.config import as_boolean, as_timedelta
from restish import http, resource
from mailman.config import config
+from mailman.core.errors import (
+ ReadOnlyPATCHRequestError, UnknownPATCHRequestError)
from mailman.interfaces.action import Action
from mailman.interfaces.archiver import ArchivePolicy
from mailman.interfaces.autorespond import ResponseAction
from mailman.interfaces.mailinglist import IAcceptableAliasSet, ReplyToMunging
-from mailman.rest.helpers import PATCH, etag, no_content
-from mailman.rest.validator import Validator, enum_validator
+from mailman.rest.helpers import GetterSetter, PATCH, etag, no_content
+from mailman.rest.validator import PatchValidator, Validator, enum_validator
-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."""
@@ -239,19 +178,6 @@ class ListConfiguration(resource.Resource):
resource[attribute] = value
return http.ok([], etag(resource))
- # XXX 2010-09-01 barry: Refactor {put,patch}_configuration() for common
- # code paths.
-
- def _set_writable_attributes(self, validator, request):
- """Common code for setting all attributes given in the request.
-
- Returns an HTTP 400 when a request tries to write to a read-only
- attribute.
- """
- converted = validator(request)
- for key, value in converted.items():
- ATTRIBUTES[key].put(self._mlist, key, value)
-
@resource.PUT()
def put_configuration(self, request):
"""Set a mailing list configuration."""
@@ -259,7 +185,7 @@ class ListConfiguration(resource.Resource):
if attribute is None:
validator = Validator(**VALIDATORS)
try:
- self._set_writable_attributes(validator, request)
+ validator.update(self._mlist, request)
except ValueError as error:
return http.bad_request([], str(error))
elif attribute not in ATTRIBUTES:
@@ -271,7 +197,7 @@ class ListConfiguration(resource.Resource):
else:
validator = Validator(**{attribute: VALIDATORS[attribute]})
try:
- self._set_writable_attributes(validator, request)
+ validator.update(self._mlist, request)
except ValueError as error:
return http.bad_request([], str(error))
return no_content()
@@ -279,20 +205,16 @@ class ListConfiguration(resource.Resource):
@PATCH()
def patch_configuration(self, request):
"""Patch the configuration (i.e. partial update)."""
- # Validate only the partial subset of attributes given in the request.
- validationators = {}
- for attribute in request.PATCH:
- if 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:
- validationators[attribute] = VALIDATORS[attribute]
- validator = Validator(**validationators)
try:
- self._set_writable_attributes(validator, request)
+ validator = PatchValidator(request, ATTRIBUTES)
+ except UnknownPATCHRequestError as error:
+ return http.bad_request(
+ [], b'Unknown attribute: {0}'.format(error.attribute))
+ except ReadOnlyPATCHRequestError as error:
+ return http.bad_request(
+ [], b'Read-only attribute: {0}'.format(error.attribute))
+ try:
+ validator.update(self._mlist, request)
except ValueError as error:
return http.bad_request([], str(error))
return no_content()
diff --git a/src/mailman/rest/docs/addresses.rst b/src/mailman/rest/docs/addresses.rst
index cb9242d2b..f05b6b9b2 100644
--- a/src/mailman/rest/docs/addresses.rst
+++ b/src/mailman/rest/docs/addresses.rst
@@ -90,7 +90,6 @@ Verifying
When the address gets verified, this attribute is available in the REST
representation.
-::
>>> from mailman.utilities.datetime import now
>>> anne.verified_on = now()
@@ -103,6 +102,47 @@ representation.
self_link: http://localhost:9001/3.0/addresses/anne@example.com
verified_on: 2005-08-01T07:49:23
+Addresses can also be verified through the REST API, by POSTing to the
+'verify' sub-resource. The POST data is ignored.
+
+ >>> dump_json('http://localhost:9001/3.0/addresses/'
+ ... 'cris@example.com/verify', {})
+ content-length: 0
+ date: ...
+ server: ...
+ status: 204
+
+Now Cris's address is verified.
+
+ >>> dump_json('http://localhost:9001/3.0/addresses/cris@example.com')
+ display_name: Cris Person
+ email: cris@example.com
+ http_etag: "..."
+ original_email: cris@example.com
+ registered_on: 2005-08-01T07:49:23
+ self_link: http://localhost:9001/3.0/addresses/cris@example.com
+ verified_on: 2005-08-01T07:49:23
+
+If you should ever need to 'unverify' an address, POST to the 'unverify'
+sub-resource. Again, the POST data is ignored.
+
+ >>> dump_json('http://localhost:9001/3.0/addresses/'
+ ... 'cris@example.com/unverify', {})
+ content-length: 0
+ date: ...
+ server: ...
+ status: 204
+
+Now Cris's address is unverified.
+
+ >>> dump_json('http://localhost:9001/3.0/addresses/cris@example.com')
+ display_name: Cris Person
+ email: cris@example.com
+ http_etag: "..."
+ original_email: cris@example.com
+ registered_on: 2005-08-01T07:49:23
+ self_link: http://localhost:9001/3.0/addresses/cris@example.com
+
User addresses
==============
diff --git a/src/mailman/rest/docs/domains.rst b/src/mailman/rest/docs/domains.rst
index c890af7fa..92c73ffbf 100644
--- a/src/mailman/rest/docs/domains.rst
+++ b/src/mailman/rest/docs/domains.rst
@@ -131,7 +131,7 @@ example.com domain does not contain any mailing lists.
... })
content-length: 0
date: ...
- location: http://localhost:9001/3.0/lists/test-domains@example.com
+ location: http://localhost:9001/3.0/lists/test-domains.example.com
...
>>> dump_json('http://localhost:9001/3.0/domains/example.com/lists')
@@ -141,7 +141,7 @@ example.com domain does not contain any mailing lists.
http_etag: "..."
...
member_count: 0
- self_link: http://localhost:9001/3.0/lists/test-domains@example.com
+ self_link: http://localhost:9001/3.0/lists/test-domains.example.com
volume: 1
http_etag: "..."
start: 0
diff --git a/src/mailman/rest/docs/lists.rst b/src/mailman/rest/docs/lists.rst
index 7f0abeb26..0c4bbc419 100644
--- a/src/mailman/rest/docs/lists.rst
+++ b/src/mailman/rest/docs/lists.rst
@@ -27,7 +27,7 @@ Create a mailing list in a domain and it's accessible via the API.
list_name: test-one
mail_host: example.com
member_count: 0
- self_link: http://localhost:9001/3.0/lists/test-one@example.com
+ self_link: http://localhost:9001/3.0/lists/test-one.example.com
volume: 1
http_etag: "..."
start: 0
@@ -45,7 +45,7 @@ You can also query for lists from a particular domain.
list_name: test-one
mail_host: example.com
member_count: 0
- self_link: http://localhost:9001/3.0/lists/test-one@example.com
+ self_link: http://localhost:9001/3.0/lists/test-one.example.com
volume: 1
http_etag: "..."
start: 0
@@ -68,7 +68,7 @@ New mailing lists can also be created through the API, by posting to the
... })
content-length: 0
date: ...
- location: http://localhost:9001/3.0/lists/test-two@example.com
+ location: http://localhost:9001/3.0/lists/test-two.example.com
...
The mailing list exists in the database.
@@ -87,6 +87,22 @@ The mailing list exists in the database.
It is also available via the location given in the response.
+ >>> dump_json('http://localhost:9001/3.0/lists/test-two.example.com')
+ display_name: Test-two
+ fqdn_listname: test-two@example.com
+ http_etag: "..."
+ list_id: test-two.example.com
+ list_name: test-two
+ mail_host: example.com
+ member_count: 0
+ self_link: http://localhost:9001/3.0/lists/test-two.example.com
+ volume: 1
+
+Normally, you access the list via its RFC 2369 list-id as shown above, but for
+backward compatibility purposes, you can also access it via the list's posting
+address, if that has never been changed (since the list-id is immutable, but
+the posting address is not).
+
>>> dump_json('http://localhost:9001/3.0/lists/test-two@example.com')
display_name: Test-two
fqdn_listname: test-two@example.com
@@ -95,7 +111,7 @@ It is also available via the location given in the response.
list_name: test-two
mail_host: example.com
member_count: 0
- self_link: http://localhost:9001/3.0/lists/test-two@example.com
+ self_link: http://localhost:9001/3.0/lists/test-two.example.com
volume: 1
However, you are not allowed to create a mailing list in a domain that does
@@ -125,34 +141,48 @@ Existing mailing lists can be deleted through the API, by doing an HTTP
``DELETE`` on the mailing list URL.
::
- >>> dump_json('http://localhost:9001/3.0/lists/test-two@example.com',
+ >>> dump_json('http://localhost:9001/3.0/lists/test-two.example.com',
... method='DELETE')
content-length: 0
date: ...
server: ...
status: 204
- # The above starts a Storm transaction, which will lock the database
- # unless we abort it.
- >>> transaction.abort()
-
The mailing list does not exist.
>>> print list_manager.get('test-two@example.com')
None
+ # Unlock the database.
+ >>> transaction.abort()
+
You cannot delete a mailing list that does not exist or has already been
deleted.
::
- >>> dump_json('http://localhost:9001/3.0/lists/test-two@example.com',
+ >>> dump_json('http://localhost:9001/3.0/lists/test-two.example.com',
... method='DELETE')
Traceback (most recent call last):
...
HTTPError: HTTP Error 404: 404 Not Found
- >>> dump_json('http://localhost:9001/3.0/lists/test-ten@example.com',
+ >>> dump_json('http://localhost:9001/3.0/lists/test-ten.example.com',
... method='DELETE')
Traceback (most recent call last):
...
HTTPError: HTTP Error 404: 404 Not Found
+
+For backward compatibility purposes, you can delete a list via its posting
+address as well.
+
+ >>> dump_json('http://localhost:9001/3.0/lists/test-one@example.com',
+ ... method='DELETE')
+ content-length: 0
+ date: ...
+ server: ...
+ status: 204
+
+The mailing list does not exist.
+
+ >>> print list_manager.get('test-one@example.com')
+ None
diff --git a/src/mailman/rest/docs/users.rst b/src/mailman/rest/docs/users.rst
index cdede10ee..888bc43fd 100644
--- a/src/mailman/rest/docs/users.rst
+++ b/src/mailman/rest/docs/users.rst
@@ -40,7 +40,7 @@ The user ids match.
>>> json['entries'][0]['user_id'] == anne.user_id.int
True
-A user might not have a real name, in which case, the attribute will not be
+A user might not have a display name, in which case, the attribute will not be
returned in the REST API.
>>> dave = user_manager.create_user('dave@example.com')
@@ -66,7 +66,8 @@ Creating users via the API
==========================
New users can be created through the REST API. To do so requires the initial
-email address for the user, a password, and optionally the user's full name.
+email address for the user, a password, and optionally the user's display
+name.
::
>>> transaction.abort()
@@ -134,6 +135,75 @@ therefore cannot be retrieved. It can be reset though.
user_id: 4
+Updating users
+==============
+
+Users have a password and a display name. The display name can be changed
+through the REST API.
+
+ >>> dump_json('http://localhost:9001/3.0/users/4', {
+ ... 'display_name': 'Chrissy Person',
+ ... }, method='PATCH')
+ content-length: 0
+ date: ...
+ server: ...
+ status: 204
+
+Cris's display name has been updated.
+
+ >>> dump_json('http://localhost:9001/3.0/users/4')
+ created_on: 2005-08-01T07:49:23
+ display_name: Chrissy Person
+ http_etag: "..."
+ password: {plaintext}...
+ self_link: http://localhost:9001/3.0/users/4
+ user_id: 4
+
+You can also change the user's password by passing in the new clear text
+password. Mailman will hash this before it is stored internally.
+
+ >>> dump_json('http://localhost:9001/3.0/users/4', {
+ ... 'cleartext_password': 'clockwork angels',
+ ... }, method='PATCH')
+ content-length: 0
+ date: ...
+ server: ...
+ status: 204
+
+Even though you see *{plaintext}clockwork angels* below, it has still been
+hashed before storage. The default hashing algorithm for the test suite is a
+plain text hash, but you can see that it works by the addition of the
+algorithm prefix.
+
+ >>> dump_json('http://localhost:9001/3.0/users/4')
+ created_on: 2005-08-01T07:49:23
+ display_name: Chrissy Person
+ http_etag: "..."
+ password: {plaintext}clockwork angels
+ self_link: http://localhost:9001/3.0/users/4
+ user_id: 4
+
+You can change both the display name and the password by PUTing the full
+resource.
+
+ >>> dump_json('http://localhost:9001/3.0/users/4', {
+ ... 'display_name': 'Christopherson Person',
+ ... 'cleartext_password': 'the garden',
+ ... }, method='PUT')
+ content-length: 0
+ date: ...
+ server: ...
+ status: 204
+
+ >>> dump_json('http://localhost:9001/3.0/users/4')
+ created_on: 2005-08-01T07:49:23
+ display_name: Christopherson Person
+ http_etag: "..."
+ password: {plaintext}the garden
+ self_link: http://localhost:9001/3.0/users/4
+ user_id: 4
+
+
Deleting users via the API
==========================
@@ -145,11 +215,21 @@ Users can also be deleted via the API.
date: ...
server: ...
status: 204
+
+Cris's resource cannot be retrieved either by email address...
+
>>> dump_json('http://localhost:9001/3.0/users/cris@example.com')
Traceback (most recent call last):
...
HTTPError: HTTP Error 404: 404 Not Found
+...or user id.
+
+ >>> dump_json('http://localhost:9001/3.0/users/4')
+ Traceback (most recent call last):
+ ...
+ HTTPError: HTTP Error 404: 404 Not Found
+
Missing users
=============
@@ -171,6 +251,23 @@ user id...
...
HTTPError: HTTP Error 404: 404 Not Found
+You also can't update a missing user.
+
+ >>> dump_json('http://localhost:9001/3.0/users/zed@example.org', {
+ ... 'display_name': 'Is Dead',
+ ... }, method='PATCH')
+ Traceback (most recent call last):
+ ...
+ HTTPError: HTTP Error 404: 404 Not Found
+
+ >>> dump_json('http://localhost:9001/3.0/users/zed@example.org', {
+ ... 'display_name': 'Is Dead',
+ ... 'cleartext_password': 'vroom',
+ ... }, method='PUT')
+ Traceback (most recent call last):
+ ...
+ HTTPError: HTTP Error 404: 404 Not Found
+
User addresses
==============
diff --git a/src/mailman/rest/helpers.py b/src/mailman/rest/helpers.py
index 2824a894e..72040c848 100644
--- a/src/mailman/rest/helpers.py
+++ b/src/mailman/rest/helpers.py
@@ -21,6 +21,7 @@ from __future__ import absolute_import, unicode_literals
__metaclass__ = type
__all__ = [
+ 'GetterSetter',
'PATCH',
'etag',
'no_content',
@@ -205,3 +206,63 @@ class PATCH(MethodDecorator):
def __call__(self, func):
really_wrapped_func = PATCHWrapper(func)
return super(PATCH, self).__call__(really_wrapped_func)
+
+
+class GetterSetter:
+ """Get and set attributes on an object.
+
+ Most attributes are fairly simple - a getattr() or setattr() on the object
+ 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 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 attribute.
+
+ :param decoder: The callable for decoding a web request value string
+ into the specific data type needed by the object's 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, obj, attribute):
+ """Return the named object attribute value.
+
+ :param obj: The object to access.
+ :type obj: object
+ :param attribute: The attribute name.
+ :type attribute: string
+ :return: The attribute value, ready for JSON encoding.
+ :rtype: object
+ """
+ return getattr(obj, attribute)
+
+ def put(self, obj, attribute, value):
+ """Set the named object attribute value.
+
+ :param obj: The object to change.
+ :type obj: object
+ :param attribute: The attribute name.
+ :type attribute: string
+ :param value: The new value for the attribute.
+ """
+ setattr(obj, 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)
diff --git a/src/mailman/rest/lists.py b/src/mailman/rest/lists.py
index a45fa94a7..4e9de6905 100644
--- a/src/mailman/rest/lists.py
+++ b/src/mailman/rest/lists.py
@@ -112,7 +112,7 @@ class _ListBase(resource.Resource, CollectionMixin):
mail_host=mlist.mail_host,
member_count=mlist.members.member_count,
volume=mlist.volume,
- self_link=path_to('lists/{0}'.format(mlist.fqdn_listname)),
+ self_link=path_to('lists/{0}'.format(mlist.list_id)),
)
def _get_collection(self, request):
@@ -123,8 +123,15 @@ class _ListBase(resource.Resource, CollectionMixin):
class AList(_ListBase):
"""A mailing list."""
- def __init__(self, list_name):
- self._mlist = getUtility(IListManager).get(list_name)
+ def __init__(self, list_identifier):
+ # list-id is preferred, but for backward compatibility, fqdn_listname
+ # is also accepted. If the string contains '@', treat it as the
+ # latter.
+ manager = getUtility(IListManager)
+ if '@' in list_identifier:
+ self._mlist = manager.get(list_identifier)
+ else:
+ self._mlist = manager.get_by_list_id(list_identifier)
@resource.GET()
def mailing_list(self, request):
@@ -193,7 +200,7 @@ class AllLists(_ListBase):
except ValueError as error:
return http.bad_request([], str(error))
# wsgiref wants headers to be bytes, not unicodes.
- location = path_to('lists/{0}'.format(mlist.fqdn_listname))
+ location = path_to('lists/{0}'.format(mlist.list_id))
# Include no extra headers or body.
return http.created(location, [], None)
diff --git a/src/mailman/rest/preferences.py b/src/mailman/rest/preferences.py
index be598458e..1961c8b84 100644
--- a/src/mailman/rest/preferences.py
+++ b/src/mailman/rest/preferences.py
@@ -30,7 +30,8 @@ from lazr.config import as_boolean
from restish import http, resource
from mailman.interfaces.member import DeliveryMode, DeliveryStatus
-from mailman.rest.helpers import PATCH, etag, no_content, path_to
+from mailman.rest.helpers import (
+ GetterSetter, PATCH, etag, no_content, path_to)
from mailman.rest.validator import (
Validator, enum_validator, language_validator)
@@ -72,7 +73,7 @@ class ReadOnlyPreferences(resource.Resource):
resource['self_link'] = path_to(
'{0}/preferences'.format(self._base_url))
return http.ok([], etag(resource))
-
+
class Preferences(ReadOnlyPreferences):
@@ -82,22 +83,20 @@ class Preferences(ReadOnlyPreferences):
if self._parent is None:
return http.not_found()
kws = dict(
- acknowledge_posts=as_boolean,
- delivery_mode=enum_validator(DeliveryMode),
- delivery_status=enum_validator(DeliveryStatus),
- preferred_language=language_validator,
- receive_list_copy=as_boolean,
- receive_own_postings=as_boolean,
+ acknowledge_posts=GetterSetter(as_boolean),
+ delivery_mode=GetterSetter(enum_validator(DeliveryMode)),
+ delivery_status=GetterSetter(enum_validator(DeliveryStatus)),
+ preferred_language=GetterSetter(language_validator),
+ receive_list_copy=GetterSetter(as_boolean),
+ receive_own_postings=GetterSetter(as_boolean),
)
if is_optional:
# For a PUT, all attributes are optional.
kws['_optional'] = kws.keys()
try:
- values = Validator(**kws)(request)
+ Validator(**kws).update(self._parent, request)
except ValueError as error:
return http.bad_request([], str(error))
- for key, value in values.items():
- setattr(self._parent, key, value)
return no_content()
@PATCH()
diff --git a/src/mailman/rest/root.py b/src/mailman/rest/root.py
index 8c1a31cf2..ea1650e75 100644
--- a/src/mailman/rest/root.py
+++ b/src/mailman/rest/root.py
@@ -123,8 +123,10 @@ class TopLevel(resource.Resource):
if len(segments) == 0:
return AllLists()
else:
- list_name = segments.pop(0)
- return AList(list_name), segments
+ # list-id is preferred, but for backward compatibility,
+ # fqdn_listname is also accepted.
+ list_identifier = segments.pop(0)
+ return AList(list_identifier), segments
@resource.child()
def members(self, request, segments):
diff --git a/src/mailman/rest/tests/test_addresses.py b/src/mailman/rest/tests/test_addresses.py
index 385b83912..01ce710b2 100644
--- a/src/mailman/rest/tests/test_addresses.py
+++ b/src/mailman/rest/tests/test_addresses.py
@@ -28,11 +28,14 @@ __all__ = [
import unittest
from urllib2 import HTTPError
+from zope.component import getUtility
from mailman.app.lifecycle import create_list
from mailman.database.transaction import transaction
+from mailman.interfaces.usermanager import IUserManager
from mailman.testing.helpers import call_api
from mailman.testing.layers import RESTLayer
+from mailman.utilities.datetime import now
@@ -52,4 +55,73 @@ class TestAddresses(unittest.TestCase):
except HTTPError as exc:
self.assertEqual(exc.code, 404)
else:
- raise AssertionError('Expected HTTPError')
+ raise AssertionError('Expected HTTPError 404')
+
+ def test_verify_a_missing_address(self):
+ # POSTing to the 'verify' sub-resource returns a 404.
+ try:
+ call_api('http://localhost:9001/3.0/addresses/'
+ 'nobody@example.com/verify', {})
+ except HTTPError as exc:
+ self.assertEqual(exc.code, 404)
+ else:
+ raise AssertionError('Expected HTTPError 404')
+
+ def test_unverify_a_missing_address(self):
+ # POSTing to the 'unverify' sub-resource returns a 404.
+ try:
+ call_api('http://localhost:9001/3.0/addresses/'
+ 'nobody@example.com/unverify', {})
+ except HTTPError as exc:
+ self.assertEqual(exc.code, 404)
+ else:
+ raise AssertionError('Expected HTTPError 404')
+
+ def test_verify_already_verified(self):
+ # It's okay to verify an already verified; it just doesn't change the
+ # value.
+ verified_on = now()
+ with transaction():
+ anne = getUtility(IUserManager).create_address('anne@example.com')
+ anne.verified_on = verified_on
+ response, content = call_api(
+ 'http://localhost:9001/3.0/addresses/anne@example.com/verify', {})
+ self.assertEqual(content['status'], '204')
+ self.assertEqual(anne.verified_on, verified_on)
+
+ def test_unverify_already_unverified(self):
+ # It's okay to unverify an already unverified; it just doesn't change
+ # the value.
+ with transaction():
+ anne = getUtility(IUserManager).create_address('anne@example.com')
+ self.assertEqual(anne.verified_on, None)
+ response, content = call_api(
+ 'http://localhost:9001/3.0/addresses/anne@example.com/unverify', {})
+ self.assertEqual(content['status'], '204')
+ self.assertEqual(anne.verified_on, None)
+
+ def test_verify_bad_request(self):
+ # Too many segments after /verify.
+ with transaction():
+ anne = getUtility(IUserManager).create_address('anne@example.com')
+ self.assertEqual(anne.verified_on, None)
+ try:
+ call_api('http://localhost:9001/3.0/addresses/'
+ 'anne@example.com/verify/foo', {})
+ except HTTPError as exc:
+ self.assertEqual(exc.code, 400)
+ else:
+ raise AssertionError('Expected HTTPError 400')
+
+ def test_unverify_bad_request(self):
+ # Too many segments after /verify.
+ with transaction():
+ anne = getUtility(IUserManager).create_address('anne@example.com')
+ self.assertEqual(anne.verified_on, None)
+ try:
+ call_api('http://localhost:9001/3.0/addresses/'
+ 'anne@example.com/unverify/foo', {})
+ except HTTPError as exc:
+ self.assertEqual(exc.code, 400)
+ else:
+ raise AssertionError('Expected HTTPError 400')
diff --git a/src/mailman/rest/users.py b/src/mailman/rest/users.py
index bce541ae5..94817da49 100644
--- a/src/mailman/rest/users.py
+++ b/src/mailman/rest/users.py
@@ -32,12 +32,34 @@ from uuid import UUID
from zope.component import getUtility
from mailman.config import config
+from mailman.core.errors import (
+ ReadOnlyPATCHRequestError, UnknownPATCHRequestError)
from mailman.interfaces.address import ExistingAddressError
from mailman.interfaces.usermanager import IUserManager
from mailman.rest.addresses import UserAddresses
-from mailman.rest.helpers import CollectionMixin, etag, no_content, path_to
+from mailman.rest.helpers import (
+ CollectionMixin, GetterSetter, PATCH, etag, no_content, path_to)
from mailman.rest.preferences import Preferences
-from mailman.rest.validator import Validator
+from mailman.rest.validator import PatchValidator, Validator
+
+
+# Attributes of a user which can be changed via the REST API.
+class PasswordEncrypterGetterSetter(GetterSetter):
+ def __init__(self):
+ super(PasswordEncrypterGetterSetter, self).__init__(
+ config.password_context.encrypt)
+ def get(self, obj, attribute):
+ assert attribute == 'cleartext_password'
+ super(PasswordEncrypterGetterSetter, self).get(obj, 'password')
+ def put(self, obj, attribute, value):
+ assert attribute == 'cleartext_password'
+ super(PasswordEncrypterGetterSetter, self).put(obj, 'password', value)
+
+
+ATTRIBUTES = dict(
+ display_name=GetterSetter(unicode),
+ cleartext_password=PasswordEncrypterGetterSetter(),
+ )
@@ -165,3 +187,37 @@ class AUser(_UserBase):
self._user.preferences,
'users/{0}'.format(self._user.user_id.int))
return child, []
+
+ @PATCH()
+ def patch_update(self, request):
+ """Patch the user's configuration (i.e. partial update)."""
+ if self._user is None:
+ return http.not_found()
+ try:
+ validator = PatchValidator(request, ATTRIBUTES)
+ except UnknownPATCHRequestError as error:
+ return http.bad_request(
+ [], b'Unknown attribute: {0}'.format(error.attribute))
+ except ReadOnlyPATCHRequestError as error:
+ return http.bad_request(
+ [], b'Read-only attribute: {0}'.format(error.attribute))
+ validator.update(self._user, request)
+ return no_content()
+
+ @resource.PUT()
+ def put_update(self, request):
+ """Put the user's configuration (i.e. full update)."""
+ if self._user is None:
+ return http.not_found()
+ validator = Validator(**ATTRIBUTES)
+ try:
+ validator.update(self._user, request)
+ except UnknownPATCHRequestError as error:
+ return http.bad_request(
+ [], b'Unknown attribute: {0}'.format(error.attribute))
+ except ReadOnlyPATCHRequestError as error:
+ return http.bad_request(
+ [], b'Read-only attribute: {0}'.format(error.attribute))
+ except ValueError as error:
+ return http.bad_request([], str(error))
+ return no_content()
diff --git a/src/mailman/rest/validator.py b/src/mailman/rest/validator.py
index 7484aa260..f6f0cd7e6 100644
--- a/src/mailman/rest/validator.py
+++ b/src/mailman/rest/validator.py
@@ -21,6 +21,7 @@ from __future__ import absolute_import, unicode_literals
__metaclass__ = type
__all__ = [
+ 'PatchValidator',
'Validator',
'enum_validator',
'language_validator',
@@ -31,6 +32,8 @@ __all__ = [
from uuid import UUID
from zope.component import getUtility
+from mailman.core.errors import (
+ ReadOnlyPATCHRequestError, UnknownPATCHRequestError)
from mailman.interfaces.languages import ILanguageManager
@@ -119,3 +122,50 @@ class Validator:
missing = COMMASPACE.join(sorted(required_keys - value_keys))
raise ValueError('Missing parameters: {0}'.format(missing))
return values
+
+ def update(self, obj, request):
+ """Update the object with the values in the request.
+
+ This first validates and converts the attributes in the request, then
+ updates the given object with the newly converted values.
+
+ :param obj: The object to update.
+ :type obj: object
+ :param request: The HTTP request.
+ :raises ValueError: if conversion failed for some attribute.
+ """
+ for key, value in self.__call__(request).items():
+ self._converters[key].put(obj, key, value)
+
+
+
+class PatchValidator(Validator):
+ """Create a special validator for PATCH requests.
+
+ PATCH is different than PUT because with the latter, you're changing the
+ entire resource, so all expected attributes must exist. With the former,
+ you're only changing a subset of the attributes, so you only validate the
+ ones that exist in the request.
+ """
+ def __init__(self, request, converters):
+ """Create a validator for the PATCH request.
+
+ :param request: The request object, which must have a .PATCH
+ attribute.
+ :param converters: A mapping of attribute names to the converter for
+ that attribute's type. Generally, this will be a GetterSetter
+ instance, but it might be something more specific for custom data
+ types (e.g. non-basic types like unicodes).
+ :raises UnknownPATCHRequestError: if the request contains an unknown
+ attribute, i.e. one that is not in the `attributes` mapping.
+ :raises ReadOnlyPATCHRequest: if the requests contains an attribute
+ that is defined as read-only.
+ """
+ validationators = {}
+ for attribute in request.PATCH:
+ if attribute not in converters:
+ raise UnknownPATCHRequestError(attribute)
+ if converters[attribute].decoder is None:
+ raise ReadOnlyPATCHRequestError(attribute)
+ validationators[attribute] = converters[attribute]
+ super(PatchValidator, self).__init__(**validationators)