Barry Warsaw has proposed merging lp:~barry/mailman/requests into lp:mailman.

Requested reviews:
  Mailman Coders (mailman-coders)

For more details, see:
https://code.launchpad.net/~barry/mailman/requests/+merge/256560

Fix subscription holds exposure to REST.
-- 
Your team Mailman Coders is requested to review the proposed merge of 
lp:~barry/mailman/requests into lp:mailman.
=== modified file 'src/mailman/app/subscriptions.py'
--- src/mailman/app/subscriptions.py	2015-04-16 02:51:39 +0000
+++ src/mailman/app/subscriptions.py	2015-04-16 19:38:59 +0000
@@ -24,7 +24,6 @@
     ]
 
 
-
 import uuid
 import logging
 
@@ -170,6 +169,8 @@
         pendable = Pendable(
             list_id=self.mlist.list_id,
             address=self.address.email,
+            hold_date=now().replace(microsecond=0).isoformat(),
+            token_owner=token_owner.name,
             )
         self.token = getUtility(IPendings).add(pendable, timedelta(days=3650))
 

=== modified file 'src/mailman/app/tests/test_subscriptions.py'
--- src/mailman/app/tests/test_subscriptions.py	2015-04-16 14:42:40 +0000
+++ src/mailman/app/tests/test_subscriptions.py	2015-04-16 19:38:59 +0000
@@ -57,6 +57,22 @@
         self.assertEqual(workflow.token_owner, TokenOwner.no_one)
         self.assertIsNone(workflow.member)
 
+    def test_pended_data(self):
+        # There is a Pendable associated with the held request, and it has
+        # some data associated with it.
+        anne = self._user_manager.create_address(self._anne)
+        workflow = SubscriptionWorkflow(self._mlist, anne)
+        try:
+            workflow.run_thru('send_confirmation')
+        except StopIteration:
+            pass
+        self.assertIsNotNone(workflow.token)
+        pendable = getUtility(IPendings).confirm(workflow.token, expunge=False)
+        self.assertEqual(pendable['list_id'], 'test.example.com')
+        self.assertEqual(pendable['address'], '[email protected]')
+        self.assertEqual(pendable['hold_date'], '2005-08-01T07:49:23')
+        self.assertEqual(pendable['token_owner'], 'subscriber')
+
     def test_user_or_address_required(self):
         # The `subscriber` attribute must be a user or address.
         workflow = SubscriptionWorkflow(self._mlist)

=== modified file 'src/mailman/interfaces/pending.py'
--- src/mailman/interfaces/pending.py	2015-04-15 14:05:35 +0000
+++ src/mailman/interfaces/pending.py	2015-04-16 19:38:59 +0000
@@ -95,4 +95,10 @@
     def evict():
         """Remove all pended items whose lifetime has expired."""
 
+    def __iter__():
+        """An iterator over all pendables.
+
+        Each element is a 2-tuple of the form (token, dict).
+        """
+
     count = Attribute('The number of pendables in the pendings database.')

=== modified file 'src/mailman/interfaces/registrar.py'
--- src/mailman/interfaces/registrar.py	2015-04-16 02:51:39 +0000
+++ src/mailman/interfaces/registrar.py	2015-04-16 19:38:59 +0000
@@ -75,12 +75,13 @@
 
         :param subscriber: The user or address to subscribe.
         :type email: ``IUser`` or ``IAddress``
