diff options
| author | Barry Warsaw | 2011-04-25 22:23:05 -0400 |
|---|---|---|
| committer | Barry Warsaw | 2011-04-25 22:23:05 -0400 |
| commit | 3f705f533191e4dd50a615c5692b00905b178e0a (patch) | |
| tree | 2d38df244ab4e809bbbcb715684731f3e71a0e55 | |
| parent | 3fb495013e82e75ed3ba0fd9675eec1bfdd3df66 (diff) | |
| download | mailman-3f705f533191e4dd50a615c5692b00905b178e0a.tar.gz mailman-3f705f533191e4dd50a615c5692b00905b178e0a.tar.zst mailman-3f705f533191e4dd50a615c5692b00905b178e0a.zip | |
Complete the ability to change a subscription address, both internally and via
the REST API. (LP: #643949)
* New resource path in REST API: /addresses/<email>/memberships gets all the
memberships for a given email address.
* In the REST API, PUTting or PATCHing a list configuration now returns a 204
(No Content) success code instead of a 200 success code with an empty body.
* When a user is subscribed with their preferred address, changing the
preferred address also changes all subscriptions.
* When a user is subscribed with a specific address, their subscription can be
changed to any verified address they control.
* Use a new naming scheme for doctests with multiple mailing lists.
| -rw-r--r-- | src/mailman/model/docs/membership.txt | 71 | ||||
| -rw-r--r-- | src/mailman/model/member.py | 18 | ||||
| -rw-r--r-- | src/mailman/model/tests/test_member.py | 79 | ||||
| -rw-r--r-- | src/mailman/rest/addresses.py | 33 | ||||
| -rw-r--r-- | src/mailman/rest/configuration.py | 6 | ||||
| -rw-r--r-- | src/mailman/rest/docs/addresses.txt | 100 | ||||
| -rw-r--r-- | src/mailman/rest/docs/configuration.txt | 6 | ||||
| -rw-r--r-- | src/mailman/rest/docs/membership.txt | 208 | ||||
| -rw-r--r-- | src/mailman/rest/lists.py | 22 | ||||
| -rw-r--r-- | src/mailman/rest/members.py | 51 | ||||
| -rw-r--r-- | src/mailman/rest/root.py | 5 | ||||
| -rw-r--r-- | src/mailman/rest/tests/test_membership.py | 4 |
12 files changed, 514 insertions, 89 deletions
diff --git a/src/mailman/model/docs/membership.txt b/src/mailman/model/docs/membership.txt index 8a3f0da16..8435e8097 100644 --- a/src/mailman/model/docs/membership.txt +++ b/src/mailman/model/docs/membership.txt @@ -18,7 +18,7 @@ store mailing list data in a different database than user data. When we create a mailing list, it starts out with no members, owners, moderators, administrators, or nonmembers. - >>> mlist = create_list('test@example.com') + >>> mlist = create_list('ant@example.com') >>> dump_list(mlist.members.members) *Empty* >>> dump_list(mlist.owners.members) @@ -53,7 +53,7 @@ her. >>> address_1 = list(user_1.addresses)[0] >>> mlist.subscribe(address_1, MemberRole.owner) <Member: Anne Person <aperson@example.com> on - test@example.com as MemberRole.owner> + ant@example.com as MemberRole.owner> >>> dump_list(member.address for member in mlist.owners.members) Anne Person <aperson@example.com> @@ -75,7 +75,7 @@ Bart becomes a moderator of the list. >>> address_2 = list(user_2.addresses)[0] >>> mlist.subscribe(address_2, MemberRole.moderator) <Member: Bart Person <bperson@example.com> - on test@example.com as MemberRole.moderator> + on ant@example.com as MemberRole.moderator> >>> dump_list(member.address for member in mlist.moderators.members) Bart Person <bperson@example.com> @@ -105,7 +105,7 @@ role. >>> member = mlist.subscribe(address_3, MemberRole.member) >>> member <Member: Cris Person <cperson@example.com> - on test@example.com as MemberRole.member> + on ant@example.com as MemberRole.member> Cris's user record can also be retrieved from her member record. @@ -129,9 +129,9 @@ It's easy to make the list administrators members of the mailing list too. ... members.append(member) >>> dump_list(members, key=attrgetter('address.email')) <Member: Anne Person <aperson@example.com> on - test@example.com as MemberRole.member> + ant@example.com as MemberRole.member> <Member: Bart Person <bperson@example.com> on - test@example.com as MemberRole.member> + ant@example.com as MemberRole.member> >>> dump_members(mlist.members.members) Anne Person <aperson@example.com> Bart Person <bperson@example.com> @@ -159,7 +159,7 @@ role. >>> address_6 = list(user_6.addresses)[0] >>> member_6 = mlist.subscribe(address_6, MemberRole.nonmember) >>> member_6 - <Member: Fred Person <fperson@example.com> on test@example.com + <Member: Fred Person <fperson@example.com> on ant@example.com as MemberRole.nonmember> >>> dump_members(mlist.nonmembers.members) Fred Person <fperson@example.com> @@ -186,16 +186,16 @@ text email address by using the ``IRoster.get_member()`` method. >>> mlist.owners.get_member('aperson@example.com') <Member: Anne Person <aperson@example.com> on - test@example.com as MemberRole.owner> + ant@example.com as MemberRole.owner> >>> mlist.administrators.get_member('aperson@example.com') <Member: Anne Person <aperson@example.com> on - test@example.com as MemberRole.owner> + ant@example.com as MemberRole.owner> >>> mlist.members.get_member('aperson@example.com') <Member: Anne Person <aperson@example.com> on - test@example.com as MemberRole.member> + ant@example.com as MemberRole.member> >>> mlist.nonmembers.get_member('fperson@example.com') <Member: Fred Person <fperson@example.com> on - test@example.com as MemberRole.nonmember> + ant@example.com as MemberRole.nonmember> However, if the address is not subscribed with the appropriate role, then None is returned. @@ -237,7 +237,7 @@ It is an error to subscribe someone to a list with the same role twice. Traceback (most recent call last): ... AlreadySubscribedError: aperson@example.com is already a MemberRole.owner - of mailing list test@example.com + of mailing list ant@example.com Moderation actions @@ -268,3 +268,50 @@ Postings by nonmembers are held for moderator approval by default. >>> for member in mlist.nonmembers.members: ... print member.address.email, member.role, member.moderation_action fperson@example.com MemberRole.nonmember Action.hold + + +Changing subscriptions +====================== + +When a user is subscribed to a mailing list via a specific address they +control (as opposed to being subscribed with their preferred address), they +can change their delivery address by setting the appropriate parameter. Note +though that the address their changing to must be verified. + + >>> bee = create_list('bee@example.com') + >>> gwen = user_manager.create_user('gwen@example.com') + >>> gwen_address = list(gwen.addresses)[0] + >>> gwen_member = bee.subscribe(gwen_address) + >>> for member in bee.members.members: + ... print member.member_id, member.mailing_list, member.address.email + 7 bee@example.com gwen@example.com + +Gwen gets a email address. + + >>> new_address = gwen.register('gperson@example.com') + +She wants to change her membership in the `test` mailing list to use her new +address, but the address is not yet verified. + + >>> gwen_member.address = new_address + Traceback (most recent call last): + ... + UnverifiedAddressError: gperson@example.com + +Her membership has not changed. + + >>> for member in bee.members.members: + ... print member.member_id, member.mailing_list, member.address.email + 7 bee@example.com gwen@example.com + +Gwen verifies her email address, and updates her membership. + + >>> from mailman.utilities.datetime import now + >>> new_address.verified_on = now() + >>> gwen_member.address = new_address + +Now her membership reflects the new address. + + >>> for member in bee.members.members: + ... print member.member_id, member.mailing_list, member.address.email + 7 bee@example.com gperson@example.com diff --git a/src/mailman/model/member.py b/src/mailman/model/member.py index 9f3c1a58a..410f037dc 100644 --- a/src/mailman/model/member.py +++ b/src/mailman/model/member.py @@ -35,8 +35,8 @@ from mailman.database.types import Enum from mailman.interfaces.action import Action from mailman.interfaces.address import IAddress from mailman.interfaces.listmanager import IListManager -from mailman.interfaces.member import IMember, MemberRole -from mailman.interfaces.user import IUser +from mailman.interfaces.member import IMember, MemberRole, MembershipError +from mailman.interfaces.user import IUser, UnverifiedAddressError from mailman.interfaces.usermanager import IUserManager from mailman.utilities.uid import UniqueIDFactory @@ -102,6 +102,20 @@ class Member(Model): if self._address is None else self._address) + @address.setter + def address(self, new_address): + """See `IMember`.""" + if self._address is None: + # XXX Either we need a better exception here, or we should allow + # changing a subscription from preferred address to explicit + # address (and vice versa via del'ing the .address attribute. + raise MembershipError('Membership is via preferred address') + if new_address.verified_on is None: + # A member cannot change their subscription address to an + # unverified address. + raise UnverifiedAddressError(new_address) + self._address = new_address + @property def user(self): """See `IMember`.""" diff --git a/src/mailman/model/tests/test_member.py b/src/mailman/model/tests/test_member.py new file mode 100644 index 000000000..b99ff4911 --- /dev/null +++ b/src/mailman/model/tests/test_member.py @@ -0,0 +1,79 @@ +# 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/>. + +"""Test members.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'test_suite', + ] + + +import unittest + +from mailman.app.lifecycle import create_list +from mailman.interfaces.member import MembershipError +from mailman.interfaces.user import UnverifiedAddressError +from mailman.interfaces.usermanager import IUserManager +from mailman.testing.layers import ConfigLayer +from mailman.utilities.datetime import now + +from zope.component import getUtility + + + +class TestMember(unittest.TestCase): + layer = ConfigLayer + + def setUp(self): + self._mlist = create_list('test@example.com') + self._usermanager = getUtility(IUserManager) + + def test_cannot_set_address_with_preferred_address_subscription(self): + # A user is subscribed to a mailing list with their preferred address. + # You cannot set the `address` attribute on such IMembers. + anne = self._usermanager.create_user('anne@example.com') + preferred = list(anne.addresses)[0] + preferred.verified_on = now() + anne.preferred_address = preferred + # Subscribe with the IUser object, not the address. This makes Anne a + # member via her preferred address. + member = self._mlist.subscribe(anne) + new_address = anne.register('aperson@example.com') + new_address.verified_on = now() + self.assertRaises(MembershipError, + setattr, member, 'address', new_address) + + def test_cannot_change_to_unverified_address(self): + # A user is subscribed to a mailing list with an explicit address. + # You cannot set the `address` attribute to an unverified address. + anne = self._usermanager.create_user('anne@example.com') + address = list(anne.addresses)[0] + member = self._mlist.subscribe(address) + new_address = anne.register('aperson@example.com') + # The new address is not verified. + self.assertRaises(UnverifiedAddressError, + setattr, member, 'address', new_address) + + + +def test_suite(): + suite = unittest.TestSuite() + suite.addTest(unittest.makeSuite(TestMember)) + return suite diff --git a/src/mailman/rest/addresses.py b/src/mailman/rest/addresses.py index d7fae3b9b..f77dc0a05 100644 --- a/src/mailman/rest/addresses.py +++ b/src/mailman/rest/addresses.py @@ -32,6 +32,7 @@ from restish import http, resource from zope.component import getUtility from mailman.rest.helpers import CollectionMixin, etag, path_to +from mailman.rest.members import MemberCollection from mailman.interfaces.usermanager import IUserManager @@ -92,6 +93,13 @@ class AnAddress(_AddressBase): return http.not_found() return http.ok([], self._resource_as_json(self._address)) + @resource.child() + def memberships(self, request, segments): + """/addresses/<email>/memberships""" + if len(segments) != 0: + return http.bad_request() + return AddressMemberships(self._address) + class UserAddresses(_AddressBase): @@ -111,3 +119,28 @@ class UserAddresses(_AddressBase): """/addresses""" resource = self._make_collection(request) return http.ok([], etag(resource)) + + + +class AddressMemberships(MemberCollection): + """All the memberships of a particular email address.""" + + def __init__(self, address): + super(AddressMemberships, self).__init__() + self._address = address + + def _get_collection(self, request): + """See `CollectionMixin`.""" + # XXX Improve this by implementing a .memberships attribute on + # IAddress, similar to the way IUser does it. + # + # Start by getting the IUser that controls this address. For now, if + # the address is not controlled by a user, return the empty set. + # Later when we address the XXX comment, it will return some + # memberships. But really, it should not be legal to subscribe an + # address to a mailing list that isn't controlled by a user -- maybe! + user = getUtility(IUserManager).get_user(self._address.email) + if user is None: + return [] + return [member for member in user.memberships.members + if member.address == self._address] diff --git a/src/mailman/rest/configuration.py b/src/mailman/rest/configuration.py index c1b798e7d..ea180a724 100644 --- a/src/mailman/rest/configuration.py +++ b/src/mailman/rest/configuration.py @@ -32,7 +32,7 @@ from mailman.config import config from mailman.interfaces.action import Action from mailman.interfaces.autorespond import ResponseAction from mailman.interfaces.mailinglist import IAcceptableAliasSet, ReplyToMunging -from mailman.rest.helpers import PATCH, etag +from mailman.rest.helpers import PATCH, etag, no_content from mailman.rest.validator import Validator, enum_validator @@ -274,7 +274,7 @@ class ListConfiguration(resource.Resource): self._set_writable_attributes(validator, request) except ValueError as error: return http.bad_request([], str(error)) - return http.ok([], '') + return no_content() @PATCH() def patch_configuration(self, request): @@ -295,4 +295,4 @@ class ListConfiguration(resource.Resource): self._set_writable_attributes(validator, request) except ValueError as error: return http.bad_request([], str(error)) - return http.ok([], '') + return no_content() diff --git a/src/mailman/rest/docs/addresses.txt b/src/mailman/rest/docs/addresses.txt index dbd1ecf86..d82396e6b 100644 --- a/src/mailman/rest/docs/addresses.txt +++ b/src/mailman/rest/docs/addresses.txt @@ -132,3 +132,103 @@ addresses live in the /addresses namespace. real_name: Dave Person registered_on: 2005-08-01T07:49:23 self_link: http://localhost:9001/3.0/addresses/dave@example.com + + +Memberships +=========== + +Addresses can be subscribed to mailing lists. When they are, all the +membership records for that address are easily accessible via the REST API. + +Elle registers several email addresses. + + >>> elle = user_manager.create_user('elle@example.com', 'Elle Person') + >>> subscriber = list(elle.addresses)[0] + >>> elle.register('eperson@example.com') + <Address: eperson@example.com [not verified] at ...> + >>> elle.register('elle.person@example.com') + <Address: elle.person@example.com [not verified] at ...> + +Elle subscribes to two mailing lists with one of her addresses. +:: + + >>> ant = create_list('ant@example.com') + >>> bee = create_list('bee@example.com') + >>> ant.subscribe(subscriber) + <Member: Elle Person <elle@example.com> on ant@example.com + as MemberRole.member> + >>> bee.subscribe(subscriber) + <Member: Elle Person <elle@example.com> on bee@example.com + as MemberRole.member> + >>> transaction.commit() + +Elle can get her memberships for each of her email addresses. +:: + + >>> dump_json('http://localhost:9001/3.0/addresses/' + ... 'elle@example.com/memberships') + entry 0: + address: elle@example.com + fqdn_listname: ant@example.com + http_etag: "..." + role: member + self_link: http://localhost:9001/3.0/members/1 + user: http://localhost:9001/3.0/users/2 + entry 1: + address: elle@example.com + fqdn_listname: bee@example.com + http_etag: "..." + role: member + self_link: http://localhost:9001/3.0/members/2 + user: http://localhost:9001/3.0/users/2 + http_etag: "..." + start: 0 + total_size: 2 + + >>> dump_json('http://localhost:9001/3.0/addresses/' + ... 'eperson@example.com/memberships') + http_etag: "..." + start: 0 + total_size: 0 + +When Elle subscribes to the `bee` list again with a different address, this +does not show up in the list of memberships for his other address. +:: + + >>> subscriber = user_manager.get_address('eperson@example.com') + >>> bee.subscribe(subscriber) + <Member: eperson@example.com on bee@example.com as MemberRole.member> + >>> transaction.commit() + + >>> dump_json('http://localhost:9001/3.0/addresses/' + ... 'elle@example.com/memberships') + entry 0: + address: elle@example.com + fqdn_listname: ant@example.com + http_etag: "..." + role: member + self_link: http://localhost:9001/3.0/members/1 + user: http://localhost:9001/3.0/users/2 + entry 1: + address: elle@example.com + fqdn_listname: bee@example.com + http_etag: "..." + role: member + self_link: http://localhost:9001/3.0/members/2 + user: http://localhost:9001/3.0/users/2 + http_etag: "..." + start: 0 + total_size: 2 + + >>> dump_json('http://localhost:9001/3.0/addresses/' + ... 'eperson@example.com/memberships') + entry 0: + address: eperson@example.com + fqdn_listname: bee@example.com + http_etag: "..." + role: member + self_link: http://localhost:9001/3.0/members/3 + user: http://localhost:9001/3.0/users/2 + http_etag: "..." + start: 0 + total_size: 1 diff --git a/src/mailman/rest/docs/configuration.txt b/src/mailman/rest/docs/configuration.txt index b149a9431..1c18434d7 100644 --- a/src/mailman/rest/docs/configuration.txt +++ b/src/mailman/rest/docs/configuration.txt @@ -109,7 +109,7 @@ all the writable attributes in one request. content-length: 0 date: ... server: WSGIServer/... - status: 200 + status: 204 These values are changed permanently. @@ -335,7 +335,7 @@ Using ``PATCH``, you can change just one attribute. content-length: 0 date: ... server: ... - status: 200 + status: 204 These values are changed permanently. @@ -378,7 +378,7 @@ dictionary are ignored. content-length: 0 date: ... server: WSGIServer/... - status: 200 + status: 204 Aliases are returned as a list on the ``aliases`` key. diff --git a/src/mailman/rest/docs/membership.txt b/src/mailman/rest/docs/membership.txt index 493772492..80b5a563d 100644 --- a/src/mailman/rest/docs/membership.txt +++ b/src/mailman/rest/docs/membership.txt @@ -16,7 +16,7 @@ There are no mailing lists and no members yet. We create a mailing list, which starts out with no members. :: - >>> mlist_one = create_list('test-one@example.com') + >>> bee = create_list('bee@example.com') >>> transaction.commit() >>> dump_json('http://localhost:9001/3.0/members') @@ -38,11 +38,11 @@ the REST interface. >>> user_manager = getUtility(IUserManager) >>> from mailman.testing.helpers import subscribe - >>> subscribe(mlist_one, 'Bart') + >>> subscribe(bee, 'Bart') >>> dump_json('http://localhost:9001/3.0/members') entry 0: address: bperson@example.com - fqdn_listname: test-one@example.com + fqdn_listname: bee@example.com http_etag: ... role: member self_link: http://localhost:9001/3.0/members/1 @@ -55,7 +55,7 @@ Bart's specific membership can be accessed directly: >>> dump_json('http://localhost:9001/3.0/members/1') address: bperson@example.com - fqdn_listname: test-one@example.com + fqdn_listname: bee@example.com http_etag: ... role: member self_link: http://localhost:9001/3.0/members/1 @@ -64,18 +64,18 @@ Bart's specific membership can be accessed directly: When Cris also joins the mailing list, her subscription is also available via the REST interface. - >>> subscribe(mlist_one, 'Cris') + >>> subscribe(bee, 'Cris') >>> dump_json('http://localhost:9001/3.0/members') entry 0: address: bperson@example.com - fqdn_listname: test-one@example.com + fqdn_listname: bee@example.com http_etag: ... role: member self_link: http://localhost:9001/3.0/members/1 user: http://localhost:9001/3.0/users/1 entry 1: address: cperson@example.com - fqdn_listname: test-one@example.com + fqdn_listname: bee@example.com http_etag: ... role: member self_link: http://localhost:9001/3.0/members/2 @@ -88,26 +88,26 @@ The subscribed members are returned in alphabetical order, so when Anna subscribes, she is returned first. :: - >>> subscribe(mlist_one, 'Anna') + >>> subscribe(bee, 'Anna') >>> dump_json('http://localhost:9001/3.0/members') entry 0: address: aperson@example.com - fqdn_listname: test-one@example.com + fqdn_listname: bee@example.com http_etag: ... role: member self_link: http://localhost:9001/3.0/members/3 user: http://localhost:9001/3.0/users/3 entry 1: address: bperson@example.com - fqdn_listname: test-one@example.com + fqdn_listname: bee@example.com http_etag: ... role: member self_link: http://localhost:9001/3.0/members/1 user: http://localhost:9001/3.0/users/1 entry 2: address: cperson@example.com - fqdn_listname: test-one@example.com + fqdn_listname: bee@example.com http_etag: ... role: member self_link: http://localhost:9001/3.0/members/2 @@ -120,44 +120,44 @@ Subscriptions are also returned alphabetically by mailing list posting address. Anna and Cris subscribe to this new mailing list. :: - >>> mlist_two = create_list('alpha@example.com') - >>> subscribe(mlist_two, 'Anna') - >>> subscribe(mlist_two, 'Cris') + >>> ant = create_list('ant@example.com') + >>> subscribe(ant, 'Anna') + >>> subscribe(ant, 'Cris') User ids are different than member ids. >>> dump_json('http://localhost:9001/3.0/members') entry 0: address: aperson@example.com - fqdn_listname: alpha@example.com + fqdn_listname: ant@example.com http_etag: ... role: member self_link: http://localhost:9001/3.0/members/4 user: http://localhost:9001/3.0/users/3 entry 1: address: cperson@example.com - fqdn_listname: alpha@example.com + fqdn_listname: ant@example.com http_etag: ... role: member self_link: http://localhost:9001/3.0/members/5 user: http://localhost:9001/3.0/users/2 entry 2: address: aperson@example.com - fqdn_listname: test-one@example.com + fqdn_listname: bee@example.com http_etag: ... role: member self_link: http://localhost:9001/3.0/members/3 user: http://localhost:9001/3.0/users/3 entry 3: address: bperson@example.com - fqdn_listname: test-one@example.com + fqdn_listname: bee@example.com http_etag: ... role: member self_link: http://localhost:9001/3.0/members/1 user: http://localhost:9001/3.0/users/1 entry 4: address: cperson@example.com - fqdn_listname: test-one@example.com + fqdn_listname: bee@example.com http_etag: ... role: member self_link: http://localhost:9001/3.0/members/2 @@ -169,17 +169,17 @@ User ids are different than member ids. We can also get just the members of a single mailing list. >>> dump_json( - ... 'http://localhost:9001/3.0/lists/alpha@example.com/roster/members') + ... 'http://localhost:9001/3.0/lists/ant@example.com/roster/members') entry 0: address: aperson@example.com - fqdn_listname: alpha@example.com + fqdn_listname: ant@example.com http_etag: ... role: member self_link: http://localhost:9001/3.0/members/4 user: http://localhost:9001/3.0/users/3 entry 1: address: cperson@example.com - fqdn_listname: alpha@example.com + fqdn_listname: ant@example.com http_etag: ... role: member self_link: http://localhost:9001/3.0/members/5 @@ -193,59 +193,59 @@ Owners and moderators ===================== Mailing list owners and moderators also show up in the REST API. Cris becomes -an owner of the alpha mailing list and Dave becomes a moderator of the -test-one mailing list. +an owner of the `ant` mailing list and Dave becomes a moderator of the `bee` +mailing list. :: - >>> subscribe(mlist_one, 'Cris', MemberRole.owner) - >>> subscribe(mlist_two, 'Dave', MemberRole.moderator) + >>> subscribe(ant, 'Dave', MemberRole.moderator) + >>> subscribe(bee, 'Cris', MemberRole.owner) >>> dump_json('http://localhost:9001/3.0/members') entry 0: address: dperson@example.com - fqdn_listname: alpha@example.com + fqdn_listname: ant@example.com http_etag: ... role: moderator - self_link: http://localhost:9001/3.0/members/7 + self_link: http://localhost:9001/3.0/members/6 user: http://localhost:9001/3.0/users/4 entry 1: address: aperson@example.com - fqdn_listname: alpha@example.com + fqdn_listname: ant@example.com http_etag: ... role: member self_link: http://localhost:9001/3.0/members/4 user: http://localhost:9001/3.0/users/3 entry 2: address: cperson@example.com - fqdn_listname: alpha@example.com + fqdn_listname: ant@example.com http_etag: ... role: member self_link: http://localhost:9001/3.0/members/5 user: http://localhost:9001/3.0/users/2 entry 3: address: cperson@example.com - fqdn_listname: test-one@example.com + fqdn_listname: bee@example.com http_etag: ... role: owner - self_link: http://localhost:9001/3.0/members/6 + self_link: http://localhost:9001/3.0/members/7 user: http://localhost:9001/3.0/users/2 entry 4: address: aperson@example.com - fqdn_listname: test-one@example.com + fqdn_listname: bee@example.com http_etag: ... role: member self_link: http://localhost:9001/3.0/members/3 user: http://localhost:9001/3.0/users/3 entry 5: address: bperson@example.com - fqdn_listname: test-one@example.com + fqdn_listname: bee@example.com http_etag: ... role: member self_link: http://localhost:9001/3.0/members/1 user: http://localhost:9001/3.0/users/1 entry 6: address: cperson@example.com - fqdn_listname: test-one@example.com + fqdn_listname: bee@example.com http_etag: ... role: member self_link: http://localhost:9001/3.0/members/2 @@ -262,12 +262,12 @@ A user can be subscribed to a mailing list via the REST API, either by a specific address, or more generally by their preferred address. A subscribed user is called a member. -Elly wants to subscribes to the alpha mailing list. Since Elly's email +Elly wants to subscribes to the `ant` mailing list. Since Elly's email address is not yet known to Mailman, a user is created for her. By default, get gets a regular delivery. >>> dump_json('http://localhost:9001/3.0/members', { - ... 'fqdn_listname': 'alpha@example.com', + ... 'fqdn_listname': 'ant@example.com', ... 'subscriber': 'eperson@example.com', ... 'real_name': 'Elly Person', ... }) @@ -285,21 +285,21 @@ Elly is now a known user, and a member of the mailing list. <User "Elly Person" (...) at ...> >>> set(member.mailing_list for member in elly.memberships.members) - set([u'alpha@example.com']) + set([u'ant@example.com']) >>> dump_json('http://localhost:9001/3.0/members') entry 0: ... entry 3: address: eperson@example.com - fqdn_listname: alpha@example.com + fqdn_listname: ant@example.com http_etag: ... role: member self_link: http://localhost:9001/3.0/members/8 user: http://localhost:9001/3.0/users/5 ... -Gwen is a user with a preferred address. She subscribes to the alpha mailing +Gwen is a user with a preferred address. She subscribes to the `ant` mailing list with her preferred address. >>> from mailman.utilities.datetime import now @@ -315,7 +315,7 @@ list with her preferred address. >>> transaction.commit() >>> dump_json('http://localhost:9001/3.0/members', { - ... 'fqdn_listname': 'alpha@example.com', + ... 'fqdn_listname': 'ant@example.com', ... 'subscriber': user_id, ... }) content-length: 0 @@ -329,7 +329,7 @@ list with her preferred address. ... entry 4: address: gwen@example.com - fqdn_listname: alpha@example.com + fqdn_listname: ant@example.com http_etag: "..." role: member self_link: http://localhost:9001/3.0/members/9 @@ -351,7 +351,7 @@ the new address. ... entry 4: address: gwen.person@example.com - fqdn_listname: alpha@example.com + fqdn_listname: ant@example.com http_etag: "..." role: member self_link: http://localhost:9001/3.0/members/9 @@ -373,7 +373,7 @@ so she leaves from the mailing list. ... method='DELETE') content-length: 0 ... - status: 200 + status: 204 Elly is no longer a member of the mailing list. @@ -384,12 +384,12 @@ Elly is no longer a member of the mailing list. Digest delivery =============== -Fred joins the alpha mailing list but wants MIME digest delivery. +Fred joins the `ant` mailing list but wants MIME digest delivery. :: >>> transaction.abort() >>> dump_json('http://localhost:9001/3.0/members', { - ... 'fqdn_listname': 'alpha@example.com', + ... 'fqdn_listname': 'ant@example.com', ... 'subscriber': 'fperson@example.com', ... 'real_name': 'Fred Person', ... 'delivery_mode': 'mime_digests', @@ -406,4 +406,118 @@ Fred joins the alpha mailing list but wants MIME digest delivery. 1 >>> memberships[0] <Member: Fred Person <fperson@example.com> - on alpha@example.com as MemberRole.member> + on ant@example.com as MemberRole.member> + + +Changing delivery address +========================= + +As shown above, Gwen is subscribed to a mailing list with her preferred email +address. If she changes her preferred address, this automatically changes the +address she will receive deliveries at for all such memberships. + +However, when Herb subscribes to a couple of mailing lists with explicit +addresses, he must change each subscription explicitly. + +Herb controls multiple email addresses. All of these addresses are verified. + + >>> herb = user_manager.create_user('herb@example.com', 'Herb Person') + >>> herb_1 = list(herb.addresses)[0] + >>> herb_2 = herb.register('hperson@example.com') + >>> herb_3 = herb.register('herb.person@example.com') + >>> for address in herb.addresses: + ... address.verified_on = now() + +Herb subscribes to both the `ant` and `bee` mailing lists with one of his +addresses. + + >>> ant.subscribe(herb_1) + <Member: Herb Person <herb@example.com> on + ant@example.com as MemberRole.member> + >>> bee.subscribe(herb_1) + <Member: Herb Person <herb@example.com> on + bee@example.com as MemberRole.member> + >>> transaction.commit() + >>> dump_json('http://localhost:9001/3.0/members') + entry 0: + ... + entry 5: + address: herb@example.com + fqdn_listname: ant@example.com + http_etag: "..." + role: member + self_link: http://localhost:9001/3.0/members/11 + user: http://localhost:9001/3.0/users/8 + ... + entry 10: + address: herb@example.com + fqdn_listname: bee@example.com + http_etag: "..." + role: member + self_link: http://localhost:9001/3.0/members/12 + user: http://localhost:9001/3.0/users/8 + http_etag: "..." + start: 0 + total_size: 11 + +In order to change all of his subscriptions to use a different email address, +Herb must iterate through his memberships explicitly. + + >>> from mailman.testing.helpers import call_api + >>> content, response = call_api('http://localhost:9001/3.0/addresses/' + ... 'herb@example.com/memberships') + >>> memberships = [entry['self_link'] for entry in content['entries']] + >>> for url in sorted(memberships): + ... print url + http://localhost:9001/3.0/members/11 + http://localhost:9001/3.0/members/12 + +For each membership resource, the subscription address is changed by PATCH'ing +the `address` attribute. + + >>> dump_json('http://localhost:9001/3.0/members/11', { + ... 'address': 'hperson@example.com', + ... }, method='PATCH') + content-length: 0 + date: ... + server: ... + status: 204 + + >>> dump_json('http://localhost:9001/3.0/members/12', { + ... 'address': 'hperson@example.com', + ... }, method='PATCH') + content-length: 0 + date: ... + server: ... + status: 204 + +Herb's memberships with the old address are gone. + + >>> dump_json('http://localhost:9001/3.0/addresses/' + ... 'herb@example.com/memberships') + http_etag: "..." + start: 0 + total_size: 0 + +Herb's memberships have been updated with his new email address. Of course, +his membership ids have not changed. + + >>> dump_json('http://localhost:9001/3.0/addresses/' + ... 'hperson@example.com/memberships') + entry 0: + address: hperson@example.com + fqdn_listname: ant@example.com + http_etag: "..." + role: member + self_link: http://localhost:9001/3.0/members/11 + user: http://localhost:9001/3.0/users/8 + entry 1: + address: hperson@example.com + fqdn_listname: bee@example.com + http_etag: "..." + role: member + self_link: http://localhost:9001/3.0/members/12 + user: http://localhost:9001/3.0/users/8 + http_etag: "..." + start: 0 + total_size: 2 diff --git a/src/mailman/rest/lists.py b/src/mailman/rest/lists.py index 991f67e2f..388da8d9d 100644 --- a/src/mailman/rest/lists.py +++ b/src/mailman/rest/lists.py @@ -27,6 +27,7 @@ __all__ = [ ] +from operator import attrgetter from restish import http, resource from zope.component import getUtility @@ -38,7 +39,7 @@ from mailman.interfaces.member import MemberRole from mailman.rest.configuration import ListConfiguration from mailman.rest.helpers import ( CollectionMixin, etag, no_content, path_to, restish_matcher) -from mailman.rest.members import AMember, MembersOfList +from mailman.rest.members import AMember, MemberCollection from mailman.rest.validator import Validator @@ -188,3 +189,22 @@ class AllLists(_ListBase): """/lists""" resource = self._make_collection(request) return http.ok([], etag(resource)) + + + +class MembersOfList(MemberCollection): + """The members of a mailing list.""" + + def __init__(self, mailing_list, role): + super(MembersOfList, self).__init__() + self._mlist = mailing_list + self._role = role + + def _get_collection(self, request): + """See `CollectionMixin`.""" + # Overrides _MemberBase._get_collection() because we only want to + # return the members from the requested roster. + roster = self._mlist.get_roster(self._role) + address_of_member = attrgetter('address.email') + return list(sorted(roster.members, key=address_of_member)) + diff --git a/src/mailman/rest/members.py b/src/mailman/rest/members.py index 06d917d11..67495874b 100644 --- a/src/mailman/rest/members.py +++ b/src/mailman/rest/members.py @@ -23,11 +23,10 @@ __metaclass__ = type __all__ = [ 'AMember', 'AllMembers', - 'MembersOfList', + 'MemberCollection', ] -from operator import attrgetter from restish import http, resource from zope.component import getUtility @@ -35,9 +34,13 @@ from mailman.app.membership import delete_member from mailman.interfaces.address import InvalidEmailAddressError from mailman.interfaces.listmanager import IListManager, NoSuchListError from mailman.interfaces.member import ( - AlreadySubscribedError, DeliveryMode, MemberRole, NotAMemberError) + AlreadySubscribedError, DeliveryMode, MemberRole, MembershipError, + NotAMemberError) from mailman.interfaces.membership import ISubscriptionService -from mailman.rest.helpers import CollectionMixin, etag, path_to +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.validator import Validator, enum_validator @@ -61,6 +64,7 @@ class _MemberBase(resource.Resource, CollectionMixin): return list(getUtility(ISubscriptionService)) + class AMember(_MemberBase): """A member.""" @@ -89,7 +93,26 @@ class AMember(_MemberBase): return http.not_found() else: self._member.unsubscribe() - return http.ok([], '') + return no_content() + + @PATCH() + def patch_membership(self, request): + """Patch the membership. + + This is how subscription changes are done. + """ + # Currently, only the `address` parameter can be patched. + values = Validator(address=unicode)(request) + assert len(values) == 1, 'Unexpected values' + email = values['address'] + address = getUtility(IUserManager).get_address(email) + if address is None: + return http.bad_request([], b'Address not registered') + try: + self._member.address = address + except (MembershipError, UnverifiedAddressError) as error: + return http.bad_request([], str(error)) + return no_content() @@ -127,20 +150,16 @@ class AllMembers(_MemberBase): -class MembersOfList(_MemberBase): - """The members of a mailing list.""" - - def __init__(self, mailing_list, role): - self._mlist = mailing_list - self._role = role +class MemberCollection(_MemberBase): + """Abstract class for supporting submemberships. + This is used for example to return a resource representing all the + memberships of a mailing list, or all memberships for a specific email + address. + """ def _get_collection(self, request): """See `CollectionMixin`.""" - # Overrides _MemberBase._get_collection() because we only want to - # return the members from the requested roster. - roster = self._mlist.get_roster(self._role) - address_of_member = attrgetter('address.email') - return list(sorted(roster.members, key=address_of_member)) + raise NotImplementedError @resource.GET() def container(self, request): diff --git a/src/mailman/rest/root.py b/src/mailman/rest/root.py index 0c48ad6b9..caa44f872 100644 --- a/src/mailman/rest/root.py +++ b/src/mailman/rest/root.py @@ -87,10 +87,9 @@ class TopLevel(resource.Resource): """ if len(segments) == 0: return AllAddresses() - elif len(segments) == 1: - return AnAddress(segments[0]), [] else: - return http.bad_request() + email = segments.pop(0) + return AnAddress(email), segments @resource.child() def domains(self, request, segments): diff --git a/src/mailman/rest/tests/test_membership.py b/src/mailman/rest/tests/test_membership.py index 7f5c8fd38..f82296355 100644 --- a/src/mailman/rest/tests/test_membership.py +++ b/src/mailman/rest/tests/test_membership.py @@ -91,10 +91,10 @@ class TestMembership(unittest.TestCase): config.db.commit() url = 'http://localhost:9001/3.0/members/1' content, response = call_api(url, method='DELETE') - # For a successful DELETE, the response code is 200 and there is no + # For a successful DELETE, the response code is 204 and there is no # content. self.assertEqual(content, None) - self.assertEqual(response.status, 200) + self.assertEqual(response.status, 204) try: # For Python 2.6. call_api(url, method='DELETE') |
