From 3f7b8401b816aa9c6ff68e2a4b004867b8d19ae4 Mon Sep 17 00:00:00 2001 From: Abhilash Raj Date: Fri, 30 Jan 2015 03:48:35 +0530 Subject: add more docs for autogenerating migrations using alembic --- src/mailman/docs/DATABASE.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/mailman/docs/DATABASE.rst b/src/mailman/docs/DATABASE.rst index a64c371f7..f6ef45f44 100644 --- a/src/mailman/docs/DATABASE.rst +++ b/src/mailman/docs/DATABASE.rst @@ -85,6 +85,14 @@ People upgrading Mailman from previous versions need not do anything manually, as soon as a new migration is added in the sources, it will be automatically reflected in the schema on first-run post-update. +'''Note:''' When autogenerating migrations using alembic, be sure to check the +created migration before adding it to the version control. For some of the +special datatypes defined in ``mailman.database.types``, you will have to +manually change the datatype. For example, ``mailman.database.types.Enum()`` +needs to be changed to ``sa.Integer()`` ,as Enum type stores just the integer in +the database. A more complex migration would be needed for ``UUID`` depending +upon the database layer to be used. + .. _SQLAlchemy: http://www.sqlalchemy.org/ .. _SQLite3: http://docs.python.org/library/sqlite3.html -- cgit v1.2.3-70-g09d2 From 4b42d1c4cca07396585dbfd6265ecd751b419b06 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Wed, 15 Apr 2015 14:36:28 -0400 Subject: Add a test for a reported (but non-reproducible) failure patching a mailing list's subscription policy. --- src/mailman/rest/tests/test_listconf.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/mailman/rest/tests/test_listconf.py b/src/mailman/rest/tests/test_listconf.py index ddb43a8ea..860adac57 100644 --- a/src/mailman/rest/tests/test_listconf.py +++ b/src/mailman/rest/tests/test_listconf.py @@ -26,7 +26,8 @@ import unittest from mailman.app.lifecycle import create_list from mailman.database.transaction import transaction -from mailman.interfaces.mailinglist import IAcceptableAliasSet +from mailman.interfaces.mailinglist import ( + IAcceptableAliasSet, SubscriptionPolicy) from mailman.testing.helpers import call_api from mailman.testing.layers import RESTLayer @@ -90,3 +91,20 @@ class TestConfiguration(unittest.TestCase): # All three acceptable aliases were set. self.assertEqual(set(IAcceptableAliasSet(self._mlist).aliases), set(aliases)) + + def test_patch_subscription_policy(self): + # The new subscription_policy value can be patched. + # + # To start with, the subscription policy is confirm by default. + resource, response = call_api( + 'http://localhost:9001/3.0/lists/test@example.com/config') + self.assertEqual(resource['subscription_policy'], 'confirm') + # Let's patch it to do some moderation. + resource, response = call_api( + 'http://localhost:9001/3.0/lists/test@example.com/config', dict( + subscription_policy='confirm_then_moderate'), + method='PATCH') + self.assertEqual(response.status, 204) + # And now we verify that it has the requested setting. + self.assertEqual(self._mlist.subscription_policy, + SubscriptionPolicy.confirm_then_moderate) -- cgit v1.2.3-70-g09d2 From 569553ab883624c9c0dc2217d4e16b6841fb0f23 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Wed, 15 Apr 2015 16:24:15 -0400 Subject: The SubscriptionWorkflow and Registar classes now have both a token and a "token owner". The latter describes who owns the token --i.e. which phase of the workflow is being waited on. It can either be no one, the subscriber, or the moderator. Tokens and token owners are properly initialized and reset when the workflow is completed, so we always know which step of the process is being waited on. Also, remove ISubscriptionService.join() since this will now be handled by the IRegistrar adapter. --- src/mailman/app/registrar.py | 4 +- src/mailman/app/subscriptions.py | 83 ++++++++++++----------------- src/mailman/app/tests/test_registrar.py | 72 +++++++++++++++---------- src/mailman/app/tests/test_subscriptions.py | 41 +++++++++++++- src/mailman/interfaces/registrar.py | 8 +-- src/mailman/interfaces/subscriptions.py | 52 +++++------------- 6 files changed, 137 insertions(+), 123 deletions(-) diff --git a/src/mailman/app/registrar.py b/src/mailman/app/registrar.py index ae4322d22..a3699de91 100644 --- a/src/mailman/app/registrar.py +++ b/src/mailman/app/registrar.py @@ -63,7 +63,7 @@ class Registrar: pre_confirmed=pre_confirmed, pre_approved=pre_approved) list(workflow) - return workflow.token + return workflow.token, workflow.token_owner def confirm(self, token): """See `IRegistrar`.""" @@ -71,7 +71,7 @@ class Registrar: workflow.token = token workflow.restore() list(workflow) - return workflow.token + return workflow.token, workflow.token_owner def discard(self, token): """See `IRegistrar`.""" diff --git a/src/mailman/app/subscriptions.py b/src/mailman/app/subscriptions.py index 3138c513b..de68a2a46 100644 --- a/src/mailman/app/subscriptions.py +++ b/src/mailman/app/subscriptions.py @@ -31,9 +31,8 @@ import logging from email.utils import formataddr from enum import Enum from datetime import timedelta -from mailman.app.membership import add_member, delete_member +from mailman.app.membership import delete_member from mailman.app.workflow import Workflow -from mailman.core.constants import system_preferences from mailman.core.i18n import _ from mailman.database.transaction import dbconnection from mailman.email.message import UserNotification @@ -42,12 +41,10 @@ from mailman.interfaces.bans import IBanManager from mailman.interfaces.listmanager import ( IListManager, ListDeletingEvent, NoSuchListError) from mailman.interfaces.mailinglist import SubscriptionPolicy -from mailman.interfaces.member import ( - DeliveryMode, MemberRole, MembershipIsBannedError) +from mailman.interfaces.member import MembershipIsBannedError from mailman.interfaces.pending import IPendable, IPendings from mailman.interfaces.registrar import ConfirmationNeededEvent -from mailman.interfaces.subscriptions import ( - ISubscriptionService, MissingUserError, RequestRecord) +from mailman.interfaces.subscriptions import ISubscriptionService, TokenOwner from mailman.interfaces.user import IUser from mailman.interfaces.usermanager import IUserManager from mailman.interfaces.workflow import IWorkflowStateManager @@ -56,7 +53,6 @@ from mailman.utilities.datetime import now from mailman.utilities.i18n import make from operator import attrgetter from sqlalchemy import and_, or_ -from uuid import UUID from zope.component import getUtility from zope.event import notify from zope.interface import implementer @@ -97,6 +93,7 @@ class SubscriptionWorkflow(Workflow): 'address_key', 'subscriber_key', 'user_key', + 'token_owner_key', ) def __init__(self, mlist, subscriber=None, *, @@ -106,6 +103,7 @@ class SubscriptionWorkflow(Workflow): self.address = None self.user = None self.which = None + self._set_token(TokenOwner.no_one) # The subscriber must be either an IUser or IAddress. if IAddress.providedBy(subscriber): self.address = subscriber @@ -151,6 +149,29 @@ class SubscriptionWorkflow(Workflow): def subscriber_key(self, key): self.which = WhichSubscriber(key) + @property + def token_owner_key(self): + return self.token_owner.value + + @token_owner_key.setter + def token_owner_key(self, value): + self.token_owner = TokenOwner(value) + + def _set_token(self, token_owner): + assert isinstance(token_owner, TokenOwner) + # Create a new token to prevent replay attacks. It seems like this + # should produce the same token, but it won't because the pending adds + # a bit of randomization. + self.token_owner = token_owner + if token_owner is TokenOwner.no_one: + self.token = None + return + pendable = Pendable( + list_id=self.mlist.list_id, + address=self.address.email, + ) + self.token = getUtility(IPendings).add(pendable, timedelta(days=3650)) + def _step_sanity_checks(self): # Ensure that we have both an address and a user, even if the address # is not verified. We can't set the preferred address until it is @@ -174,13 +195,7 @@ class SubscriptionWorkflow(Workflow): # Is this email address banned? if IBanManager(self.mlist).is_banned(self.address.email): raise MembershipIsBannedError(self.mlist, self.address.email) - # Create a pending record. This will give us the hash token we can use - # to uniquely name this workflow. - pendable = Pendable( - list_id=self.mlist.list_id, - address=self.address.email, - ) - self.token = getUtility(IPendings).add(pendable, timedelta(days=3650)) + # Start out with the subscriber being the token owner. self.push('verification_checks') def _step_verification_checks(self): @@ -229,6 +244,7 @@ class SubscriptionWorkflow(Workflow): # Here's the next step in the workflow, assuming the moderator # approves of the subscription. If they don't, the workflow and # subscription request will just be thrown away. + self._set_token(TokenOwner.moderator) self.push('subscribe_from_restored') self.save() log.info('{}: held subscription request from {}'.format( @@ -255,6 +271,8 @@ class SubscriptionWorkflow(Workflow): raise StopIteration def _step_subscribe_from_restored(self): + # Prevent replay attacks. + self._set_token(TokenOwner.no_one) # Restore a little extra state that can't be stored in the database # (because the order of setattr() on restore is indeterminate), then # subscribe the user. @@ -270,9 +288,9 @@ class SubscriptionWorkflow(Workflow): self.mlist.subscribe(self.subscriber) # This workflow is done so throw away any associated state. getUtility(IWorkflowStateManager).restore(self.name, self.token) - self.token = None def _step_send_confirmation(self): + self._set_token(TokenOwner.subscriber) self.push('do_confirm_verify') self.save() # Triggering this event causes the confirmation message to be sent. @@ -290,14 +308,8 @@ class SubscriptionWorkflow(Workflow): else: assert self.which is WhichSubscriber.user self.subscriber = self.user - # Create a new token to prevent replay attacks. It seems like this - # should produce the same token, but it won't because the pending adds - # a bit of randomization. - pendable = Pendable( - list_id=self.mlist.list_id, - address=self.address.email, - ) - self.token = getUtility(IPendings).add(pendable, timedelta(days=3650)) + # Reset the token so it can't be used in a replay attack. + self._set_token(TokenOwner.no_one) # The user has confirmed their subscription request, and also verified # their email address if necessary. This latter needs to be set on the # IAddress, but there's nothing more to do about the confirmation step. @@ -396,31 +408,6 @@ class SubscriptionService: for member in self.get_members(): yield member - def join(self, list_id, subscriber, - display_name=None, - delivery_mode=DeliveryMode.regular, - role=MemberRole.member): - """See `ISubscriptionService`.""" - mlist = getUtility(IListManager).get_by_list_id(list_id) - if mlist is None: - raise NoSuchListError(list_id) - # Is the subscriber an email address or user id? - if isinstance(subscriber, str): - if display_name is None: - display_name, at, domain = subscriber.partition('@') - return add_member( - mlist, - RequestRecord(subscriber, display_name, delivery_mode, - system_preferences.preferred_language), - role) - else: - # We have to assume it's a UUID. - assert isinstance(subscriber, UUID), 'Not a UUID' - user = getUtility(IUserManager).get_user_by_id(subscriber) - if user is None: - raise MissingUserError(subscriber) - return mlist.subscribe(user, role) - def leave(self, list_id, email): """See `ISubscriptionService`.""" mlist = getUtility(IListManager).get_by_list_id(list_id) diff --git a/src/mailman/app/tests/test_registrar.py b/src/mailman/app/tests/test_registrar.py index 4f5e1e3f9..1431b5e6f 100644 --- a/src/mailman/app/tests/test_registrar.py +++ b/src/mailman/app/tests/test_registrar.py @@ -28,6 +28,7 @@ from mailman.app.lifecycle import create_list from mailman.interfaces.mailinglist import SubscriptionPolicy from mailman.interfaces.pending import IPendings from mailman.interfaces.registrar import IRegistrar +from mailman.interfaces.subscriptions import TokenOwner from mailman.interfaces.usermanager import IUserManager from mailman.testing.layers import ConfigLayer from mailman.utilities.datetime import now @@ -49,35 +50,30 @@ class TestRegistrar(unittest.TestCase): def test_unique_token(self): # Registering a subscription request provides a unique token associated - # with a pendable. + # with a pendable, and the owner of the token. self.assertEqual(self._pendings.count, 0) - token = self._registrar.register(self._anne) + token, token_owner = self._registrar.register(self._anne) self.assertIsNotNone(token) + self.assertEqual(token_owner, TokenOwner.subscriber) self.assertEqual(self._pendings.count, 1) record = self._pendings.confirm(token, expunge=False) self.assertEqual(record['list_id'], self._mlist.list_id) self.assertEqual(record['address'], 'anne@example.com') - def test_no_token(self): + def test_subscribe(self): # Registering a subscription request where no confirmation or - # moderation steps are needed, leaves us with no token, since there's - # nothing more to do. + # moderation steps are needed, leaves us with no token or owner, since + # there's nothing more to do. self._mlist.subscription_policy = SubscriptionPolicy.open self._anne.verified_on = now() - token = self._registrar.register(self._anne) + token, token_owner = self._registrar.register(self._anne) self.assertIsNone(token) - record = self._pendings.confirm(token, expunge=False) - self.assertIsNone(record) - - def test_is_subscribed(self): - # Where no confirmation or moderation steps are needed, registration - # happens immediately. - self._mlist.subscription_policy = SubscriptionPolicy.open - self._anne.verified_on = now() - status = self._registrar.register(self._anne) - self.assertIsNone(status) + self.assertEqual(token_owner, TokenOwner.no_one) member = self._mlist.regular_members.get_member('anne@example.com') self.assertEqual(member.address, self._anne) + # There's nothing to confirm. + record = self._pendings.confirm(token, expunge=False) + self.assertIsNone(record) def test_no_such_token(self): # Given a token which is not in the database, a LookupError is raised. @@ -90,12 +86,15 @@ class TestRegistrar(unittest.TestCase): # to approve. Running the workflow gives us a token. Confirming the # token subscribes the user. self._mlist.subscription_policy = SubscriptionPolicy.open - token = self._registrar.register(self._anne) + token, token_owner = self._registrar.register(self._anne) self.assertIsNotNone(token) + self.assertEqual(token_owner, TokenOwner.subscriber) member = self._mlist.regular_members.get_member('anne@example.com') self.assertIsNone(member) # Now confirm the subscription. - self._registrar.confirm(token) + token, token_owner = self._registrar.confirm(token) + self.assertIsNone(token) + self.assertEqual(token_owner, TokenOwner.no_one) member = self._mlist.regular_members.get_member('anne@example.com') self.assertEqual(member.address, self._anne) @@ -106,12 +105,15 @@ class TestRegistrar(unittest.TestCase): # user. self._mlist.subscription_policy = SubscriptionPolicy.confirm self._anne.verified_on = now() - token = self._registrar.register(self._anne) + token, token_owner = self._registrar.register(self._anne) self.assertIsNotNone(token) + self.assertEqual(token_owner, TokenOwner.subscriber) member = self._mlist.regular_members.get_member('anne@example.com') self.assertIsNone(member) # Now confirm the subscription. - self._registrar.confirm(token) + token, token_owner = self._registrar.confirm(token) + self.assertIsNone(token) + self.assertEqual(token_owner, TokenOwner.no_one) member = self._mlist.regular_members.get_member('anne@example.com') self.assertEqual(member.address, self._anne) @@ -121,12 +123,15 @@ class TestRegistrar(unittest.TestCase): # token subscribes the user. self._mlist.subscription_policy = SubscriptionPolicy.moderate self._anne.verified_on = now() - token = self._registrar.register(self._anne) + token, token_owner = self._registrar.register(self._anne) self.assertIsNotNone(token) + self.assertEqual(token_owner, TokenOwner.moderator) member = self._mlist.regular_members.get_member('anne@example.com') self.assertIsNone(member) # Now confirm the subscription. - self._registrar.confirm(token) + token, token_owner = self._registrar.confirm(token) + self.assertIsNone(token) + self.assertEqual(token_owner, TokenOwner.no_one) member = self._mlist.regular_members.get_member('anne@example.com') self.assertEqual(member.address, self._anne) @@ -140,21 +145,26 @@ class TestRegistrar(unittest.TestCase): SubscriptionPolicy.confirm_then_moderate self._anne.verified_on = now() # Runs until subscription confirmation. - token = self._registrar.register(self._anne) + token, token_owner = self._registrar.register(self._anne) self.assertIsNotNone(token) + self.assertEqual(token_owner, TokenOwner.subscriber) member = self._mlist.regular_members.get_member('anne@example.com') self.assertIsNone(member) # Now confirm the subscription, and wait for the moderator to approve # the subscription. She is still not subscribed. - new_token = self._registrar.confirm(token) + new_token, token_owner = self._registrar.confirm(token) # The new token, used for the moderator to approve the message, is not # the same as the old token. self.assertNotEqual(new_token, token) + self.assertIsNotNone(new_token) + self.assertEqual(token_owner, TokenOwner.moderator) member = self._mlist.regular_members.get_member('anne@example.com') self.assertIsNone(member) # Confirm once more, this time as the moderator approving the # subscription. Now she's a member. - self._registrar.confirm(new_token) + token, token_owner = self._registrar.confirm(new_token) + self.assertIsNone(token) + self.assertEqual(token_owner, TokenOwner.no_one) member = self._mlist.regular_members.get_member('anne@example.com') self.assertEqual(member.address, self._anne) @@ -167,16 +177,18 @@ class TestRegistrar(unittest.TestCase): SubscriptionPolicy.confirm_then_moderate self._anne.verified_on = now() # Runs until subscription confirmation. - token = self._registrar.register(self._anne) + token, token_owner = self._registrar.register(self._anne) self.assertIsNotNone(token) + self.assertEqual(token_owner, TokenOwner.subscriber) member = self._mlist.regular_members.get_member('anne@example.com') self.assertIsNone(member) # Now confirm the subscription, and wait for the moderator to approve # the subscription. She is still not subscribed. - new_token = self._registrar.confirm(token) + new_token, token_owner = self._registrar.confirm(token) # The status is not true because the user has not yet been subscribed # to the mailing list. self.assertIsNotNone(new_token) + self.assertEqual(token_owner, TokenOwner.moderator) member = self._mlist.regular_members.get_member('anne@example.com') self.assertIsNone(member) # The new token is different than the old token. @@ -185,9 +197,10 @@ class TestRegistrar(unittest.TestCase): self.assertRaises(LookupError, self._registrar.confirm, token) # Confirm once more, this time with the new token, as the moderator # approving the subscription. Now she's a member. - done_token = self._registrar.confirm(new_token) + done_token, token_owner = self._registrar.confirm(new_token) # The token is None, signifying that the member has been subscribed. self.assertIsNone(done_token) + self.assertEqual(token_owner, TokenOwner.no_one) member = self._mlist.regular_members.get_member('anne@example.com') self.assertEqual(member.address, self._anne) @@ -197,8 +210,9 @@ class TestRegistrar(unittest.TestCase): self._mlist.subscription_policy = SubscriptionPolicy.confirm self._anne.verified_on = now() # Runs until subscription confirmation. - token = self._registrar.register(self._anne) + token, token_owner = self._registrar.register(self._anne) self.assertIsNotNone(token) + self.assertEqual(token_owner, TokenOwner.subscriber) member = self._mlist.regular_members.get_member('anne@example.com') self.assertIsNone(member) # Now discard the subscription request. diff --git a/src/mailman/app/tests/test_subscriptions.py b/src/mailman/app/tests/test_subscriptions.py index a4971d793..77f308b9c 100644 --- a/src/mailman/app/tests/test_subscriptions.py +++ b/src/mailman/app/tests/test_subscriptions.py @@ -34,7 +34,7 @@ from mailman.interfaces.member import ( MemberRole, MembershipIsBannedError, MissingPreferredAddressError) from mailman.interfaces.pending import IPendings from mailman.interfaces.subscriptions import ( - MissingUserError, ISubscriptionService) + ISubscriptionService, MissingUserError, TokenOwner) from mailman.testing.helpers import LogFileMark, get_queue_messages from mailman.testing.layers import ConfigLayer from mailman.interfaces.mailinglist import SubscriptionPolicy @@ -45,6 +45,7 @@ from zope.component import getUtility +@unittest.skip('XXX -- no more .join()') class TestJoin(unittest.TestCase): layer = ConfigLayer @@ -88,6 +89,12 @@ class TestSubscriptionWorkflow(unittest.TestCase): self._anne = 'anne@example.com' self._user_manager = getUtility(IUserManager) + def test_no_token_to_start_with(self): + # The workflow starts with no tokens. + workflow = SubscriptionWorkflow(self._mlist) + self.assertIsNone(workflow.token) + self.assertEqual(workflow.token_owner, TokenOwner.no_one) + def test_user_or_address_required(self): # The `subscriber` attribute must be a user or address. workflow = SubscriptionWorkflow(self._mlist) @@ -311,6 +318,9 @@ class TestSubscriptionWorkflow(unittest.TestCase): # Anne is now a member of the mailing list. member = self._mlist.regular_members.get_member(self._anne) self.assertEqual(member.address, anne) + # No further token is needed. + self.assertIsNone(workflow.token) + self.assertEqual(workflow.token_owner, TokenOwner.no_one) def test_do_subscription_pre_approved(self): # An moderation-requiring subscription policy plus a pre-verified and @@ -326,6 +336,9 @@ class TestSubscriptionWorkflow(unittest.TestCase): # Anne is now a member of the mailing list. member = self._mlist.regular_members.get_member(self._anne) self.assertEqual(member.address, anne) + # No further token is needed. + self.assertIsNone(workflow.token) + self.assertEqual(workflow.token_owner, TokenOwner.no_one) def test_do_subscription_pre_approved_pre_confirmed(self): # An moderation-requiring subscription policy plus a pre-verified and @@ -343,6 +356,9 @@ class TestSubscriptionWorkflow(unittest.TestCase): # Anne is now a member of the mailing list. member = self._mlist.regular_members.get_member(self._anne) self.assertEqual(member.address, anne) + # No further token is needed. + self.assertIsNone(workflow.token) + self.assertEqual(workflow.token_owner, TokenOwner.no_one) def test_do_subscription_cleanups(self): # Once the user is subscribed, the token, and its associated pending @@ -362,6 +378,7 @@ class TestSubscriptionWorkflow(unittest.TestCase): self.assertEqual(member.address, anne) # The workflow is done, so it has no token. self.assertIsNone(workflow.token) + self.assertEqual(workflow.token_owner, TokenOwner.no_one) # The pendable associated with the token has been evicted. self.assertIsNone(getUtility(IPendings).confirm(token, expunge=False)) # There is no saved workflow associated with the token. This shows up @@ -384,6 +401,9 @@ class TestSubscriptionWorkflow(unittest.TestCase): # The user is not currently subscribed to the mailing list. member = self._mlist.regular_members.get_member(self._anne) self.assertIsNone(member) + # The token is owned by the moderator. + self.assertIsNotNone(workflow.token) + self.assertEqual(workflow.token_owner, TokenOwner.moderator) # Create a new workflow with the previous workflow's save token, and # restore its state. This models an approved subscription and should # result in the user getting subscribed. @@ -394,6 +414,9 @@ class TestSubscriptionWorkflow(unittest.TestCase): # Now the user is subscribed to the mailing list. member = self._mlist.regular_members.get_member(self._anne) self.assertEqual(member.address, anne) + # No further token is needed. + self.assertIsNone(approved_workflow.token) + self.assertEqual(approved_workflow.token_owner, TokenOwner.no_one) def test_get_moderator_approval_log_on_hold(self): # When the subscription is held for moderator approval, a message is @@ -530,6 +553,10 @@ approval: workflow = SubscriptionWorkflow(self._mlist, anne) list(workflow) self.assertIsNone(self._mlist.regular_members.get_member(self._anne)) + # The token is owned by the subscriber. + self.assertIsNotNone(workflow.token) + self.assertEqual(workflow.token_owner, TokenOwner.subscriber) + # Confirm. confirm_workflow = SubscriptionWorkflow(self._mlist) confirm_workflow.token = workflow.token confirm_workflow.restore() @@ -537,6 +564,9 @@ approval: self.assertIsNotNone(anne.verified_on) self.assertEqual( self._mlist.regular_members.get_member(self._anne).address, anne) + # No further token is needed. + self.assertIsNone(confirm_workflow.token) + self.assertEqual(confirm_workflow.token_owner, TokenOwner.no_one) def test_prevent_confirmation_replay_attacks(self): # Ensure that if the workflow requires two confirmations, e.g. first @@ -553,11 +583,17 @@ approval: # Anne is not yet a member of the mailing list. member = self._mlist.regular_members.get_member(self._anne) self.assertIsNone(member) + # The token is owned by the subscriber. + self.assertIsNotNone(workflow.token) + self.assertEqual(workflow.token_owner, TokenOwner.subscriber) # The old token will not work for moderator approval. moderator_workflow = SubscriptionWorkflow(self._mlist) moderator_workflow.token = token moderator_workflow.restore() list(moderator_workflow) + # The token is owned by the moderator. + self.assertIsNotNone(moderator_workflow.token) + self.assertEqual(moderator_workflow.token_owner, TokenOwner.moderator) # While we wait for the moderator to approve the subscription, note # that there's a new token for the next steps. self.assertNotEqual(token, moderator_workflow.token) @@ -578,3 +614,6 @@ approval: # And now Anne is a member. member = self._mlist.regular_members.get_member(self._anne) self.assertEqual(member.address.email, self._anne) + # No further token is needed. + self.assertIsNone(final_workflow.token) + self.assertEqual(final_workflow.token_owner, TokenOwner.no_one) diff --git a/src/mailman/interfaces/registrar.py b/src/mailman/interfaces/registrar.py index ff3f26898..e19a3137c 100644 --- a/src/mailman/interfaces/registrar.py +++ b/src/mailman/interfaces/registrar.py @@ -75,9 +75,11 @@ class IRegistrar(Interface): :type mlist: `IMailingList` :param subscriber: The user or address to subscribe. :type email: ``IUser`` or ``IAddress`` - :return: The confirmation token string, or None if the workflow - completes (i.e. the member has been subscribed). - :rtype: str or None + :return: None if the workflow completes with the member being + subscribed. If the workflow is paused for user confirmation or + moderator approval, a 2-tuple is returned where the first element + is a ``TokenOwner`` and the second element is the token hash. + :rtype: None or 2-tuple of (TokenOwner, str) :raises MembershipIsBannedError: when the address being subscribed appears in the global or list-centric bans. """ diff --git a/src/mailman/interfaces/subscriptions.py b/src/mailman/interfaces/subscriptions.py index 677f591ef..e6ffd29ce 100644 --- a/src/mailman/interfaces/subscriptions.py +++ b/src/mailman/interfaces/subscriptions.py @@ -19,14 +19,16 @@ __all__ = [ 'ISubscriptionService', + 'MissingUserError', 'RequestRecord', + 'TokenOwner', ] from collections import namedtuple - +from enum import Enum from mailman.interfaces.errors import MailmanError -from mailman.interfaces.member import DeliveryMode, MemberRole +from mailman.interfaces.member import DeliveryMode from zope.interface import Interface @@ -55,6 +57,14 @@ def RequestRecord(email, display_name='', return _RequestRecord(email, display_name, delivery_mode, language) + +class TokenOwner(Enum): + """Who 'owns' the token returned from the registrar?""" + no_one = 0 + subscriber = 1 + moderator = 2 + + class ISubscriptionService(Interface): """General Subscription services.""" @@ -104,44 +114,6 @@ class ISubscriptionService(Interface): def __iter__(): """See `get_members()`.""" - def join(list_id, subscriber, display_name=None, - delivery_mode=DeliveryMode.regular, - role=MemberRole.member): - """Subscribe to a mailing list. - - A user for the address is created if it is not yet known to Mailman, - however newly registered addresses will not yet be validated. No - confirmation message will be sent to the address, and the approval of - the subscription request is still dependent on the policy of the - mailing list. - - :param list_id: The list id of the mailing list the user is - subscribing to. - :type list_id: string - :param subscriber: The email address or user id of the user getting - subscribed. - :type subscriber: string or int - :param display_name: The name of the user. This is only used if a new - user is created, and it defaults to the local part of the email - address if not given. - :type display_name: string - :param delivery_mode: The delivery mode for this subscription. This - can be one of the enum values of `DeliveryMode`. If not given, - regular delivery is assumed. - :type delivery_mode: string - :param role: The membership role for this subscription. - :type role: `MemberRole` - :return: The just created member. - :rtype: `IMember` - :raises AlreadySubscribedError: if the user is already subscribed to - the mailing list. - :raises InvalidEmailAddressError: if the email address is not valid. - :raises MembershipIsBannedError: if the membership is not allowed. - :raises MissingUserError: when a bogus user id is given. - :raises NoSuchListError: if the named mailing list does not exist. - :raises ValueError: when `delivery_mode` is invalid. - """ - def leave(list_id, email): """Unsubscribe from a mailing list. -- cgit v1.2.3-70-g09d2 From 1a18d7bf45c04c91151f6f74110f88f9869954be Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Wed, 15 Apr 2015 17:45:04 -0400 Subject: Rewrite the subscription service doctest for the updated API. --- src/mailman/app/docs/subscriptions.rst | 276 +++++++++++++++------------------ src/mailman/app/subscriptions.py | 1 + 2 files changed, 125 insertions(+), 152 deletions(-) diff --git a/src/mailman/app/docs/subscriptions.rst b/src/mailman/app/docs/subscriptions.rst index eaccdc3cc..2fc59d9a7 100644 --- a/src/mailman/app/docs/subscriptions.rst +++ b/src/mailman/app/docs/subscriptions.rst @@ -2,9 +2,10 @@ Subscription services ===================== -The `ISubscriptionService` utility provides higher level convenience methods -useful for searching, retrieving, iterating, adding, and removing -memberships. +The ``ISubscriptionService`` utility provides higher level convenience methods +useful for searching, retrieving, iterating, and removing memberships across +all mailing lists on th esystem. Adding new users is handled by the +``IRegistrar`` interface. >>> from mailman.interfaces.subscriptions import ISubscriptionService >>> from zope.component import getUtility @@ -22,183 +23,154 @@ membership role. At first, there are no memberships. None -Adding new members -================== - -The service can be used to subscribe new members, by default with the `member` -role. At a minimum, a mailing list and an address for the new user is -required. - - >>> mlist = create_list('test@example.com') - >>> anne = service.join('test.example.com', 'anne@example.com') - >>> anne - on test@example.com as MemberRole.member> +Listing members +=============== -The real name of the new member can be given. +When there are some members, of any role on any mailing list, they can be +retrieved through the subscription service. - >>> bart = service.join('test.example.com', 'bart@example.com', - ... 'Bart Person') - >>> bart - - on test@example.com as MemberRole.member> + >>> from mailman.app.lifecycle import create_list + >>> ant = create_list('ant@example.com') + >>> bee = create_list('bee@example.com') + >>> cat = create_list('cat@example.com') -Other roles can also be subscribed. +Some people become members. >>> from mailman.interfaces.member import MemberRole - >>> anne_owner = service.join('test.example.com', 'anne@example.com', - ... role=MemberRole.owner) - >>> anne_owner - on test@example.com as MemberRole.owner> - -And all the subscribed members can now be displayed. - - >>> service.get_members() - [ on test@example.com as MemberRole.owner>, - on test@example.com - as MemberRole.member>, - on test@example.com - as MemberRole.member>] - >>> sum(1 for member in service) - 3 - >>> print(service.get_member(UUID(int=3))) - on test@example.com as MemberRole.owner> - -New members can also be added by providing an existing user id instead of an -email address. However, the user must have a preferred email address. -:: - - >>> from mailman.utilities.datetime import now - >>> address = list(bart.user.addresses)[0] - >>> address.verified_on = now() - >>> bart.user.preferred_address = address - >>> service.join('test.example.com', bart.user.user_id, - ... role=MemberRole.owner) - - on test@example.com as MemberRole.owner> - - -Removing members -================ - -Regular members can also be removed. - - >>> cris = service.join('test.example.com', 'cris@example.com') - >>> service.get_members() - [ on test@example.com - as MemberRole.owner>, - on test@example.com - as MemberRole.owner>, - on test@example.com - as MemberRole.member>, - on test@example.com - as MemberRole.member>, - on test@example.com - as MemberRole.member>] - >>> sum(1 for member in service) - 5 - >>> service.leave('test.example.com', 'cris@example.com') - >>> service.get_members() - [ on test@example.com - as MemberRole.owner>, - on test@example.com - as MemberRole.owner>, - on test@example.com - as MemberRole.member>, - on test@example.com - as MemberRole.member>] - >>> sum(1 for member in service) - 4 + >>> from mailman.testing.helpers import subscribe + >>> anne_1 = subscribe(ant, 'Anne') + >>> anne_2 = subscribe(ant, 'Anne', MemberRole.owner) + >>> bart_1 = subscribe(ant, 'Bart', MemberRole.moderator) + >>> bart_2 = subscribe(bee, 'Bart', MemberRole.owner) + >>> anne_3 = subscribe(cat, 'Anne', email='anne@example.com') + >>> cris_1 = subscribe(cat, 'Cris') + +The service can be used to iterate over them. + + >>> for member in service.get_members(): + ... print(member) + + on ant@example.com as MemberRole.owner> + + on ant@example.com as MemberRole.moderator> + + on ant@example.com as MemberRole.member> + + on bee@example.com as MemberRole.owner> + + on cat@example.com as MemberRole.member> + + on cat@example.com as MemberRole.member> + +The service can also be used to get the information about a single member. + + >>> print(service.get_member(bart_2.member_id)) + + on bee@example.com as MemberRole.owner> + +There is an iteration shorthand for getting all the members. + + >>> for member in service: + ... print(member) + + on ant@example.com as MemberRole.owner> + + on ant@example.com as MemberRole.moderator> + + on ant@example.com as MemberRole.member> + + on bee@example.com as MemberRole.owner> + + on cat@example.com as MemberRole.member> + + on cat@example.com as MemberRole.member> Finding members =============== -If you know the member id for a specific member, you can get that member. - - >>> service.get_member(UUID(int=3)) - on test@example.com as MemberRole.owner> - -If you know the member's address, you can find all their memberships, based on -specific search criteria. We start by subscribing Anne to a couple of new -mailing lists. - - >>> mlist2 = create_list('foo@example.com') - >>> mlist3 = create_list('bar@example.com') - >>> address = list(anne.user.addresses)[0] - >>> address.verified_on = now() - >>> anne.user.preferred_address = address - >>> mlist.subscribe(anne.user, MemberRole.moderator) - on test@example.com - as MemberRole.moderator> - >>> mlist2.subscribe(anne.user, MemberRole.member) - on foo@example.com as MemberRole.member> - >>> mlist3.subscribe(anne.user, MemberRole.owner) - on bar@example.com as MemberRole.owner> - -And now we can find all of Anne's memberships. - - >>> service.find_members('anne@example.com') - [ on bar@example.com as MemberRole.owner>, - on foo@example.com as MemberRole.member>, - on test@example.com - as MemberRole.member>, - on test@example.com - as MemberRole.owner>, - on test@example.com - as MemberRole.moderator>] +The subscription service can be used to find memberships based on specific +search criteria. For example, we can find all the mailing lists that Anne is +a member of with her ``aperson@example.com`` address. + + >>> for member in service.find_members('aperson@example.com'): + ... print(member) + + on ant@example.com as MemberRole.member> + + on ant@example.com as MemberRole.owner> There may be no matching memberships. - >>> service.find_members('cris@example.com') + >>> service.find_members('dave@example.com') [] Memberships can also be searched for by user id. - >>> service.find_members(UUID(int=1)) - [ on bar@example.com as MemberRole.owner>, - on foo@example.com as MemberRole.member>, - on test@example.com - as MemberRole.member>, - on test@example.com - as MemberRole.owner>, - on test@example.com - as MemberRole.moderator>] + >>> for member in service.find_members(anne_1.user.user_id): + ... print(member) + + on ant@example.com as MemberRole.member> + + on ant@example.com as MemberRole.owner> You can find all the memberships for a specific mailing list. - >>> service.find_members(list_id='test.example.com') - [ on test@example.com - as MemberRole.member>, - on test@example.com as MemberRole.owner>, - on test@example.com - as MemberRole.moderator>, - on test@example.com - as MemberRole.member>, - on test@example.com - as MemberRole.owner>] + >>> for member in service.find_members(list_id='ant.example.com'): + ... print(member) + + on ant@example.com as MemberRole.member> + + on ant@example.com as MemberRole.owner> + + on ant@example.com as MemberRole.moderator> You can find all the memberships for an address on a specific mailing list, but you have to give it the list id, not the fqdn listname since the former is stable but the latter could change if the list is moved. - >>> service.find_members('anne@example.com', 'test.example.com') - [ on test@example.com - as MemberRole.member>, - on test@example.com - as MemberRole.owner>, - on test@example.com - as MemberRole.moderator>] + >>> for member in service.find_members( + ... 'bperson@example.com', 'ant.example.com'): + ... print(member) + + on ant@example.com as MemberRole.moderator> You can find all the memberships for an address with a specific role. - >>> service.find_members('anne@example.com', role=MemberRole.owner) - [ on bar@example.com as MemberRole.owner>, - on test@example.com - as MemberRole.owner>] + >>> for member in service.find_members( + ... list_id='ant.example.com', role=MemberRole.owner): + ... print(member) + + on ant@example.com as MemberRole.owner> You can also find a specific membership by all three criteria. - >>> service.find_members('anne@example.com', 'test.example.com', - ... MemberRole.owner) - [ on test@example.com - as MemberRole.owner>] + >>> for member in service.find_members( + ... 'bperson@example.com', 'bee.example.com', MemberRole.owner): + ... print(member) + + on bee@example.com as MemberRole.owner> + + +Removing members +================ + +Members can be removed via this service. + + >>> len(service.get_members()) + 6 + >>> service.leave('cat.example.com', 'cperson@example.com') + >>> len(service.get_members()) + 5 + >>> for member in service: + ... print(member) + + on ant@example.com as MemberRole.owner> + + on ant@example.com as MemberRole.moderator> + + on ant@example.com as MemberRole.member> + + on bee@example.com as MemberRole.owner> + + on cat@example.com as MemberRole.member> diff --git a/src/mailman/app/subscriptions.py b/src/mailman/app/subscriptions.py index de68a2a46..669a32432 100644 --- a/src/mailman/app/subscriptions.py +++ b/src/mailman/app/subscriptions.py @@ -326,6 +326,7 @@ class SubscriptionWorkflow(Workflow): self.push(next_step) + @implementer(ISubscriptionService) class SubscriptionService: """Subscription services for the REST API.""" -- cgit v1.2.3-70-g09d2 From ee6061a2385437bc9d6967ddfaf1514f06cb290a Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Wed, 15 Apr 2015 18:27:11 -0400 Subject: A better email validator which actually validates the email address. --- src/mailman/rest/tests/test_validator.py | 18 +++++++++++++++++- src/mailman/rest/validator.py | 6 +++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/mailman/rest/tests/test_validator.py b/src/mailman/rest/tests/test_validator.py index c670fc77c..2d515f828 100644 --- a/src/mailman/rest/tests/test_validator.py +++ b/src/mailman/rest/tests/test_validator.py @@ -24,8 +24,11 @@ __all__ = [ import unittest -from mailman.rest.validator import list_of_strings_validator +from mailman.interfaces.usermanager import IUserManager +from mailman.rest.validator import ( + list_of_strings_validator, subscriber_validator) from mailman.testing.layers import RESTLayer +from zope.component import getUtility @@ -46,3 +49,16 @@ class TestValidators(unittest.TestCase): # Strings are required. self.assertRaises(ValueError, list_of_strings_validator, 7) self.assertRaises(ValueError, list_of_strings_validator, ['ant', 7]) + + def test_subscriber_validator_uuid(self): + # Convert from an existing user id to a UUID. + anne = getUtility(IUserManager).make_user('anne@example.com') + uuid = subscriber_validator(str(anne.user_id.int)) + self.assertEqual(anne.user_id, uuid) + + def test_subscriber_validator_bad_uuid(self): + self.assertRaises(ValueError, subscriber_validator, 'not-a-thing') + + def test_subscriber_validator_email_address(self): + self.assertEqual(subscriber_validator('anne@example.com'), + 'anne@example.com') diff --git a/src/mailman/rest/validator.py b/src/mailman/rest/validator.py index 720d7adc1..1d5ad4ef9 100644 --- a/src/mailman/rest/validator.py +++ b/src/mailman/rest/validator.py @@ -29,6 +29,7 @@ __all__ = [ from mailman.core.errors import ( ReadOnlyPATCHRequestError, UnknownPATCHRequestError) +from mailman.interfaces.address import IEmailValidator from mailman.interfaces.languages import ILanguageManager from uuid import UUID from zope.component import getUtility @@ -59,7 +60,10 @@ def subscriber_validator(subscriber): try: return UUID(int=int(subscriber)) except ValueError: - return subscriber + # It must be an email address. + if getUtility(IEmailValidator).is_valid(subscriber): + return subscriber + raise ValueError def language_validator(code): -- cgit v1.2.3-70-g09d2 From 6c094ce9d81cd5e12ba13c851cbd1018ca3fb935 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Wed, 15 Apr 2015 18:42:42 -0400 Subject: Give the SubscriptionWorkflow a member attribute which gets set to the member once their subscription is completed. Remove some dead tests. Update an interface. --- src/mailman/app/subscriptions.py | 3 +- src/mailman/app/tests/test_subscriptions.py | 59 ++++++++++------------------- src/mailman/interfaces/registrar.py | 6 +-- 3 files changed, 25 insertions(+), 43 deletions(-) diff --git a/src/mailman/app/subscriptions.py b/src/mailman/app/subscriptions.py index 669a32432..aa9d600bf 100644 --- a/src/mailman/app/subscriptions.py +++ b/src/mailman/app/subscriptions.py @@ -103,6 +103,7 @@ class SubscriptionWorkflow(Workflow): self.address = None self.user = None self.which = None + self.member = None self._set_token(TokenOwner.no_one) # The subscriber must be either an IUser or IAddress. if IAddress.providedBy(subscriber): @@ -285,7 +286,7 @@ class SubscriptionWorkflow(Workflow): def _step_do_subscription(self): # We can immediately subscribe the user to the mailing list. - self.mlist.subscribe(self.subscriber) + self.member = self.mlist.subscribe(self.subscriber) # This workflow is done so throw away any associated state. getUtility(IWorkflowStateManager).restore(self.name, self.token) diff --git a/src/mailman/app/tests/test_subscriptions.py b/src/mailman/app/tests/test_subscriptions.py index 77f308b9c..fa4fec78f 100644 --- a/src/mailman/app/tests/test_subscriptions.py +++ b/src/mailman/app/tests/test_subscriptions.py @@ -44,40 +44,6 @@ from unittest.mock import patch from zope.component import getUtility - -@unittest.skip('XXX -- no more .join()') -class TestJoin(unittest.TestCase): - layer = ConfigLayer - - def setUp(self): - self._mlist = create_list('test@example.com') - self._service = getUtility(ISubscriptionService) - - def test_join_user_with_bogus_id(self): - # When `subscriber` is a missing user id, an exception is raised. - with self.assertRaises(MissingUserError) as cm: - self._service.join('test.example.com', uuid.UUID(int=99)) - self.assertEqual(cm.exception.user_id, uuid.UUID(int=99)) - - def test_join_user_with_invalid_email_address(self): - # When `subscriber` is a string that is not an email address, an - # exception is raised. - with self.assertRaises(InvalidEmailAddressError) as cm: - self._service.join('test.example.com', 'bogus') - self.assertEqual(cm.exception.email, 'bogus') - - def test_missing_preferred_address(self): - # A user cannot join a mailing list if they have no preferred address. - anne = self._service.join( - 'test.example.com', 'anne@example.com', 'Anne Person') - # Try to join Anne as a user with a different role. Her user has no - # preferred address, so this will fail. - self.assertRaises(MissingPreferredAddressError, - self._service.join, - 'test.example.com', anne.user.user_id, - role=MemberRole.owner) - - class TestSubscriptionWorkflow(unittest.TestCase): layer = ConfigLayer @@ -89,11 +55,12 @@ class TestSubscriptionWorkflow(unittest.TestCase): self._anne = 'anne@example.com' self._user_manager = getUtility(IUserManager) - def test_no_token_to_start_with(self): - # The workflow starts with no tokens. + def test_start_state(self): + # The workflow starts with no tokens or member. workflow = SubscriptionWorkflow(self._mlist) self.assertIsNone(workflow.token) self.assertEqual(workflow.token_owner, TokenOwner.no_one) + self.assertIsNone(workflow.member) def test_user_or_address_required(self): # The `subscriber` attribute must be a user or address. @@ -318,6 +285,7 @@ class TestSubscriptionWorkflow(unittest.TestCase): # Anne is now a member of the mailing list. member = self._mlist.regular_members.get_member(self._anne) self.assertEqual(member.address, anne) + self.assertEqual(workflow.member, member) # No further token is needed. self.assertIsNone(workflow.token) self.assertEqual(workflow.token_owner, TokenOwner.no_one) @@ -336,6 +304,7 @@ class TestSubscriptionWorkflow(unittest.TestCase): # Anne is now a member of the mailing list. member = self._mlist.regular_members.get_member(self._anne) self.assertEqual(member.address, anne) + self.assertEqual(workflow.member, member) # No further token is needed. self.assertIsNone(workflow.token) self.assertEqual(workflow.token_owner, TokenOwner.no_one) @@ -356,6 +325,7 @@ class TestSubscriptionWorkflow(unittest.TestCase): # Anne is now a member of the mailing list. member = self._mlist.regular_members.get_member(self._anne) self.assertEqual(member.address, anne) + self.assertEqual(workflow.member, member) # No further token is needed. self.assertIsNone(workflow.token) self.assertEqual(workflow.token_owner, TokenOwner.no_one) @@ -376,6 +346,7 @@ class TestSubscriptionWorkflow(unittest.TestCase): # Anne is now a member of the mailing list. member = self._mlist.regular_members.get_member(self._anne) self.assertEqual(member.address, anne) + self.assertEqual(workflow.member, member) # The workflow is done, so it has no token. self.assertIsNone(workflow.token) self.assertEqual(workflow.token_owner, TokenOwner.no_one) @@ -401,6 +372,7 @@ class TestSubscriptionWorkflow(unittest.TestCase): # The user is not currently subscribed to the mailing list. member = self._mlist.regular_members.get_member(self._anne) self.assertIsNone(member) + self.assertIsNone(workflow.member) # The token is owned by the moderator. self.assertIsNotNone(workflow.token) self.assertEqual(workflow.token_owner, TokenOwner.moderator) @@ -414,6 +386,7 @@ class TestSubscriptionWorkflow(unittest.TestCase): # Now the user is subscribed to the mailing list. member = self._mlist.regular_members.get_member(self._anne) self.assertEqual(member.address, anne) + self.assertEqual(approved_workflow.member, member) # No further token is needed. self.assertIsNone(approved_workflow.token) self.assertEqual(approved_workflow.token_owner, TokenOwner.no_one) @@ -552,7 +525,10 @@ approval: self.assertIsNone(anne.verified_on) workflow = SubscriptionWorkflow(self._mlist, anne) list(workflow) - self.assertIsNone(self._mlist.regular_members.get_member(self._anne)) + # Anne is not yet a member. + member = self._mlist.regular_members.get_member(self._anne) + self.assertIsNone(member) + self.assertIsNone(workflow.member) # The token is owned by the subscriber. self.assertIsNotNone(workflow.token) self.assertEqual(workflow.token_owner, TokenOwner.subscriber) @@ -562,8 +538,10 @@ approval: confirm_workflow.restore() list(confirm_workflow) self.assertIsNotNone(anne.verified_on) - self.assertEqual( - self._mlist.regular_members.get_member(self._anne).address, anne) + # Anne is now a member. + member = self._mlist.regular_members.get_member(self._anne) + self.assertEqual(member.address, anne) + self.assertEqual(confirm_workflow.member, member) # No further token is needed. self.assertIsNone(confirm_workflow.token) self.assertEqual(confirm_workflow.token_owner, TokenOwner.no_one) @@ -583,6 +561,7 @@ approval: # Anne is not yet a member of the mailing list. member = self._mlist.regular_members.get_member(self._anne) self.assertIsNone(member) + self.assertIsNone(workflow.member) # The token is owned by the subscriber. self.assertIsNotNone(workflow.token) self.assertEqual(workflow.token_owner, TokenOwner.subscriber) @@ -606,6 +585,7 @@ approval: # Anne is still not subscribed. member = self._mlist.regular_members.get_member(self._anne) self.assertIsNone(member) + self.assertIsNone(final_workflow.member) # However, if we use the new token, her subscription request will be # approved by the moderator. final_workflow.token = moderator_workflow.token @@ -614,6 +594,7 @@ approval: # And now Anne is a member. member = self._mlist.regular_members.get_member(self._anne) self.assertEqual(member.address.email, self._anne) + self.assertEqual(final_workflow.member, member) # No further token is needed. self.assertIsNone(final_workflow.token) self.assertEqual(final_workflow.token_owner, TokenOwner.no_one) diff --git a/src/mailman/interfaces/registrar.py b/src/mailman/interfaces/registrar.py index e19a3137c..019ce9d2a 100644 --- a/src/mailman/interfaces/registrar.py +++ b/src/mailman/interfaces/registrar.py @@ -52,9 +52,11 @@ class IRegistrar(Interface): This is a higher level interface to user registration, email address confirmation, etc. than the IUserManager. The latter does no validation, syntax checking, or confirmation, while this interface does. + + To use this, adapt an ``IMailingList`` to this interface. """ - def register(mlist, subscriber=None, *, + def register(subscriber=None, *, pre_verified=False, pre_confirmed=False, pre_approved=False): """Subscribe an address or user according to subscription policies. @@ -71,8 +73,6 @@ class IRegistrar(Interface): approve the subscription. Use the ``confirm(token)`` method to resume the workflow. - :param mlist: The mailing list to subscribe to. - :type mlist: `IMailingList` :param subscriber: The user or address to subscribe. :type email: ``IUser`` or ``IAddress`` :return: None if the workflow completes with the member being -- cgit v1.2.3-70-g09d2 From 08f457799cd36349a4fd22642f4c05b4eabb306d Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Wed, 15 Apr 2015 22:51:39 -0400 Subject: Plumb the subscription policy through the REST API. --- TODO.rst | 2 +- src/mailman/app/registrar.py | 4 +- src/mailman/app/subscriptions.py | 15 ++- src/mailman/app/tests/test_registrar.py | 47 ++++++--- src/mailman/app/tests/test_subscriptions.py | 32 +++++- src/mailman/commands/eml_confirm.py | 11 ++- src/mailman/commands/tests/test_confirm.py | 3 +- src/mailman/interfaces/registrar.py | 5 +- src/mailman/model/docs/registration.rst | 18 +++- src/mailman/rest/docs/membership.rst | 136 +++++-------------------- src/mailman/rest/helpers.py | 6 ++ src/mailman/rest/members.py | 148 +++++++++++++++++++++++----- src/mailman/rest/tests/test_membership.py | 12 +++ src/mailman/rest/tests/test_users.py | 3 +- src/mailman/runners/docs/command.rst | 2 +- src/mailman/runners/tests/test_confirm.py | 3 +- src/mailman/runners/tests/test_join.py | 10 +- 17 files changed, 287 insertions(+), 170 deletions(-) diff --git a/TODO.rst b/TODO.rst index 362499258..131c52faf 100644 --- a/TODO.rst +++ b/TODO.rst @@ -1,5 +1,5 @@ * TO DO: - - plumb subscription policy through rest api + - rename ISubscriptionService to IMemberSearch or somesuch - get rid of hold_subscription - subsume handle_subscription - workflow for unsubscription diff --git a/src/mailman/app/registrar.py b/src/mailman/app/registrar.py index a3699de91..240742bc0 100644 --- a/src/mailman/app/registrar.py +++ b/src/mailman/app/registrar.py @@ -63,7 +63,7 @@ class Registrar: pre_confirmed=pre_confirmed, pre_approved=pre_approved) list(workflow) - return workflow.token, workflow.token_owner + return workflow.token, workflow.token_owner, workflow.member def confirm(self, token): """See `IRegistrar`.""" @@ -71,7 +71,7 @@ class Registrar: workflow.token = token workflow.restore() list(workflow) - return workflow.token, workflow.token_owner + return workflow.token, workflow.token_owner, workflow.member def discard(self, token): """See `IRegistrar`.""" diff --git a/src/mailman/app/subscriptions.py b/src/mailman/app/subscriptions.py index aa9d600bf..1c6b96016 100644 --- a/src/mailman/app/subscriptions.py +++ b/src/mailman/app/subscriptions.py @@ -223,10 +223,16 @@ class SubscriptionWorkflow(Workflow): if self.mlist.subscription_policy is SubscriptionPolicy.moderate: self.push('moderation_checks') return - # If the subscription has been pre-confirmed, then we can skip to the - # moderation checks. + # If the subscription has been pre-confirmed, then we can skip the + # confirmation check can be skipped. If moderator approval is + # required we need to check that, otherwise we can go straight to + # subscription. if self.pre_confirmed: - self.push('moderation_checks') + next_step = ('moderation_checks' + if self.mlist.subscription_policy is + SubscriptionPolicy.confirm_then_moderate + else 'do_subscription') + self.push(next_step) return # The user must confirm their subscription. self.push('send_confirmation') @@ -235,7 +241,8 @@ class SubscriptionWorkflow(Workflow): # Does the moderator need to approve the subscription request? assert self.mlist.subscription_policy in ( SubscriptionPolicy.moderate, - SubscriptionPolicy.confirm_then_moderate) + SubscriptionPolicy.confirm_then_moderate, + ), self.mlist.subscription_policy if self.pre_approved: self.push('do_subscription') else: diff --git a/src/mailman/app/tests/test_registrar.py b/src/mailman/app/tests/test_registrar.py index 1431b5e6f..a2ab2c686 100644 --- a/src/mailman/app/tests/test_registrar.py +++ b/src/mailman/app/tests/test_registrar.py @@ -48,13 +48,14 @@ class TestRegistrar(unittest.TestCase): self._anne = getUtility(IUserManager).create_address( 'anne@example.com') - def test_unique_token(self): + def test_initial_conditions(self): # Registering a subscription request provides a unique token associated # with a pendable, and the owner of the token. self.assertEqual(self._pendings.count, 0) - token, token_owner = self._registrar.register(self._anne) + token, token_owner, member = self._registrar.register(self._anne) self.assertIsNotNone(token) self.assertEqual(token_owner, TokenOwner.subscriber) + self.assertIsNone(member) self.assertEqual(self._pendings.count, 1) record = self._pendings.confirm(token, expunge=False) self.assertEqual(record['list_id'], self._mlist.list_id) @@ -66,10 +67,11 @@ class TestRegistrar(unittest.TestCase): # there's nothing more to do. self._mlist.subscription_policy = SubscriptionPolicy.open self._anne.verified_on = now() - token, token_owner = self._registrar.register(self._anne) + token, token_owner, rmember = self._registrar.register(self._anne) self.assertIsNone(token) self.assertEqual(token_owner, TokenOwner.no_one) member = self._mlist.regular_members.get_member('anne@example.com') + self.assertEqual(rmember, member) self.assertEqual(member.address, self._anne) # There's nothing to confirm. record = self._pendings.confirm(token, expunge=False) @@ -86,16 +88,18 @@ class TestRegistrar(unittest.TestCase): # to approve. Running the workflow gives us a token. Confirming the # token subscribes the user. self._mlist.subscription_policy = SubscriptionPolicy.open - token, token_owner = self._registrar.register(self._anne) + token, token_owner, rmember = self._registrar.register(self._anne) self.assertIsNotNone(token) self.assertEqual(token_owner, TokenOwner.subscriber) + self.assertIsNone(rmember) member = self._mlist.regular_members.get_member('anne@example.com') self.assertIsNone(member) # Now confirm the subscription. - token, token_owner = self._registrar.confirm(token) + token, token_owner, rmember = self._registrar.confirm(token) self.assertIsNone(token) self.assertEqual(token_owner, TokenOwner.no_one) member = self._mlist.regular_members.get_member('anne@example.com') + self.assertEqual(rmember, member) self.assertEqual(member.address, self._anne) def test_confirm_because_confirm(self): @@ -105,16 +109,18 @@ class TestRegistrar(unittest.TestCase): # user. self._mlist.subscription_policy = SubscriptionPolicy.confirm self._anne.verified_on = now() - token, token_owner = self._registrar.register(self._anne) + token, token_owner, rmember = self._registrar.register(self._anne) self.assertIsNotNone(token) self.assertEqual(token_owner, TokenOwner.subscriber) + self.assertIsNone(rmember) member = self._mlist.regular_members.get_member('anne@example.com') self.assertIsNone(member) # Now confirm the subscription. - token, token_owner = self._registrar.confirm(token) + token, token_owner, rmember = self._registrar.confirm(token) self.assertIsNone(token) self.assertEqual(token_owner, TokenOwner.no_one) member = self._mlist.regular_members.get_member('anne@example.com') + self.assertEqual(rmember, member) self.assertEqual(member.address, self._anne) def test_confirm_because_moderation(self): @@ -123,16 +129,18 @@ class TestRegistrar(unittest.TestCase): # token subscribes the user. self._mlist.subscription_policy = SubscriptionPolicy.moderate self._anne.verified_on = now() - token, token_owner = self._registrar.register(self._anne) + token, token_owner, rmember = self._registrar.register(self._anne) self.assertIsNotNone(token) self.assertEqual(token_owner, TokenOwner.moderator) + self.assertIsNone(rmember) member = self._mlist.regular_members.get_member('anne@example.com') self.assertIsNone(member) # Now confirm the subscription. - token, token_owner = self._registrar.confirm(token) + token, token_owner, rmember = self._registrar.confirm(token) self.assertIsNone(token) self.assertEqual(token_owner, TokenOwner.no_one) member = self._mlist.regular_members.get_member('anne@example.com') + self.assertEqual(rmember, member) self.assertEqual(member.address, self._anne) def test_confirm_because_confirm_then_moderation(self): @@ -145,27 +153,30 @@ class TestRegistrar(unittest.TestCase): SubscriptionPolicy.confirm_then_moderate self._anne.verified_on = now() # Runs until subscription confirmation. - token, token_owner = self._registrar.register(self._anne) + token, token_owner, rmember = self._registrar.register(self._anne) self.assertIsNotNone(token) self.assertEqual(token_owner, TokenOwner.subscriber) + self.assertIsNone(rmember) member = self._mlist.regular_members.get_member('anne@example.com') self.assertIsNone(member) # Now confirm the subscription, and wait for the moderator to approve # the subscription. She is still not subscribed. - new_token, token_owner = self._registrar.confirm(token) + new_token, token_owner, rmember = self._registrar.confirm(token) # The new token, used for the moderator to approve the message, is not # the same as the old token. self.assertNotEqual(new_token, token) self.assertIsNotNone(new_token) self.assertEqual(token_owner, TokenOwner.moderator) + self.assertIsNone(rmember) member = self._mlist.regular_members.get_member('anne@example.com') self.assertIsNone(member) # Confirm once more, this time as the moderator approving the # subscription. Now she's a member. - token, token_owner = self._registrar.confirm(new_token) + token, token_owner, rmember = self._registrar.confirm(new_token) self.assertIsNone(token) self.assertEqual(token_owner, TokenOwner.no_one) member = self._mlist.regular_members.get_member('anne@example.com') + self.assertEqual(rmember, member) self.assertEqual(member.address, self._anne) def test_confirm_then_moderate_with_different_tokens(self): @@ -177,18 +188,20 @@ class TestRegistrar(unittest.TestCase): SubscriptionPolicy.confirm_then_moderate self._anne.verified_on = now() # Runs until subscription confirmation. - token, token_owner = self._registrar.register(self._anne) + token, token_owner, rmember = self._registrar.register(self._anne) self.assertIsNotNone(token) self.assertEqual(token_owner, TokenOwner.subscriber) + self.assertIsNone(rmember) member = self._mlist.regular_members.get_member('anne@example.com') self.assertIsNone(member) # Now confirm the subscription, and wait for the moderator to approve # the subscription. She is still not subscribed. - new_token, token_owner = self._registrar.confirm(token) + new_token, token_owner, rmember = self._registrar.confirm(token) # The status is not true because the user has not yet been subscribed # to the mailing list. self.assertIsNotNone(new_token) self.assertEqual(token_owner, TokenOwner.moderator) + self.assertIsNone(rmember) member = self._mlist.regular_members.get_member('anne@example.com') self.assertIsNone(member) # The new token is different than the old token. @@ -197,11 +210,12 @@ class TestRegistrar(unittest.TestCase): self.assertRaises(LookupError, self._registrar.confirm, token) # Confirm once more, this time with the new token, as the moderator # approving the subscription. Now she's a member. - done_token, token_owner = self._registrar.confirm(new_token) + done_token, token_owner, rmember = self._registrar.confirm(new_token) # The token is None, signifying that the member has been subscribed. self.assertIsNone(done_token) self.assertEqual(token_owner, TokenOwner.no_one) member = self._mlist.regular_members.get_member('anne@example.com') + self.assertEqual(rmember, member) self.assertEqual(member.address, self._anne) def test_discard_waiting_for_confirmation(self): @@ -210,9 +224,10 @@ class TestRegistrar(unittest.TestCase): self._mlist.subscription_policy = SubscriptionPolicy.confirm self._anne.verified_on = now() # Runs until subscription confirmation. - token, token_owner = self._registrar.register(self._anne) + token, token_owner, rmember = self._registrar.register(self._anne) self.assertIsNotNone(token) self.assertEqual(token_owner, TokenOwner.subscriber) + self.assertIsNone(rmember) member = self._mlist.regular_members.get_member('anne@example.com') self.assertIsNone(member) # Now discard the subscription request. diff --git a/src/mailman/app/tests/test_subscriptions.py b/src/mailman/app/tests/test_subscriptions.py index fa4fec78f..42984c77c 100644 --- a/src/mailman/app/tests/test_subscriptions.py +++ b/src/mailman/app/tests/test_subscriptions.py @@ -203,13 +203,29 @@ class TestSubscriptionWorkflow(unittest.TestCase): def test_confirmation_checks_confirm_pre_confirmed(self): # The subscription policy requires user confirmation, but their - # subscription is pre-confirmed. + # subscription is pre-confirmed. Since moderation is not required, + # the user will be immediately subscribed. self._mlist.subscription_policy = SubscriptionPolicy.confirm anne = self._user_manager.create_address(self._anne) workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True, pre_confirmed=True) workflow.run_thru('confirmation_checks') + with patch.object(workflow, '_step_do_subscription') as step: + next(workflow) + step.assert_called_once_with() + + def test_confirmation_checks_confirm_then_moderate_pre_confirmed(self): + # The subscription policy requires user confirmation, but their + # subscription is pre-confirmed. Since moderation is required, that + # check will be performed. + self._mlist.subscription_policy = \ + SubscriptionPolicy.confirm_then_moderate + anne = self._user_manager.create_address(self._anne) + workflow = SubscriptionWorkflow(self._mlist, anne, + pre_verified=True, + pre_confirmed=True) + workflow.run_thru('confirmation_checks') with patch.object(workflow, '_step_moderation_checks') as step: next(workflow) step.assert_called_once_with() @@ -598,3 +614,17 @@ approval: # No further token is needed. self.assertIsNone(final_workflow.token) self.assertEqual(final_workflow.token_owner, TokenOwner.no_one) + + def test_confirmation_needed_and_pre_confirmed(self): + # The subscription policy is 'confirm' but the subscription is + # pre-confirmed so the moderation checks can be skipped. + self._mlist.subscription_policy = SubscriptionPolicy.confirm + anne = self._user_manager.create_address(self._anne) + workflow = SubscriptionWorkflow( + self._mlist, anne, + pre_verified=True, pre_confirmed=True, pre_approved=True) + list(workflow) + # Anne was subscribed. + self.assertIsNone(workflow.token) + self.assertEqual(workflow.token_owner, TokenOwner.no_one) + self.assertEqual(workflow.member.address, anne) diff --git a/src/mailman/commands/eml_confirm.py b/src/mailman/commands/eml_confirm.py index 077fab9a6..ddf0db0e2 100644 --- a/src/mailman/commands/eml_confirm.py +++ b/src/mailman/commands/eml_confirm.py @@ -25,6 +25,7 @@ __all__ = [ from mailman.core.i18n import _ from mailman.interfaces.command import ContinueProcessing, IEmailCommand from mailman.interfaces.registrar import IRegistrar +from mailman.interfaces.subscriptions import TokenOwner from zope.interface import implementer @@ -53,7 +54,15 @@ class Confirm: tokens.add(token) results.confirms = tokens try: - succeeded = (IRegistrar(mlist).confirm(token) is None) + token, token_owner, member = IRegistrar(mlist).confirm(token) + if token is None: + assert token_owner is TokenOwner.no_one, token_owner + assert member is not None, member + succeeded = True + else: + assert token_owner is not TokenOwner.no_one, token_owner + assert member is None, member + succeeded = False except LookupError: # The token must not exist in the database. succeeded = False diff --git a/src/mailman/commands/tests/test_confirm.py b/src/mailman/commands/tests/test_confirm.py index 98a26bf7d..e980141b0 100644 --- a/src/mailman/commands/tests/test_confirm.py +++ b/src/mailman/commands/tests/test_confirm.py @@ -46,7 +46,8 @@ class TestConfirm(unittest.TestCase): self._mlist = create_list('test@example.com') anne = getUtility(IUserManager).create_address( 'anne@example.com', 'Anne Person') - self._token = IRegistrar(self._mlist).register(anne) + self._token, token_owner, member = IRegistrar(self._mlist).register( + anne) self._command = Confirm() # Clear the virgin queue. get_queue_messages('virgin') diff --git a/src/mailman/interfaces/registrar.py b/src/mailman/interfaces/registrar.py index 019ce9d2a..6f049b9d7 100644 --- a/src/mailman/interfaces/registrar.py +++ b/src/mailman/interfaces/registrar.py @@ -77,8 +77,9 @@ class IRegistrar(Interface): :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 2-tuple is returned where the first element - is a ``TokenOwner`` and the second element is the token hash. + 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) :raises MembershipIsBannedError: when the address being subscribed appears in the global or list-centric bans. diff --git a/src/mailman/model/docs/registration.rst b/src/mailman/model/docs/registration.rst index fc7ad6f1a..4b1e13520 100644 --- a/src/mailman/model/docs/registration.rst +++ b/src/mailman/model/docs/registration.rst @@ -35,11 +35,13 @@ which represents this work flow. Anne attempts to join the mailing list. - >>> token = registrar.register(anne) + >>> token, token_owner, member = registrar.register(anne) Because her email address has not yet been verified, she has not yet become a member of the mailing list. + >>> print(member) + None >>> print(mlist.members.get_member('anne@example.com')) None @@ -47,7 +49,10 @@ Once she verifies her email address, she will become a member of the mailing list. In this case, verifying implies that she also confirms her wish to join the mailing list. - >>> registrar.confirm(token) + >>> token, token_owner, member = registrar.confirm(token) + >>> member + on ant@example.com + as MemberRole.member> >>> mlist.members.get_member('anne@example.com') on ant@example.com as MemberRole.member> @@ -78,13 +83,18 @@ Now when Bart registers as a user for the mailing list, a token will still be generated, but this is only used by the moderator. At first, Bart is not subscribed to the mailing list. - >>> token = registrar.register(bart) + >>> token, token_owner, member = registrar.register(bart) + >>> print(member) + None >>> print(mlist.members.get_member('bart@example.com')) None When the moderator confirms Bart's subscription, he joins the mailing list. - >>> registrar.confirm(token) + >>> token, token_owner, member = registrar.confirm(token) + >>> member + on ant@example.com + as MemberRole.member> >>> mlist.members.get_member('bart@example.com') on ant@example.com as MemberRole.member> diff --git a/src/mailman/rest/docs/membership.rst b/src/mailman/rest/docs/membership.rst index 3cb83a0c8..f3a09cfe9 100644 --- a/src/mailman/rest/docs/membership.rst +++ b/src/mailman/rest/docs/membership.rst @@ -598,14 +598,17 @@ A user can be subscribed to a mailing list via the REST API, either by a specific address, or more generally by their preferred address. A subscribed user is called a member. -Elly wants to subscribes to the `ant` mailing list. Since Elly's email -address is not yet known to Mailman, a user is created for her. By default, -get gets a regular delivery. +The list owner wants to subscribe Elly to the `ant` mailing list. Since +Elly's email address is not yet known to Mailman, a user is created for her. +By default, get gets a regular delivery. >>> dump_json('http://localhost:9001/3.0/members', { ... 'list_id': 'ant.example.com', ... 'subscriber': 'eperson@example.com', ... 'display_name': 'Elly Person', + ... 'pre_verified': True, + ... 'pre_confirmed': True, + ... 'pre_approved': True, ... }) content-length: 0 date: ... @@ -659,6 +662,9 @@ list with her preferred address. >>> dump_json('http://localhost:9001/3.0/members', { ... 'list_id': 'ant.example.com', ... 'subscriber': user_id, + ... 'pre_verified': True, + ... 'pre_confirmed': True, + ... 'pre_approved': True, ... }) content-length: 0 date: ... @@ -729,94 +735,6 @@ Elly is no longer a member of the mailing list. set() -Digest delivery -=============== - -Fred joins the `ant` mailing list but wants MIME digest delivery. -:: - - >>> transaction.abort() - >>> dump_json('http://localhost:9001/3.0/members', { - ... 'list_id': 'ant.example.com', - ... 'subscriber': 'fperson@example.com', - ... 'display_name': 'Fred Person', - ... 'delivery_mode': 'mime_digests', - ... }) - content-length: 0 - date: ... - location: http://localhost:9001/3.0/members/10 - server: ... - status: 201 - - >>> fred = user_manager.get_user('fperson@example.com') - >>> memberships = list(fred.memberships.members) - >>> len(memberships) - 1 - -Fred is getting MIME deliveries. - - >>> memberships[0] - - on ant@example.com as MemberRole.member> - >>> print(memberships[0].delivery_mode) - DeliveryMode.mime_digests - - >>> dump_json('http://localhost:9001/3.0/members/10') - address: http://localhost:9001/3.0/addresses/fperson@example.com - delivery_mode: mime_digests - email: fperson@example.com - http_etag: "..." - list_id: ant.example.com - member_id: 10 - role: member - self_link: http://localhost:9001/3.0/members/10 - user: http://localhost:9001/3.0/users/7 - -Fred wants to change his delivery from MIME digest back to regular delivery. -This can be done by PATCH'ing his member with the `delivery_mode` parameter. -:: - - >>> transaction.abort() - >>> dump_json('http://localhost:9001/3.0/members/10', { - ... 'delivery_mode': 'regular', - ... }, method='PATCH') - content-length: 0 - date: ... - server: ... - status: 204 - - >>> dump_json('http://localhost:9001/3.0/members/10') - address: http://localhost:9001/3.0/addresses/fperson@example.com - delivery_mode: regular - email: fperson@example.com - http_etag: "..." - list_id: ant.example.com - member_id: 10 - role: member - self_link: http://localhost:9001/3.0/members/10 - user: http://localhost:9001/3.0/users/7 - -If a PATCH request changes no attributes, nothing happens. -:: - - >>> dump_json('http://localhost:9001/3.0/members/10', {}, method='PATCH') - content-length: 0 - date: ... - server: ... - status: 204 - - >>> dump_json('http://localhost:9001/3.0/members/10') - address: http://localhost:9001/3.0/addresses/fperson@example.com - delivery_mode: regular - email: fperson@example.com - http_etag: "..." - list_id: ant.example.com - member_id: 10 - role: member - self_link: http://localhost:9001/3.0/members/10 - user: http://localhost:9001/3.0/users/7 - - Changing delivery address ========================= @@ -849,30 +767,30 @@ addresses. >>> dump_json('http://localhost:9001/3.0/members') entry 0: ... - entry 5: + entry 4: address: http://localhost:9001/3.0/addresses/herb@example.com delivery_mode: regular email: herb@example.com http_etag: "..." list_id: ant.example.com - member_id: 11 + member_id: 10 role: member - self_link: http://localhost:9001/3.0/members/11 - user: http://localhost:9001/3.0/users/8 + self_link: http://localhost:9001/3.0/members/10 + user: http://localhost:9001/3.0/users/7 ... - entry 10: + entry 9: address: http://localhost:9001/3.0/addresses/herb@example.com delivery_mode: regular email: herb@example.com http_etag: "..." list_id: bee.example.com - member_id: 12 + member_id: 11 role: member - self_link: http://localhost:9001/3.0/members/12 - user: http://localhost:9001/3.0/users/8 + self_link: http://localhost:9001/3.0/members/11 + user: http://localhost:9001/3.0/users/7 http_etag: "..." start: 0 - total_size: 11 + total_size: 10 In order to change all of his subscriptions to use a different email address, Herb must iterate through his memberships explicitly. @@ -883,13 +801,13 @@ Herb must iterate through his memberships explicitly. >>> memberships = [entry['self_link'] for entry in content['entries']] >>> for url in sorted(memberships): ... print(url) + http://localhost:9001/3.0/members/10 http://localhost:9001/3.0/members/11 - http://localhost:9001/3.0/members/12 For each membership resource, the subscription address is changed by PATCH'ing the `address` attribute. - >>> dump_json('http://localhost:9001/3.0/members/11', { + >>> dump_json('http://localhost:9001/3.0/members/10', { ... 'address': 'hperson@example.com', ... }, method='PATCH') content-length: 0 @@ -897,7 +815,7 @@ the `address` attribute. server: ... status: 204 - >>> dump_json('http://localhost:9001/3.0/members/12', { + >>> dump_json('http://localhost:9001/3.0/members/11', { ... 'address': 'hperson@example.com', ... }, method='PATCH') content-length: 0 @@ -924,20 +842,20 @@ his membership ids have not changed. email: hperson@example.com http_etag: "..." list_id: ant.example.com - member_id: 11 + member_id: 10 role: member - self_link: http://localhost:9001/3.0/members/11 - user: http://localhost:9001/3.0/users/8 + self_link: http://localhost:9001/3.0/members/10 + user: http://localhost:9001/3.0/users/7 entry 1: address: http://localhost:9001/3.0/addresses/hperson@example.com delivery_mode: regular email: hperson@example.com http_etag: "..." list_id: bee.example.com - member_id: 12 + member_id: 11 role: member - self_link: http://localhost:9001/3.0/members/12 - user: http://localhost:9001/3.0/users/8 + self_link: http://localhost:9001/3.0/members/11 + user: http://localhost:9001/3.0/users/7 http_etag: "..." start: 0 total_size: 2 diff --git a/src/mailman/rest/helpers.py b/src/mailman/rest/helpers.py index edd57b76b..c737fcbc7 100644 --- a/src/mailman/rest/helpers.py +++ b/src/mailman/rest/helpers.py @@ -300,6 +300,12 @@ def not_found(response, body=b'404 Not Found'): response.body = body +def accepted(response, body=None): + response.status = falcon.HTTP_202 + if body is not None: + response.body = body + + def bad_request(response, body='400 Bad Request'): response.status = falcon.HTTP_400 if body is not None: diff --git a/src/mailman/rest/members.py b/src/mailman/rest/members.py index a0b5d4f4e..d6a57a673 100644 --- a/src/mailman/rest/members.py +++ b/src/mailman/rest/members.py @@ -25,18 +25,20 @@ __all__ = [ ] -from mailman.app.membership import delete_member -from mailman.interfaces.address import InvalidEmailAddressError -from mailman.interfaces.listmanager import IListManager, NoSuchListError +from mailman.app.membership import add_member, delete_member +from mailman.interfaces.address import IAddress, InvalidEmailAddressError +from mailman.interfaces.listmanager import IListManager from mailman.interfaces.member import ( AlreadySubscribedError, DeliveryMode, MemberRole, MembershipError, - NotAMemberError) -from mailman.interfaces.subscriptions import ISubscriptionService -from mailman.interfaces.user import UnverifiedAddressError + MembershipIsBannedError, NotAMemberError) +from mailman.interfaces.registrar import IRegistrar +from mailman.interfaces.subscriptions import ( + ISubscriptionService, RequestRecord, TokenOwner) +from mailman.interfaces.user import IUser, UnverifiedAddressError from mailman.interfaces.usermanager import IUserManager from mailman.rest.helpers import ( - CollectionMixin, NotFound, bad_request, child, conflict, created, etag, - no_content, not_found, okay, paginate, path_to) + CollectionMixin, NotFound, accepted, bad_request, child, conflict, + created, etag, no_content, not_found, okay, paginate, path_to) from mailman.rest.preferences import Preferences, ReadOnlyPreferences from mailman.rest.validator import ( Validator, enum_validator, subscriber_validator) @@ -202,7 +204,6 @@ class AllMembers(_MemberBase): def on_post(self, request, response): """Create a new member.""" - service = getUtility(ISubscriptionService) try: validator = Validator( list_id=str, @@ -210,22 +211,125 @@ class AllMembers(_MemberBase): display_name=str, delivery_mode=enum_validator(DeliveryMode), role=enum_validator(MemberRole), - _optional=('delivery_mode', 'display_name', 'role')) - member = service.join(**validator(request)) - except AlreadySubscribedError: - conflict(response, b'Member already subscribed') - except NoSuchListError: - bad_request(response, b'No such list') - except InvalidEmailAddressError: - bad_request(response, b'Invalid email address') + pre_verified=bool, + pre_confirmed=bool, + pre_approved=bool, + _optional=('delivery_mode', 'display_name', 'role', + 'pre_verified', 'pre_confirmed', 'pre_approved')) + arguments = validator(request) except ValueError as error: bad_request(response, str(error)) + return + # Dig the mailing list out of the arguments. + list_id = arguments.pop('list_id') + mlist = getUtility(IListManager).get_by_list_id(list_id) + if mlist is None: + bad_request(response, b'No such list') + return + # Figure out what kind of subscriber is being registered. Either it's + # a user via their preferred email address or it's an explicit address. + # If it's a UUID, then it must be associated with an existing user. + subscriber = arguments.pop('subscriber') + user_manager = getUtility(IUserManager) + # We use the display name if there is one. + display_name = arguments.pop('display_name', '') + if isinstance(subscriber, UUID): + user = user_manager.get_user_by_id(subscriber) + if user is None: + bad_request(response, b'No such user') + return + subscriber = user else: - # The member_id are UUIDs. We need to use the integer equivalent - # in the URL. - member_id = member.member_id.int - location = path_to('members/{0}'.format(member_id)) - created(response, location) + # This must be an email address. See if there's an existing + # address object associated with this email. + address = user_manager.get_address(subscriber) + if address is None: + # Create a new address, which of course will not be validated. + address = user_manager.create_address( + subscriber, display_name) + subscriber = address + # What role are we subscribing? Regular members go through the + # subscription policy workflow while owners, moderators, and + # nonmembers go through the legacy API for now. + role = arguments.pop('role', MemberRole.member) + if role is MemberRole.member: + # Get the pre_ flags for the subscription workflow. + pre_verified = arguments.pop('pre_verified', False) + pre_confirmed = arguments.pop('pre_confirmed', False) + pre_approved = arguments.pop('pre_approved', False) + # Now we can run the registration process until either the + # subscriber is subscribed, or the workflow is paused for + # verification, confirmation, or approval. + registrar = IRegistrar(mlist) + try: + token, token_owner, member = registrar.register( + subscriber, + pre_verified=pre_verified, + pre_confirmed=pre_confirmed, + pre_approved=pre_approved) + except AlreadySubscribedError: + conflict(response, b'Member already subscribed') + return + if token is None: + assert token_owner is TokenOwner.no_one, token_owner + # The subscription completed. Let's get the resulting member + # and return the location to the new member. Member ids are + # UUIDs and need to be converted to URLs because JSON doesn't + # directly support UUIDs. + member_id = member.member_id.int + location = path_to('members/{0}'.format(member_id)) + created(response, location) + return + # The member could not be directly subscribed because there are + # some out-of-band steps that need to be completed. E.g. the user + # must confirm their subscription or the moderator must approve + # it. In this case, an HTTP 202 Accepted is exactly the code that + # we should use, and we'll return both the confirmation token and + # the "token owner" so the client knows who should confirm it. + assert token is not None, token + assert token_owner is not TokenOwner.no_one, token_owner + assert member is None, member + content = dict(token=token, token_owner=token_owner.name) + accepted(response, etag(content)) + return + # 2015-04-15 BAW: We're subscribing some role other than a regular + # member. Use the legacy API for this for now. + assert role in (MemberRole.owner, + MemberRole.moderator, + MemberRole.nonmember) + # 2015-04-15 BAW: We're limited to using an email address with this + # legacy API, so if the subscriber is a user, the user must have a + # preferred address, which we'll use, even though it will subscribe + # the explicit address. It is an error if the user does not have a + # preferred address. + # + # If the subscriber is an address object, just use that. + if IUser.providedBy(subscriber): + if subscriber.preferred_address is None: + bad_request(response, b'User without preferred address') + return + email = subscriber.preferred_address.email + else: + assert IAddress.providedBy(subscriber) + email = subscriber.email + delivery_mode = arguments.pop('delivery_mode', DeliveryMode.regular) + record = RequestRecord(email, display_name, delivery_mode) + try: + member = add_member(mlist, record, role) + except InvalidEmailAddressError: + bad_request(response, b'Invalid email address') + return + except MembershipIsBannedError: + bad_request(response, b'Membership is banned') + return + # The subscription completed. Let's get the resulting member + # and return the location to the new member. Member ids are + # UUIDs and need to be converted to URLs because JSON doesn't + # directly support UUIDs. + member_id = member.member_id.int + location = path_to('members/{0}'.format(member_id)) + created(response, location) + return def on_get(self, request, response): """/members""" diff --git a/src/mailman/rest/tests/test_membership.py b/src/mailman/rest/tests/test_membership.py index a77dea3b5..4542677b6 100644 --- a/src/mailman/rest/tests/test_membership.py +++ b/src/mailman/rest/tests/test_membership.py @@ -100,6 +100,9 @@ class TestMembership(unittest.TestCase): call_api('http://localhost:9001/3.0/members', { 'list_id': 'test.example.com', 'subscriber': 'anne@example.com', + 'pre_verified': True, + 'pre_confirmed': True, + 'pre_approved': True, }) self.assertEqual(cm.exception.code, 409) self.assertEqual(cm.exception.reason, b'Member already subscribed') @@ -115,6 +118,9 @@ class TestMembership(unittest.TestCase): call_api('http://localhost:9001/3.0/members', { 'list_id': 'test.example.com', 'subscriber': 'ANNE@example.com', + 'pre_verified': True, + 'pre_confirmed': True, + 'pre_approved': True, }) self.assertEqual(cm.exception.code, 409) self.assertEqual(cm.exception.reason, b'Member already subscribed') @@ -130,6 +136,9 @@ class TestMembership(unittest.TestCase): call_api('http://localhost:9001/3.0/members', { 'list_id': 'test.example.com', 'subscriber': 'anne@example.com', + 'pre_verified': True, + 'pre_confirmed': True, + 'pre_approved': True, }) self.assertEqual(cm.exception.code, 409) self.assertEqual(cm.exception.reason, b'Member already subscribed') @@ -151,6 +160,9 @@ class TestMembership(unittest.TestCase): 'list_id': 'test.example.com', 'subscriber': 'hugh/person@example.com', 'display_name': 'Hugh Person', + 'pre_verified': True, + 'pre_confirmed': True, + 'pre_approved': True, }) self.assertEqual(content, None) self.assertEqual(response.status, 201) diff --git a/src/mailman/rest/tests/test_users.py b/src/mailman/rest/tests/test_users.py index af2c9f0d1..ac8d018e8 100644 --- a/src/mailman/rest/tests/test_users.py +++ b/src/mailman/rest/tests/test_users.py @@ -376,7 +376,8 @@ class TestLP1074374(unittest.TestCase): call_api('http://localhost:9001/3.0/members', dict( list_id='test.example.com', subscriber='anne@example.com', - role='member')) + role='member', + pre_verified=True, pre_confirmed=True, pre_approved=True)) # This is not the Anne you're looking for. (IOW, the new Anne is a # different user). content, response = call_api( diff --git a/src/mailman/runners/docs/command.rst b/src/mailman/runners/docs/command.rst index 19d772b00..c97e6454c 100644 --- a/src/mailman/runners/docs/command.rst +++ b/src/mailman/runners/docs/command.rst @@ -147,7 +147,7 @@ address, and the other is the results of his email command. ... print('Subject:', subject) ... if 'confirm' in str(subject): ... token = str(subject).split()[1].strip() - ... new_token = registrar.confirm(token) + ... new_token, token_owner, member = registrar.confirm(token) ... assert new_token is None, 'Confirmation failed' Subject: The results of your email commands Subject: confirm ... diff --git a/src/mailman/runners/tests/test_confirm.py b/src/mailman/runners/tests/test_confirm.py index 71ec5988d..b24d14a52 100644 --- a/src/mailman/runners/tests/test_confirm.py +++ b/src/mailman/runners/tests/test_confirm.py @@ -53,7 +53,8 @@ class TestConfirm(unittest.TestCase): self._mlist = create_list('test@example.com') self._mlist.send_welcome_message = False anne = getUtility(IUserManager).create_address('anne@example.org') - self._token = IRegistrar(self._mlist).register(anne) + registrar = IRegistrar(self._mlist) + self._token, token_owner, member = registrar.register(anne) def test_confirm_with_re_prefix(self): subject = 'Re: confirm {0}'.format(self._token) diff --git a/src/mailman/runners/tests/test_join.py b/src/mailman/runners/tests/test_join.py index c3b52cf0c..1067517e2 100644 --- a/src/mailman/runners/tests/test_join.py +++ b/src/mailman/runners/tests/test_join.py @@ -30,7 +30,7 @@ from mailman.app.lifecycle import create_list from mailman.config import config from mailman.interfaces.member import DeliveryMode from mailman.interfaces.registrar import IRegistrar -from mailman.interfaces.subscriptions import ISubscriptionService +from mailman.interfaces.subscriptions import ISubscriptionService, TokenOwner from mailman.interfaces.usermanager import IUserManager from mailman.runners.command import CommandRunner from mailman.testing.helpers import ( @@ -160,14 +160,16 @@ class TestJoinWithDigests(unittest.TestCase): subject_words = str(messages[1].msg['subject']).split() self.assertEqual(subject_words[0], 'confirm') token = subject_words[1] - status = IRegistrar(self._mlist).confirm(token) - self.assertIsNone(status, 'Confirmation failed') + token, token_owner, rmember = IRegistrar(self._mlist).confirm(token) + self.assertIsNone(token) + self.assertEqual(token_owner, TokenOwner.no_one) # Now, make sure that Anne is a member of the list and is receiving # digest deliveries. members = getUtility(ISubscriptionService).find_members( 'anne@example.org') self.assertEqual(len(members), 1) - return members[0] + self.assertEqual(rmember, members[0]) + return rmember def test_join_with_implicit_no_digests(self): # Test the digest=mime argument to the join command. -- cgit v1.2.3-70-g09d2 From f5a4133b693a892826ed4156d52d41270c2fc828 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Thu, 16 Apr 2015 08:20:19 -0400 Subject: Update versions for 3.0rc1, but don't tag yet. --- TODO.rst | 1 - src/mailman/docs/NEWS.rst | 4 ++-- src/mailman/version.py | 6 +++--- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/TODO.rst b/TODO.rst index 131c52faf..0c1bfa969 100644 --- a/TODO.rst +++ b/TODO.rst @@ -5,7 +5,6 @@ - workflow for unsubscription - make sure registration checks IEmailValidator - Test all the various options in eml_membership's get_subscriber() - - Bump version to 3.0.0 - Admin notification on membership changes? + admin_notify_mchanges + admin_immed_notify diff --git a/src/mailman/docs/NEWS.rst b/src/mailman/docs/NEWS.rst index ebe90a6df..6248d0d57 100644 --- a/src/mailman/docs/NEWS.rst +++ b/src/mailman/docs/NEWS.rst @@ -8,8 +8,8 @@ Copyright (C) 1998-2015 by the Free Software Foundation, Inc. Here is a history of user visible changes to Mailman. -3.0 beta 6 -- "Show Don't Tell" -=============================== +3.0 rc 1 -- "Show Don't Tell" +============================= (2015-XX-XX) Architecture diff --git a/src/mailman/version.py b/src/mailman/version.py index 42089aff5..4904454ad 100644 --- a/src/mailman/version.py +++ b/src/mailman/version.py @@ -18,7 +18,7 @@ """Mailman version strings.""" # Mailman version. -VERSION = '3.0.0b6' +VERSION = '3.0.0rc1' CODENAME = "Show Don't Tell" # And as a hex number in the manner of PY_VERSION_HEX. @@ -32,9 +32,9 @@ FINAL = 0xf MAJOR_REV = 3 MINOR_REV = 0 MICRO_REV = 0 -REL_LEVEL = BETA +REL_LEVEL = RC # At most 15 beta releases! -REL_SERIAL = 6 +REL_SERIAL = 1 HEX_VERSION = ((MAJOR_REV << 24) | (MINOR_REV << 16) | (MICRO_REV << 8) | (REL_LEVEL << 4) | (REL_SERIAL << 0)) -- cgit v1.2.3-70-g09d2 From 2830907eee46c3b3fd808e9f920d092009462aeb Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Thu, 16 Apr 2015 10:24:26 -0400 Subject: Move a doctest to the right directory. --- src/mailman/app/docs/chains.rst | 344 --------------------------------------- src/mailman/core/docs/chains.rst | 344 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 344 insertions(+), 344 deletions(-) delete mode 100644 src/mailman/app/docs/chains.rst create mode 100644 src/mailman/core/docs/chains.rst diff --git a/src/mailman/app/docs/chains.rst b/src/mailman/app/docs/chains.rst deleted file mode 100644 index a79999de0..000000000 --- a/src/mailman/app/docs/chains.rst +++ /dev/null @@ -1,344 +0,0 @@ -====== -Chains -====== - -When a new message is posted to a mailing list, Mailman uses a set of rule -chains to decide whether the message gets accepted for posting, rejected, -discarded, or held for moderator approval. - -There are a number of built-in chains available that act as end-points in the -processing of messages. - - -The Discard chain -================= - -The `discard` chain simply throws the message away. -:: - - >>> chain = config.chains['discard'] - >>> print(chain.name) - discard - >>> print(chain.description) - Discard a message and stop processing. - - >>> mlist = create_list('test@example.com') - >>> msg = message_from_string("""\ - ... From: aperson@example.com - ... To: test@example.com - ... Subject: My first post - ... Message-ID: - ... - ... An important message. - ... """) - - >>> def print_msgid(event): - ... print('{0}: {1}'.format( - ... event.chain.name.upper(), event.msg.get('message-id', 'n/a'))) - - >>> from mailman.core.chains import process - >>> from mailman.testing.helpers import event_subscribers - - >>> with event_subscribers(print_msgid): - ... process(mlist, msg, {}, 'discard') - DISCARD: - - -The Reject chain -================ - -The `reject` chain bounces the message back to the original sender, and logs -this action. -:: - - >>> chain = config.chains['reject'] - >>> print(chain.name) - reject - >>> print(chain.description) - Reject/bounce a message and stop processing. - - >>> with event_subscribers(print_msgid): - ... process(mlist, msg, {}, 'reject') - REJECT: - -The bounce message is now sitting in the `virgin` queue. - - >>> from mailman.testing.helpers import get_queue_messages - >>> qfiles = get_queue_messages('virgin') - >>> len(qfiles) - 1 - >>> print(qfiles[0].msg.as_string()) - Subject: My first post - From: test-owner@example.com - To: aperson@example.com - ... - [No bounce details are available] - ... - Content-Type: message/rfc822 - MIME-Version: 1.0 - - From: aperson@example.com - To: test@example.com - Subject: My first post - Message-ID: - - An important message. - - ... - - -The Hold Chain -============== - -The `hold` chain places the message into the administrative request database -and depending on the list's settings, sends a notification to both the -original sender and the list moderators. :: - - >>> chain = config.chains['hold'] - >>> print(chain.name) - hold - >>> print(chain.description) - Hold a message and stop processing. - - >>> with event_subscribers(print_msgid): - ... process(mlist, msg, {}, 'hold') - HOLD: - -There are now two messages in the virgin queue, one to the list moderators and -one to the original author. - - >>> qfiles = get_queue_messages('virgin', sort_on='to') - >>> len(qfiles) - 2 - -One of the message is addressed to the mailing list moderators, and the other -is addressed to the original sender. - - >>> from operator import itemgetter - >>> messages = sorted((item.msg for item in qfiles), - ... key=itemgetter('to'), reverse=True) - -This one is addressed to the list moderators. - - >>> print(messages[0].as_string()) - Subject: test@example.com post from aperson@example.com requires approval - From: test-owner@example.com - To: test-owner@example.com - MIME-Version: 1.0 - ... - As list administrator, your authorization is requested for the - following mailing list posting: - - List: test@example.com - From: aperson@example.com - Subject: My first post - Reason: XXX - - At your convenience, visit: - - http://lists.example.com/admindb/test@example.com - - to approve or deny the request. - - ... - Content-Type: message/rfc822 - MIME-Version: 1.0 - - From: aperson@example.com - To: test@example.com - Subject: My first post - Message-ID: - X-Message-ID-Hash: RXJU4JL6N2OUN3OYMXXPPSCR7P7JE2BW - - An important message. - - ... - Content-Type: message/rfc822 - MIME-Version: 1.0 - - Content-Type: text/plain; charset="us-ascii" - MIME-Version: 1.0 - Content-Transfer-Encoding: 7bit - Subject: confirm ... - From: test-request@example.com - ... - - If you reply to this message, keeping the Subject: header intact, - Mailman will discard the held message. Do this if the message is - spam. If you reply to this message and include an Approved: header - with the list password in it, the message will be approved for posting - to the list. The Approved: header can also appear in the first line - of the body of the reply. - ... - -This message is addressed to the sender of the message. - - >>> print(messages[1].as_string()) - MIME-Version: 1.0 - Content-Type: text/plain; charset="us-ascii" - Content-Transfer-Encoding: 7bit - Subject: Your message to test@example.com awaits moderator approval - From: test-bounces@example.com - To: aperson@example.com - ... - Your mail to 'test@example.com' with the subject - - My first post - - Is being held until the list moderator can review it for approval. - - The reason it is being held: - - XXX - - Either the message will get posted to the list, or you will receive - notification of the moderator's decision. If you would like to cancel - this posting, please visit the following URL: - - http://lists.example.com/confirm/test@example.com/... - - - -In addition, the pending database is holding the original messages, waiting -for them to be disposed of by the original author or the list moderators. The -database is essentially a dictionary, with the keys being the randomly -selected tokens included in the urls and the values being a 2-tuple where the -first item is a type code and the second item is a message id. -:: - - >>> import re - >>> cookie = None - >>> for line in messages[1].get_payload().splitlines(): - ... mo = re.search('confirm/[^/]+/(?P.*)$', line) - ... if mo: - ... cookie = mo.group('cookie') - ... break - >>> assert cookie is not None, 'No confirmation token found' - - >>> from mailman.interfaces.pending import IPendings - >>> from zope.component import getUtility - - >>> data = getUtility(IPendings).confirm(cookie) - >>> dump_msgdata(data) - id : 1 - type: held message - -The message itself is held in the message store. -:: - - >>> from mailman.interfaces.requests import IListRequests - >>> list_requests = IListRequests(mlist) - >>> rkey, rdata = list_requests.get_request(data['id']) - - >>> from mailman.interfaces.messages import IMessageStore - >>> from zope.component import getUtility - >>> msg = getUtility(IMessageStore).get_message_by_id( - ... rdata['_mod_message_id']) - - >>> print(msg.as_string()) - From: aperson@example.com - To: test@example.com - Subject: My first post - Message-ID: - X-Message-ID-Hash: RXJU4JL6N2OUN3OYMXXPPSCR7P7JE2BW - - An important message. - - - -The Accept chain -================ - -The `accept` chain sends the message on the `pipeline` queue, where it will be -processed and sent on to the list membership. -:: - - >>> chain = config.chains['accept'] - >>> print(chain.name) - accept - >>> print(chain.description) - Accept a message. - - >>> with event_subscribers(print_msgid): - ... process(mlist, msg, {}, 'accept') - ACCEPT: - - >>> qfiles = get_queue_messages('pipeline') - >>> len(qfiles) - 1 - >>> print(qfiles[0].msg.as_string()) - From: aperson@example.com - To: test@example.com - Subject: My first post - Message-ID: - X-Message-ID-Hash: RXJU4JL6N2OUN3OYMXXPPSCR7P7JE2BW - - An important message. - - - -Run-time chains -=============== - -We can also define chains at run time, and these chains can be mutated. -Run-time chains are made up of links where each link associates both a rule -and a `jump`. The rule is really a rule name, which is looked up when -needed. The jump names a chain which is jumped to if the rule matches. - -There is one built-in posting chain. This is the default chain to use when no -other input chain is defined for a mailing list. It runs through the default -rules. - - >>> chain = config.chains['default-posting-chain'] - >>> print(chain.name) - default-posting-chain - >>> print(chain.description) - The built-in moderation chain. - -Once the sender is a member of the mailing list, the previously created -message is innocuous enough that it should pass through all default rules. -This message will end up in the `pipeline` queue. -:: - - >>> from mailman.testing.helpers import subscribe - >>> subscribe(mlist, 'Anne') - - - >>> with event_subscribers(print_msgid): - ... process(mlist, msg, {}) - ACCEPT: - - >>> qfiles = get_queue_messages('pipeline') - >>> len(qfiles) - 1 - >>> print(qfiles[0].msg.as_string()) - From: aperson@example.com - To: test@example.com - Subject: My first post - Message-ID: - X-Message-ID-Hash: RXJU4JL6N2OUN3OYMXXPPSCR7P7JE2BW - X-Mailman-Rule-Misses: approved; emergency; loop; member-moderation; - administrivia; implicit-dest; max-recipients; max-size; - news-moderation; no-subject; suspicious-header; nonmember-moderation - - An important message. - - -In addition, the message metadata now contains lists of all rules that have -hit and all rules that have missed. - - >>> dump_list(qfiles[0].msgdata['rule_hits']) - *Empty* - >>> dump_list(qfiles[0].msgdata['rule_misses']) - administrivia - approved - emergency - implicit-dest - loop - max-recipients - max-size - member-moderation - news-moderation - no-subject - nonmember-moderation - suspicious-header diff --git a/src/mailman/core/docs/chains.rst b/src/mailman/core/docs/chains.rst new file mode 100644 index 000000000..a79999de0 --- /dev/null +++ b/src/mailman/core/docs/chains.rst @@ -0,0 +1,344 @@ +====== +Chains +====== + +When a new message is posted to a mailing list, Mailman uses a set of rule +chains to decide whether the message gets accepted for posting, rejected, +discarded, or held for moderator approval. + +There are a number of built-in chains available that act as end-points in the +processing of messages. + + +The Discard chain +================= + +The `discard` chain simply throws the message away. +:: + + >>> chain = config.chains['discard'] + >>> print(chain.name) + discard + >>> print(chain.description) + Discard a message and stop processing. + + >>> mlist = create_list('test@example.com') + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... To: test@example.com + ... Subject: My first post + ... Message-ID: + ... + ... An important message. + ... """) + + >>> def print_msgid(event): + ... print('{0}: {1}'.format( + ... event.chain.name.upper(), event.msg.get('message-id', 'n/a'))) + + >>> from mailman.core.chains import process + >>> from mailman.testing.helpers import event_subscribers + + >>> with event_subscribers(print_msgid): + ... process(mlist, msg, {}, 'discard') + DISCARD: + + +The Reject chain +================ + +The `reject` chain bounces the message back to the original sender, and logs +this action. +:: + + >>> chain = config.chains['reject'] + >>> print(chain.name) + reject + >>> print(chain.description) + Reject/bounce a message and stop processing. + + >>> with event_subscribers(print_msgid): + ... process(mlist, msg, {}, 'reject') + REJECT: + +The bounce message is now sitting in the `virgin` queue. + + >>> from mailman.testing.helpers import get_queue_messages + >>> qfiles = get_queue_messages('virgin') + >>> len(qfiles) + 1 + >>> print(qfiles[0].msg.as_string()) + Subject: My first post + From: test-owner@example.com + To: aperson@example.com + ... + [No bounce details are available] + ... + Content-Type: message/rfc822 + MIME-Version: 1.0 + + From: aperson@example.com + To: test@example.com + Subject: My first post + Message-ID: + + An important message. + + ... + + +The Hold Chain +============== + +The `hold` chain places the message into the administrative request database +and depending on the list's settings, sends a notification to both the +original sender and the list moderators. :: + + >>> chain = config.chains['hold'] + >>> print(chain.name) + hold + >>> print(chain.description) + Hold a message and stop processing. + + >>> with event_subscribers(print_msgid): + ... process(mlist, msg, {}, 'hold') + HOLD: + +There are now two messages in the virgin queue, one to the list moderators and +one to the original author. + + >>> qfiles = get_queue_messages('virgin', sort_on='to') + >>> len(qfiles) + 2 + +One of the message is addressed to the mailing list moderators, and the other +is addressed to the original sender. + + >>> from operator import itemgetter + >>> messages = sorted((item.msg for item in qfiles), + ... key=itemgetter('to'), reverse=True) + +This one is addressed to the list moderators. + + >>> print(messages[0].as_string()) + Subject: test@example.com post from aperson@example.com requires approval + From: test-owner@example.com + To: test-owner@example.com + MIME-Version: 1.0 + ... + As list administrator, your authorization is requested for the + following mailing list posting: + + List: test@example.com + From: aperson@example.com + Subject: My first post + Reason: XXX + + At your convenience, visit: + + http://lists.example.com/admindb/test@example.com + + to approve or deny the request. + + ... + Content-Type: message/rfc822 + MIME-Version: 1.0 + + From: aperson@example.com + To: test@example.com + Subject: My first post + Message-ID: + X-Message-ID-Hash: RXJU4JL6N2OUN3OYMXXPPSCR7P7JE2BW + + An important message. + + ... + Content-Type: message/rfc822 + MIME-Version: 1.0 + + Content-Type: text/plain; charset="us-ascii" + MIME-Version: 1.0 + Content-Transfer-Encoding: 7bit + Subject: confirm ... + From: test-request@example.com + ... + + If you reply to this message, keeping the Subject: header intact, + Mailman will discard the held message. Do this if the message is + spam. If you reply to this message and include an Approved: header + with the list password in it, the message will be approved for posting + to the list. The Approved: header can also appear in the first line + of the body of the reply. + ... + +This message is addressed to the sender of the message. + + >>> print(messages[1].as_string()) + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + Content-Transfer-Encoding: 7bit + Subject: Your message to test@example.com awaits moderator approval + From: test-bounces@example.com + To: aperson@example.com + ... + Your mail to 'test@example.com' with the subject + + My first post + + Is being held until the list moderator can review it for approval. + + The reason it is being held: + + XXX + + Either the message will get posted to the list, or you will receive + notification of the moderator's decision. If you would like to cancel + this posting, please visit the following URL: + + http://lists.example.com/confirm/test@example.com/... + + + +In addition, the pending database is holding the original messages, waiting +for them to be disposed of by the original author or the list moderators. The +database is essentially a dictionary, with the keys being the randomly +selected tokens included in the urls and the values being a 2-tuple where the +first item is a type code and the second item is a message id. +:: + + >>> import re + >>> cookie = None + >>> for line in messages[1].get_payload().splitlines(): + ... mo = re.search('confirm/[^/]+/(?P.*)$', line) + ... if mo: + ... cookie = mo.group('cookie') + ... break + >>> assert cookie is not None, 'No confirmation token found' + + >>> from mailman.interfaces.pending import IPendings + >>> from zope.component import getUtility + + >>> data = getUtility(IPendings).confirm(cookie) + >>> dump_msgdata(data) + id : 1 + type: held message + +The message itself is held in the message store. +:: + + >>> from mailman.interfaces.requests import IListRequests + >>> list_requests = IListRequests(mlist) + >>> rkey, rdata = list_requests.get_request(data['id']) + + >>> from mailman.interfaces.messages import IMessageStore + >>> from zope.component import getUtility + >>> msg = getUtility(IMessageStore).get_message_by_id( + ... rdata['_mod_message_id']) + + >>> print(msg.as_string()) + From: aperson@example.com + To: test@example.com + Subject: My first post + Message-ID: + X-Message-ID-Hash: RXJU4JL6N2OUN3OYMXXPPSCR7P7JE2BW + + An important message. + + + +The Accept chain +================ + +The `accept` chain sends the message on the `pipeline` queue, where it will be +processed and sent on to the list membership. +:: + + >>> chain = config.chains['accept'] + >>> print(chain.name) + accept + >>> print(chain.description) + Accept a message. + + >>> with event_subscribers(print_msgid): + ... process(mlist, msg, {}, 'accept') + ACCEPT: + + >>> qfiles = get_queue_messages('pipeline') + >>> len(qfiles) + 1 + >>> print(qfiles[0].msg.as_string()) + From: aperson@example.com + To: test@example.com + Subject: My first post + Message-ID: + X-Message-ID-Hash: RXJU4JL6N2OUN3OYMXXPPSCR7P7JE2BW + + An important message. + + + +Run-time chains +=============== + +We can also define chains at run time, and these chains can be mutated. +Run-time chains are made up of links where each link associates both a rule +and a `jump`. The rule is really a rule name, which is looked up when +needed. The jump names a chain which is jumped to if the rule matches. + +There is one built-in posting chain. This is the default chain to use when no +other input chain is defined for a mailing list. It runs through the default +rules. + + >>> chain = config.chains['default-posting-chain'] + >>> print(chain.name) + default-posting-chain + >>> print(chain.description) + The built-in moderation chain. + +Once the sender is a member of the mailing list, the previously created +message is innocuous enough that it should pass through all default rules. +This message will end up in the `pipeline` queue. +:: + + >>> from mailman.testing.helpers import subscribe + >>> subscribe(mlist, 'Anne') + + + >>> with event_subscribers(print_msgid): + ... process(mlist, msg, {}) + ACCEPT: + + >>> qfiles = get_queue_messages('pipeline') + >>> len(qfiles) + 1 + >>> print(qfiles[0].msg.as_string()) + From: aperson@example.com + To: test@example.com + Subject: My first post + Message-ID: + X-Message-ID-Hash: RXJU4JL6N2OUN3OYMXXPPSCR7P7JE2BW + X-Mailman-Rule-Misses: approved; emergency; loop; member-moderation; + administrivia; implicit-dest; max-recipients; max-size; + news-moderation; no-subject; suspicious-header; nonmember-moderation + + An important message. + + +In addition, the message metadata now contains lists of all rules that have +hit and all rules that have missed. + + >>> dump_list(qfiles[0].msgdata['rule_hits']) + *Empty* + >>> dump_list(qfiles[0].msgdata['rule_misses']) + administrivia + approved + emergency + implicit-dest + loop + max-recipients + max-size + member-moderation + news-moderation + no-subject + nonmember-moderation + suspicious-header -- cgit v1.2.3-70-g09d2 From 53d4229bff96677a54fd443322c166c2bdcc3054 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Thu, 16 Apr 2015 15:28:06 -0400 Subject: 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 +- src/mailman/rest/moderation.py | 252 ------------------- src/mailman/rest/post_moderation.py | 155 ++++++++++++ src/mailman/rest/tests/test_moderation.py | 219 +++++++++++++---- 12 files changed, 577 insertions(+), 677 deletions(-) delete mode 100644 src/mailman/rest/docs/moderation.rst create mode 100644 src/mailman/rest/docs/post-moderation.rst delete mode 100644 src/mailman/rest/moderation.py create mode 100644 src/mailman/rest/post_moderation.py 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/moderation.py deleted file mode 100644 index 984e2d34a..000000000 --- a/src/mailman/rest/moderation.py +++ /dev/null @@ -1,252 +0,0 @@ -# Copyright (C) 2012-2015 by the Free Software Foundation, Inc. -# -# This file is part of GNU Mailman. -# -# GNU Mailman is free software: you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along with -# GNU Mailman. If not, see . - -"""REST API for Message moderation.""" - -__all__ = [ - 'HeldMessage', - 'HeldMessages', - 'MembershipChangeRequest', - 'SubscriptionRequests', - ] - - -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 -from mailman.rest.helpers import ( - CollectionMixin, bad_request, child, etag, no_content, not_found, okay) -from mailman.rest.validator import Validator, enum_validator -from zope.component import getUtility - - -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 _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 list(resource): - 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 for moderating a held message.""" - - 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) - if resource is None: - not_found(response) - else: - 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, RequestType.held_message) - if results is None: - not_found(response) - else: - handle_message(self._mlist, request_id, **arguments) - no_content(response) - - - -class HeldMessages(_HeldMessageBase, CollectionMixin): - """Resource for messages held for moderation.""" - - def __init__(self, mlist): - self._mlist = mlist - self._requests = None - - def _resource_as_dict(self, request): - """See `CollectionMixin`.""" - return self._make_resource(request.id) - - def _get_collection(self, request): - requests = IListRequests(self._mlist) - self._requests = requests - return list(requests.of_type(RequestType.held_message)) - - def on_get(self, request, response): - """/lists/listname/held""" - resource = self._make_collection(request) - okay(response, etag(resource)) - - @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/post_moderation.py b/src/mailman/rest/post_moderation.py new file mode 100644 index 000000000..6156fa39f --- /dev/null +++ b/src/mailman/rest/post_moderation.py @@ -0,0 +1,155 @@ +# Copyright (C) 2012-2015 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see . + +"""REST API for held message moderation.""" + +__all__ = [ + 'HeldMessage', + 'HeldMessages', + ] + + +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 +from mailman.rest.helpers import ( + CollectionMixin, bad_request, child, etag, no_content, not_found, okay) +from mailman.rest.validator import Validator, enum_validator +from zope.component import getUtility + + + +class _ModerationBase: + """Common base class.""" + + def _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) + # Check for a matching request type, and insert the type name into the + # resource. + request_type = RequestType[resource.pop('_request_type')] + if request_type is not RequestType.held_message: + return None + 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. + 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 list(resource): + 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 for moderating a held message.""" + + 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) + if resource is None: + not_found(response) + else: + 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, RequestType.held_message) + if results is None: + not_found(response) + else: + handle_message(self._mlist, request_id, **arguments) + no_content(response) + + + +class HeldMessages(_HeldMessageBase, CollectionMixin): + """Resource for messages held for moderation.""" + + def __init__(self, mlist): + self._mlist = mlist + self._requests = None + + def _resource_as_dict(self, request): + """See `CollectionMixin`.""" + return self._make_resource(request.id) + + def _get_collection(self, request): + requests = IListRequests(self._mlist) + self._requests = requests + return list(requests.of_type(RequestType.held_message)) + + def on_get(self, request, response): + """/lists/listname/held""" + resource = self._make_collection(request) + okay(response, etag(resource)) + + @child(r'^(?P[^/]+)') + def message(self, request, segments, **kw): + return HeldMessage(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), '') -- cgit v1.2.3-70-g09d2 From 63f0f56219875f1001f8b9eb4572436b46bea30d Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Thu, 16 Apr 2015 15:28:16 -0400 Subject: Checkpointing. --- src/mailman/rest/docs/sub-moderation.rst | 113 ++++++++++++++++++++++ src/mailman/rest/sub_moderation.py | 155 +++++++++++++++++++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 src/mailman/rest/docs/sub-moderation.rst create mode 100644 src/mailman/rest/sub_moderation.py diff --git a/src/mailman/rest/docs/sub-moderation.rst b/src/mailman/rest/docs/sub-moderation.rst new file mode 100644 index 000000000..6d3d4993c --- /dev/null +++ b/src/mailman/rest/docs/sub-moderation.rst @@ -0,0 +1,113 @@ +========================= + Subscription moderation +========================= + +Subscription (and sometimes unsubscription) requests can similarly be +accepted, discarded, rejected, or deferred by the list moderators. + + +Viewing subscription requests +============================= + +A mailing list starts with no pending subscription or unsubscription requests. + + >>> ant = create_list('ant@example.com') + >>> ant.admin_immed_notify = False + >>> from mailman.interfaces.mailinglist import SubscriptionPolicy + >>> ant.subscription_policy = SubscriptionPolicy.moderate + >>> transaction.commit() + >>> dump_json('http://localhost:9001/3.0/lists/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.interfaces.registrar import IRegistrar + >>> from mailman.interfaces.usermanager import IUserManager + >>> from zope.component import getUtility + >>> registrar = IRegistrar(ant) + >>> manager = getUtility(IUserManager) + >>> anne = manager.create_address('anne@example.com', 'Anne Person') + >>> token, token_owner, member = registrar.register( + ... anne, pre_verified=True, pre_confirmed=True) + >>> print(member) + None + +The message is being held for moderator approval. + + >>> token_owner.name + TokenOwner.moderator + +The subscription request can be viewed in the REST API. + + >>> 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 individual requests +=========================== + +You can view an individual membership change request by providing the token +(a.k.a. request id). Anne's subscription request looks like this. + + >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/' + ... 'requests/{}'.format(token)) + 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 + + +Disposing of subscription requests +================================== + +Moderators can dispose of held subscription requests by POSTing back to the +request's resource. The POST data requires an action of one of the following: + + * discard - throw the request away. + * reject - the request is denied and a notification is sent to the email + address requesting the membership change. + * defer - defer any action on this membership change (continue to hold it). + * accept - accept the membership change. + +Anne's subscription request is accepted. + + >>> dump_json('http://localhost:9001/3.0/lists/' + ... 'ant@example.com/requests/{}'.format(token), + ... {'action': 'accept'}) + content-length: 0 + date: ... + server: ... + status: 204 + +Anne is now a member of the mailing list. + + >>> transaction.abort() + >>> ant.members.get_member('anne@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/sub_moderation.py b/src/mailman/rest/sub_moderation.py new file mode 100644 index 000000000..4f8cf2e8e --- /dev/null +++ b/src/mailman/rest/sub_moderation.py @@ -0,0 +1,155 @@ +# Copyright (C) 2012-2015 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see . + +"""REST API for held subscription requests.""" + +__all__ = [ + 'SubscriptionRequests', + ] + + +from mailman.interfaces.action import Action +from mailman.interfaces.pending import IPendings +from mailman.interfaces.registrar import IRegistrar +from mailman.rest.helpers import ( + CollectionMixin, bad_request, child, etag, no_content, not_found, okay) +from mailman.rest.validator import Validator, enum_validator +from zope.component import getUtility + + + +class _ModerationBase: + """Common base class.""" + + def _make_resource(self, token): + 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 IndividualRequest(_ModerationBase): + """Resource for moderating a membership change.""" + + def __init__(self, mlist, token): + self._mlist = mlist + self._registrar = IRegistrar(self._mlist) + self._token = token + + def on_get(self, request, response): + try: + token, token_owner, member = self._registrar.confirm(self._token) + except LookupError: + not_found(response) + return + try: + request_id = int(self._request_id) + except ValueError: + bad_request(response) + return + resource = self._make_resource(request_id, MEMBERSHIP_CHANGE_REQUESTS) + if resource is None: + not_found(response) + else: + # Remove unnecessary keys. + del resource['key'] + okay(response, etag(resource)) + + def on_post(self, request, response): + try: + validator = Validator(action=enum_validator(Action)) + arguments = validator(request) + except ValueError as error: + bad_request(response, str(error)) + return + requests = IListRequests(self._mlist) + try: + request_id = int(self._request_id) + except ValueError: + bad_request(response) + return + results = requests.get_request(request_id) + if results is None: + not_found(response) + return + key, data = results + try: + request_type = RequestType[data['_request_type']] + except ValueError: + bad_request(response) + return + if request_type is RequestType.subscription: + handle_subscription(self._mlist, request_id, **arguments) + elif request_type is RequestType.unsubscription: + handle_unsubscription(self._mlist, request_id, **arguments) + else: + bad_request(response) + return + no_content(response) + + +class SubscriptionRequests(_ModerationBase, CollectionMixin): + """Resource for membership change requests.""" + + def __init__(self, mlist): + self._mlist = mlist + + 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): + # There's currently no better way to query the pendings database for + # all the entries that are associated with subscription holds on this + # mailing list. Brute force for now. + items = [] + for token, pendable in getUtility(IPendings): + token_owner = pendable.get('token_owner') + if token_owner is None: + continue + item = dict(token=token) + item.update(pendable) + items.append(item) + 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 IndividualRequest(self._mlist, kw['token']) -- cgit v1.2.3-70-g09d2 From c2d15f3c7c2e86912f62326b51977abca0c9bb5e Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Thu, 16 Apr 2015 15:37:24 -0400 Subject: Updates. --- src/mailman/rest/docs/sub-moderation.rst | 4 +-- src/mailman/rest/sub_moderation.py | 42 ++++++-------------------------- 2 files changed, 10 insertions(+), 36 deletions(-) diff --git a/src/mailman/rest/docs/sub-moderation.rst b/src/mailman/rest/docs/sub-moderation.rst index 6d3d4993c..a658ac410 100644 --- a/src/mailman/rest/docs/sub-moderation.rst +++ b/src/mailman/rest/docs/sub-moderation.rst @@ -37,8 +37,8 @@ moderator approval. The message is being held for moderator approval. - >>> token_owner.name - TokenOwner.moderator + >>> print(token_owner.name) + moderator The subscription request can be viewed in the REST API. diff --git a/src/mailman/rest/sub_moderation.py b/src/mailman/rest/sub_moderation.py index 4f8cf2e8e..7bc6554a6 100644 --- a/src/mailman/rest/sub_moderation.py +++ b/src/mailman/rest/sub_moderation.py @@ -35,25 +35,13 @@ from zope.component import getUtility class _ModerationBase: """Common base class.""" + def __init__(self): + self._pendings = getUtility(IPendings) + def _make_resource(self, token): - 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) + pendable = self._pendings.confirm(token, expunge=False) + resource = dict(token=token) + resource.update(pendable) return resource @@ -122,28 +110,14 @@ class SubscriptionRequests(_ModerationBase, CollectionMixin): """Resource for membership change requests.""" def __init__(self, mlist): + super().__init__() self._mlist = mlist - 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): # There's currently no better way to query the pendings database for # all the entries that are associated with subscription holds on this # mailing list. Brute force for now. - items = [] - for token, pendable in getUtility(IPendings): - token_owner = pendable.get('token_owner') - if token_owner is None: - continue - item = dict(token=token) - item.update(pendable) - items.append(item) - return items + return [token for token, pendable in getUtility(IPendings)] def on_get(self, request, response): """/lists/listname/requests""" -- cgit v1.2.3-70-g09d2 From 3857294c3dc8fdf294f23179926f062c4e0a6c90 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Fri, 17 Apr 2015 11:30:23 -0400 Subject: Check pointing new subscription moderation REST API. --- src/mailman/app/subscriptions.py | 5 +- src/mailman/app/tests/test_subscriptions.py | 5 +- src/mailman/rest/docs/post-moderation.rst | 1 + src/mailman/rest/docs/sub-moderation.rst | 8 +- src/mailman/rest/sub_moderation.py | 89 ++++++++++--------- src/mailman/rest/tests/test_moderation.py | 131 ++++++++++++++++++---------- 6 files changed, 147 insertions(+), 92 deletions(-) diff --git a/src/mailman/app/subscriptions.py b/src/mailman/app/subscriptions.py index 46ce549af..8794fb78a 100644 --- a/src/mailman/app/subscriptions.py +++ b/src/mailman/app/subscriptions.py @@ -168,8 +168,9 @@ class SubscriptionWorkflow(Workflow): return pendable = Pendable( list_id=self.mlist.list_id, - address=self.address.email, - hold_date=now().replace(microsecond=0).isoformat(), + email=self.address.email, + display_name=self.address.display_name, + when=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 6b5d88026..2d5a3733b 100644 --- a/src/mailman/app/tests/test_subscriptions.py +++ b/src/mailman/app/tests/test_subscriptions.py @@ -69,8 +69,9 @@ class TestSubscriptionWorkflow(unittest.TestCase): 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['email'], 'anne@example.com') + self.assertEqual(pendable['display_name'], '') + self.assertEqual(pendable['when'], '2005-08-01T07:49:23') self.assertEqual(pendable['token_owner'], 'subscriber') def test_user_or_address_required(self): diff --git a/src/mailman/rest/docs/post-moderation.rst b/src/mailman/rest/docs/post-moderation.rst index f082de157..6dd96e71a 100644 --- a/src/mailman/rest/docs/post-moderation.rst +++ b/src/mailman/rest/docs/post-moderation.rst @@ -13,6 +13,7 @@ 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 diff --git a/src/mailman/rest/docs/sub-moderation.rst b/src/mailman/rest/docs/sub-moderation.rst index a658ac410..0468d75b5 100644 --- a/src/mailman/rest/docs/sub-moderation.rst +++ b/src/mailman/rest/docs/sub-moderation.rst @@ -42,15 +42,15 @@ The message is being held for moderator approval. The subscription request can be viewed in the REST API. + >>> transaction.commit() >>> 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 + list_id: ant.example.com + token: ... + token_owner: moderator when: 2005-08-01T07:49:23 http_etag: "..." start: 0 diff --git a/src/mailman/rest/sub_moderation.py b/src/mailman/rest/sub_moderation.py index 7bc6554a6..12bd8ab26 100644 --- a/src/mailman/rest/sub_moderation.py +++ b/src/mailman/rest/sub_moderation.py @@ -38,8 +38,11 @@ class _ModerationBase: def __init__(self): self._pendings = getUtility(IPendings) - def _make_resource(self, token): + def _resource_as_dict(self, token): pendable = self._pendings.confirm(token, expunge=False) + if pendable is None: + # This token isn't in the database. + raise LookupError resource = dict(token=token) resource.update(pendable) return resource @@ -50,28 +53,20 @@ class IndividualRequest(_ModerationBase): """Resource for moderating a membership change.""" def __init__(self, mlist, token): + super().__init__() self._mlist = mlist self._registrar = IRegistrar(self._mlist) self._token = token def on_get(self, request, response): + # Get the pended record associated with this token, if it exists in + # the pending table. try: - token, token_owner, member = self._registrar.confirm(self._token) + resource = self._resource_as_dict(self._token) except LookupError: not_found(response) return - try: - request_id = int(self._request_id) - except ValueError: - bad_request(response) - return - resource = self._make_resource(request_id, MEMBERSHIP_CHANGE_REQUESTS) - if resource is None: - not_found(response) - else: - # Remove unnecessary keys. - del resource['key'] - okay(response, etag(resource)) + okay(response, etag(resource)) def on_post(self, request, response): try: @@ -80,32 +75,34 @@ class IndividualRequest(_ModerationBase): 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) + action = arguments['action'] + if action is Action.defer: + # At least see if the token is in the database. + pendable = self._pendings.confirm(self._token, expunge=False) + if pendable is None: + not_found(response) + else: + no_content(response) + elif action is Action.accept: + try: + self._registrar.confirm(self._token) + except LookupError: + not_found(response) + else: + no_content(response) + elif action is Action.discard: + # At least see if the token is in the database. + pendable = self._pendings.confirm(self._token, expunge=True) + if pendable is None: + not_found(response) + else: + no_content(response) + elif action is Action.reject: + # XXX + no_content(response) + class SubscriptionRequests(_ModerationBase, CollectionMixin): """Resource for membership change requests.""" @@ -116,8 +113,20 @@ class SubscriptionRequests(_ModerationBase, CollectionMixin): def _get_collection(self, request): # There's currently no better way to query the pendings database for # all the entries that are associated with subscription holds on this - # mailing list. Brute force for now. - return [token for token, pendable in getUtility(IPendings)] + # mailing list. Brute force iterating over all the pendables. + collection = [] + for token, pendable in getUtility(IPendings): + if 'token_owner' not in pendable: + # This isn't a subscription hold. + continue + list_id = pendable.get('list_id') + if list_id != self._mlist.list_id: + # Either there isn't a list_id field, in which case it can't + # be a subscription hold, or this is a hold for some other + # mailing list. + continue + collection.append(token) + return collection def on_get(self, request, response): """/lists/listname/requests""" diff --git a/src/mailman/rest/tests/test_moderation.py b/src/mailman/rest/tests/test_moderation.py index ab7383251..cc1f67f7e 100644 --- a/src/mailman/rest/tests/test_moderation.py +++ b/src/mailman/rest/tests/test_moderation.py @@ -145,30 +145,51 @@ class TestSubscriptionModeration(unittest.TestCase): 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) + with transaction(): + token_1, token_owner, member = self._registrar.register(self._anne) + # Anne's subscription request got held. + self.assertIsNotNone(token_1) + self.assertIsNone(member) + token_2, token_owner, member = self._registrar.register(self._bart) + self.assertIsNotNone(token_2) + self.assertIsNone(member) 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() + tokens = set(json['token'] for json in content['entries']) + self.assertEqual(tokens, {token_1, token_2}) + emails = set(json['email'] for json in content['entries']) + self.assertEqual(emails, {'anne@example.com', 'bart@example.com'}) + + def test_individual_request(self): + # We can view an individual request. + with transaction(): + token, token_owner, member = self._registrar.register(self._anne) + # Anne's subscription request got held. + self.assertIsNotNone(token) + self.assertIsNone(member) + url = 'http://localhost:9001/3.0/lists/ant@example.com/requests/{}' + content, response = call_api(url.format(token)) + self.assertEqual(response.status, 200) + self.assertEqual(content['token'], token) + self.assertEqual(content['token_owner'], token_owner.name) + self.assertEqual(content['email'], 'anne@example.com') def test_accept(self): # POST to the request to accept it. - token, token_owner, member = self._registrar.register(self._anne) + with transaction(): + 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) + content, response = call_api(url.format(token), dict( + action='accept', + )) + self.assertEqual(response.status, 204) # Anne is a member. self.assertEqual( - self._mlist.get_members('anne@example.com').address, + self._mlist.members.get_member('anne@example.com').address, self._anne) # The request URL no longer exists. with self.assertRaises(HTTPError) as cm: @@ -177,19 +198,27 @@ class TestSubscriptionModeration(unittest.TestCase): )) self.assertEqual(cm.exception.code, 404) + def test_accept_bad_token(self): + # Try to accept a request with a bogus token. + with self.assertRaises(HTTPError) as cm: + call_api('http://localhost:9001/3.0/lists/ant@example.com' + '/requests/bogus', + dict(action='accept')) + self.assertEqual(cm.exception.code, 404) + def test_discard(self): # POST to the request to discard it. - token, token_owner, member = self._registrar.register(self._anne) + with transaction(): + 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='discard', - )) - self.assertEqual(cm.exception.code, 204) + content, response = call_api(url.format(token), dict( + action='discard', + )) + self.assertEqual(response.status, 204) # Anne is not a member. - self.assertIsNone(self._mlist.get_members('anne@example.com')) + self.assertIsNone(self._mlist.members.get_member('anne@example.com')) # The request URL no longer exists. with self.assertRaises(HTTPError) as cm: call_api(url.format(token), dict( @@ -199,32 +228,30 @@ class TestSubscriptionModeration(unittest.TestCase): def test_defer(self): # Defer the decision for some other moderator. - token, token_owner, member = self._registrar.register(self._anne) + with transaction(): + 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) + content, response = call_api(url.format(token), dict( + action='defer', + )) + self.assertEqual(response.status, 204) # Anne is not a member. - self.assertIsNone(self._mlist.get_members('anne@example.com')) + self.assertIsNone(self._mlist.members.get_member('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) + content, response = call_api(url.format(token), dict( + action='defer', + )) + self.assertEqual(response.status, 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) + content, response = call_api(url.format(token), dict( + action='accept', + )) + self.assertEqual(response.status, 204) # Anne is a member. self.assertEqual( - self._mlist.get_members('anne@example.com').address, + self._mlist.members.get_member('anne@example.com').address, self._anne) # The request URL no longer exists. with self.assertRaises(HTTPError) as cm: @@ -233,23 +260,31 @@ class TestSubscriptionModeration(unittest.TestCase): )) self.assertEqual(cm.exception.code, 404) + def test_defer_bad_token(self): + # Try to accept a request with a bogus token. + with self.assertRaises(HTTPError) as cm: + call_api('http://localhost:9001/3.0/lists/ant@example.com' + '/requests/bogus', + dict(action='defer')) + 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) + with transaction(): + 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) + content, response = call_api(url.format(token), dict( + action='reject', + )) + self.assertEqual(response.status, 204) # Anne is not a member. - self.assertIsNone(self._mlist.get_members('anne@example.com')) + self.assertIsNone(self._mlist.members.get_member('anne@example.com')) # The request URL no longer exists. with self.assertRaises(HTTPError) as cm: call_api(url.format(token), dict( @@ -260,3 +295,11 @@ class TestSubscriptionModeration(unittest.TestCase): items = get_queue_messages('virgin') self.assertEqual(len(items), 1) self.assertEqual(str(items[0].msg), '') + + def test_reject_bad_token(self): + # Try to accept a request with a bogus token. + with self.assertRaises(HTTPError) as cm: + call_api('http://localhost:9001/3.0/lists/ant@example.com' + '/requests/bogus', + dict(action='reject')) + self.assertEqual(cm.exception.code, 404) -- cgit v1.2.3-70-g09d2 From b37f33558b54c298916f70ff274dd06d18d3e38a Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Fri, 17 Apr 2015 14:12:00 -0400 Subject: Branch his ready. --- src/mailman/app/moderator.py | 22 ++++++++------ src/mailman/app/subscriptions.py | 10 +++++-- src/mailman/app/tests/test_registrar.py | 2 +- src/mailman/rest/docs/sub-moderation.rst | 9 ++---- src/mailman/rest/sub_moderation.py | 14 +++++++-- src/mailman/rest/tests/test_moderation.py | 48 +++++++++++++++++++++++++++---- 6 files changed, 79 insertions(+), 26 deletions(-) diff --git a/src/mailman/app/moderator.py b/src/mailman/app/moderator.py index 0e4d59479..eb848ea08 100644 --- a/src/mailman/app/moderator.py +++ b/src/mailman/app/moderator.py @@ -25,6 +25,7 @@ __all__ = [ 'hold_message', 'hold_subscription', 'hold_unsubscription', + 'send_rejection', ] @@ -125,8 +126,9 @@ def handle_message(mlist, id, action, language = member.preferred_language else: language = None - _refuse(mlist, _('Posting of your message titled "$subject"'), - sender, comment or _('[No reason given]'), language) + send_rejection( + mlist, _('Posting of your message titled "$subject"'), + sender, comment or _('[No reason given]'), language) elif action is Action.accept: # Start by getting the message from the message store. msg = message_store.get_message_by_id(message_id) @@ -236,10 +238,11 @@ def handle_subscription(mlist, id, action, comment=None): pass elif action is Action.reject: key, data = requestdb.get_request(id) - _refuse(mlist, _('Subscription request'), - data['email'], - comment or _('[No reason given]'), - lang=getUtility(ILanguageManager)[data['language']]) + send_rejection( + mlist, _('Subscription request'), + data['email'], + comment or _('[No reason given]'), + lang=getUtility(ILanguageManager)[data['language']]) elif action is Action.accept: key, data = requestdb.get_request(id) delivery_mode = DeliveryMode[data['delivery_mode']] @@ -307,8 +310,9 @@ def handle_unsubscription(mlist, id, action, comment=None): pass elif action is Action.reject: key, data = requestdb.get_request(id) - _refuse(mlist, _('Unsubscription request'), email, - comment or _('[No reason given]')) + send_rejection( + mlist, _('Unsubscription request'), email, + comment or _('[No reason given]')) elif action is Action.accept: key, data = requestdb.get_request(id) try: @@ -324,7 +328,7 @@ def handle_unsubscription(mlist, id, action, comment=None): -def _refuse(mlist, request, recip, comment, origmsg=None, lang=None): +def send_rejection(mlist, request, recip, comment, origmsg=None, lang=None): # As this message is going to the requester, try to set the language to # his/her language choice, if they are a member. Otherwise use the list's # preferred language. diff --git a/src/mailman/app/subscriptions.py b/src/mailman/app/subscriptions.py index 8794fb78a..1593b4d58 100644 --- a/src/mailman/app/subscriptions.py +++ b/src/mailman/app/subscriptions.py @@ -159,9 +159,13 @@ class SubscriptionWorkflow(Workflow): def _set_token(self, token_owner): assert isinstance(token_owner, TokenOwner) + pendings = getUtility(IPendings) + # Clear out the previous pending token if there is one. + if self.token is not None: + pendings.confirm(self.token) # Create a new token to prevent replay attacks. It seems like this - # should produce the same token, but it won't because the pending adds - # a bit of randomization. + # would produce the same token, but it won't because the pending adds a + # bit of randomization. self.token_owner = token_owner if token_owner is TokenOwner.no_one: self.token = None @@ -173,7 +177,7 @@ class SubscriptionWorkflow(Workflow): when=now().replace(microsecond=0).isoformat(), token_owner=token_owner.name, ) - self.token = getUtility(IPendings).add(pendable, timedelta(days=3650)) + self.token = pendings.add(pendable, timedelta(days=3650)) def _step_sanity_checks(self): # Ensure that we have both an address and a user, even if the address diff --git a/src/mailman/app/tests/test_registrar.py b/src/mailman/app/tests/test_registrar.py index a2ab2c686..e76009454 100644 --- a/src/mailman/app/tests/test_registrar.py +++ b/src/mailman/app/tests/test_registrar.py @@ -59,7 +59,7 @@ class TestRegistrar(unittest.TestCase): self.assertEqual(self._pendings.count, 1) record = self._pendings.confirm(token, expunge=False) self.assertEqual(record['list_id'], self._mlist.list_id) - self.assertEqual(record['address'], 'anne@example.com') + self.assertEqual(record['email'], 'anne@example.com') def test_subscribe(self): # Registering a subscription request where no confirmation or diff --git a/src/mailman/rest/docs/sub-moderation.rst b/src/mailman/rest/docs/sub-moderation.rst index 0468d75b5..52b2b89fd 100644 --- a/src/mailman/rest/docs/sub-moderation.rst +++ b/src/mailman/rest/docs/sub-moderation.rst @@ -65,13 +65,12 @@ You can view an individual membership change request by providing the token >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/' ... 'requests/{}'.format(token)) - delivery_mode: regular display_name: Anne Person email: anne@example.com http_etag: "..." - language: en - request_id: ... - type: subscription + list_id: ant.example.com + token: ... + token_owner: moderator when: 2005-08-01T07:49:23 @@ -99,11 +98,9 @@ Anne's subscription request is accepted. 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() There are no more membership change requests. diff --git a/src/mailman/rest/sub_moderation.py b/src/mailman/rest/sub_moderation.py index 12bd8ab26..ebb09b9b3 100644 --- a/src/mailman/rest/sub_moderation.py +++ b/src/mailman/rest/sub_moderation.py @@ -22,12 +22,14 @@ __all__ = [ ] +from mailman.app.moderator import send_rejection from mailman.interfaces.action import Action from mailman.interfaces.pending import IPendings from mailman.interfaces.registrar import IRegistrar from mailman.rest.helpers import ( CollectionMixin, bad_request, child, etag, no_content, not_found, okay) from mailman.rest.validator import Validator, enum_validator +from mailman.utilities.i18n import _ from zope.component import getUtility @@ -98,8 +100,16 @@ class IndividualRequest(_ModerationBase): else: no_content(response) elif action is Action.reject: - # XXX - no_content(response) + # Like discard but sends a rejection notice to the user. + pendable = self._pendings.confirm(self._token, expunge=True) + if pendable is None: + not_found(response) + else: + no_content(response) + send_rejection( + self._mlist, _('Subscription request'), + pendable['email'], + _('[No reason given]')) diff --git a/src/mailman/rest/tests/test_moderation.py b/src/mailman/rest/tests/test_moderation.py index cc1f67f7e..e1d1f9ab3 100644 --- a/src/mailman/rest/tests/test_moderation.py +++ b/src/mailman/rest/tests/test_moderation.py @@ -28,6 +28,7 @@ import unittest from mailman.app.lifecycle import create_list from mailman.app.moderator import hold_message from mailman.database.transaction import transaction +from mailman.interfaces.mailinglist import SubscriptionPolicy from mailman.interfaces.registrar import IRegistrar from mailman.interfaces.usermanager import IUserManager from mailman.testing.helpers import ( @@ -100,6 +101,7 @@ Something else. class TestSubscriptionModeration(unittest.TestCase): layer = RESTLayer + maxDiff = None def setUp(self): with transaction(): @@ -206,6 +208,38 @@ class TestSubscriptionModeration(unittest.TestCase): dict(action='accept')) self.assertEqual(cm.exception.code, 404) + def test_accept_by_moderator_clears_request_queue(self): + # After accepting a message held for moderator approval, there are no + # more requests to handle. + # + # We start with nothing in the queue. + content, response = call_api( + 'http://localhost:9001/3.0/lists/ant@example.com/requests') + self.assertEqual(content['total_size'], 0) + # Anne tries to subscribe to a list that only requests moderator + # approval. + with transaction(): + self._mlist.subscription_policy = SubscriptionPolicy.moderate + token, token_owner, member = self._registrar.register( + self._anne, + pre_verified=True, pre_confirmed=True) + # There's now one request in the queue, and it's waiting on moderator + # approval. + content, response = call_api( + 'http://localhost:9001/3.0/lists/ant@example.com/requests') + self.assertEqual(content['total_size'], 1) + json = content['entries'][0] + self.assertEqual(json['token_owner'], 'moderator') + self.assertEqual(json['email'], 'anne@example.com') + # The moderator approves the request. + url = 'http://localhost:9001/3.0/lists/ant@example.com/requests/{}' + content, response = call_api(url.format(token), {'action': 'accept'}) + self.assertEqual(response.status, 204) + # And now the request queue is empty. + content, response = call_api( + 'http://localhost:9001/3.0/lists/ant@example.com/requests') + self.assertEqual(content['total_size'], 0) + def test_discard(self): # POST to the request to discard it. with transaction(): @@ -275,9 +309,9 @@ class TestSubscriptionModeration(unittest.TestCase): 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) + # Clear out the virgin queue, which currently contains the + # confirmation message sent to Anne. + get_queue_messages('virgin') url = 'http://localhost:9001/3.0/lists/ant@example.com/requests/{}' content, response = call_api(url.format(token), dict( action='reject', @@ -291,10 +325,14 @@ class TestSubscriptionModeration(unittest.TestCase): action='reject', )) self.assertEqual(cm.exception.code, 404) - # And the rejection message is now in the virgin queue. + # And the rejection message to Anne is now in the virgin queue. items = get_queue_messages('virgin') self.assertEqual(len(items), 1) - self.assertEqual(str(items[0].msg), '') + message = items[0].msg + self.assertEqual(message['From'], 'ant-bounces@example.com') + self.assertEqual(message['To'], 'anne@example.com') + self.assertEqual(message['Subject'], + 'Request to mailing list "Ant" rejected') def test_reject_bad_token(self): # Try to accept a request with a bogus token. -- cgit v1.2.3-70-g09d2 From 88fbe9137f1ba0320682d146febb997a6ea7ba2e Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Fri, 17 Apr 2015 15:28:24 -0400 Subject: Fix MANIFEST.in to include all the necessary test data files. --- MANIFEST.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MANIFEST.in b/MANIFEST.in index e4c64eedc..093f1bd38 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -11,6 +11,6 @@ prune src/web prune eggs prune parts prune .tox -include src/mailman/testing/config.pck +include src/mailman/testing/config*.pck include src/mailman/database/alembic/script.py.mako include src/mailman/database/alembic/versions/*.py -- cgit v1.2.3-70-g09d2 From d641c900620772645d44ac1ce03f0721fba005c4 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Fri, 17 Apr 2015 17:36:47 -0400 Subject: VERSION. --- src/mailman/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mailman/version.py b/src/mailman/version.py index 4904454ad..94d9ea44f 100644 --- a/src/mailman/version.py +++ b/src/mailman/version.py @@ -18,7 +18,7 @@ """Mailman version strings.""" # Mailman version. -VERSION = '3.0.0rc1' +VERSION = '3.0rc1' CODENAME = "Show Don't Tell" # And as a hex number in the manner of PY_VERSION_HEX. -- cgit v1.2.3-70-g09d2 From 2026c90c4165cf2f63fb9540c318b8e62fd283df Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Fri, 17 Apr 2015 19:18:41 -0400 Subject: Remove todo from release branch. --- TODO.rst | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 TODO.rst diff --git a/TODO.rst b/TODO.rst deleted file mode 100644 index 0c1bfa969..000000000 --- a/TODO.rst +++ /dev/null @@ -1,10 +0,0 @@ -* TO DO: - - rename ISubscriptionService to IMemberSearch or somesuch - - get rid of hold_subscription - - subsume handle_subscription - - workflow for unsubscription - - make sure registration checks IEmailValidator - - Test all the various options in eml_membership's get_subscriber() - - Admin notification on membership changes? - + admin_notify_mchanges - + admin_immed_notify -- cgit v1.2.3-70-g09d2 From c736cc7db3e60c65e512cb8b69ed8436fa837e64 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Fri, 17 Apr 2015 19:24:12 -0400 Subject: maxking's nickname --- src/mailman/docs/ACKNOWLEDGMENTS.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mailman/docs/ACKNOWLEDGMENTS.rst b/src/mailman/docs/ACKNOWLEDGMENTS.rst index 4577a26ab..f1c4ec6a9 100644 --- a/src/mailman/docs/ACKNOWLEDGMENTS.rst +++ b/src/mailman/docs/ACKNOWLEDGMENTS.rst @@ -25,7 +25,7 @@ Core Developers The following folks are or have been core developers of Mailman (in reverse alphabetical order): -* Abhilash Raj +* Abhilash Raj, Mailman's Youngest Core Dev * Aurélien Bompard * Barry Warsaw, Mailman's yappy guard dog * Florian Fuchs -- cgit v1.2.3-70-g09d2 From a285b802ba1bcb49e2cc7ec3e3d0724caf97c434 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Fri, 17 Apr 2015 21:35:01 -0400 Subject: Fix spellings. --- src/mailman/docs/ACKNOWLEDGMENTS.rst | 2 +- src/mailman/docs/INTRODUCTION.rst | 2 +- src/mailman/docs/RELEASENOTES.rst | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/mailman/docs/ACKNOWLEDGMENTS.rst b/src/mailman/docs/ACKNOWLEDGMENTS.rst index f1c4ec6a9..0a3b0e9c5 100644 --- a/src/mailman/docs/ACKNOWLEDGMENTS.rst +++ b/src/mailman/docs/ACKNOWLEDGMENTS.rst @@ -12,7 +12,7 @@ Governance GNU Mailman was invented by John Viega. Barry Warsaw is the current project leader. Aurélien Bompard leads HyperKitty and bundler development. Florian -Fuchs leads Postorious development. Development of mailman.client is a group +Fuchs leads Postorius development. Development of mailman.client is a group effort. All project decisions are made by consensus via the Mailman Cabal, er, diff --git a/src/mailman/docs/INTRODUCTION.rst b/src/mailman/docs/INTRODUCTION.rst index f6f9f2df2..e529beac7 100644 --- a/src/mailman/docs/INTRODUCTION.rst +++ b/src/mailman/docs/INTRODUCTION.rst @@ -86,7 +86,7 @@ Mailman 3 is really a suite of 5 projects: * Core - the core message processing and delivery system, exposing a REST API for administrative control. Requires `Python 3.4`_ or newer. - * Postorious - the new web user interfaces built on `Django`_. + * Postorius - the new web user interfaces built on `Django`_. * HyperKitty - the new archiver, also built on `Django`_. * mailman.client - a Python binding to the core's REST API. Compatible with both Python 2 and Python 3. diff --git a/src/mailman/docs/RELEASENOTES.rst b/src/mailman/docs/RELEASENOTES.rst index 226b256a2..ca2a34eeb 100644 --- a/src/mailman/docs/RELEASENOTES.rst +++ b/src/mailman/docs/RELEASENOTES.rst @@ -19,5 +19,5 @@ Mailman 3 may have bugs. Mailman 3 is not yet feature complete with Mailman 2.1. The documentation here describes the Mailman Core in great detail. -Postorious, Hyperkitty, mailman.client, and the bundler are described and +Postorius, Hyperkitty, mailman.client, and the bundler are described and developed elsewhere. -- cgit v1.2.3-70-g09d2 From 3f85c6f6fdab9e4a7e660e58b21ee1bc14091f58 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Fri, 17 Apr 2015 21:47:27 -0400 Subject: Date in NEWS. --- src/mailman/docs/NEWS.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mailman/docs/NEWS.rst b/src/mailman/docs/NEWS.rst index 6248d0d57..35709846e 100644 --- a/src/mailman/docs/NEWS.rst +++ b/src/mailman/docs/NEWS.rst @@ -10,7 +10,7 @@ Here is a history of user visible changes to Mailman. 3.0 rc 1 -- "Show Don't Tell" ============================= -(2015-XX-XX) +(2015-04-17) Architecture ------------ -- cgit v1.2.3-70-g09d2 From f5612fc226fecc18d651cbcf02a16967c6c32d78 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Sun, 19 Apr 2015 12:53:20 -0400 Subject: Set up for 3.0.0 final. --- src/mailman/version.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/mailman/version.py b/src/mailman/version.py index 94d9ea44f..f11701242 100644 --- a/src/mailman/version.py +++ b/src/mailman/version.py @@ -18,7 +18,7 @@ """Mailman version strings.""" # Mailman version. -VERSION = '3.0rc1' +VERSION = '3.0.0' CODENAME = "Show Don't Tell" # And as a hex number in the manner of PY_VERSION_HEX. @@ -32,9 +32,9 @@ FINAL = 0xf MAJOR_REV = 3 MINOR_REV = 0 MICRO_REV = 0 -REL_LEVEL = RC +REL_LEVEL = FINAL # At most 15 beta releases! -REL_SERIAL = 1 +REL_SERIAL = 0 HEX_VERSION = ((MAJOR_REV << 24) | (MINOR_REV << 16) | (MICRO_REV << 8) | (REL_LEVEL << 4) | (REL_SERIAL << 0)) -- cgit v1.2.3-70-g09d2 From d48b501bb1e1b6970e8cc93e184e4e4da357e1a3 Mon Sep 17 00:00:00 2001 From: Sumana Harihareswara Date: Sun, 19 Apr 2015 21:36:21 -0400 Subject: typofixes --- src/mailman/commands/cli_control.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mailman/commands/cli_control.py b/src/mailman/commands/cli_control.py index f58005e0f..5bd9ef1e3 100644 --- a/src/mailman/commands/cli_control.py +++ b/src/mailman/commands/cli_control.py @@ -56,7 +56,7 @@ class Start: default=False, action='store_true', help=_("""\ If the master watcher finds an existing master lock, it will - normally exit with an error message. With this option,the master + normally exit with an error message. With this option, the master will perform an extra level of checking. If a process matching the host/pid described in the lock file is running, the master will still exit, requiring you to manually clean up the lock. But @@ -78,7 +78,7 @@ class Start: This flag is not recommended for normal production environments. Note though, that if you run with -u and are not in the mailman - group, you may have permission problems, such as begin unable to + group, you may have permission problems, such as being unable to delete a list's archives through the web. Tough luck!""")) command_parser.add_argument( '-q', '--quiet', -- cgit v1.2.3-70-g09d2