diff options
| author | Barry Warsaw | 2011-09-23 15:31:49 -0400 |
|---|---|---|
| committer | Barry Warsaw | 2011-09-23 15:31:49 -0400 |
| commit | 416db276612ac6338524ce350e3b87216ffaffb7 (patch) | |
| tree | b29605f97447d5a4daf32f392b9c8f9d41981bd2 /src | |
| parent | e4b34fefabd2ae3f14db83e96076fe741aa7c5b8 (diff) | |
| download | mailman-416db276612ac6338524ce350e3b87216ffaffb7.tar.gz mailman-416db276612ac6338524ce350e3b87216ffaffb7.tar.zst mailman-416db276612ac6338524ce350e3b87216ffaffb7.zip | |
Diffstat (limited to 'src')
| -rw-r--r-- | src/mailman/docs/NEWS.rst | 4 | ||||
| -rw-r--r-- | src/mailman/interfaces/mailinglist.py | 4 | ||||
| -rw-r--r-- | src/mailman/interfaces/member.py | 8 | ||||
| -rw-r--r-- | src/mailman/rest/addresses.py | 13 | ||||
| -rw-r--r-- | src/mailman/rest/docs/preferences.rst | 233 | ||||
| -rw-r--r-- | src/mailman/rest/members.py | 25 | ||||
| -rw-r--r-- | src/mailman/rest/preferences.py | 119 | ||||
| -rw-r--r-- | src/mailman/rest/root.py | 19 | ||||
| -rw-r--r-- | src/mailman/rest/tests/test_membership.py | 11 | ||||
| -rw-r--r-- | src/mailman/rest/tests/test_root.py | 93 | ||||
| -rw-r--r-- | src/mailman/rest/users.py | 13 | ||||
| -rw-r--r-- | src/mailman/rest/validator.py | 11 |
12 files changed, 541 insertions, 12 deletions
diff --git a/src/mailman/docs/NEWS.rst b/src/mailman/docs/NEWS.rst index 3c175d0ed..a81bff574 100644 --- a/src/mailman/docs/NEWS.rst +++ b/src/mailman/docs/NEWS.rst @@ -38,6 +38,10 @@ Architecture REST ---- + * Preferences for addresses, users, and members can be accessed, changed, and + deleted through the REST interface. Hierarchical, combined preferences for + members, and system preferences can be read through the REST interface. + (LP: #821438) * The IMailingList attribute ``host_name`` has been renamed to ``mail_host`` for consistency. This changes the REST API for mailing list resources. (LP: #787599) diff --git a/src/mailman/interfaces/mailinglist.py b/src/mailman/interfaces/mailinglist.py index 9ae779409..c08e257c1 100644 --- a/src/mailman/interfaces/mailinglist.py +++ b/src/mailman/interfaces/mailinglist.py @@ -32,6 +32,8 @@ __all__ = [ from flufl.enum import Enum from zope.interface import Interface, Attribute +from mailman.interfaces.member import MemberRole + class Personalization(Enum): @@ -232,7 +234,7 @@ class IMailingList(Interface): :rtype: Roster """ - def subscribe(subscriber, role): + def subscribe(subscriber, role=MemberRole.member): """Subscribe the given address or user to the mailing list. :param subscriber: The address or user to subscribe to the mailing diff --git a/src/mailman/interfaces/member.py b/src/mailman/interfaces/member.py index 0a8a5cfd5..5073b3059 100644 --- a/src/mailman/interfaces/member.py +++ b/src/mailman/interfaces/member.py @@ -184,7 +184,7 @@ class IMember(Interface): receive_list_copy = Attribute( """Should an explicit recipient receive a list copy? - Unlike going through the preferences, this attribute return the + Unlike going through `preferences`, this attribute returns the preference value based on the following lookup order: 1. The member @@ -196,7 +196,7 @@ class IMember(Interface): receive_own_postings = Attribute( """Should the poster get a list copy of their own messages? - Unlike going through the preferences, this attribute return the + Unlike going through `preferences`, this attribute returns the preference value based on the following lookup order: 1. The member @@ -208,7 +208,7 @@ class IMember(Interface): delivery_mode = Attribute( """The preferred delivery mode. - Unlike going through the preferences, this attribute return the + Unlike going through `preferences`, this attribute returns the preference value based on the following lookup order: 1. The member @@ -220,7 +220,7 @@ class IMember(Interface): delivery_status = Attribute( """The delivery status. - Unlike going through the preferences, this attribute return the + Unlike going through `preferences`, this attribute returns the preference value based on the following lookup order: 1. The member diff --git a/src/mailman/rest/addresses.py b/src/mailman/rest/addresses.py index a97043e9c..5d479d9cb 100644 --- a/src/mailman/rest/addresses.py +++ b/src/mailman/rest/addresses.py @@ -33,6 +33,7 @@ from zope.component import getUtility from mailman.rest.helpers import CollectionMixin, etag, path_to from mailman.rest.members import MemberCollection +from mailman.rest.preferences import Preferences from mailman.interfaces.usermanager import IUserManager @@ -102,6 +103,18 @@ class AnAddress(_AddressBase): return http.not_found() return AddressMemberships(self._address) + @resource.child() + def preferences(self, request, segments): + """/addresses/<email>/preferences""" + if len(segments) != 0: + return http.bad_request() + if self._address is None: + return http.not_found() + child = Preferences( + self._address.preferences, + 'addresses/{0}'.format(self._address.email)) + return child, [] + class UserAddresses(_AddressBase): diff --git a/src/mailman/rest/docs/preferences.rst b/src/mailman/rest/docs/preferences.rst new file mode 100644 index 000000000..e82fd6001 --- /dev/null +++ b/src/mailman/rest/docs/preferences.rst @@ -0,0 +1,233 @@ +=========== +Preferences +=========== + +Addresses have preferences. +:: + + >>> from mailman.interfaces.usermanager import IUserManager + >>> from zope.component import getUtility + >>> user_manager = getUtility(IUserManager) + + >>> anne = user_manager.create_address('anne@example.com') + >>> transaction.commit() + +Although to start with, an address has no preferences. + + >>> dump_json('http://localhost:9001/3.0/addresses/anne@example.com' + ... '/preferences') + http_etag: "..." + self_link: http://localhost:9001/3.0/addresses/anne@example.com/preferences + +Once the address is given some preferences, they are available through the +REST API. + + >>> anne.preferences.acknowledge_posts = True + >>> transaction.commit() + >>> dump_json('http://localhost:9001/3.0/addresses/anne@example.com' + ... '/preferences') + acknowledge_posts: True + http_etag: "..." + self_link: http://localhost:9001/3.0/addresses/anne@example.com/preferences + +Similarly, users have their own set of preferences, which also start out empty. +:: + + >>> bart = user_manager.create_user('bart@example.com') + >>> transaction.commit() + + >>> dump_json('http://localhost:9001/3.0/addresses/bart@example.com' + ... '/preferences') + http_etag: "..." + self_link: http://localhost:9001/3.0/addresses/bart@example.com/preferences + +Setting a preference on the user's address does not set them on the user. +:: + + >>> list(bart.addresses)[0].preferences.acknowledge_posts = True + >>> transaction.commit() + + >>> dump_json('http://localhost:9001/3.0/addresses/bart@example.com' + ... '/preferences') + acknowledge_posts: True + http_etag: "..." + self_link: http://localhost:9001/3.0/addresses/bart@example.com/preferences + + >>> dump_json('http://localhost:9001/3.0/users/1/preferences') + http_etag: "..." + self_link: http://localhost:9001/3.0/users/1/preferences + +Users have their own set of preferences. + + >>> bart.preferences.receive_own_postings = False + >>> transaction.commit() + >>> dump_json('http://localhost:9001/3.0/users/1/preferences') + http_etag: "..." + receive_own_postings: False + self_link: http://localhost:9001/3.0/users/1/preferences + +Similarly, members have their own separate set of preferences, and just like +the above, setting a preference on the member's address or user does not set +the preference on the member. +:: + + >>> from mailman.interfaces.member import MemberRole + >>> mlist = create_list('test@example.com') + >>> bart_member = mlist.subscribe(list(bart.addresses)[0]) + >>> bart_member.preferences.receive_list_copy = False + >>> transaction.commit() + >>> dump_json('http://localhost:9001/3.0/members/1/preferences') + http_etag: "..." + receive_list_copy: False + self_link: http://localhost:9001/3.0/members/1/preferences + + +Changing preferences +==================== + +Preferences for the address, user, or member can be changed through the API. +You can change all the preferences for a particular object by using an HTTP +PUT operation. +:: + + >>> dump_json('http://localhost:9001/3.0/addresses/bart@example.com' + ... '/preferences', { + ... 'acknowledge_posts': True, + ... 'delivery_mode': 'plaintext_digests', + ... 'delivery_status': 'by_user', + ... 'preferred_language': 'ja', + ... 'receive_list_copy': True, + ... 'receive_own_postings': False, + ... }, method='PUT') + content-length: 0 + date: ... + server: ... + status: 204 + + >>> dump_json('http://localhost:9001/3.0/addresses/bart@example.com' + ... '/preferences') + acknowledge_posts: True + delivery_mode: plaintext_digests + delivery_status: by_user + http_etag: "..." + preferred_language: ja + receive_list_copy: True + receive_own_postings: False + self_link: http://localhost:9001/3.0/addresses/bart@example.com/preferences + +You can also update just a few of the attributes using PATCH. +:: + + >>> dump_json('http://localhost:9001/3.0/addresses/bart@example.com' + ... '/preferences', { + ... 'delivery_mode': 'plaintext_digests', + ... 'receive_list_copy': False, + ... }, method='PATCH') + content-length: 0 + date: ... + server: ... + status: 204 + + >>> dump_json('http://localhost:9001/3.0/addresses/bart@example.com' + ... '/preferences') + acknowledge_posts: True + delivery_mode: plaintext_digests + delivery_status: by_user + http_etag: "..." + preferred_language: ja + receive_list_copy: False + receive_own_postings: False + self_link: http://localhost:9001/3.0/addresses/bart@example.com/preferences + + +Deleting preferences +==================== + +Preferences for any of the levels, member, user, or address, can be entirely +deleted. +:: + + >>> dump_json('http://localhost:9001/3.0/addresses/anne@example.com' + ... '/preferences', { + ... 'preferred_language': 'ja', + ... }, method='PATCH') + content-length: 0 + date: ... + server: ... + status: 204 + + >>> dump_json('http://localhost:9001/3.0/addresses/anne@example.com' + ... '/preferences') + acknowledge_posts: True + http_etag: "5219245d1eea98bc107032013af20ef91bfb5c51" + preferred_language: ja + self_link: http://localhost:9001/3.0/addresses/anne@example.com/preferences + + >>> dump_json('http://localhost:9001/3.0/addresses/anne@example.com' + ... '/preferences', method='DELETE') + content-length: 0 + date: ... + server: ... + status: 204 + + >>> dump_json('http://localhost:9001/3.0/addresses/anne@example.com' + ... '/preferences') + http_etag: "..." + self_link: http://localhost:9001/3.0/addresses/anne@example.com/preferences + + +Combined member preferences +=========================== + +The member resource provides a way to access the set of preference in effect +for a specific subscription. This stacks the preferences, so that a value is +always available. The preference value is looked up first on the member, +falling back to the address, then user, then system preference. + +Preferences accessed through this interface are always read only. + + >>> dump_json('http://localhost:9001/3.0/members/1/all/preferences') + acknowledge_posts: True + delivery_mode: plaintext_digests + delivery_status: by_user + http_etag: "..." + preferred_language: ja + receive_list_copy: False + receive_own_postings: False + self_link: http://localhost:9001/3.0/members/1/all/preferences + +These preferences cannot be changed. + + >>> dump_json('http://localhost:9001/3.0/members/1/all/preferences', { + ... 'delivery_status': 'enabled', + ... }, method='PATCH') + Traceback (most recent call last): + ... + HTTPError: HTTP Error 405: 405 Method Not Allowed + + +System preferences +================== + +The Mailman system itself has a default set of preference. All preference +lookups fall back to these values, which are read-only. + + >>> dump_json('http://localhost:9001/3.0/system/preferences') + acknowledge_posts: False + delivery_mode: regular + delivery_status: enabled + hide_address: True + http_etag: "..." + preferred_language: en + receive_list_copy: True + receive_own_postings: True + self_link: http://localhost:9001/3.0/system/preferences + +These preferences cannot be changed. + + >>> dump_json('http://localhost:9001/3.0/system/preferences', { + ... 'delivery_status': 'enabled', + ... }, method='PATCH') + Traceback (most recent call last): + ... + HTTPError: HTTP Error 405: 405 Method Not Allowed diff --git a/src/mailman/rest/members.py b/src/mailman/rest/members.py index f15a2204a..bf739ed32 100644 --- a/src/mailman/rest/members.py +++ b/src/mailman/rest/members.py @@ -44,6 +44,7 @@ from mailman.interfaces.user import UnverifiedAddressError from mailman.interfaces.usermanager import IUserManager from mailman.rest.helpers import ( CollectionMixin, PATCH, etag, no_content, path_to) +from mailman.rest.preferences import Preferences, ReadOnlyPreferences from mailman.rest.validator import ( Validator, enum_validator, subscriber_validator) @@ -115,6 +116,30 @@ class AMember(_MemberBase): return http.not_found() return http.ok([], self._resource_as_json(self._member)) + @resource.child() + def preferences(self, request, segments): + """/members/<id>/preferences""" + if len(segments) != 0: + return http.bad_request() + if self._member is None: + return http.not_found() + child = Preferences( + self._member.preferences, + 'members/{0}'.format(self._member.member_id.int)) + return child, [] + + @resource.child() + def all(self, request, segments): + """/members/<id>/all/preferences""" + if len(segments) == 0: + return http.not_found() + if self._member is None: + return http.not_found() + child = ReadOnlyPreferences( + self._member, + 'members/{0}/all'.format(self._member.member_id.int)) + return child, [] + @resource.DELETE() def delete(self, request): """Delete the member (i.e. unsubscribe).""" diff --git a/src/mailman/rest/preferences.py b/src/mailman/rest/preferences.py new file mode 100644 index 000000000..d231d1055 --- /dev/null +++ b/src/mailman/rest/preferences.py @@ -0,0 +1,119 @@ +# Copyright (C) 2011 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/>. + +"""Preferences.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'ReadOnlyPreferences', + 'Preferences', + ] + + +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.validator import ( + Validator, enum_validator, language_validator) + + +PREFERENCES = ( + 'acknowledge_posts', + 'delivery_mode', + 'delivery_status', + 'hide_address', + 'preferred_language', + 'receive_list_copy', + 'receive_own_postings', + ) + + + +class ReadOnlyPreferences(resource.Resource): + """.../<object>/preferences""" + + def __init__(self, parent, base_url): + self._parent = parent + self._base_url = base_url + + @resource.GET() + def preferences(self, segments): + resource = dict() + for attr in PREFERENCES: + # Handle this one specially. + if attr == 'preferred_language': + continue + value = getattr(self._parent, attr, None) + if value is not None: + resource[attr] = value + # Add the preferred language, if it's not missing. + preferred_language = self._parent.preferred_language + if preferred_language is not None: + resource['preferred_language'] = preferred_language.code + # Add the self link. + resource['self_link'] = path_to( + '{0}/preferences'.format(self._base_url)) + return http.ok([], etag(resource)) + + + +class Preferences(ReadOnlyPreferences): + """Preferences which can be changed.""" + + def patch_put(self, request, is_optional): + 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, + ) + if is_optional: + # For a PUT, all attributes are optional. + kws['_optional'] = kws.keys() + try: + values = Validator(**kws)(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() + def patch_preferences(self, request): + """Patch the preferences.""" + return self.patch_put(request, is_optional=True) + + @resource.PUT() + def put_preferences(self, request): + """Change all preferences.""" + return self.patch_put(request, is_optional=False) + + @resource.DELETE() + def delete_preferences(self, request): + """Delete all preferences.""" + for attr in PREFERENCES: + if hasattr(self._parent, attr): + setattr(self._parent, attr, None) + return no_content() diff --git a/src/mailman/rest/root.py b/src/mailman/rest/root.py index dc9717de4..9ba6ad20d 100644 --- a/src/mailman/rest/root.py +++ b/src/mailman/rest/root.py @@ -29,12 +29,14 @@ from base64 import b64decode from restish import guard, http, resource from mailman.config import config +from mailman.core.constants import system_preferences from mailman.core.system import system from mailman.rest.addresses import AllAddresses, AnAddress from mailman.rest.domains import ADomain, AllDomains from mailman.rest.helpers import etag, path_to from mailman.rest.lists import AList, AllLists from mailman.rest.members import AMember, AllMembers, FindMembers +from mailman.rest.preferences import ReadOnlyPreferences from mailman.rest.users import AUser, AllUsers @@ -73,11 +75,18 @@ class TopLevel(resource.Resource): @resource.child() def system(self, request, segments): """/<api>/system""" - resource = dict( - mailman_version=system.mailman_version, - python_version=system.python_version, - self_link=path_to('system'), - ) + if len(segments) == 0: + resource = dict( + mailman_version=system.mailman_version, + python_version=system.python_version, + self_link=path_to('system'), + ) + elif len(segments) > 1: + return http.bad_request() + elif segments[0] == 'preferences': + return ReadOnlyPreferences(system_preferences, 'system'), [] + else: + return http.bad_request() return http.ok([], etag(resource)) @resource.child() diff --git a/src/mailman/rest/tests/test_membership.py b/src/mailman/rest/tests/test_membership.py index 2d2351fca..ea3357506 100644 --- a/src/mailman/rest/tests/test_membership.py +++ b/src/mailman/rest/tests/test_membership.py @@ -229,6 +229,17 @@ class TestMembership(unittest.TestCase): else: raise AssertionError('Expected HTTPError') + def test_member_all_without_preferences(self): + # /members/<id>/all should return a 404 when it isn't trailed by + # `preferences` + try: + # For Python 2.6 + call_api('http://localhost:9001/3.0/members/1/all') + except HTTPError as exc: + self.assertEqual(exc.code, 404) + else: + raise AssertionError('Expected HTTPError') + def test_suite(): diff --git a/src/mailman/rest/tests/test_root.py b/src/mailman/rest/tests/test_root.py new file mode 100644 index 000000000..856767947 --- /dev/null +++ b/src/mailman/rest/tests/test_root.py @@ -0,0 +1,93 @@ +# Copyright (C) 2011 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/>. + +"""REST root object tests.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'test_suite', + ] + + +import unittest + +from urllib2 import HTTPError + +from mailman.testing.helpers import call_api +from mailman.testing.layers import RESTLayer + + + +class TestSystem(unittest.TestCase): + layer = RESTLayer + + def test_system_url_too_long(self): + # /system/foo/bar is not allowed. + try: + # For Python 2.6. + call_api('http://localhost:9001/3.0/system/foo/bar') + except HTTPError as exc: + self.assertEqual(exc.code, 400) + else: + raise AssertionError('Expected HTTPError') + + def test_system_url_not_preferences(self): + # /system/foo where `foo` is not `preferences`. + try: + # For Python 2.6. + call_api('http://localhost:9001/3.0/system/foo') + except HTTPError as exc: + self.assertEqual(exc.code, 400) + else: + raise AssertionError('Expected HTTPError') + + def test_system_preferences_are_read_only(self): + # /system/preferences are read-only. + try: + # For Python 2.6. + call_api('http://localhost:9001/3.0/system/preferences', { + 'acknowledge_posts': True, + }, method='PATCH') + except HTTPError as exc: + self.assertEqual(exc.code, 405) + else: + raise AssertionError('Expected HTTPError') + # /system/preferences are read-only. + try: + # For Python 2.6. + call_api('http://localhost:9001/3.0/system/preferences', { + 'acknowledge_posts': False, + 'delivery_mode': 'regular', + 'delivery_status': 'enabled', + 'hide_address': True, + 'preferred_language': 'en', + 'receive_list_copy': True, + 'receive_own_postings': True, + }, method='PUT') + except HTTPError as exc: + self.assertEqual(exc.code, 405) + else: + raise AssertionError('Expected HTTPError') + + + +def test_suite(): + suite = unittest.TestSuite() + suite.addTest(unittest.makeSuite(TestSystem)) + return suite diff --git a/src/mailman/rest/users.py b/src/mailman/rest/users.py index 3fdf5d7e8..857d29471 100644 --- a/src/mailman/rest/users.py +++ b/src/mailman/rest/users.py @@ -34,6 +34,7 @@ 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.preferences import Preferences from mailman.rest.validator import Validator from mailman.utilities.passwords import ( encrypt_password, make_user_friendly_password) @@ -152,3 +153,15 @@ class AUser(_UserBase): return http.not_found() getUtility(IUserManager).delete_user(self._user) return no_content() + + @resource.child() + def preferences(self, request, segments): + """/addresses/<email>/preferences""" + if len(segments) != 0: + return http.bad_request() + if self._user is None: + return http.not_found() + child = Preferences( + self._user.preferences, + 'users/{0}'.format(self._user.user_id.int)) + return child, [] diff --git a/src/mailman/rest/validator.py b/src/mailman/rest/validator.py index cf32ca838..60d8a22d6 100644 --- a/src/mailman/rest/validator.py +++ b/src/mailman/rest/validator.py @@ -23,11 +23,15 @@ __metaclass__ = type __all__ = [ 'Validator', 'enum_validator', + 'language_validator', 'subscriber_validator', ] from uuid import UUID +from zope.component import getUtility + +from mailman.interfaces.languages import ILanguageManager COMMASPACE = ', ' @@ -46,8 +50,6 @@ class enum_validator: return self._enum_class[enum_value] - - def subscriber_validator(subscriber): """Convert an email-or-int to an email-or-UUID.""" try: @@ -56,6 +58,11 @@ def subscriber_validator(subscriber): return subscriber +def language_validator(code): + """Convert a language code to a Language object.""" + return getUtility(ILanguageManager)[code] + + class Validator: """A validator of parameter input.""" |
