diff options
| author | Jimmy Bergman | 2012-09-24 08:26:50 +0200 |
|---|---|---|
| committer | Jimmy Bergman | 2012-09-24 08:26:50 +0200 |
| commit | a3516893922c74b88f71ce62227adf5a683baa00 (patch) | |
| tree | 838f38fd2a6d571da443a6c0335c17044ff86371 /src | |
| parent | 8271738ba287c4688173ff760118996c1590b84f (diff) | |
| parent | 12b9839a5e7f1e9fda477c5e40ed190e08292da7 (diff) | |
| download | mailman-a3516893922c74b88f71ce62227adf5a683baa00.tar.gz mailman-a3516893922c74b88f71ce62227adf5a683baa00.tar.zst mailman-a3516893922c74b88f71ce62227adf5a683baa00.zip | |
Merge with trunk
Diffstat (limited to 'src')
| -rw-r--r-- | src/mailman/core/errors.py | 22 | ||||
| -rw-r--r-- | src/mailman/docs/MTA.rst | 11 | ||||
| -rw-r--r-- | src/mailman/docs/NEWS.rst | 17 | ||||
| -rw-r--r-- | src/mailman/mta/postfix.py | 60 | ||||
| -rw-r--r-- | src/mailman/rest/addresses.py | 41 | ||||
| -rw-r--r-- | src/mailman/rest/configuration.py | 108 | ||||
| -rw-r--r-- | src/mailman/rest/docs/addresses.rst | 42 | ||||
| -rw-r--r-- | src/mailman/rest/docs/domains.rst | 4 | ||||
| -rw-r--r-- | src/mailman/rest/docs/lists.rst | 52 | ||||
| -rw-r--r-- | src/mailman/rest/docs/users.rst | 101 | ||||
| -rw-r--r-- | src/mailman/rest/helpers.py | 61 | ||||
| -rw-r--r-- | src/mailman/rest/lists.py | 15 | ||||
| -rw-r--r-- | src/mailman/rest/preferences.py | 21 | ||||
| -rw-r--r-- | src/mailman/rest/root.py | 6 | ||||
| -rw-r--r-- | src/mailman/rest/tests/test_addresses.py | 74 | ||||
| -rw-r--r-- | src/mailman/rest/users.py | 60 | ||||
| -rw-r--r-- | src/mailman/rest/validator.py | 50 |
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) |