-        :return: None if the workflow completes with the member being
-            subscribed.  If the workflow is paused for user confirmation or
-            moderator approval, a 3-tuple is returned where the first element
-            is a ``TokenOwner`` the second element is the token hash, and the
-            third element is the subscribed member.
-        :rtype: None or 2-tuple of (TokenOwner, str)
+        :return: A 3-tuple is returned where the first element is the token
+            hash, the second element is a ``TokenOwner`, and the third element
+            is the subscribed member.  If the subscriber got subscribed
+            immediately, the token will be None and the member will be
+            an ``IMember``.  If the subscription got held, the token
+            will be a hash and the member will be None.
+        :rtype: (str-or-None, ``TokenOwner``, ``IMember``-or-None)
         :raises MembershipIsBannedError: when the address being subscribed
             appears in the global or list-centric bans.
         """
@@ -94,9 +95,13 @@
 
         :param token: A token matching a workflow.
         :type token: string
-        :return: The new token for any follow up confirmation, or None if the
-            user was subscribed.
-        :rtype: str or None
+        :return: A 3-tuple is returned where the first element is the token
+            hash, the second element is a ``TokenOwner`, and the third element
+            is the subscribed member.  If the subscriber got subscribed
+            immediately, the token will be None and the member will be
+            an ``IMember``.  If the subscription is still being held, the token
+            will be a hash and the member will be None.
+        :rtype: (str-or-None, ``TokenOwner``, ``IMember``-or-None)
         :raises LookupError: when no workflow is associated with the token.
         """
 

=== modified file 'src/mailman/model/docs/pending.rst'
--- src/mailman/model/docs/pending.rst	2015-04-15 14:05:35 +0000
+++ src/mailman/model/docs/pending.rst	2015-04-16 19:38:59 +0000
@@ -43,10 +43,9 @@
     >>> pendingdb.count
     1
 
-There's not much you can do with tokens except to *confirm* them, which
-basically means returning the `IPendable` structure (as a dictionary) from the
-database that matches the token.  If the token isn't in the database, None is
-returned.
+You can *confirm* the pending, which means returning the `IPendable` structure
+(as a dictionary) from the database that matches the token.  If the token
+isn't in the database, None is returned.
 
     >>> pendable = pendingdb.confirm(b'missing')
     >>> print(pendable)
@@ -83,6 +82,18 @@
     >>> print(pendingdb.confirm(token_1))
     None
 
+You can iterate over all the pendings in the database.
+
+    >>> pendables = list(pendingdb)
+    >>> def sort_key(item):
+    ...     token, pendable = item
+    ...     return pendable['type']
+    >>> sorted_pendables = sorted(pendables, key=sort_key)
+    >>> for token, pendable in sorted_pendables:
+    ...     print(pendable['type'])
+    three
+    two
+
 An event can be given a lifetime when it is pended, otherwise it just uses a
 default lifetime.
 

=== modified file 'src/mailman/model/pending.py'
--- src/mailman/model/pending.py	2015-04-15 14:05:35 +0000
+++ src/mailman/model/pending.py	2015-04-16 19:38:59 +0000
@@ -166,6 +166,11 @@
                     store.delete(keyvalue)
                 store.delete(pending)
 
+    @dbconnection
+    def __iter__(self, store):
+        for pending in store.query(Pended).all():
+            yield pending.token, self.confirm(pending.token, expunge=False)
+
     @property
     @dbconnection
     def count(self, store):

=== renamed file 'src/mailman/rest/docs/moderation.rst' => 'src/mailman/rest/docs/post-moderation.rst'
--- src/mailman/rest/docs/moderation.rst	2015-03-29 20:30:30 +0000
+++ src/mailman/rest/docs/post-moderation.rst	2015-04-16 19:38:59 +0000
@@ -1,24 +1,18 @@
-==========
-Moderation
-==========
-
-There are two kinds of moderation tasks a list administrator may need to
-perform.  Messages which are held for approval can be accepted, rejected,
-discarded, or deferred.  Subscription (and sometimes unsubscription) requests
-can similarly be accepted, discarded, rejected, or deferred.
-
-
-Message moderation
-==================
+===============
+Post Moderation
+===============
+
+Messages which are held for approval can be accepted, rejected, discarded, or
+deferred by the list moderators.
+
 
 Viewing the list of held messages
----------------------------------
+=================================
 
 Held messages can be moderated through the REST API.  A mailing list starts
 with no held messages.
 
     >>> ant = create_list('[email protected]')
-    >>> transaction.commit()
     >>> dump_json('http://localhost:9001/3.0/lists/[email protected]/held')
     http_etag: "..."
     start: 0
@@ -90,7 +84,7 @@
 
 
 Disposing of held messages
---------------------------
+==========================
 
 Individual messages can be moderated through the API by POSTing back to the
 held message's resource.   The POST data requires an action of one of the
@@ -196,166 +190,3 @@
     1
     >>> print(messages[0].msg['subject'])
     Request to mailing list "Ant" rejected
-
-
-Subscription moderation
-=======================
-
-Viewing subscription requests
------------------------------
-
-Subscription and unsubscription requests can be moderated via the REST API as
-well.  A mailing list starts with no pending subscription or unsubscription
-requests.
-
-    >>> ant.admin_immed_notify = False
-    >>> dump_json('http://localhost:9001/3.0/lists/[email protected]/requests')
-    http_etag: "..."
-    start: 0
-    total_size: 0
-
-When Anne tries to subscribe to the Ant list, her subscription is held for
-moderator approval.
-::
-
-    >>> from mailman.app.moderator import hold_subscription
-    >>> from mailman.interfaces.member import DeliveryMode
-    >>> from mailman.interfaces.subscriptions import RequestRecord
-
-    >>> sub_req_id = hold_subscription(
-    ...     ant, RequestRecord('[email protected]', 'Anne Person',
-    ...                        DeliveryMode.regular, 'en'))
-    >>> transaction.commit()
-
-The subscription request is available from the mailing list.
-
-    >>> dump_json('http://localhost:9001/3.0/lists/[email protected]/requests')
-    entry 0:
-        delivery_mode: regular
-        display_name: Anne Person
-        email: [email protected]
-        http_etag: "..."
-        language: en
-        request_id: ...
-        type: subscription
-        when: 2005-08-01T07:49:23
-    http_etag: "..."
-    start: 0
-    total_size: 1
-
-
-Viewing unsubscription requests
--------------------------------
-
-Bart tries to leave a mailing list, but he may not be allowed to.
-
-    >>> from mailman.testing.helpers import subscribe
-    >>> from mailman.app.moderator import hold_unsubscription
-    >>> bart = subscribe(ant, 'Bart', email='[email protected]')
-    >>> unsub_req_id = hold_unsubscription(ant, '[email protected]')
-    >>> transaction.commit()
-
-The unsubscription request is also available from the mailing list.
-
-    >>> dump_json('http://localhost:9001/3.0/lists/[email protected]/requests')
-    entry 0:
-        delivery_mode: regular
-        display_name: Anne Person
-        email: [email protected]
-        http_etag: "..."
-        language: en
-        request_id: ...
-        type: subscription
-        when: 2005-08-01T07:49:23
-    entry 1:
-        email: [email protected]
-        http_etag: "..."
-        request_id: ...
-        type: unsubscription
-    http_etag: "..."
-    start: 0
-    total_size: 2
-
-
-Viewing individual requests
----------------------------
-
-You can view an individual membership change request by providing the
-request id.  Anne's subscription request looks like this.
-
-    >>> dump_json('http://localhost:9001/3.0/lists/[email protected]/'
-    ...           'requests/{}'.format(sub_req_id))
-    delivery_mode: regular
-    display_name: Anne Person
-    email: [email protected]
-    http_etag: "..."
-    language: en
-    request_id: ...
-    type: subscription
-    when: 2005-08-01T07:49:23
-
-Bart's unsubscription request looks like this.
-
-    >>> dump_json('http://localhost:9001/3.0/lists/[email protected]/'
-    ...           'requests/{}'.format(unsub_req_id))
-    email: [email protected]
-    http_etag: "..."
-    request_id: ...
-    type: unsubscription
-
-
-Disposing of subscription requests
-----------------------------------
-
-Similar to held messages, you can dispose of held subscription and
-unsubscription requests by POSTing back to the request's resource.  The POST
-data requires an action of one of the following:
-
- * discard - throw the request away.
- * reject - the request is denied and a notification is sent to the email
-            address requesting the membership change.
- * defer - defer any action on this membership change (continue to hold it).
- * accept - accept the membership change.
-
-Anne's subscription request is accepted.
-
-    >>> dump_json('http://localhost:9001/3.0/lists/'
-    ...           '[email protected]/requests/{}'.format(sub_req_id),
-    ...           {'action': 'accept'})
-    content-length: 0
-    date: ...
-    server: ...
-    status: 204
-
-Anne is now a member of the mailing list.
-
-    >>> transaction.abort()
-    >>> ant.members.get_member('[email protected]')
-    <Member: Anne Person <[email protected]> on [email protected]
-             as MemberRole.member>
-    >>> transaction.abort()
-
-Bart's unsubscription request is discarded.
-
-    >>> dump_json('http://localhost:9001/3.0/lists/'
-    ...           '[email protected]/requests/{}'.format(unsub_req_id),
-    ...           {'action': 'discard'})
-    content-length: 0
-    date: ...
-    server: ...
-    status: 204
-
-Bart is still a member of the mailing list.
-
-    >>> transaction.abort()
-    >>> print(ant.members.get_member('[email protected]'))
-    <Member: Bart Person <[email protected]> on [email protected]
-             as MemberRole.member>
-    >>> transaction.abort()
-
-There are no more membership change requests.
-
-    >>> dump_json('http://localhost:9001/3.0/lists/[email protected]/requests')
-    http_etag: "..."
-    start: 0
-    total_size: 0

=== added file 'src/mailman/rest/docs/sub-moderation.rst'
--- src/mailman/rest/docs/sub-moderation.rst	1970-01-01 00:00:00 +0000
+++ src/mailman/rest/docs/sub-moderation.rst	2015-04-16 19:38:59 +0000
@@ -0,0 +1,113 @@
+=========================
+ Subscription moderation
+=========================
+
+Subscription (and sometimes unsubscription) requests can similarly be
+accepted, discarded, rejected, or deferred by the list moderators.
+
+
+Viewing subscription requests
+=============================
+
+A mailing list starts with no pending subscription or unsubscription requests.
+
+    >>> ant = create_list('[email protected]')
+    >>> ant.admin_immed_notify = False
+    >>> from mailman.interfaces.mailinglist import SubscriptionPolicy
+    >>> ant.subscription_policy = SubscriptionPolicy.moderate
+    >>> transaction.commit()
+    >>> dump_json('http://localhost:9001/3.0/lists/[email protected]/requests')
+    http_etag: "..."
+    start: 0
+    total_size: 0
+
+When Anne tries to subscribe to the Ant list, her subscription is held for
+moderator approval.
+
+    >>> from mailman.interfaces.registrar import IRegistrar
+    >>> from mailman.interfaces.usermanager import IUserManager
+    >>> from zope.component import getUtility
+    >>> registrar = IRegistrar(ant)
+    >>> manager = getUtility(IUserManager)
+    >>> anne = manager.create_address('[email protected]', 'Anne Person')
+    >>> token, token_owner, member = registrar.register(
+    ...     anne, pre_verified=True, pre_confirmed=True)
+    >>> print(member)
+    None
+
+The message is being held for moderator approval.
+
+    >>> print(token_owner.name)
+    moderator
+
+The subscription request can be viewed in the REST API.
+
+    >>> dump_json('http://localhost:9001/3.0/lists/[email protected]/requests')
+    entry 0:
+        delivery_mode: regular
+        display_name: Anne Person
+        email: [email protected]
+        http_etag: "..."
+        language: en
+        request_id: ...
+        type: subscription
+        when: 2005-08-01T07:49:23
+    http_etag: "..."
+    start: 0
+    total_size: 1
+
+
+Viewing individual requests
+===========================
+
+You can view an individual membership change request by providing the token
+(a.k.a. request id).  Anne's subscription request looks like this.
+
+    >>> dump_json('http://localhost:9001/3.0/lists/[email protected]/'
+    ...           'requests/{}'.format(token))
+    delivery_mode: regular
+    display_name: Anne Person
+    email: [email protected]
+    http_etag: "..."
+    language: en
+    request_id: ...
+    type: subscription
+    when: 2005-08-01T07:49:23
+
+
+Disposing of subscription requests
+==================================
+
+Moderators can dispose of held subscription requests by POSTing back to the
+request's resource.  The POST data requires an action of one of the following:
+
+ * discard - throw the request away.
+ * reject - the request is denied and a notification is sent to the email
+            address requesting the membership change.
+ * defer - defer any action on this membership change (continue to hold it).
+ * accept - accept the membership change.
+
+Anne's subscription request is accepted.
+
+    >>> dump_json('http://localhost:9001/3.0/lists/'
+    ...           '[email protected]/requests/{}'.format(token),
+    ...           {'action': 'accept'})
+    content-length: 0
+    date: ...
+    server: ...
+    status: 204
+
+Anne is now a member of the mailing list.
+
+    >>> transaction.abort()
+    >>> ant.members.get_member('[email protected]')
+    <Member: Anne Person <[email protected]> on [email protected]
+             as MemberRole.member>
+    >>> transaction.abort()
+
+There are no more membership change requests.
+
+    >>> dump_json('http://localhost:9001/3.0/lists/[email protected]/requests')
+    http_etag: "..."
+    start: 0
+    total_size: 0

=== modified file 'src/mailman/rest/lists.py'
--- src/mailman/rest/lists.py	2015-04-06 23:07:42 +0000
+++ src/mailman/rest/lists.py	2015-04-16 19:38:59 +0000
@@ -42,7 +42,8 @@
     CollectionMixin, GetterSetter, NotFound, bad_request, child, created,
     etag, no_content, not_found, okay, paginate, path_to)
 from mailman.rest.members import AMember, MemberCollection
-from mailman.rest.moderation import HeldMessages, SubscriptionRequests
+from mailman.rest.post_moderation import HeldMessages
+from mailman.rest.sub_moderation import SubscriptionRequests
 from mailman.rest.validator import Validator
 from operator import attrgetter
 from zope.component import getUtility

=== renamed file 'src/mailman/rest/moderation.py' => 'src/mailman/rest/post_moderation.py'
--- src/mailman/rest/moderation.py	2015-01-05 01:22:39 +0000
+++ src/mailman/rest/post_moderation.py	2015-04-16 19:38:59 +0000
@@ -15,18 +15,15 @@
 # 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 API for Message moderation."""
