summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/mailman/model/docs/membership.txt71
-rw-r--r--src/mailman/model/member.py18
-rw-r--r--src/mailman/model/tests/test_member.py79
-rw-r--r--src/mailman/rest/addresses.py33
-rw-r--r--src/mailman/rest/configuration.py6
-rw-r--r--src/mailman/rest/docs/addresses.txt100
-rw-r--r--src/mailman/rest/docs/configuration.txt6
-rw-r--r--src/mailman/rest/docs/membership.txt208
-rw-r--r--src/mailman/rest/lists.py22
-rw-r--r--src/mailman/rest/members.py51
-rw-r--r--src/mailman/rest/root.py5
-rw-r--r--src/mailman/rest/tests/test_membership.py4
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')