summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/mailman/app/subscriptions.py3
-rw-r--r--src/mailman/app/tests/test_subscriptions.py16
-rw-r--r--src/mailman/interfaces/pending.py6
-rw-r--r--src/mailman/interfaces/registrar.py23
-rw-r--r--src/mailman/model/docs/pending.rst19
-rw-r--r--src/mailman/model/pending.py5
-rw-r--r--src/mailman/rest/docs/moderation.rst361
-rw-r--r--src/mailman/rest/docs/post-moderation.rst192
-rw-r--r--src/mailman/rest/lists.py3
-rw-r--r--src/mailman/rest/post_moderation.py (renamed from src/mailman/rest/moderation.py)109
-rw-r--r--src/mailman/rest/tests/test_moderation.py219
11 files changed, 428 insertions, 528 deletions
diff --git a/src/mailman/app/subscriptions.py b/src/mailman/app/subscriptions.py
index 1c6b96016..46ce549af 100644
--- a/src/mailman/app/subscriptions.py
+++ b/src/mailman/app/subscriptions.py
@@ -24,7 +24,6 @@ __all__ = [
]
-
import uuid
import logging
@@ -170,6 +169,8 @@ class SubscriptionWorkflow(Workflow):
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))
diff --git a/src/mailman/app/tests/test_subscriptions.py b/src/mailman/app/tests/test_subscriptions.py
index 732266ac3..6b5d88026 100644
--- a/src/mailman/app/tests/test_subscriptions.py
+++ b/src/mailman/app/tests/test_subscriptions.py
@@ -57,6 +57,22 @@ class TestSubscriptionWorkflow(unittest.TestCase):
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'], 'anne@example.com')
+ 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)
diff --git a/src/mailman/interfaces/pending.py b/src/mailman/interfaces/pending.py
index 222f0dfbf..c921123de 100644
--- a/src/mailman/interfaces/pending.py
+++ b/src/mailman/interfaces/pending.py
@@ -95,4 +95,10 @@ class IPendings(Interface):
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.')
diff --git a/src/mailman/interfaces/registrar.py b/src/mailman/interfaces/registrar.py
index 6f049b9d7..959e0bf6a 100644
--- a/src/mailman/interfaces/registrar.py
+++ b/src/mailman/interfaces/registrar.py
@@ -75,12 +75,13 @@ class IRegistrar(Interface):
: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 @@ class IRegistrar(Interface):
: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.
"""
diff --git a/src/mailman/model/docs/pending.rst b/src/mailman/model/docs/pending.rst
index 4a14edb2a..03eee3772 100644
--- a/src/mailman/model/docs/pending.rst
+++ b/src/mailman/model/docs/pending.rst
@@ -43,10 +43,9 @@ There's exactly one entry in the pendings database now.
>>> 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 @@ expunge it.
>>> 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.
diff --git a/src/mailman/model/pending.py b/src/mailman/model/pending.py
index bbe95d5f0..04b63f2ca 100644
--- a/src/mailman/model/pending.py
+++ b/src/mailman/model/pending.py
@@ -166,6 +166,11 @@ class Pendings:
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):
diff --git a/src/mailman/rest/docs/moderation.rst b/src/mailman/rest/docs/moderation.rst
deleted file mode 100644
index 0a9fd59a8..000000000
--- a/src/mailman/rest/docs/moderation.rst
+++ /dev/null
@@ -1,361 +0,0 @@
-==========
-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
-==================
-
-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('ant@example.com')
- >>> transaction.commit()
- >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/held')
- http_etag: "..."
- start: 0
- total_size: 0
-
-When a message gets held for moderator approval, it shows up in this list.
-::
-
- >>> msg = message_from_string("""\
- ... From: anne@example.com
- ... To: ant@example.com
- ... Subject: Something
- ... Message-ID: <alpha>
- ...
- ... Something else.
- ... """)
-
- >>> from mailman.app.moderator import hold_message
- >>> request_id = hold_message(ant, msg, {'extra': 7}, 'Because')
- >>> transaction.commit()
-
- >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/held')
- entry 0:
- extra: 7
- hold_date: 2005-08-01T07:49:23
- http_etag: "..."
- message_id: <alpha>
- msg: From: anne@example.com
- To: ant@example.com
- Subject: Something
- Message-ID: <alpha>
- X-Message-ID-Hash: GCSMSG43GYWWVUMO6F7FBUSSPNXQCJ6M
- <BLANKLINE>
- Something else.
- <BLANKLINE>
- reason: Because
- request_id: 1
- sender: anne@example.com
- subject: Something
- http_etag: "..."
- start: 0
- total_size: 1
-
-You can get an individual held message by providing the *request id* for that
-message. This will include the text of the message.
-::
-
- >>> def url(request_id):
- ... return ('http://localhost:9001/3.0/lists/'
- ... 'ant@example.com/held/{0}'.format(request_id))
-
- >>> dump_json(url(request_id))
- extra: 7
- hold_date: 2005-08-01T07:49:23
- http_etag: "..."
- message_id: <alpha>
- msg: From: anne@example.com
- To: ant@example.com
- Subject: Something
- Message-ID: <alpha>
- X-Message-ID-Hash: GCSMSG43GYWWVUMO6F7FBUSSPNXQCJ6M
- <BLANKLINE>
- Something else.
- <BLANKLINE>
- reason: Because
- request_id: 1
- sender: anne@example.com
- subject: Something
-
-
-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
-following:
-
- * discard - throw the message away.
- * reject - bounces the message back to the original author.
- * defer - defer any action on the message (continue to hold it)
- * accept - accept the message for posting.
-
-Let's see what happens when the above message is deferred.
-
- >>> dump_json(url(request_id), {
- ... 'action': 'defer',
- ... })
- content-length: 0
- date: ...
- server: ...
- status: 204
-
-The message is still in the moderation queue.
-
- >>> dump_json(url(request_id))
- extra: 7
- hold_date: 2005-08-01T07:49:23
- http_etag: "..."
- message_id: <alpha>
- msg: From: anne@example.com
- To: ant@example.com
- Subject: Something
- Message-ID: <alpha>
- X-Message-ID-Hash: GCSMSG43GYWWVUMO6F7FBUSSPNXQCJ6M
- <BLANKLINE>
- Something else.
- <BLANKLINE>
- reason: Because
- request_id: 1
- sender: anne@example.com
- subject: Something
-
-The held message can be discarded.
-
- >>> dump_json(url(request_id), {
- ... 'action': 'discard',
- ... })
- content-length: 0
- date: ...
- server: ...
- status: 204
-
-Messages can also be accepted via the REST API. Let's hold a new message for
-moderation.
-::
-
- >>> del msg['message-id']
- >>> msg['Message-ID'] = '<bravo>'
- >>> request_id = hold_message(ant, msg)
- >>> transaction.commit()
-
- >>> results = call_http(url(request_id))
- >>> print(results['message_id'])
- <bravo>
-
- >>> dump_json(url(request_id), {
- ... 'action': 'accept',
- ... })
- content-length: 0
- date: ...
- server: ...
- status: 204
-
- >>> from mailman.testing.helpers import get_queue_messages
- >>> messages = get_queue_messages('pipeline')
- >>> len(messages)
- 1
- >>> print(messages[0].msg['message-id'])
- <bravo>
-
-Messages can be rejected via the REST API too. These bounce the message back
-to the original author.
-::
-
- >>> del msg['message-id']
- >>> msg['Message-ID'] = '<charlie>'
- >>> request_id = hold_message(ant, msg)
- >>> transaction.commit()
-
- >>> results = call_http(url(request_id))
- >>> print(results['message_id'])
- <charlie>
-
- >>> dump_json(url(request_id), {
- ... 'action': 'reject',
- ... })
- content-length: 0
- date: ...
- server: ...
- status: 204
-
- >>> from mailman.testing.helpers import get_queue_messages
- >>> messages = get_queue_messages('virgin')
- >>> len(messages)
- 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/ant@example.com/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('anne@example.com', 'Anne Person',
- ... DeliveryMode.regular, 'en'))
- >>> transaction.commit()
-
-The subscription request is available from the mailing list.
-
- >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/requests')
- entry 0:
- delivery_mode: regular
- display_name: Anne Person
- email: anne@example.com
- 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='bart@example.com')
- >>> unsub_req_id = hold_unsubscription(ant, 'bart@example.com')
- >>> transaction.commit()
-
-The unsubscription request is also available from the mailing list.
-
- >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/requests')
- entry 0:
- delivery_mode: regular
- display_name: Anne Person
- email: anne@example.com
- http_etag: "..."
- language: en
- request_id: ...
- type: subscription
- when: 2005-08-01T07:49:23
- entry 1:
- email: bart@example.com
- 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/ant@example.com/'
- ... 'requests/{}'.format(sub_req_id))
- delivery_mode: regular
- display_name: Anne Person
- email: anne@example.com
- 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/ant@example.com/'
- ... 'requests/{}'.format(unsub_req_id))
- email: bart@example.com
- 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/'
- ... 'ant@example.com/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('anne@example.com')
- <Member: Anne Person <anne@example.com> on ant@example.com
- as MemberRole.member>
- >>> transaction.abort()
-
-Bart's unsubscription request is discarded.
-
- >>> dump_json('http://localhost:9001/3.0/lists/'
- ... 'ant@example.com/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('bart@example.com'))
- <Member: Bart Person <bart@example.com> on ant@example.com
- as MemberRole.member>
- >>> transaction.abort()
-
-There are no more membership change requests.
-
- >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/requests')
- http_etag: "..."
- start: 0
- total_size: 0
diff --git a/src/mailman/rest/docs/post-moderation.rst b/src/mailman/rest/docs/post-moderation.rst
new file mode 100644
index 000000000..f082de157
--- /dev/null
+++ b/src/mailman/rest/docs/post-moderation.rst
@@ -0,0 +1,192 @@
+===============
+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('ant@example.com')
+ >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/held')
+ http_etag: "..."
+ start: 0
+ total_size: 0
+
+When a message gets held for moderator approval, it shows up in this list.
+::
+
+ >>> msg = message_from_string("""\
+ ... From: anne@example.com
+ ... To: ant@example.com
+ ... Subject: Something
+ ... Message-ID: <alpha>
+ ...
+ ... Something else.
+ ... """)
+
+ >>> from mailman.app.moderator import hold_message
+ >>> request_id = hold_message(ant, msg, {'extra': 7}, 'Because')
+ >>> transaction.commit()
+
+ >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/held')
+ entry 0:
+ extra: 7
+ hold_date: 2005-08-01T07:49:23
+ http_etag: "..."
+ message_id: <alpha>
+ msg: From: anne@example.com
+ To: ant@example.com
+ Subject: Something
+ Message-ID: <alpha>
+ X-Message-ID-Hash: GCSMSG43GYWWVUMO6F7FBUSSPNXQCJ6M
+ <BLANKLINE>
+ Something else.
+ <BLANKLINE>
+ reason: Because
+ request_id: 1
+ sender: anne@example.com
+ subject: Something
+ http_etag: "..."
+ start: 0
+ total_size: 1
+
+You can get an individual held message by providing the *request id* for that
+message. This will include the text of the message.
+::
+
+ >>> def url(request_id):
+ ... return ('http://localhost:9001/3.0/lists/'
+ ... 'ant@example.com/held/{0}'.format(request_id))
+
+ >>> dump_json(url(request_id))
+ extra: 7
+ hold_date: 2005-08-01T07:49:23
+ http_etag: "..."
+ message_id: <alpha>
+ msg: From: anne@example.com
+ To: ant@example.com
+ Subject: Something
+ Message-ID: <alpha>
+ X-Message-ID-Hash: GCSMSG43GYWWVUMO6F7FBUSSPNXQCJ6M
+ <BLANKLINE>
+ Something else.
+ <BLANKLINE>
+ reason: Because
+ request_id: 1
+ sender: anne@example.com
+ subject: Something
+
+
+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
+following:
+
+ * discard - throw the message away.
+ * reject - bounces the message back to the original author.
+ * defer - defer any action on the message (continue to hold it)
+ * accept - accept the message for posting.
+
+Let's see what happens when the above message is deferred.
+
+ >>> dump_json(url(request_id), {
+ ... 'action': 'defer',
+ ... })
+ content-length: 0
+ date: ...
+ server: ...
+ status: 204
+
+The message is still in the moderation queue.
+
+ >>> dump_json(url(request_id))
+ extra: 7
+ hold_date: 2005-08-01T07:49:23
+ http_etag: "..."
+ message_id: <alpha>
+ msg: From: anne@example.com
+ To: ant@example.com
+ Subject: Something
+ Message-ID: <alpha>
+ X-Message-ID-Hash: GCSMSG43GYWWVUMO6F7FBUSSPNXQCJ6M
+ <BLANKLINE>
+ Something else.
+ <BLANKLINE>
+ reason: Because
+ request_id: 1
+ sender: anne@example.com
+ subject: Something
+
+The held message can be discarded.
+
+ >>> dump_json(url(request_id), {
+ ... 'action': 'discard',
+ ... })
+ content-length: 0
+ date: ...
+ server: ...
+ status: 204
+
+Messages can also be accepted via the REST API. Let's hold a new message for
+moderation.
+::
+
+ >>> del msg['message-id']
+ >>> msg['Message-ID'] = '<bravo>'
+ >>> request_id = hold_message(ant, msg)
+ >>> transaction.commit()
+
+ >>> results = call_http(url(request_id))
+ >>> print(results['message_id'])
+ <bravo>
+
+ >>> dump_json(url(request_id), {
+ ... 'action': 'accept',
+ ... })
+ content-length: 0
+ date: ...
+ server: ...
+ status: 204
+
+ >>> from mailman.testing.helpers import get_queue_messages
+ >>> messages = get_queue_messages('pipeline')
+ >>> len(messages)
+ 1
+ >>> print(messages[0].msg['message-id'])
+ <bravo>
+
+Messages can be rejected via the REST API too. These bounce the message back
+to the original author.
+::
+
+ >>> del msg['message-id']
+ >>> msg['Message-ID'] = '<charlie>'
+ >>> request_id = hold_message(ant, msg)
+ >>> transaction.commit()
+
+ >>> results = call_http(url(request_id))
+ >>> print(results['message_id'])
+ <charlie>
+
+ >>> dump_json(url(request_id), {
+ ... 'action': 'reject',
+ ... })
+ content-length: 0
+ date: ...
+ server: ...
+ status: 204
+
+ >>> from mailman.testing.helpers import get_queue_messages
+ >>> messages = get_queue_messages('virgin')
+ >>> len(messages)
+ 1
+ >>> print(messages[0].msg['subject'])
+ Request to mailing list "Ant" rejected
diff --git a/src/mailman/rest/lists.py b/src/mailman/rest/lists.py
index f6bc27917..0607102cb 100644
--- a/src/mailman/rest/lists.py
+++ b/src/mailman/rest/lists.py
@@ -42,7 +42,8 @@ from mailman.rest.helpers import (
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
diff --git a/src/mailman/rest/moderation.py b/src/mailman/rest/post_moderation.py
index 984e2d34a..6156fa39f 100644
--- a/src/mailman/rest/moderation.py
+++ b/src/mailman/rest/post_moderation.py
@@ -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 mailman.rest.validator import Validator, enum_validator
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 @@ class _ModerationBase:
# 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 @@ class _HeldMessageBase(_ModerationBase):
"""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 @@ class HeldMessages(_HeldMessageBase, CollectionMixin):
@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'])
diff --git a/src/mailman/rest/tests/test_moderation.py b/src/mailman/rest/tests/test_moderation.py
index c77ae2aca..ab7383251 100644
--- a/src/mailman/rest/tests/test_moderation.py
+++ b/src/mailman/rest/tests/test_moderation.py
@@ -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 @@ Something else.
call_api('http://localhost:9001/3.0/lists/ant@example.com/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('bperson@example.net', 'Bart Person',
- DeliveryMode.regular, 'en'))
- config.db.store.commit()
- url = 'http://localhost:9001/3.0/lists/ant@example.com/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,43 +83,180 @@ Something else.
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.
+ def test_discard(self):
+ # Discarding a message removes it from the moderation queue.
+ with transaction():
+ held_id = hold_message(self._mlist, self._msg)
+ url = 'http://localhost:9001/3.0/lists/ant@example.com/held/{}'.format(
+ held_id)
+ content, response = call_api(url, dict(action='discard'))
+ self.assertEqual(response.status, 204)
+ # Now it's gone.
with self.assertRaises(HTTPError) as cm:
- call_api('http://localhost:9001/3.0/lists/ant@example.com/'
- 'requests/bogus')
- self.assertEqual(cm.exception.code, 400)
+ call_api(url, dict(action='discard'))
+ self.assertEqual(cm.exception.code, 404)
- def test_missing_subscription_request_id(self):
- # Bad request when the request_id is not in the database.
+
+
+class TestSubscriptionModeration(unittest.TestCase):
+ layer = RESTLayer
+
+ def setUp(self):
+ with transaction():
+ self._mlist = create_list('ant@example.com')
+ self._registrar = IRegistrar(self._mlist)
+ manager = getUtility(IUserManager)
+ self._anne = manager.create_address(
+ 'anne@example.com', 'Anne Person')
+ self._bart = manager.make_user(
+ 'bart@example.com', '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/bee@example.com/'
+ '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/ant@example.com/'
- 'requests/99')
+ 'requests/missing')
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('cperson@example.net', 'Cris Person',
- DeliveryMode.regular, 'en'))
- config.db.store.commit()
- url = 'http://localhost:9001/3.0/lists/ant@example.com/requests/{0}'
+ 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/ant@example.com/requests/{}'
with self.assertRaises(HTTPError) as cm:
- call_api(url.format(held_id), {'action': 'bogus'})
+ 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/ant@example.com/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/ant@example.com/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('anne@example.com').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):
- # Discarding a message removes it from the moderation queue.
- with transaction():
- held_id = hold_message(self._mlist, self._msg)
- url = 'http://localhost:9001/3.0/lists/ant@example.com/held/{}'.format(
- held_id)
- content, response = call_api(url, dict(action='discard'))
- self.assertEqual(response.status, 204)
- # Now it's gone.
+ # 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/ant@example.com/requests/{}'
with self.assertRaises(HTTPError) as cm:
- call_api(url, dict(action='discard'))
+ 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('anne@example.com'))
+ # 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/ant@example.com/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('anne@example.com'))
+ # 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('anne@example.com').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/ant@example.com/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('anne@example.com'))
+ # 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), '')