+"""REST API for held message moderation."""
 
 __all__ = [
     'HeldMessage',
     'HeldMessages',
-    'MembershipChangeRequest',
-    'SubscriptionRequests',
     ]
 
 
-from mailman.app.moderator import (
-    handle_message, handle_subscription, handle_unsubscription)
+from mailman.app.moderator import handle_message
 from mailman.interfaces.action import Action
 from mailman.interfaces.messages import IMessageStore
 from mailman.interfaces.requests import IListRequests, RequestType
@@ -36,16 +33,11 @@
 from zope.component import getUtility
 
 
-HELD_MESSAGE_REQUESTS = (RequestType.held_message,)
-MEMBERSHIP_CHANGE_REQUESTS = (RequestType.subscription,
-                              RequestType.unsubscription)
-
-
 
 class _ModerationBase:
     """Common base class."""
 
-    def _make_resource(self, request_id, expected_request_types):
+    def _make_resource(self, request_id):
         requests = IListRequests(self._mlist)
         results = requests.get_request(request_id)
         if results is None:
@@ -57,9 +49,9 @@
         # Check for a matching request type, and insert the type name into the
         # resource.
         request_type = RequestType[resource.pop('_request_type')]
-        if request_type not in expected_request_types:
+        if request_type is not RequestType.held_message:
             return None
