From 53d4229bff96677a54fd443322c166c2bdcc3054 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Thu, 16 Apr 2015 15:28:06 -0400 Subject: [PATCH] Checkpointing. --- src/mailman/app/subscriptions.py | 3 +- src/mailman/app/tests/test_subscriptions.py | 16 + src/mailman/interfaces/pending.py | 6 + src/mailman/interfaces/registrar.py | 23 +- src/mailman/model/docs/pending.rst | 19 +- src/mailman/model/pending.py | 5 + src/mailman/rest/docs/moderation.rst | 361 --------------------- src/mailman/rest/docs/post-moderation.rst | 192 +++++++++++ src/mailman/rest/lists.py | 3 +- .../rest/{moderation.py => post_moderation.py} | 109 +------ src/mailman/rest/tests/test_moderation.py | 219 ++++++++++--- 11 files changed, 428 insertions(+), 528 deletions(-) delete mode 100644 src/mailman/rest/docs/moderation.rst create mode 100644 src/mailman/rest/docs/post-moderation.rst rename src/mailman/rest/{moderation.py => post_moderation.py} (60%) 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: - ... - ... 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: - msg: From: anne@example.com - To: ant@example.com - Subject: Something - Message-ID: - X-Message-ID-Hash: GCSMSG43GYWWVUMO6F7FBUSSPNXQCJ6M - - Something else. - - 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: - msg: From: anne@example.com - To: ant@example.com - Subject: Something - Message-ID: - X-Message-ID-Hash: GCSMSG43GYWWVUMO6F7FBUSSPNXQCJ6M - - Something else. - - 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: - msg: From: anne@example.com - To: ant@example.com - Subject: Something - Message-ID: - X-Message-ID-Hash: GCSMSG43GYWWVUMO6F7FBUSSPNXQCJ6M - - Something else. - - 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'] = '' - >>> request_id = hold_message(ant, msg) - >>> transaction.commit() - - >>> results = call_http(url(request_id)) - >>> print(results['message_id']) - - - >>> 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']) - - -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'] = '' - >>> request_id = hold_message(ant, msg) - >>> transaction.commit() - - >>> results = call_http(url(request_id)) - >>> print(results['message_id']) - - - >>> 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') - 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')) - 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: + ... + ... 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: + msg: From: anne@example.com + To: ant@example.com + Subject: Something + Message-ID: + X-Message-ID-Hash: GCSMSG43GYWWVUMO6F7FBUSSPNXQCJ6M + + Something else. + + 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: + msg: From: anne@example.com + To: ant@example.com + Subject: Something + Message-ID: + X-Message-ID-Hash: GCSMSG43GYWWVUMO6F7FBUSSPNXQCJ6M + + Something else. + + 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: + msg: From: anne@example.com + To: ant@example.com + Subject: Something + Message-ID: + X-Message-ID-Hash: GCSMSG43GYWWVUMO6F7FBUSSPNXQCJ6M + + Something else. + + 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'] = '' + >>> request_id = hold_message(ant, msg) + >>> transaction.commit() + + >>> results = call_http(url(request_id)) + >>> print(results['message_id']) + + + >>> 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']) + + +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'] = '' + >>> request_id = hold_message(ant, msg) + >>> transaction.commit() + + >>> results = call_http(url(request_id)) + >>> print(results['message_id']) + + + >>> 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 similarity index 60% rename from src/mailman/rest/moderation.py rename to 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 . -"""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[^/]+)') 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[^/]+)') - 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'], '') - 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), '') -- 2.11.4.GIT