summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/mailman/model/requests.py4
-rw-r--r--src/mailman/rest/docs/moderation.rst171
-rw-r--r--src/mailman/rest/moderation.py153
3 files changed, 253 insertions, 75 deletions
diff --git a/src/mailman/model/requests.py b/src/mailman/model/requests.py
index 9de5df8b3..58f2f2d4c 100644
--- a/src/mailman/model/requests.py
+++ b/src/mailman/model/requests.py
@@ -114,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()
@@ -124,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/rest/docs/moderation.rst b/src/mailman/rest/docs/moderation.rst
index 6883b5061..1b08b8d06 100644
--- a/src/mailman/rest/docs/moderation.rst
+++ b/src/mailman/rest/docs/moderation.rst
@@ -11,6 +11,9 @@ 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.
@@ -39,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
@@ -62,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>
@@ -81,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
@@ -104,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
@@ -122,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.
@@ -150,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), {
@@ -178,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), {
@@ -200,6 +208,9 @@ to the original author.
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.
@@ -229,16 +240,19 @@ The subscription request is available from the mailing list.
delivery_mode: regular
display_name: Anne Person
http_etag: "..."
- id: 1
- key: anne@example.com
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
@@ -257,18 +271,101 @@ The unsubscription request is also available from the mailing list.
delivery_mode: regular
display_name: Anne Person
http_etag: "..."
- id: 1
- key: anne@example.com
language: en
password: password
+ request_id: 1
type: subscription
when: 2005-08-01T07:49:23
entry 1:
address: bart@example.com
http_etag: "..."
- id: 2
- key: bart@example.com
+ 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/moderation.py b/src/mailman/rest/moderation.py
index 693216aba..8b777429b 100644
--- a/src/mailman/rest/moderation.py
+++ b/src/mailman/rest/moderation.py
@@ -23,6 +23,7 @@ __metaclass__ = type
__all__ = [
'HeldMessage',
'HeldMessages',
+ 'MembershipChangeRequest',
'SubscriptionRequests',
]
@@ -30,7 +31,8 @@ __all__ = [
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
@@ -39,7 +41,57 @@ from mailman.rest.validator import Validator, enum_validator
-class HeldMessage(resource.Resource, CollectionMixin):
+class _ModerationBase:
+ """Common base class."""
+
+ def _make_resource(self, request_id):
+ 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)
+ # Rename this key, and put the request_id in the resource.
+ resource['type'] = resource.pop('_request_type')
+ # 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 _HeldMessageBase(_ModerationBase):
+ """Held messages are a little different."""
+
+ def _make_resource(self, request_id):
+ resource = super(_HeldMessageBase, self)._make_resource(request_id)
+ 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):
@@ -48,24 +100,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,
- # XXX convert _mod_{subject,hold_date,reason,sender,message_id}
- # into top level values of the resource dict.
- data=data,
- msg=msg.as_string(),
- id=request_id,
- )
return http.ok([], etag(resource))
@resource.POST()
@@ -88,7 +129,7 @@ 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):
@@ -97,12 +138,7 @@ class HeldMessages(resource.Resource, CollectionMixin):
def _resource_as_dict(self, request):
"""See `CollectionMixin`."""
- key, data = self._requests.get_request(request.id)
- return dict(
- key=key,
- data=data,
- id=request.id,
- )
+ return self._make_resource(request.id)
def _get_collection(self, request):
requests = IListRequests(self._mlist)
@@ -122,25 +158,68 @@ class HeldMessages(resource.Resource, CollectionMixin):
-class SubscriptionRequests(resource.Resource, CollectionMixin):
- """Resource for subscription and unsubscription requests."""
+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)
+ 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_and_type):
+ def _resource_as_dict(self, request):
"""See `CollectionMixin`."""
- request, request_type = request_and_type
- key, data = self._requests.get_request(request.id)
- resource = dict(
- key=key,
- id=request.id,
- )
- # Flatten the IRequest payload into the JSON representation.
- resource.update(data)
- # Add a key indicating what type of subscription request this is.
- resource['type'] = request_type.name
+ resource = self._make_resource(request.id)
+ # Remove unnecessary keys.
+ del resource['key']
return resource
def _get_collection(self, request):
@@ -150,7 +229,7 @@ class SubscriptionRequests(resource.Resource, CollectionMixin):
for request_type in (RequestType.subscription,
RequestType.unsubscription):
for request in requests.of_type(request_type):
- items.append((request, request_type))
+ items.append(request)
return items
@resource.GET()
@@ -162,4 +241,4 @@ class SubscriptionRequests(resource.Resource, CollectionMixin):
@resource.child('{id}')
def subscription(self, request, segments, **kw):
- pass
+ return MembershipChangeRequest(self._mlist, kw['id'])