-        resource['type'] = request_type.name
+        resource['type'] = RequestType.held_message.name
         # This key isn't what you think it is.  Usually, it's the Pendable
         # record's row id, which isn't helpful at all.  If it's not there,
         # that's fine too.
@@ -72,8 +64,7 @@
     """Held messages are a little different."""
 
     def _make_resource(self, request_id):
-        resource = super(_HeldMessageBase, self)._make_resource(
-            request_id, HELD_MESSAGE_REQUESTS)
+        resource = super(_HeldMessageBase, self)._make_resource(request_id)
         if resource is None:
             return None
         # Grab the message and insert its text representation into the
@@ -162,91 +153,3 @@
     @child(r'^(?P<id>[^/]+)')
     def message(self, request, segments, **kw):
         return HeldMessage(self._mlist, kw['id'])
-
-
-
-class MembershipChangeRequest(_ModerationBase):
-    """Resource for moderating a membership change."""
-
-    def __init__(self, mlist, request_id):
-        self._mlist = mlist
-        self._request_id = request_id
-
-    def on_get(self, request, response):
-        try:
-            request_id = int(self._request_id)
-        except ValueError:
-            bad_request(response)
-            return
-        resource = self._make_resource(request_id, MEMBERSHIP_CHANGE_REQUESTS)
-        if resource is None:
-            not_found(response)
-        else:
-            # Remove unnecessary keys.
-            del resource['key']
-            okay(response, etag(resource))
-
-    def on_post(self, request, response):
-        try:
-            validator = Validator(action=enum_validator(Action))
-            arguments = validator(request)
-        except ValueError as error:
-            bad_request(response, str(error))
-            return
-        requests = IListRequests(self._mlist)
-        try:
-            request_id = int(self._request_id)
-        except ValueError:
-            bad_request(response)
-            return
-        results = requests.get_request(request_id)
-        if results is None:
-            not_found(response)
-            return
-        key, data = results
-        try:
-            request_type = RequestType[data['_request_type']]
-        except ValueError:
-            bad_request(response)
-            return
-        if request_type is RequestType.subscription:
-            handle_subscription(self._mlist, request_id, **arguments)
-        elif request_type is RequestType.unsubscription:
-            handle_unsubscription(self._mlist, request_id, **arguments)
-        else:
-            bad_request(response)
-            return
-        no_content(response)
-
-
-class SubscriptionRequests(_ModerationBase, CollectionMixin):
-    """Resource for membership change requests."""
-
-    def __init__(self, mlist):
-        self._mlist = mlist
-        self._requests = None
-
-    def _resource_as_dict(self, request):
-        """See `CollectionMixin`."""
-        resource = self._make_resource(request.id, MEMBERSHIP_CHANGE_REQUESTS)
-        # Remove unnecessary keys.
-        del resource['key']
-        return resource
-
-    def _get_collection(self, request):
-        requests = IListRequests(self._mlist)
-        self._requests = requests
-        items = []
-        for request_type in MEMBERSHIP_CHANGE_REQUESTS:
-            for request in requests.of_type(request_type):
-                items.append(request)
-        return items
-
-    def on_get(self, request, response):
-        """/lists/listname/requests"""
-        resource = self._make_collection(request)
-        okay(response, etag(resource))
-
-    @child(r'^(?P<id>[^/]+)')
-    def subscription(self, request, segments, **kw):
-        return MembershipChangeRequest(self._mlist, kw['id'])

