summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/mailman/app/docs/moderator.rst538
-rw-r--r--src/mailman/app/membership.py2
-rw-r--r--src/mailman/app/moderator.py5
-rw-r--r--src/mailman/app/notifications.py45
-rw-r--r--src/mailman/app/tests/test_moderation.py46
-rw-r--r--src/mailman/docs/NEWS.rst8
-rw-r--r--src/mailman/model/docs/requests.rst873
-rw-r--r--src/mailman/model/requests.py7
-rw-r--r--src/mailman/model/tests/test_requests.py21
-rw-r--r--src/mailman/rest/docs/moderation.rst253
-rw-r--r--src/mailman/rest/lists.py11
-rw-r--r--src/mailman/rest/moderation.py179
-rw-r--r--src/mailman/rest/tests/test_moderation.py2
13 files changed, 1090 insertions, 900 deletions
diff --git a/src/mailman/app/docs/moderator.rst b/src/mailman/app/docs/moderator.rst
new file mode 100644
index 000000000..ec48f6e88
--- /dev/null
+++ b/src/mailman/app/docs/moderator.rst
@@ -0,0 +1,538 @@
+.. _app-moderator:
+
+============================
+Application level moderation
+============================
+
+At an application level, moderation involves holding messages and membership
+changes for moderator approval. This utilizes the :ref:`lower level interface
+<model-requests>` for list-centric moderation requests.
+
+Moderation is always mailing list-centric.
+
+ >>> mlist = create_list('ant@example.com')
+ >>> mlist.preferred_language = 'en'
+ >>> mlist.display_name = 'A Test List'
+ >>> mlist.admin_immed_notify = False
+
+We'll use the lower level API for diagnostic purposes.
+
+ >>> from mailman.interfaces.requests import IListRequests
+ >>> requests = IListRequests(mlist)
+
+
+Message moderation
+==================
+
+Holding messages
+----------------
+
+Anne posts a message to the mailing list, but she is not a member of the list,
+so the message is held for moderator approval.
+
+ >>> msg = message_from_string("""\
+ ... From: anne@example.org
+ ... To: ant@example.com
+ ... Subject: Something important
+ ... Message-ID: <aardvark>
+ ...
+ ... Here's something important about our mailing list.
+ ... """)
+
+*Holding a message* means keeping a copy of it that a moderator must approve
+before the message is posted to the mailing list. To hold the message, the
+message, its metadata, and a reason for the hold must be provided. In this
+case, we won't include any additional metadata.
+
+ >>> from mailman.app.moderator import hold_message
+ >>> hold_message(mlist, msg, {}, 'Needs approval')
+ 1
+
+We can also hold a message with some additional metadata.
+::
+
+ >>> msg = message_from_string("""\
+ ... From: bart@example.org
+ ... To: ant@example.com
+ ... Subject: Something important
+ ... Message-ID: <badger>
+ ...
+ ... Here's something important about our mailing list.
+ ... """)
+ >>> msgdata = dict(sender='anne@example.com', approved=True)
+
+ >>> hold_message(mlist, msg, msgdata, 'Feeling ornery')
+ 2
+
+
+Disposing of messages
+---------------------
+
+The moderator can select one of several dispositions:
+
+ * 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.
+
+The most trivial is to simply defer a decision for now.
+
+ >>> from mailman.interfaces.action import Action
+ >>> from mailman.app.moderator import handle_message
+ >>> handle_message(mlist, 1, Action.defer)
+
+This leaves the message in the requests database.
+
+ >>> key, data = requests.get_request(1)
+ >>> print key
+ <aardvark>
+
+The moderator can also discard the message.
+
+ >>> handle_message(mlist, 1, Action.discard)
+ >>> print requests.get_request(1)
+ None
+
+The message can be rejected, which bounces the message back to the original
+sender.
+
+ >>> handle_message(mlist, 2, Action.reject, 'Off topic')
+
+The message is no longer available in the requests database.
+
+ >>> print requests.get_request(2)
+ None
+
+And there is one message in the *virgin* queue - the rejection notice.
+
+ >>> from mailman.testing.helpers import get_queue_messages
+ >>> messages = get_queue_messages('virgin')
+ >>> len(messages)
+ 1
+ >>> print messages[0].msg.as_string()
+ MIME-Version: 1.0
+ ...
+ Subject: Request to mailing list "A Test List" rejected
+ From: ant-bounces@example.com
+ To: bart@example.org
+ ...
+ <BLANKLINE>
+ Your request to the ant@example.com mailing list
+ <BLANKLINE>
+ Posting of your message titled "Something important"
+ <BLANKLINE>
+ has been rejected by the list moderator. The moderator gave the
+ following reason for rejecting your request:
+ <BLANKLINE>
+ "Off topic"
+ <BLANKLINE>
+ Any questions or comments should be directed to the list administrator
+ at:
+ <BLANKLINE>
+ ant-owner@example.com
+ <BLANKLINE>
+
+The bounce gets sent to the original sender.
+
+ >>> for recipient in sorted(messages[0].msgdata['recipients']):
+ ... print recipient
+ bart@example.org
+
+Or the message can be approved.
+
+ >>> msg = message_from_string("""\
+ ... From: cris@example.org
+ ... To: ant@example.com
+ ... Subject: Something important
+ ... Message-ID: <caribou>
+ ...
+ ... Here's something important about our mailing list.
+ ... """)
+ >>> id = hold_message(mlist, msg, {}, 'Needs approval')
+ >>> handle_message(mlist, id, Action.accept)
+
+This places the message back into the incoming queue for further processing,
+however the message metadata indicates that the message has been approved.
+::
+
+ >>> messages = get_queue_messages('pipeline')
+ >>> len(messages)
+ 1
+ >>> print messages[0].msg.as_string()
+ From: cris@example.org
+ To: ant@example.com
+ Subject: Something important
+ ...
+
+ >>> dump_msgdata(messages[0].msgdata)
+ _parsemsg : False
+ approved : True
+ moderator_approved: True
+ version : 3
+
+
+Preserving and forwarding the message
+-------------------------------------
+
+In addition to any of the above dispositions, the message can also be
+preserved for further study. Ordinarily the message is removed from the
+global message store after its disposition (though approved messages may be
+re-added to the message store later). When handling a message, we can ask for
+a copy to be preserve, which skips deleting the message from the storage.
+::
+
+ >>> msg = message_from_string("""\
+ ... From: dave@example.org
+ ... To: ant@example.com
+ ... Subject: Something important
+ ... Message-ID: <dolphin>
+ ...
+ ... Here's something important about our mailing list.
+ ... """)
+ >>> id = hold_message(mlist, msg, {}, 'Needs approval')
+ >>> handle_message(mlist, id, Action.discard, preserve=True)
+
+ >>> from mailman.interfaces.messages import IMessageStore
+ >>> from zope.component import getUtility
+ >>> message_store = getUtility(IMessageStore)
+ >>> print message_store.get_message_by_id('<dolphin>')['message-id']
+ <dolphin>
+
+Orthogonal to preservation, the message can also be forwarded to another
+address. This is helpful for getting the message into the inbox of one of the
+moderators.
+::
+
+ >>> msg = message_from_string("""\
+ ... From: elly@example.org
+ ... To: ant@example.com
+ ... Subject: Something important
+ ... Message-ID: <elephant>
+ ...
+ ... Here's something important about our mailing list.
+ ... """)
+ >>> hold_message(mlist, msg, {}, 'Needs approval')
+ 2
+ >>> handle_message(mlist, 2, Action.discard, forward=['zack@example.com'])
+
+The forwarded message is in the virgin queue, destined for the moderator.
+::
+
+ >>> messages = get_queue_messages('virgin')
+ >>> len(messages)
+ 1
+ >>> print messages[0].msg.as_string()
+ Subject: Forward of moderated message
+ From: ant-bounces@example.com
+ To: zack@example.com
+ ...
+
+ >>> for recipient in sorted(messages[0].msgdata['recipients']):
+ ... print recipient
+ zack@example.com
+
+
+Holding subscription requests
+=============================
+
+For closed lists, subscription requests will also be held for moderator
+approval. In this case, several pieces of information related to the
+subscription must be provided, including the subscriber's address and real
+name, their password (possibly hashed), what kind of delivery option they are
+choosing and their preferred language.
+
+ >>> from mailman.app.moderator import hold_subscription
+ >>> from mailman.interfaces.member import DeliveryMode
+ >>> hold_subscription(mlist,
+ ... 'fred@example.org', 'Fred Person',
+ ... '{NONE}abcxyz', DeliveryMode.regular, 'en')
+ 2
+
+
+Disposing of membership change requests
+---------------------------------------
+
+Just as with held messages, the moderator can select one of several
+dispositions for this membership change request. The most trivial is to
+simply defer a decision for now.
+
+ >>> from mailman.app.moderator import handle_subscription
+ >>> handle_subscription(mlist, 2, Action.defer)
+ >>> requests.get_request(2) is not None
+ True
+
+The held subscription can also be discarded.
+
+ >>> handle_subscription(mlist, 2, Action.discard)
+ >>> print requests.get_request(2)
+ None
+
+Gwen tries to subscribe to the mailing list, but...
+
+ >>> hold_subscription(mlist,
+ ... 'gwen@example.org', 'Gwen Person',
+ ... '{NONE}zyxcba', DeliveryMode.regular, 'en')
+ 2
+
+...her request is rejected...
+
+ >>> handle_subscription(mlist, 2, Action.reject, 'This is a closed list')
+ >>> messages = get_queue_messages('virgin')
+ >>> len(messages)
+ 1
+
+...and she receives a rejection notice.
+
+ >>> print messages[0].msg.as_string()
+ MIME-Version: 1.0
+ ...
+ Subject: Request to mailing list "A Test List" rejected
+ From: ant-bounces@example.com
+ To: gwen@example.org
+ ...
+ Your request to the ant@example.com mailing list
+ <BLANKLINE>
+ Subscription request
+ <BLANKLINE>
+ has been rejected by the list moderator. The moderator gave the
+ following reason for rejecting your request:
+ <BLANKLINE>
+ "This is a closed list"
+ ...
+
+The subscription can also be accepted. This subscribes the address to the
+mailing list.
+
+ >>> mlist.send_welcome_message = False
+ >>> hold_subscription(mlist,
+ ... 'herb@example.org', 'Herb Person',
+ ... 'abcxyz', DeliveryMode.regular, 'en')
+ 2
+
+The moderators accept the subscription request.
+
+ >>> handle_subscription(mlist, 2, Action.accept)
+
+And now Herb is a member of the mailing list.
+
+ >>> print mlist.members.get_member('herb@example.org').address
+ Herb Person <herb@example.org>
+
+
+Holding unsubscription requests
+===============================
+
+Some lists require moderator approval for unsubscriptions. In this case, only
+the unsubscribing address is required.
+
+Herb now wants to leave the mailing list, but his request must be approved.
+
+ >>> from mailman.app.moderator import hold_unsubscription
+ >>> hold_unsubscription(mlist, 'herb@example.org')
+ 2
+
+As with subscription requests, the unsubscription request can be deferred.
+
+ >>> from mailman.app.moderator import handle_unsubscription
+ >>> handle_unsubscription(mlist, 2, Action.defer)
+ >>> print mlist.members.get_member('herb@example.org').address
+ Herb Person <herb@example.org>
+
+The held unsubscription can also be discarded, and the member will remain
+subscribed.
+
+ >>> handle_unsubscription(mlist, 2, Action.discard)
+ >>> print mlist.members.get_member('herb@example.org').address
+ Herb Person <herb@example.org>
+
+The request can be rejected, in which case a message is sent to the member,
+and the person remains a member of the mailing list.
+
+ >>> hold_unsubscription(mlist, 'herb@example.org')
+ 2
+ >>> handle_unsubscription(mlist, 2, Action.reject, 'No can do')
+ >>> print mlist.members.get_member('herb@example.org').address
+ Herb Person <herb@example.org>
+
+Herb gets a rejection notice.
+::
+
+ >>> messages = get_queue_messages('virgin')
+ >>> len(messages)
+ 1
+
+ >>> print messages[0].msg.as_string()
+ MIME-Version: 1.0
+ ...
+ Subject: Request to mailing list "A Test List" rejected
+ From: ant-bounces@example.com
+ To: herb@example.org
+ ...
+ Your request to the ant@example.com mailing list
+ <BLANKLINE>
+ Unsubscription request
+ <BLANKLINE>
+ has been rejected by the list moderator. The moderator gave the
+ following reason for rejecting your request:
+ <BLANKLINE>
+ "No can do"
+ ...
+
+The unsubscription request can also be accepted. This removes the member from
+the mailing list.
+
+ >>> hold_unsubscription(mlist, 'herb@example.org')
+ 2
+ >>> mlist.send_goodbye_message = False
+ >>> handle_unsubscription(mlist, 2, Action.accept)
+ >>> print mlist.members.get_member('herb@example.org')
+ None
+
+
+Notifications
+=============
+
+Membership change requests
+--------------------------
+
+Usually, the list administrators want to be notified when there are membership
+change requests they need to moderate. These notifications are sent when the
+list is configured to send them.
+
+ >>> mlist.admin_immed_notify = True
+
+Iris tries to subscribe to the mailing list.
+
+ >>> hold_subscription(mlist, 'iris@example.org', 'Iris Person',
+ ... 'password', DeliveryMode.regular, 'en')
+ 2
+
+There's now a message in the virgin queue, destined for the list owner.
+
+ >>> messages = get_queue_messages('virgin')
+ >>> len(messages)
+ 1
+ >>> print messages[0].msg.as_string()
+ MIME-Version: 1.0
+ ...
+ Subject: New subscription request to A Test List from iris@example.org
+ From: ant-owner@example.com
+ To: ant-owner@example.com
+ ...
+ Your authorization is required for a mailing list subscription request
+ approval:
+ <BLANKLINE>
+ For: iris@example.org
+ List: ant@example.com
+ ...
+
+Similarly, the administrator gets notifications on unsubscription requests.
+Jeff is a member of the mailing list, and chooses to unsubscribe.
+
+ >>> hold_unsubscription(mlist, 'jeff@example.org')
+ 3
+ >>> messages = get_queue_messages('virgin')
+ >>> len(messages)
+ 1
+ >>> print messages[0].msg.as_string()
+ MIME-Version: 1.0
+ ...
+ Subject: New unsubscription request from A Test List by jeff@example.org
+ From: ant-owner@example.com
+ To: ant-owner@example.com
+ ...
+ Your authorization is required for a mailing list unsubscription
+ request approval:
+ <BLANKLINE>
+ By: jeff@example.org
+ From: ant@example.com
+ ...
+
+
+Membership changes
+------------------
+
+When a new member request is accepted, the mailing list administrators can
+receive a membership change notice.
+
+ >>> mlist.admin_notify_mchanges = True
+ >>> mlist.admin_immed_notify = False
+ >>> handle_subscription(mlist, 2, Action.accept)
+ >>> messages = get_queue_messages('virgin')
+ >>> len(messages)
+ 1
+ >>> print messages[0].msg.as_string()
+ MIME-Version: 1.0
+ ...
+ Subject: A Test List subscription notification
+ From: noreply@example.com
+ To: ant-owner@example.com
+ ...
+ Iris Person <iris@example.org> has been successfully subscribed to A
+ Test List.
+
+Similarly when an unsubscription request is accepted, the administrators can
+get a notification.
+
+ >>> hold_unsubscription(mlist, 'iris@example.org')
+ 4
+ >>> handle_unsubscription(mlist, 4, Action.accept)
+ >>> messages = get_queue_messages('virgin')
+ >>> len(messages)
+ 1
+ >>> print messages[0].msg.as_string()
+ MIME-Version: 1.0
+ ...
+ Subject: A Test List unsubscription notification
+ From: noreply@example.com
+ To: ant-owner@example.com
+ ...
+ Iris Person <iris@example.org> has been removed from A Test List.
+
+
+Welcome messages
+----------------
+
+When a member is subscribed to the mailing list via moderator approval, she
+can get a welcome message.
+
+ >>> mlist.admin_notify_mchanges = False
+ >>> mlist.send_welcome_message = True
+ >>> hold_subscription(mlist, 'kate@example.org', 'Kate Person',
+ ... 'password', DeliveryMode.regular, 'en')
+ 4
+ >>> handle_subscription(mlist, 4, Action.accept)
+ >>> messages = get_queue_messages('virgin')
+ >>> len(messages)
+ 1
+ >>> print messages[0].msg.as_string()
+ MIME-Version: 1.0
+ ...
+ Subject: Welcome to the "A Test List" mailing list
+ From: ant-request@example.com
+ To: Kate Person <kate@example.org>
+ ...
+ Welcome to the "A Test List" mailing list!
+ ...
+
+
+Goodbye messages
+----------------
+
+Similarly, when the member's unsubscription request is approved, she'll get a
+goodbye message.
+
+ >>> mlist.send_goodbye_message = True
+ >>> hold_unsubscription(mlist, 'kate@example.org')
+ 4
+ >>> handle_unsubscription(mlist, 4, Action.accept)
+ >>> messages = get_queue_messages('virgin')
+ >>> len(messages)
+ 1
+ >>> print messages[0].msg.as_string()
+ MIME-Version: 1.0
+ ...
+ Subject: You have been unsubscribed from the A Test List mailing list
+ From: ant-bounces@example.com
+ To: kate@example.org
+ ...
diff --git a/src/mailman/app/membership.py b/src/mailman/app/membership.py
index 3f012e5a5..dfe794610 100644
--- a/src/mailman/app/membership.py
+++ b/src/mailman/app/membership.py
@@ -131,7 +131,7 @@ def delete_member(mlist, email, admin_notif=None, userack=None):
mailing list.
"""
if userack is None:
- userack = mlist.send_goodbye_msg
+ userack = mlist.send_goodbye_message
if admin_notif is None:
admin_notif = mlist.admin_notify_mchanges
# Delete a member, for which we know the approval has been made.
diff --git a/src/mailman/app/moderator.py b/src/mailman/app/moderator.py
index 2e2711809..7e6f4758e 100644
--- a/src/mailman/app/moderator.py
+++ b/src/mailman/app/moderator.py
@@ -202,7 +202,7 @@ def hold_subscription(mlist, address, display_name, password, mode, language):
address=address,
display_name=display_name,
password=password,
- delivery_mode=str(mode),
+ delivery_mode=mode.name,
language=language)
# Now hold this request. We'll use the address as the key.
requestsdb = IListRequests(mlist)
@@ -246,8 +246,7 @@ def handle_subscription(mlist, id, action, comment=None):
lang=getUtility(ILanguageManager)[data['language']])
elif action is Action.accept:
key, data = requestdb.get_request(id)
- enum_value = data['delivery_mode'].split('.')[-1]
- delivery_mode = DeliveryMode(enum_value)
+ delivery_mode = DeliveryMode(data['delivery_mode'])
address = data['address']
display_name = data['display_name']
language = getUtility(ILanguageManager)[data['language']]
diff --git a/src/mailman/app/notifications.py b/src/mailman/app/notifications.py
index bc5b326ac..841822c84 100644
--- a/src/mailman/app/notifications.py
+++ b/src/mailman/app/notifications.py
@@ -47,6 +47,24 @@ log = logging.getLogger('mailman.error')
+def _get_message(uri_template, mlist, language):
+ if not uri_template:
+ return ''
+ try:
+ uri = expand(uri_template, dict(
+ listname=mlist.fqdn_listname,
+ language=language.code,
+ ))
+ message = getUtility(ITemplateLoader).get(uri)
+ except URLError:
+ log.exception('Message URI not found ({0}): {1}'.format(
+ mlist.fqdn_listname, uri_template))
+ return ''
+ else:
+ return wrap(message)
+
+
+
def send_welcome_message(mlist, address, language, delivery_mode, text=''):
"""Send a welcome message to a subscriber.
@@ -62,28 +80,15 @@ def send_welcome_message(mlist, address, language, delivery_mode, text=''):
:param delivery_mode: the type of delivery the subscriber is getting
:type delivery_mode: DeliveryMode
"""
- if mlist.welcome_message_uri:
- try:
- uri = expand(mlist.welcome_message_uri, dict(
- listname=mlist.fqdn_listname,
- language=language.code,
- ))
- welcome_message = getUtility(ITemplateLoader).get(uri)
- except URLError:
- log.exception('Welcome message URI not found ({0}): {1}'.format(
- mlist.fqdn_listname, mlist.welcome_message_uri))
- welcome = ''
- else:
- welcome = wrap(welcome_message)
- else:
- welcome = ''
+ welcome_message = _get_message(mlist.welcome_message_uri,
+ mlist, language)
# Find the IMember object which is subscribed to the mailing list, because
# from there, we can get the member's options url.
member = mlist.members.get_member(address)
user_name = member.user.display_name
options_url = member.options_url
# Get the text from the template.
- text = expand(welcome, dict(
+ text = expand(welcome_message, dict(
fqdn_listname=mlist.fqdn_listname,
list_name=mlist.display_name,
listinfo_uri=mlist.script_url('listinfo'),
@@ -119,15 +124,13 @@ def send_goodbye_message(mlist, address, language):
:param language: the language of the response
:type language: string
"""
- if mlist.goodbye_msg:
- goodbye = wrap(mlist.goodbye_msg) + '\n'
- else:
- goodbye = ''
+ goodbye_message = _get_message(mlist.goodbye_message_uri,
+ mlist, language)
msg = UserNotification(
address, mlist.bounces_address,
_('You have been unsubscribed from the $mlist.display_name '
'mailing list'),
- goodbye, language)
+ goodbye_message, language)
msg.send(mlist, verp=as_boolean(config.mta.verp_personalized_deliveries))
diff --git a/src/mailman/app/tests/test_moderation.py b/src/mailman/app/tests/test_moderation.py
index dc1217d67..8efffb48b 100644
--- a/src/mailman/app/tests/test_moderation.py
+++ b/src/mailman/app/tests/test_moderation.py
@@ -27,15 +27,18 @@ __all__ = [
import unittest
+from zope.component import getUtility
+
from mailman.app.lifecycle import create_list
from mailman.app.moderator import handle_message, hold_message
from mailman.interfaces.action import Action
+from mailman.interfaces.messages import IMessageStore
from mailman.interfaces.requests import IListRequests
from mailman.runners.incoming import IncomingRunner
from mailman.runners.outgoing import OutgoingRunner
from mailman.runners.pipeline import PipelineRunner
from mailman.testing.helpers import (
- make_testable_runner, specialized_message_from_string)
+ get_queue_messages, make_testable_runner, specialized_message_from_string)
from mailman.testing.layers import SMTPLayer
from mailman.utilities.datetime import now
@@ -48,6 +51,7 @@ class TestModeration(unittest.TestCase):
def setUp(self):
self._mlist = create_list('test@example.com')
+ self._request_db = IListRequests(self._mlist)
self._msg = specialized_message_from_string("""\
From: anne@example.com
To: test@example.com
@@ -101,11 +105,10 @@ Message-ID: <alpha>
request_id = hold_message(self._mlist, self._msg)
handle_message(self._mlist, request_id, Action.defer)
# The message is still in the pending requests.
- requests_db = IListRequests(self._mlist)
- key, data = requests_db.get_request(request_id)
+ key, data = self._request_db.get_request(request_id)
self.assertEqual(key, '<alpha>')
handle_message(self._mlist, request_id, Action.hold)
- key, data = requests_db.get_request(request_id)
+ key, data = self._request_db.get_request(request_id)
self.assertEqual(key, '<alpha>')
def test_lp_1031391(self):
@@ -115,6 +118,37 @@ Message-ID: <alpha>
received_time = now()
msgdata = dict(received_time=received_time)
request_id = hold_message(self._mlist, self._msg, msgdata)
- requests_db = IListRequests(self._mlist)
- key, data = requests_db.get_request(request_id)
+ key, data = self._request_db.get_request(request_id)
self.assertEqual(data['received_time'], received_time)
+
+ def test_non_preserving_disposition(self):
+ # By default, disposed messages are not preserved.
+ request_id = hold_message(self._mlist, self._msg)
+ handle_message(self._mlist, request_id, Action.discard)
+ message_store = getUtility(IMessageStore)
+ self.assertIsNone(message_store.get_message_by_id('<alpha>'))
+
+ def test_preserving_disposition(self):
+ # Preserving a message keeps it in the store.
+ request_id = hold_message(self._mlist, self._msg)
+ handle_message(self._mlist, request_id, Action.discard, preserve=True)
+ message_store = getUtility(IMessageStore)
+ preserved_message = message_store.get_message_by_id('<alpha>')
+ self.assertEqual(preserved_message['message-id'], '<alpha>')
+
+ def test_preserve_and_forward(self):
+ # We can both preserve and forward the message.
+ request_id = hold_message(self._mlist, self._msg)
+ handle_message(self._mlist, request_id, Action.discard,
+ preserve=True, forward=['zack@example.com'])
+ # The message is preserved in the store.
+ message_store = getUtility(IMessageStore)
+ preserved_message = message_store.get_message_by_id('<alpha>')
+ self.assertEqual(preserved_message['message-id'], '<alpha>')
+ # And the forwarded message lives in the virgin queue.
+ messages = get_queue_messages('virgin')
+ self.assertEqual(len(messages), 1)
+ self.assertEqual(str(messages[0].msg['subject']),
+ 'Forward of moderated message')
+ self.assertEqual(messages[0].msgdata['recipients'],
+ ['zack@example.com'])
diff --git a/src/mailman/docs/NEWS.rst b/src/mailman/docs/NEWS.rst
index 4c660fffd..f24f2f7d1 100644
--- a/src/mailman/docs/NEWS.rst
+++ b/src/mailman/docs/NEWS.rst
@@ -16,12 +16,14 @@ Compatibility
-------------
* Python 2.7 is not required. Python 2.6 is no longer officially supported.
The code base is now also `python2.7 -3` clean, although there are still
- some warnings in 3rd party dependencies. LP: #1073506
+ some warnings in 3rd party dependencies. (LP: #1073506)
REST
----
* Allow the getting/setting of IMailingList.subject_prefix via the REST API
(given by Terri Oda). (LP: #1062893)
+ * Expose a REST API for membership change (subscriptions and unsubscriptions)
+ moderation. (LP: #1090753)
* Add list_id to JSON representation for a mailing list (given by Jimmy
Bergman).
* The canonical resource for a mailing list (and thus its self_link) is now
@@ -71,6 +73,10 @@ Commands
* `bin/mailman start` was passing the wrong relative path to its runner
subprocesses when -C was given. LP: #982551
+Bugs
+----
+ * Fixed `send_goodbye_message()`. (LP: #1091321)
+
3.0 beta 2 -- "Freeze"
======================
diff --git a/src/mailman/model/docs/requests.rst b/src/mailman/model/docs/requests.rst
index a51cbc099..f911e8fbb 100644
--- a/src/mailman/model/docs/requests.rst
+++ b/src/mailman/model/docs/requests.rst
@@ -1,3 +1,5 @@
+.. _model-requests:
+
==================
Moderator requests
==================
@@ -6,22 +8,15 @@ Various actions will be held for moderator approval, such as subscriptions to
closed lists, or postings by non-members. The requests database is the low
level interface to these actions requiring approval.
-.. Here is a helper function for printing out held requests.
-
- >>> def show_holds(requests):
- ... for request in requests.held_requests:
- ... key, data = requests.get_request(request.id)
- ... print request.id, str(request.request_type), key
- ... if data is not None:
- ... for key in sorted(data):
- ... print ' {0}: {1}'.format(key, data[key])
+An :ref:`application level interface <app-moderator>` for holding messages and
+membership changes is also available.
Mailing list-centric
====================
-A set of requests are always related to a particular mailing list, so given a
-mailing list you need to get its requests object.
+A set of requests are always related to a particular mailing list. Adapt the
+mailing list to get its requests.
::
>>> from mailman.interfaces.requests import IListRequests
@@ -48,60 +43,48 @@ The list's requests database starts out empty.
At the lowest level, the requests database is very simple. Holding a request
requires a request type (as an enum value), a key, and an optional dictionary
of associated data. The request database assigns no semantics to the held
-data, except for the request type. Here we hold some simple bits of data.
+data, except for the request type.
>>> from mailman.interfaces.requests import RequestType
- >>> id_1 = requests.hold_request(RequestType.held_message, 'hold_1')
- >>> id_2 = requests.hold_request(RequestType.subscription, 'hold_2')
- >>> id_3 = requests.hold_request(RequestType.unsubscription, 'hold_3')
- >>> id_4 = requests.hold_request(RequestType.held_message, 'hold_4')
- >>> id_1, id_2, id_3, id_4
- (1, 2, 3, 4)
-And of course, now we can see that there are four requests being held.
+We can hold messages for moderator approval.
- >>> requests.count
- 4
- >>> requests.count_of(RequestType.held_message)
- 2
- >>> requests.count_of(RequestType.subscription)
+ >>> requests.hold_request(RequestType.held_message, 'hold_1')
1
- >>> requests.count_of(RequestType.unsubscription)
- 1
- >>> show_holds(requests)
- 1 RequestType.held_message hold_1
- 2 RequestType.subscription hold_2
- 3 RequestType.unsubscription hold_3
- 4 RequestType.held_message hold_4
-If we try to hold a request with a bogus type, we get an exception.
+We can hold subscription requests for moderator approval.
- >>> requests.hold_request(5, 'foo')
- Traceback (most recent call last):
- ...
- TypeError: 5
+ >>> requests.hold_request(RequestType.subscription, 'hold_2')
+ 2
-We can hold requests with additional data.
+We can hold unsubscription requests for moderator approval.
- >>> data = dict(foo='yes', bar='no')
- >>> id_5 = requests.hold_request(RequestType.held_message, 'hold_5', data)
- >>> id_5
- 5
- >>> requests.count
- 5
- >>> show_holds(requests)
- 1 RequestType.held_message hold_1
- 2 RequestType.subscription hold_2
- 3 RequestType.unsubscription hold_3
- 4 RequestType.held_message hold_4
- 5 RequestType.held_message hold_5
- bar: no
- foo: yes
+ >>> requests.hold_request(RequestType.unsubscription, 'hold_3')
+ 3
Getting requests
================
+We can see the total number of requests being held.
+
+ >>> requests.count
+ 3
+
+We can also see the number of requests being held by request type.
+
+ >>> requests.count_of(RequestType.subscription)
+ 1
+ >>> requests.count_of(RequestType.unsubscription)
+ 1
+
+We can also see when there are multiple held requests of a particular type.
+
+ >>> requests.hold_request(RequestType.held_message, 'hold_4')
+ 4
+ >>> requests.count_of(RequestType.held_message)
+ 2
+
We can ask the requests database for a specific request, by providing the id
of the request data we want. This returns a 2-tuple of the key and data we
originally held.
@@ -110,25 +93,37 @@ originally held.
>>> print key
hold_2
-Because we did not store additional data with request 2, it comes back as None
-now.
+There was no additional data associated with request 2.
>>> print data
None
-However, if we ask for a request that had data, we'd get it back now.
+If we ask for a request that is not in the database, we get None back.
+
+ >>> print requests.get_request(801)
+ None
+
+
+Additional data
+===============
+
+When a request is held, additional data can be associated with it, in the form
+of a dictionary with string values.
+
+ >>> data = dict(foo='yes', bar='no')
+ >>> requests.hold_request(RequestType.held_message, 'hold_5', data)
+ 5
+
+The data is returned when the request is retrieved. The dictionary will have
+an additional key which holds the name of the request type.
>>> key, data = requests.get_request(5)
>>> print key
hold_5
>>> dump_msgdata(data)
- bar: no
- foo: yes
-
-If we ask for a request that is not in the database, we get None back.
-
- >>> print requests.get_request(801)
- None
+ _request_type: held_message
+ bar : no
+ foo : yes
Iterating over requests
@@ -140,767 +135,37 @@ over by type.
>>> requests.count_of(RequestType.held_message)
3
>>> for request in requests.of_type(RequestType.held_message):
- ... assert request.request_type is RequestType.held_message
... key, data = requests.get_request(request.id)
- ... print request.id, key
+ ... print request.id, request.request_type, key
... if data is not None:
... for key in sorted(data):
... print ' {0}: {1}'.format(key, data[key])
- 1 hold_1
- 4 hold_4
- 5 hold_5
- bar: no
- foo: yes
+ 1 RequestType.held_message hold_1
+ 4 RequestType.held_message hold_4
+ 5 RequestType.held_message hold_5
+ _request_type: held_message
+ bar: no
+ foo: yes
Deleting requests
=================
-Once a specific request has been handled, it will be deleted from the requests
+Once a specific request has been handled, it can be deleted from the requests
database.
+ >>> requests.count
+ 5
>>> requests.delete_request(2)
>>> requests.count
4
- >>> show_holds(requests)
- 1 RequestType.held_message hold_1
- 3 RequestType.unsubscription hold_3
- 4 RequestType.held_message hold_4
- 5 RequestType.held_message hold_5
- bar: no
- foo: yes
- >>> print requests.get_request(2)
- None
-We get an exception if we ask to delete a request that isn't in the database.
+Request 2 is no longer in the database.
- >>> requests.delete_request(801)
- Traceback (most recent call last):
- ...
- KeyError: 801
-
-For the next section, we first clean up all the current requests.
+ >>> print requests.get_request(2)
+ None
>>> for request in requests.held_requests:
... requests.delete_request(request.id)
>>> requests.count
0
-
-
-Application support
-===================
-
-There are several higher level interfaces available in the ``mailman.app``
-package which can be used to hold messages, subscription, and unsubscriptions.
-There are also interfaces for disposing of these requests in an application
-specific and consistent way.
-
- >>> from mailman.app import moderator
-
-
-Holding messages
-================
-
-For this section, we need a mailing list and at least one message.
-
- >>> mlist = create_list('alist@example.com')
- >>> mlist.preferred_language = 'en'
- >>> mlist.display_name = 'A Test List'
- >>> msg = message_from_string("""\
- ... From: aperson@example.org
- ... To: alist@example.com
- ... Subject: Something important
- ...
- ... Here's something important about our mailing list.
- ... """)
-
-Holding a message means keeping a copy of it that a moderator must approve
-before the message is posted to the mailing list. To hold the message, you
-must supply the message, message metadata, and a text reason for the hold. In
-this case, we won't include any additional metadata.
-
- >>> id_1 = moderator.hold_message(mlist, msg, {}, 'Needs approval')
- >>> requests.get_request(id_1) is not None
- True
-
-We can also hold a message with some additional metadata.
-::
-
- # Delete the Message-ID from the previous hold so we don't try to store
- # collisions in the message storage.
- >>> del msg['message-id']
- >>> msgdata = dict(sender='aperson@example.com',
- ... approved=True)
- >>> id_2 = moderator.hold_message(mlist, msg, msgdata, 'Feeling ornery')
- >>> requests.get_request(id_2) is not None
- True
-
-Once held, the moderator can select one of several dispositions. The most
-trivial is to simply defer a decision for now.
-
- >>> from mailman.interfaces.action import Action
- >>> moderator.handle_message(mlist, id_1, Action.defer)
- >>> requests.get_request(id_1) is not None
- True
-
-The moderator can also discard the message. This is often done with spam.
-Bye bye message!
-
- >>> moderator.handle_message(mlist, id_1, Action.discard)
- >>> print requests.get_request(id_1)
- None
- >>> from mailman.testing.helpers import get_queue_messages
- >>> get_queue_messages('virgin')
- []
-
-The message can be rejected, meaning it is bounced back to the sender.
-
- >>> moderator.handle_message(mlist, id_2, Action.reject, 'Off topic')
- >>> print requests.get_request(id_2)
- None
- >>> messages = get_queue_messages('virgin')
- >>> len(messages)
- 1
- >>> print messages[0].msg.as_string()
- MIME-Version: 1.0
- Content-Type: text/plain; charset="us-ascii"
- Content-Transfer-Encoding: 7bit
- Subject: Request to mailing list "A Test List" rejected
- From: alist-bounces@example.com
- To: aperson@example.org
- Message-ID: ...
- Date: ...
- Precedence: bulk
- <BLANKLINE>
- Your request to the alist@example.com mailing list
- <BLANKLINE>
- Posting of your message titled "Something important"
- <BLANKLINE>
- has been rejected by the list moderator. The moderator gave the
- following reason for rejecting your request:
- <BLANKLINE>
- "Off topic"
- <BLANKLINE>
- Any questions or comments should be directed to the list administrator
- at:
- <BLANKLINE>
- alist-owner@example.com
- <BLANKLINE>
- >>> dump_msgdata(messages[0].msgdata)
- _parsemsg : False
- listname : alist@example.com
- nodecorate : True
- recipients : set([u'aperson@example.org'])
- reduced_list_headers: True
- version : 3
-
-Or the message can be approved. This actually places the message back into
-the incoming queue for further processing, however the message metadata
-indicates that the message has been approved.
-
- >>> id_3 = moderator.hold_message(mlist, msg, msgdata, 'Needs approval')
- >>> moderator.handle_message(mlist, id_3, Action.accept)
- >>> messages = get_queue_messages('pipeline')
- >>> len(messages)
- 1
- >>> print messages[0].msg.as_string()
- From: aperson@example.org
- To: alist@example.com
- Subject: Something important
- Message-ID: ...
- X-Message-ID-Hash: ...
- X-Mailman-Approved-At: ...
- <BLANKLINE>
- Here's something important about our mailing list.
- <BLANKLINE>
- >>> dump_msgdata(messages[0].msgdata)
- _parsemsg : False
- approved : True
- moderator_approved: True
- sender : aperson@example.com
- version : 3
-
-In addition to any of the above dispositions, the message can also be
-preserved for further study. Ordinarily the message is removed from the
-global message store after its disposition (though approved messages may be
-re-added to the message store). When handling a message, we can tell the
-moderator interface to also preserve a copy, essentially telling it not to
-delete the message from the storage. First, without the switch, the message
-is deleted.
-::
-
- >>> msg = message_from_string("""\
- ... From: aperson@example.org
- ... To: alist@example.com
- ... Subject: Something important
- ... Message-ID: <12345>
- ...
- ... Here's something important about our mailing list.
- ... """)
- >>> id_4 = moderator.hold_message(mlist, msg, {}, 'Needs approval')
- >>> moderator.handle_message(mlist, id_4, Action.discard)
-
- >>> from mailman.interfaces.messages import IMessageStore
- >>> from zope.component import getUtility
- >>> message_store = getUtility(IMessageStore)
-
- >>> print message_store.get_message_by_id('<12345>')
- None
-
-But if we ask to preserve the message when we discard it, it will be held in
-the message store after disposition.
-
- >>> id_4 = moderator.hold_message(mlist, msg, {}, 'Needs approval')
- >>> moderator.handle_message(mlist, id_4, Action.discard, preserve=True)
- >>> stored_msg = message_store.get_message_by_id('<12345>')
- >>> print stored_msg.as_string()
- From: aperson@example.org
- To: alist@example.com
- Subject: Something important
- Message-ID: <12345>
- X-Message-ID-Hash: 4CF7EAU3SIXBPXBB5S6PEUMO62MWGQN6
- <BLANKLINE>
- Here's something important about our mailing list.
- <BLANKLINE>
-
-Orthogonal to preservation, the message can also be forwarded to another
-address. This is helpful for getting the message into the inbox of one of the
-moderators.
-::
-
- # Set a new Message-ID from the previous hold so we don't try to store
- # collisions in the message storage.
- >>> del msg['message-id']
- >>> msg['Message-ID'] = '<abcde>'
- >>> id_4 = moderator.hold_message(mlist, msg, {}, 'Needs approval')
- >>> moderator.handle_message(mlist, id_4, Action.discard,
- ... forward=['zperson@example.com'])
-
- >>> messages = get_queue_messages('virgin')
- >>> len(messages)
- 1
- >>> print messages[0].msg.as_string()
- Subject: Forward of moderated message
- From: alist-bounces@example.com
- To: zperson@example.com
- MIME-Version: 1.0
- Content-Type: message/rfc822
- Message-ID: ...
- Date: ...
- Precedence: bulk
- <BLANKLINE>
- From: aperson@example.org
- To: alist@example.com
- Subject: Something important
- Message-ID: <abcde>
- X-Message-ID-Hash: EN2R5UQFMOUTCL44FLNNPLSXBIZW62ER
- <BLANKLINE>
- Here's something important about our mailing list.
- <BLANKLINE>
-
- >>> dump_msgdata(messages[0].msgdata)
- _parsemsg : False
- listname : alist@example.com
- nodecorate : True
- recipients : [u'zperson@example.com']
- reduced_list_headers: True
- version : 3
-
-
-Holding subscription requests
-=============================
-
-For closed lists, subscription requests will also be held for moderator
-approval. In this case, several pieces of information related to the
-subscription must be provided, including the subscriber's address and real
-name, their password (possibly hashed), what kind of delivery option they are
-choosing and their preferred language.
-
- >>> from mailman.interfaces.member import DeliveryMode
- >>> mlist.admin_immed_notify = False
- >>> id_3 = moderator.hold_subscription(mlist,
- ... 'bperson@example.org', 'Ben Person',
- ... '{NONE}abcxyz', DeliveryMode.regular, 'en')
- >>> requests.get_request(id_3) is not None
- True
-
-In the above case the mailing list was not configured to send the list
-moderators a notice about the hold, so no email message is in the virgin
-queue.
-
- >>> get_queue_messages('virgin')
- []
-
-But if we set the list up to notify the list moderators immediately when a
-message is held for approval, there will be a message placed in the virgin
-queue when the message is held.
-::
-
- >>> mlist.admin_immed_notify = True
- >>> # XXX This will almost certainly change once we've worked out the web
- >>> # space layout for mailing lists now.
- >>> id_4 = moderator.hold_subscription(mlist,
- ... 'cperson@example.org', 'Claire Person',
- ... '{NONE}zyxcba', DeliveryMode.regular, 'en')
- >>> requests.get_request(id_4) is not None
- True
- >>> messages = get_queue_messages('virgin')
- >>> len(messages)
- 1
-
- >>> print messages[0].msg.as_string()
- MIME-Version: 1.0
- Content-Type: text/plain; charset="us-ascii"
- Content-Transfer-Encoding: 7bit
- Subject: New subscription request to A Test List from cperson@example.org
- From: alist-owner@example.com
- To: alist-owner@example.com
- Message-ID: ...
- Date: ...
- Precedence: bulk
- <BLANKLINE>
- Your authorization is required for a mailing list subscription request
- approval:
- <BLANKLINE>
- For: cperson@example.org
- List: alist@example.com
- <BLANKLINE>
- At your convenience, visit:
- <BLANKLINE>
- http://lists.example.com/admindb/alist@example.com
- <BLANKLINE>
- to process the request.
- <BLANKLINE>
-
- >>> dump_msgdata(messages[0].msgdata)
- _parsemsg : False
- listname : alist@example.com
- nodecorate : True
- recipients : set([u'alist-owner@example.com'])
- reduced_list_headers: True
- tomoderators : True
- version : 3
-
-Once held, the moderator can select one of several dispositions. The most
-trivial is to simply defer a decision for now.
-
- >>> moderator.handle_subscription(mlist, id_3, Action.defer)
- >>> requests.get_request(id_3) is not None
- True
-
-The held subscription can also be discarded.
-
- >>> moderator.handle_subscription(mlist, id_3, Action.discard)
- >>> print requests.get_request(id_3)
- None
-
-The request can be rejected, in which case a message is sent to the
-subscriber.
-::
-
- >>> moderator.handle_subscription(mlist, id_4, Action.reject,
- ... 'This is a closed list')
- >>> print requests.get_request(id_4)
- None
- >>> messages = get_queue_messages('virgin')
- >>> len(messages)
- 1
-
- >>> print messages[0].msg.as_string()
- MIME-Version: 1.0
- Content-Type: text/plain; charset="us-ascii"
- Content-Transfer-Encoding: 7bit
- Subject: Request to mailing list "A Test List" rejected
- From: alist-bounces@example.com
- To: cperson@example.org
- Message-ID: ...
- Date: ...
- Precedence: bulk
- <BLANKLINE>
- Your request to the alist@example.com mailing list
- <BLANKLINE>
- Subscription request
- <BLANKLINE>
- has been rejected by the list moderator. The moderator gave the
- following reason for rejecting your request:
- <BLANKLINE>
- "This is a closed list"
- <BLANKLINE>
- Any questions or comments should be directed to the list administrator
- at:
- <BLANKLINE>
- alist-owner@example.com
- <BLANKLINE>
-
- >>> dump_msgdata(messages[0].msgdata)
- _parsemsg : False
- listname : alist@example.com
- nodecorate : True
- recipients : set([u'cperson@example.org'])
- reduced_list_headers: True
- version : 3
-
-The subscription can also be accepted. This subscribes the address to the
-mailing list.
-
- >>> mlist.send_welcome_message = True
- >>> mlist.welcome_message_uri = 'mailman:///welcome.txt'
- >>> id_4 = moderator.hold_subscription(mlist,
- ... 'fperson@example.org', 'Frank Person',
- ... 'abcxyz', DeliveryMode.regular, 'en')
-
-A message will be sent to the moderators telling them about the held
-subscription and the fact that they may need to approve it.
-::
-
- >>> messages = get_queue_messages('virgin')
- >>> len(messages)
- 1
-
- >>> print messages[0].msg.as_string()
- MIME-Version: 1.0
- Content-Type: text/plain; charset="us-ascii"
- Content-Transfer-Encoding: 7bit
- Subject: New subscription request to A Test List from fperson@example.org
- From: alist-owner@example.com
- To: alist-owner@example.com
- Message-ID: ...
- Date: ...
- Precedence: bulk
- <BLANKLINE>
- Your authorization is required for a mailing list subscription request
- approval:
- <BLANKLINE>
- For: fperson@example.org
- List: alist@example.com
- <BLANKLINE>
- At your convenience, visit:
- <BLANKLINE>
- http://lists.example.com/admindb/alist@example.com
- <BLANKLINE>
- to process the request.
- <BLANKLINE>
-
- >>> dump_msgdata(messages[0].msgdata)
- _parsemsg : False
- listname : alist@example.com
- nodecorate : True
- recipients : set([u'alist-owner@example.com'])
- reduced_list_headers: True
- tomoderators : True
- version : 3
-
-Accept the subscription request.
-
- >>> mlist.admin_notify_mchanges = True
- >>> moderator.handle_subscription(mlist, id_4, Action.accept)
-
-There are now two messages in the virgin queue. One is a welcome message
-being sent to the user and the other is a subscription notification that is
-sent to the moderators. The only good way to tell which is which is to look
-at the recipient list.
-
- >>> messages = get_queue_messages('virgin', sort_on='subject')
- >>> len(messages)
- 2
-
-The welcome message is sent to the person who just subscribed.
-::
-
- >>> print messages[1].msg.as_string()
- MIME-Version: 1.0
- Content-Type: text/plain; charset="us-ascii"
- Content-Transfer-Encoding: 7bit
- Subject: Welcome to the "A Test List" mailing list
- From: alist-request@example.com
- To: Frank Person <fperson@example.org>
- X-No-Archive: yes
- Message-ID: ...
- Date: ...
- Precedence: bulk
- <BLANKLINE>
- Welcome to the "A Test List" mailing list!
- <BLANKLINE>
- To post to this list, send your email to:
- <BLANKLINE>
- alist@example.com
- <BLANKLINE>
- General information about the mailing list is at:
- <BLANKLINE>
- http://lists.example.com/listinfo/alist@example.com
- <BLANKLINE>
- If you ever want to unsubscribe or change your options (eg, switch to
- or from digest mode, change your password, etc.), visit your
- subscription page at:
- <BLANKLINE>
- http://example.com/fperson@example.org
- <BLANKLINE>
- You can also make such adjustments via email by sending a message to:
- <BLANKLINE>
- alist-request@example.com
- <BLANKLINE>
- with the word 'help' in the subject or body (don't include the
- quotes), and you will get back a message with instructions. You will
- need your password to change your options, but for security purposes,
- this email is not included here. There is also a button on your
- options page that will send your current password to you.
- <BLANKLINE>
-
- >>> dump_msgdata(messages[1].msgdata)
- _parsemsg : False
- listname : alist@example.com
- nodecorate : True
- recipients : set([u'Frank Person <fperson@example.org>'])
- reduced_list_headers: True
- verp : False
- version : 3
-
-The admin message is sent to the moderators.
-::
-
- >>> print messages[0].msg.as_string()
- MIME-Version: 1.0
- Content-Type: text/plain; charset="us-ascii"
- Content-Transfer-Encoding: 7bit
- Subject: A Test List subscription notification
- From: noreply@example.com
- To: alist-owner@example.com
- Message-ID: ...
- Date: ...
- Precedence: bulk
- <BLANKLINE>
- Frank Person <fperson@example.org> has been successfully subscribed to
- A Test List.
- <BLANKLINE>
-
- >>> dump_msgdata(messages[0].msgdata)
- _parsemsg : False
- envsender : noreply@example.com
- listname : alist@example.com
- nodecorate : True
- recipients : set([])
- reduced_list_headers: True
- version : 3
-
-Frank Person is now a member of the mailing list.
-::
-
- >>> member = mlist.members.get_member('fperson@example.org')
- >>> member
- <Member: Frank Person <fperson@example.org>
- on alist@example.com as MemberRole.member>
- >>> member.preferred_language
- <Language [en] English (USA)>
- >>> print member.delivery_mode
- DeliveryMode.regular
- >>> print member.user.display_name
- Frank Person
- >>> print member.user.password
- {plaintext}abcxyz
-
-
-Holding unsubscription requests
-===============================
-
-Some lists, though it is rare, require moderator approval for unsubscriptions.
-In this case, only the unsubscribing address is required. Like subscriptions,
-unsubscription holds can send the list's moderators an immediate
-notification.
-::
-
-
- >>> from mailman.interfaces.usermanager import IUserManager
- >>> from zope.component import getUtility
- >>> user_manager = getUtility(IUserManager)
-
- >>> mlist.admin_immed_notify = False
- >>> from mailman.interfaces.member import MemberRole
- >>> user_1 = user_manager.create_user('gperson@example.com')
- >>> address_1 = list(user_1.addresses)[0]
- >>> mlist.subscribe(address_1, MemberRole.member)
- <Member: gperson@example.com on alist@example.com as MemberRole.member>
-
- >>> user_2 = user_manager.create_user('hperson@example.com')
- >>> address_2 = list(user_2.addresses)[0]
- >>> mlist.subscribe(address_2, MemberRole.member)
- <Member: hperson@example.com on alist@example.com as MemberRole.member>
-
- >>> id_5 = moderator.hold_unsubscription(mlist, 'gperson@example.com')
- >>> requests.get_request(id_5) is not None
- True
- >>> get_queue_messages('virgin')
- []
- >>> mlist.admin_immed_notify = True
- >>> id_6 = moderator.hold_unsubscription(mlist, 'hperson@example.com')
-
- >>> messages = get_queue_messages('virgin')
- >>> len(messages)
- 1
- >>> print messages[0].msg.as_string()
- MIME-Version: 1.0
- Content-Type: text/plain; charset="us-ascii"
- Content-Transfer-Encoding: 7bit
- Subject: New unsubscription request from A Test List by hperson@example.com
- From: alist-owner@example.com
- To: alist-owner@example.com
- Message-ID: ...
- Date: ...
- Precedence: bulk
- <BLANKLINE>
- Your authorization is required for a mailing list unsubscription
- request approval:
- <BLANKLINE>
- By: hperson@example.com
- From: alist@example.com
- <BLANKLINE>
- At your convenience, visit:
- <BLANKLINE>
- http://lists.example.com/admindb/alist@example.com
- <BLANKLINE>
- to process the request.
- <BLANKLINE>
-
- >>> dump_msgdata(messages[0].msgdata)
- _parsemsg : False
- listname : alist@example.com
- nodecorate : True
- recipients : set([u'alist-owner@example.com'])
- reduced_list_headers: True
- tomoderators : True
- version : 3
-
-There are now two addresses with held unsubscription requests. As above, one
-of the actions we can take is to defer to the decision.
-
- >>> moderator.handle_unsubscription(mlist, id_5, Action.defer)
- >>> requests.get_request(id_5) is not None
- True
-
-The held unsubscription can also be discarded, and the member will remain
-subscribed.
-
- >>> moderator.handle_unsubscription(mlist, id_5, Action.discard)
- >>> print requests.get_request(id_5)
- None
- >>> mlist.members.get_member('gperson@example.com')
- <Member: gperson@example.com on alist@example.com as MemberRole.member>
-
-The request can be rejected, in which case a message is sent to the member,
-and the person remains a member of the mailing list.
-::
-
- >>> moderator.handle_unsubscription(mlist, id_6, Action.reject,
- ... 'This list is a prison.')
- >>> print requests.get_request(id_6)
- None
- >>> messages = get_queue_messages('virgin')
- >>> len(messages)
- 1
-
- >>> print messages[0].msg.as_string()
- MIME-Version: 1.0
- Content-Type: text/plain; charset="us-ascii"
- Content-Transfer-Encoding: 7bit
- Subject: Request to mailing list "A Test List" rejected
- From: alist-bounces@example.com
- To: hperson@example.com
- Message-ID: ...
- Date: ...
- Precedence: bulk
- <BLANKLINE>
- Your request to the alist@example.com mailing list
- <BLANKLINE>
- Unsubscription request
- <BLANKLINE>
- has been rejected by the list moderator. The moderator gave the
- following reason for rejecting your request:
- <BLANKLINE>
- "This list is a prison."
- <BLANKLINE>
- Any questions or comments should be directed to the list administrator
- at:
- <BLANKLINE>
- alist-owner@example.com
- <BLANKLINE>
-
- >>> dump_msgdata(messages[0].msgdata)
- _parsemsg : False
- listname : alist@example.com
- nodecorate : True
- recipients : set([u'hperson@example.com'])
- reduced_list_headers: True
- version : 3
-
- >>> mlist.members.get_member('hperson@example.com')
- <Member: hperson@example.com on alist@example.com as MemberRole.member>
-
-The unsubscription request can also be accepted. This removes the member from
-the mailing list.
-
- >>> mlist.send_goodbye_msg = True
- >>> mlist.goodbye_msg = 'So long!'
- >>> mlist.admin_immed_notify = False
- >>> id_7 = moderator.hold_unsubscription(mlist, 'gperson@example.com')
- >>> moderator.handle_unsubscription(mlist, id_7, Action.accept)
- >>> print mlist.members.get_member('gperson@example.com')
- None
-
-There are now two messages in the virgin queue, one to the member who was just
-unsubscribed and another to the moderators informing them of this membership
-change.
-
- >>> messages = get_queue_messages('virgin')
- >>> len(messages)
- 2
-
-The goodbye message...
-::
-
- >>> print messages[0].msg.as_string()
- MIME-Version: 1.0
- Content-Type: text/plain; charset="us-ascii"
- Content-Transfer-Encoding: 7bit
- Subject: You have been unsubscribed from the A Test List mailing list
- From: alist-bounces@example.com
- To: gperson@example.com
- Message-ID: ...
- Date: ...
- Precedence: bulk
- <BLANKLINE>
- So long!
- <BLANKLINE>
-
- >>> dump_msgdata(messages[0].msgdata)
- _parsemsg : False
- listname : alist@example.com
- nodecorate : True
- recipients : set([u'gperson@example.com'])
- reduced_list_headers: True
- verp : False
- version : 3
-
-...and the admin message.
-::
-
- >>> print messages[1].msg.as_string()
- MIME-Version: 1.0
- Content-Type: text/plain; charset="us-ascii"
- Content-Transfer-Encoding: 7bit
- Subject: A Test List unsubscription notification
- From: noreply@example.com
- To: alist-owner@example.com
- Message-ID: ...
- Date: ...
- Precedence: bulk
- <BLANKLINE>
- gperson@example.com has been removed from A Test List.
- <BLANKLINE>
-
- >>> dump_msgdata(messages[1].msgdata)
- _parsemsg : False
- envsender : noreply@example.com
- listname : alist@example.com
- nodecorate : True
- recipients : set([])
- reduced_list_headers: True
- version : 3
diff --git a/src/mailman/model/requests.py b/src/mailman/model/requests.py
index 5eb940233..58f2f2d4c 100644
--- a/src/mailman/model/requests.py
+++ b/src/mailman/model/requests.py
@@ -40,6 +40,8 @@ from mailman.interfaces.requests import IListRequests, RequestType
@implementer(IPendable)
class DataPendable(dict):
+ """See `IPendable`."""
+
def update(self, mapping):
# Keys and values must be strings (unicodes, but bytes values are
# accepted for now). Any other types for keys are a programming
@@ -58,6 +60,7 @@ class DataPendable(dict):
@implementer(IListRequests)
class ListRequests:
+ """See `IListRequests`."""
def __init__(self, mailing_list):
self.mailing_list = mailing_list
@@ -111,7 +114,7 @@ class ListRequests:
if request_type is not None and result.request_type != request_type:
return None
if result.data_hash is None:
- return result.key, result.data_hash
+ return result.key, None
pendable = getUtility(IPendings).confirm(
result.data_hash, expunge=False)
data = dict()
@@ -121,6 +124,8 @@ class ListRequests:
data[key[5:]] = loads(value.encode('raw-unicode-escape'))
else:
data[key] = value
+ # Some APIs need the request type.
+ data['_request_type'] = result.request_type.name
return result.key, data
@dbconnection
diff --git a/src/mailman/model/tests/test_requests.py b/src/mailman/model/tests/test_requests.py
index a85add748..5e4d614fb 100644
--- a/src/mailman/model/tests/test_requests.py
+++ b/src/mailman/model/tests/test_requests.py
@@ -21,6 +21,7 @@ from __future__ import absolute_import, print_function, unicode_literals
__metaclass__ = type
__all__ = [
+ 'TestRequests',
]
@@ -39,6 +40,7 @@ class TestRequests(unittest.TestCase):
def setUp(self):
self._mlist = create_list('ant@example.com')
+ self._requests_db = IListRequests(self._mlist)
self._msg = mfs("""\
From: anne@example.com
To: ant@example.com
@@ -51,16 +53,27 @@ Something else.
def test_get_request_with_type(self):
# get_request() takes an optional request type.
request_id = hold_message(self._mlist, self._msg)
- requests_db = IListRequests(self._mlist)
# Submit a request with a non-matching type. This should return None
# as if there were no matches.
- response = requests_db.get_request(
+ response = self._requests_db.get_request(
request_id, RequestType.subscription)
self.assertEqual(response, None)
# Submit the same request with a matching type.
- key, data = requests_db.get_request(
+ key, data = self._requests_db.get_request(
request_id, RequestType.held_message)
self.assertEqual(key, '<alpha>')
# It should also succeed with no optional request type given.
- key, data = requests_db.get_request(request_id)
+ key, data = self._requests_db.get_request(request_id)
self.assertEqual(key, '<alpha>')
+
+ def test_hold_with_bogus_type(self):
+ # Calling hold_request() with a bogus request type is an error.
+ with self.assertRaises(TypeError) as cm:
+ self._requests_db.hold_request(5, 'foo')
+ self.assertEqual(cm.exception.message, 5)
+
+ def test_delete_missing_request(self):
+ # Trying to delete a missing request is an error.
+ with self.assertRaises(KeyError) as cm:
+ self._requests_db.delete_request(801)
+ self.assertEqual(cm.exception.message, 801)
diff --git a/src/mailman/rest/docs/moderation.rst b/src/mailman/rest/docs/moderation.rst
index e4c298dc4..1b08b8d06 100644
--- a/src/mailman/rest/docs/moderation.rst
+++ b/src/mailman/rest/docs/moderation.rst
@@ -1,9 +1,21 @@
-=======================
-Held message moderation
-=======================
+==========
+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
-out with no held messages.
+with no held messages.
>>> ant = create_list('ant@example.com')
>>> transaction.commit()
@@ -30,16 +42,22 @@ When a message gets held for moderator approval, it shows up in this list.
>>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/held')
entry 0:
- data: {u'_mod_subject': u'Something',
- u'_mod_message_id': u'<alpha>',
- u'extra': 7,
- u'_mod_fqdn_listname': u'ant@example.com',
- u'_mod_hold_date': u'2005-08-01T07:49:23',
- u'_mod_reason': u'Because',
- u'_mod_sender': u'anne@example.com'}
+ extra: 7
+ hold_date: 2005-08-01T07:49:23
http_etag: "..."
- id: ...
- key: <alpha>
+ 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
@@ -53,18 +71,11 @@ message. This will include the text of the message.
... 'ant@example.com/held/{0}'.format(request_id))
>>> dump_json(url(request_id))
- data: {u'_mod_subject': u'Something',
- u'_mod_message_id': u'<alpha>',
- u'extra': 7,
- u'_mod_fqdn_listname': u'ant@example.com',
- u'_mod_hold_date': u'2005-08-01T07:49:23',
- u'_mod_reason': u'Because',
- u'_mod_sender': u'anne@example.com'}
+ extra: 7
+ hold_date: 2005-08-01T07:49:23
http_etag: "..."
- id: 1
- key: <alpha>
- msg:
- From: anne@example.com
+ message_id: <alpha>
+ msg: From: anne@example.com
To: ant@example.com
Subject: Something
Message-ID: <alpha>
@@ -72,6 +83,14 @@ message. This will include the text of the message.
<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
@@ -95,16 +114,10 @@ Let's see what happens when the above message is deferred.
The message is still in the moderation queue.
>>> dump_json(url(request_id))
- data: {u'_mod_subject': u'Something',
- u'_mod_message_id': u'<alpha>',
- u'extra': 7,
- u'_mod_fqdn_listname': u'ant@example.com',
- u'_mod_hold_date': u'2005-08-01T07:49:23',
- u'_mod_reason': u'Because',
- u'_mod_sender': u'anne@example.com'}
+ extra: 7
+ hold_date: 2005-08-01T07:49:23
http_etag: "..."
- id: 1
- key: <alpha>
+ message_id: <alpha>
msg: From: anne@example.com
To: ant@example.com
Subject: Something
@@ -113,6 +126,10 @@ The message is still in the moderation queue.
<BLANKLINE>
Something else.
<BLANKLINE>
+ reason: Because
+ request_id: 1
+ sender: anne@example.com
+ subject: Something
The held message can be discarded.
@@ -141,7 +158,7 @@ moderation.
>>> transaction.commit()
>>> results = call_http(url(request_id))
- >>> print results['key']
+ >>> print results['message_id']
<bravo>
>>> dump_json(url(request_id), {
@@ -169,7 +186,7 @@ to the original author.
>>> transaction.commit()
>>> results = call_http(url(request_id))
- >>> print results['key']
+ >>> print results['message_id']
<charlie>
>>> dump_json(url(request_id), {
@@ -186,3 +203,169 @@ to the original author.
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
+ >>> hold_subscription(
+ ... ant, 'anne@example.com', 'Anne Person',
+ ... 'password', DeliveryMode.regular, 'en')
+ 1
+ >>> 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:
+ address: anne@example.com
+ delivery_mode: regular
+ display_name: Anne Person
+ http_etag: "..."
+ language: en
+ password: password
+ request_id: 1
+ 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.app.membership import add_member
+ >>> from mailman.app.moderator import hold_unsubscription
+ >>> bart = add_member(ant, 'bart@example.com', 'Bart Person',
+ ... 'password', DeliveryMode.regular, 'en')
+ >>> hold_unsubscription(ant, 'bart@example.com')
+ 2
+ >>> 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:
+ address: anne@example.com
+ delivery_mode: regular
+ display_name: Anne Person
+ http_etag: "..."
+ language: en
+ password: password
+ request_id: 1
+ type: subscription
+ when: 2005-08-01T07:49:23
+ entry 1:
+ address: bart@example.com
+ http_etag: "..."
+ request_id: 2
+ 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/1')
+ address: anne@example.com
+ delivery_mode: regular
+ display_name: Anne Person
+ http_etag: "..."
+ language: en
+ password: password
+ request_id: 1
+ 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/2')
+ address: bart@example.com
+ http_etag: "..."
+ request_id: 2
+ 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/1', {
+ ... '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/2', {
+ ... '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/lists.py b/src/mailman/rest/lists.py
index 4e9de6905..4a4b243b3 100644
--- a/src/mailman/rest/lists.py
+++ b/src/mailman/rest/lists.py
@@ -42,7 +42,7 @@ 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, MemberCollection
-from mailman.rest.moderation import HeldMessages
+from mailman.rest.moderation import HeldMessages, SubscriptionRequests
from mailman.rest.validator import Validator
@@ -176,11 +176,18 @@ class AList(_ListBase):
@resource.child()
def held(self, request, segments):
- """Return a list of held messages for the mailign list."""
+ """Return a list of held messages for the mailing list."""
if self._mlist is None:
return http.not_found()
return HeldMessages(self._mlist)
+ @resource.child()
+ def requests(self, request, segments):
+ """Return a list of subscription/unsubscription requests."""
+ if self._mlist is None:
+ return http.not_found()
+ return SubscriptionRequests(self._mlist)
+
class AllLists(_ListBase):
diff --git a/src/mailman/rest/moderation.py b/src/mailman/rest/moderation.py
index 7075a75be..8fc519996 100644
--- a/src/mailman/rest/moderation.py
+++ b/src/mailman/rest/moderation.py
@@ -23,13 +23,16 @@ __metaclass__ = type
__all__ = [
'HeldMessage',
'HeldMessages',
+ 'MembershipChangeRequest',
+ 'SubscriptionRequests',
]
from restish import http, resource
from zope.component import getUtility
-from mailman.app.moderator import handle_message
+from mailman.app.moderator import (
+ handle_message, handle_subscription, handle_unsubscription)
from mailman.interfaces.action import Action
from mailman.interfaces.messages import IMessageStore
from mailman.interfaces.requests import IListRequests, RequestType
@@ -37,8 +40,68 @@ from mailman.rest.helpers import CollectionMixin, etag, no_content
from mailman.rest.validator import Validator, enum_validator
+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):
+ requests = IListRequests(self._mlist)
+ results = requests.get_request(request_id)
+ if results is None:
+ return None
+ key, data = results
+ resource = dict(key=key, request_id=request_id)
+ # Flatten the IRequest payload into the JSON representation.
+ resource.update(data)
+ # 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:
+ return None
+ resource['type'] = request_type.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.
+ resource.pop('id', None)
+ return resource
+
+
-class HeldMessage(resource.Resource, CollectionMixin):
+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)
+ if resource is None:
+ return None
+ # Grab the message and insert its text representation into the
+ # resource. XXX See LP: #967954
+ key = resource.pop('key')
+ msg = getUtility(IMessageStore).get_message_by_id(key)
+ resource['msg'] = msg.as_string()
+ # Some of the _mod_* keys we want to rename and place into the JSON
+ # resource. Others we can drop. Since we're mutating the dictionary,
+ # we need to make a copy of the keys. When you port this to Python 3,
+ # you'll need to list()-ify the .keys() dictionary view.
+ for key in resource.keys():
+ if key in ('_mod_subject', '_mod_hold_date', '_mod_reason',
+ '_mod_sender', '_mod_message_id'):
+ resource[key[5:]] = resource.pop(key)
+ elif key.startswith('_mod_'):
+ del resource[key]
+ # Also, held message resources will always be this type, so ignore
+ # this key value.
+ del resource['type']
+ return resource
+
+
+class HeldMessage(_HeldMessageBase, resource.Resource):
"""Resource for moderating a held message."""
def __init__(self, mlist, request_id):
@@ -47,22 +110,13 @@ class HeldMessage(resource.Resource, CollectionMixin):
@resource.GET()
def details(self, request):
- requests = IListRequests(self._mlist)
try:
request_id = int(self._request_id)
except ValueError:
return http.bad_request()
- results = requests.get_request(request_id, RequestType.held_message)
- if results is None:
+ resource = self._make_resource(request_id)
+ if resource is None:
return http.not_found()
- key, data = results
- msg = getUtility(IMessageStore).get_message_by_id(key)
- resource = dict(
- key=key,
- data=data,
- msg=msg.as_string(),
- id=request_id,
- )
return http.ok([], etag(resource))
@resource.POST()
@@ -85,20 +139,16 @@ class HeldMessage(resource.Resource, CollectionMixin):
-class HeldMessages(resource.Resource, CollectionMixin):
+class HeldMessages(_HeldMessageBase, resource.Resource, CollectionMixin):
"""Resource for messages held for moderation."""
def __init__(self, mlist):
self._mlist = mlist
+ self._requests = None
- def _resource_as_dict(self, req):
+ def _resource_as_dict(self, request):
"""See `CollectionMixin`."""
- key, data = self._requests.get_request(req.id)
- return dict(
- key=key,
- data=data,
- id=req.id,
- )
+ return self._make_resource(request.id)
def _get_collection(self, request):
requests = IListRequests(self._mlist)
@@ -108,9 +158,96 @@ class HeldMessages(resource.Resource, CollectionMixin):
@resource.GET()
def requests(self, request):
"""/lists/listname/held"""
+ # `request` is a restish.http.Request object.
resource = self._make_collection(request)
return http.ok([], etag(resource))
@resource.child('{id}')
def message(self, request, segments, **kw):
return HeldMessage(self._mlist, kw['id'])
+
+
+
+class MembershipChangeRequest(resource.Resource, _ModerationBase):
+ """Resource for moderating a membership change."""
+
+ def __init__(self, mlist, request_id):
+ self._mlist = mlist
+ self._request_id = request_id
+
+ @resource.GET()
+ def details(self, request):
+ try:
+ request_id = int(self._request_id)
+ except ValueError:
+ return http.bad_request()
+ resource = self._make_resource(request_id, MEMBERSHIP_CHANGE_REQUESTS)
+ if resource is None:
+ return http.not_found()
+ # Remove unnecessary keys.
+ del resource['key']
+ return http.ok([], etag(resource))
+
+ @resource.POST()
+ def moderate(self, request):
+ try:
+ validator = Validator(action=enum_validator(Action))
+ arguments = validator(request)
+ except ValueError as error:
+ return http.bad_request([], str(error))
+ requests = IListRequests(self._mlist)
+ try:
+ request_id = int(self._request_id)
+ except ValueError:
+ return http.bad_request()
+ results = requests.get_request(request_id)
+ if results is None:
+ return http.not_found()
+ key, data = results
+ try:
+ request_type = RequestType(data['_request_type'])
+ except ValueError:
+ return http.bad_request()
+ 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:
+ return http.bad_request()
+ return no_content()
+
+
+class SubscriptionRequests(
+ _ModerationBase, resource.Resource, 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
+
+ @resource.GET()
+ def requests(self, request):
+ """/lists/listname/requests"""
+ # `request` is a restish.http.Request object.
+ resource = self._make_collection(request)
+ return http.ok([], etag(resource))
+
+ @resource.child('{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 2ee796c87..46cc86d60 100644
--- a/src/mailman/rest/tests/test_moderation.py
+++ b/src/mailman/rest/tests/test_moderation.py
@@ -82,7 +82,7 @@ Something else.
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['key'], '<alpha>')
+ self.assertEqual(response['message_id'], '<alpha>')
def test_bad_action(self):
# POSTing to a held message with a bad action.