=== added file 'src/mailman/rest/sub_moderation.py'
--- src/mailman/rest/sub_moderation.py	1970-01-01 00:00:00 +0000
+++ src/mailman/rest/sub_moderation.py	2015-04-16 19:38:59 +0000
@@ -0,0 +1,129 @@
+# Copyright (C) 2012-2015 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 API for held subscription requests."""
+
+__all__ = [
+    'SubscriptionRequests',
+    ]
+
+
+from mailman.interfaces.action import Action
+from mailman.interfaces.pending import IPendings
+from mailman.interfaces.registrar import IRegistrar
+from mailman.rest.helpers import (
+    CollectionMixin, bad_request, child, etag, no_content, not_found, okay)
+from mailman.rest.validator import Validator, enum_validator
+from zope.component import getUtility
+
+
+
+class _ModerationBase:
+    """Common base class."""
+
+    def __init__(self):
+        self._pendings = getUtility(IPendings)
+
+    def _make_resource(self, token):
+        pendable = self._pendings.confirm(token, expunge=False)
+        resource = dict(token=token)
+        resource.update(pendable)
+        return resource
+
+
+
+class IndividualRequest(_ModerationBase):
+    """Resource for moderating a membership change."""
+
+    def __init__(self, mlist, token):
+        self._mlist = mlist
+        self._registrar = IRegistrar(self._mlist)
+        self._token = token
+
+    def on_get(self, request, response):
+        try:
+            token, token_owner, member = self._registrar.confirm(self._token)
+        except LookupError:
+            not_found(response)
+            return
+        try:
+            request_id = int(self._request_id)
+        except ValueError:
+            bad_request(response)
+            return
+        resource = self._make_resource(request_id, MEMBERSHIP_CHANGE_REQUESTS)
+        if resource is None:
+            not_found(response)
+        else:
+            # Remove unnecessary keys.
+            del resource['key']
+            okay(response, etag(resource))
+
+    def on_post(self, request, response):
+        try:
+            validator = Validator(action=enum_validator(Action))
+            arguments = validator(request)
+        except ValueError as error:
+            bad_request(response, str(error))
+            return
+        requests = IListRequests(self._mlist)
+        try:
+            request_id = int(self._request_id)
+        except ValueError:
+            bad_request(response)
+            return
+        results = requests.get_request(request_id)
+        if results is None:
+            not_found(response)
+            return
+        key, data = results
+        try:
+            request_type = RequestType[data['_request_type']]
+        except ValueError:
+            bad_request(response)
+            return
+        if request_type is RequestType.subscription:
+            handle_subscription(self._mlist, request_id, **arguments)
+        elif request_type is RequestType.unsubscription:
+            handle_unsubscription(self._mlist, request_id, **arguments)
+        else:
+            bad_request(response)
+            return
+        no_content(response)
+
+
+class SubscriptionRequests(_ModerationBase, CollectionMixin):
+    """Resource for membership change requests."""
+
+    def __init__(self, mlist):
+        super().__init__()
+        self._mlist = mlist
+
+    def _get_collection(self, request):
+        # There's currently no better way to query the pendings database for
+        # all the entries that are associated with subscription holds on this
+        # mailing list.  Brute force for now.
+        return [token for token, pendable in getUtility(IPendings)]
+
+    def on_get(self, request, response):
+        """/lists/listname/requests"""
+        resource = self._make_collection(request)
+        okay(response, etag(resource))
+
+    @child(r'^(?P<token>[^/]+)')
+    def subscription(self, request, segments, **kw):
+        return IndividualRequest(self._mlist, kw['token'])

=== modified file 'src/mailman/rest/tests/test_moderation.py'
--- src/mailman/rest/tests/test_moderation.py	2015-03-29 20:30:30 +0000
+++ src/mailman/rest/tests/test_moderation.py	2015-04-16 19:38:59 +0000
@@ -18,26 +18,28 @@
 """REST moderation tests."""
 
 __all__ = [
-    'TestModeration',
+    'TestPostModeration',
+    'TestSubscriptionModeration',
     ]
 
 
 import unittest
 
 from mailman.app.lifecycle import create_list
-from mailman.app.moderator import hold_message, hold_subscription
-from mailman.config import config
+from mailman.app.moderator import hold_message
 from mailman.database.transaction import transaction
-from mailman.interfaces.member import DeliveryMode
-from mailman.interfaces.subscriptions import RequestRecord
+from mailman.interfaces.registrar import IRegistrar
+from mailman.interfaces.usermanager import IUserManager
 from mailman.testing.helpers import (
-    call_api, specialized_message_from_string as mfs)
+    call_api, get_queue_messages, specialized_message_from_string as mfs)
 from mailman.testing.layers import RESTLayer
+from mailman.utilities.datetime import now
 from urllib.error import HTTPError
+from zope.component import getUtility
 
 
 
-class TestModeration(unittest.TestCase):
+class TestPostModeration(unittest.TestCase):
     layer = RESTLayer
 
     def setUp(self):
@@ -71,24 +73,6 @@
             call_api('http://localhost:9001/3.0/lists/[email protected]/held/99')
         self.assertEqual(cm.exception.code, 404)
 
-    def test_subscription_request_as_held_message(self):
-        # Provide the request id of a subscription request using the held
-        # message API returns a not-found even though the request id is
-        # in the database.
-        held_id = hold_message(self._mlist, self._msg)
-        subscribe_id = hold_subscription(
-            self._mlist,
-            RequestRecord('[email protected]', 'Bart Person',
-                          DeliveryMode.regular, 'en'))
-        config.db.store.commit()
-        url = 'http://localhost:9001/3.0/lists/[email protected]/held/{0}'
-        with self.assertRaises(HTTPError) as cm:
-            call_api(url.format(subscribe_id))
-        self.assertEqual(cm.exception.code, 404)
-        # But using the held_id returns a valid response.
-        response, content = call_api(url.format(held_id))
-        self.assertEqual(response['message_id'], '<alpha>')
-
     def test_bad_held_message_action(self):
         # POSTing to a held message with a bad action.
         held_id = hold_message(self._mlist, self._msg)
@@ -99,34 +83,6 @@
         self.assertEqual(cm.exception.msg,
                          b'Cannot convert parameters: action')
 
-    def test_bad_subscription_request_id(self):
-        # Bad request when request_id is not an integer.
-        with self.assertRaises(HTTPError) as cm:
-            call_api('http://localhost:9001/3.0/lists/[email protected]/'
-                     'requests/bogus')
-        self.assertEqual(cm.exception.code, 400)
-
-    def test_missing_subscription_request_id(self):
-        # Bad request when the request_id is not in the database.
-        with self.assertRaises(HTTPError) as cm:
-            call_api('http://localhost:9001/3.0/lists/[email protected]/'
-                     'requests/99')
-        self.assertEqual(cm.exception.code, 404)
-
-    def test_bad_subscription_action(self):
-        # POSTing to a held message with a bad action.
-        held_id = hold_subscription(
-            self._mlist,
-            RequestRecord('[email protected]', 'Cris Person',
-                          DeliveryMode.regular, 'en'))
-        config.db.store.commit()
-        url = 'http://localhost:9001/3.0/lists/[email protected]/requests/{0}'
-        with self.assertRaises(HTTPError) as cm:
-            call_api(url.format(held_id), {'action': 'bogus'})
-        self.assertEqual(cm.exception.code, 400)
-        self.assertEqual(cm.exception.msg,
-                         b'Cannot convert parameters: action')
-
     def test_discard(self):
         # Discarding a message removes it from the moderation queue.
         with transaction():
@@ -139,3 +95,168 @@
         with self.assertRaises(HTTPError) as cm:
             call_api(url, dict(action='discard'))
         self.assertEqual(cm.exception.code, 404)
+
+
+
+class TestSubscriptionModeration(unittest.TestCase):
+    layer = RESTLayer
+
+    def setUp(self):
+        with transaction():
+            self._mlist = create_list('[email protected]')
+            self._registrar = IRegistrar(self._mlist)
+            manager = getUtility(IUserManager)
+            self._anne = manager.create_address(
+                '[email protected]', 'Anne Person')
+            self._bart = manager.make_user(
+                '[email protected]', 'Bart Person')
+            preferred = list(self._bart.addresses)[0]
+            preferred.verified_on = now()
+            self._bart.preferred_address = preferred
+
+    def test_no_such_list(self):
+        # Try to get the requests of a nonexistent list.
+        with self.assertRaises(HTTPError) as cm:
+            call_api('http://localhost:9001/3.0/lists/[email protected]/'
+                     'requests')
+        self.assertEqual(cm.exception.code, 404)
+
+    def test_no_such_subscription_token(self):
+        # Bad request when the token is not in the database.
+        with self.assertRaises(HTTPError) as cm:
+            call_api('http://localhost:9001/3.0/lists/[email protected]/'
+                     'requests/missing')
+        self.assertEqual(cm.exception.code, 404)
+
+    def test_bad_subscription_action(self):
+        # POSTing to a held message with a bad action.
+        token, token_owner, member = self._registrar.register(self._anne)
+        # Anne's subscription request got held.
+        self.assertIsNone(member)
+        # Let's try to handle her request, but with a bogus action.
+        url = 'http://localhost:9001/3.0/lists/[email protected]/requests/{}'
+        with self.assertRaises(HTTPError) as cm:
+            call_api(url.format(token), dict(
+                action='bogus',
+                ))
+        self.assertEqual(cm.exception.code, 400)
+        self.assertEqual(cm.exception.msg,
+                         b'Cannot convert parameters: action')
+
+    def test_list_held_requests(self):
+        # We can view all the held requests.
+        token_1, token_owner, member = self._registrar.register(self._anne)
+        # Anne's subscription request got held.
+        self.assertIsNone(member)
+        token_2, token_owner, member = self._registrar.register(self._bart)
+        content, response = call_api(
+            'http://localhost:9001/3.0/lists/[email protected]/requests')
+        self.assertEqual(response.status, 200)
+        self.assertEqual(content['total_size'], 2)
+        import pdb; pdb.set_trace()
+
+    def test_accept(self):
+        # POST to the request to accept it.
+        token, token_owner, member = self._registrar.register(self._anne)
+        # Anne's subscription request got held.
+        self.assertIsNone(member)
+        url = 'http://localhost:9001/3.0/lists/[email protected]/requests/{}'
+        with self.assertRaises(HTTPError) as cm:
+            call_api(url.format(token), dict(
+                action='accept',
+                ))
+        self.assertEqual(cm.exception.code, 204)
+        # Anne is a member.
+        self.assertEqual(
+            self._mlist.get_members('[email protected]').address,
+            self._anne)
+        # The request URL no longer exists.
+        with self.assertRaises(HTTPError) as cm:
+            call_api(url.format(token), dict(
+                action='accept',
+                ))
+        self.assertEqual(cm.exception.code, 404)
+
+    def test_discard(self):
+        # POST to the request to discard it.
+        token, token_owner, member = self._registrar.register(self._anne)
+        # Anne's subscription request got held.
+        self.assertIsNone(member)
+        url = 'http://localhost:9001/3.0/lists/[email protected]/requests/{}'
+        with self.assertRaises(HTTPError) as cm:
+            call_api(url.format(token), dict(
+                action='discard',
+                ))
+        self.assertEqual(cm.exception.code, 204)
+        # Anne is not a member.
+        self.assertIsNone(self._mlist.get_members('[email protected]'))
+        # The request URL no longer exists.
+        with self.assertRaises(HTTPError) as cm:
+            call_api(url.format(token), dict(
+                action='discard',
+                ))
+        self.assertEqual(cm.exception.code, 404)
+
+    def test_defer(self):
+        # Defer the decision for some other moderator.
+        token, token_owner, member = self._registrar.register(self._anne)
+        # Anne's subscription request got held.
+        self.assertIsNone(member)
+        url = 'http://localhost:9001/3.0/lists/[email protected]/requests/{}'
+        with self.assertRaises(HTTPError) as cm:
+            call_api(url.format(token), dict(
+                action='defer',
+                ))
+        self.assertEqual(cm.exception.code, 204)
+        # Anne is not a member.
+        self.assertIsNone(self._mlist.get_members('[email protected]'))
+        # The request URL still exists.
+        with self.assertRaises(HTTPError) as cm:
+            call_api(url.format(token), dict(
+                action='defer',
+                ))
+        self.assertEqual(cm.exception.code, 204)
+        # And now we can accept it.
+        with self.assertRaises(HTTPError) as cm:
+            call_api(url.format(token), dict(
+                action='accept',
+                ))
+        self.assertEqual(cm.exception.code, 204)
+        # Anne is a member.
+        self.assertEqual(
+            self._mlist.get_members('[email protected]').address,
+            self._anne)
+        # The request URL no longer exists.
+        with self.assertRaises(HTTPError) as cm:
+            call_api(url.format(token), dict(
+                action='accept',
+                ))
+        self.assertEqual(cm.exception.code, 404)
+
+    def test_reject(self):
+        # POST to the request to reject it.  This leaves a bounce message in
+        # the virgin queue.
+        token, token_owner, member = self._registrar.register(self._anne)
+        # Anne's subscription request got held.
+        self.assertIsNone(member)
+        # There are currently no messages in the virgin queue.
+        items = get_queue_messages('virgin')
+        self.assertEqual(len(items), 0)
+        url = 'http://localhost:9001/3.0/lists/[email protected]/requests/{}'
+        with self.assertRaises(HTTPError) as cm:
+            call_api(url.format(token), dict(
+                action='reject',
+                ))
+        self.assertEqual(cm.exception.code, 204)
+        # Anne is not a member.
+        self.assertIsNone(self._mlist.get_members('[email protected]'))
+        # The request URL no longer exists.
+        with self.assertRaises(HTTPError) as cm:
+            call_api(url.format(token), dict(
+                action='reject',
+                ))
+        self.assertEqual(cm.exception.code, 404)
+        # And the rejection message is now in the virgin queue.
+        items = get_queue_messages('virgin')
+        self.assertEqual(len(items), 1)
+        self.assertEqual(str(items[0].msg), '')

_______________________________________________
Mailman-coders mailing list
[email protected]
https://mail.python.org/mailman/listinfo/mailman-coders

Reply via email to