diff options
Diffstat (limited to 'src/mailman/app')
| -rw-r--r-- | src/mailman/app/subscriptions.py | 177 | ||||
| -rw-r--r-- | src/mailman/app/tests/test_subscriptions.py | 124 |
2 files changed, 297 insertions, 4 deletions
diff --git a/src/mailman/app/subscriptions.py b/src/mailman/app/subscriptions.py index cc363b30d..1e45da43e 100644 --- a/src/mailman/app/subscriptions.py +++ b/src/mailman/app/subscriptions.py @@ -19,10 +19,13 @@ __all__ = [ 'SubscriptionService', + 'SubscriptionWorkflow', 'handle_ListDeletingEvent', ] +import json +from collections import deque from operator import attrgetter from sqlalchemy import and_, or_ from uuid import UUID @@ -30,18 +33,23 @@ from zope.component import getUtility from zope.interface import implementer from mailman.app.membership import add_member, delete_member +from mailman.app.moderator import hold_subscription from mailman.core.constants import system_preferences from mailman.database.transaction import dbconnection +from mailman.interfaces.address import IAddress from mailman.interfaces.listmanager import ( IListManager, ListDeletingEvent, NoSuchListError) +from mailman.interfaces.mailinglist import SubscriptionPolicy from mailman.interfaces.member import DeliveryMode, MemberRole from mailman.interfaces.subscriptions import ( ISubscriptionService, MissingUserError, RequestRecord) +from mailman.interfaces.user import IUser from mailman.interfaces.usermanager import IUserManager +from mailman.interfaces.workflowstate import IWorkflowStateManager from mailman.model.member import Member +from mailman.utilities.datetime import now - def _membership_sort_key(member): """Sort function for find_members(). @@ -51,7 +59,171 @@ def _membership_sort_key(member): return (member.list_id, member.address.email, member.role.value) - +class Workflow: + """Generic workflow.""" + # TODO: move this class to a more generic module + + _save_key = "" + _save_attributes = [] + _initial_state = [] + + def __init__(self): + self._next = deque(self._initial_state) + + def __iter__(self): + return self + + def _pop(self): + name = self._next.popleft() + step = getattr(self, '_step_{}'.format(name)) + return step, name + + def __next__(self): + try: + step, name = self._pop() + step() + except IndexError: + raise StopIteration + except: + raise + + def save_state(self): + state_manager = getUtility(IWorkflowStateManager) + data = {attr: getattr(self, attr) for attr in self._save_attributes} + # Note: only the next step is saved, not the whole stack. Not an issue + # since there's never more than a single step in the queue anyway. + # If we want to support more than a single step in the queue AND want + # to support state saving/restoring, change this method and the + # restore_state() method. + if len(self._next) == 0: + step = None + elif len(self._next) == 1: + step = self._next[0] + else: + raise AssertionError( + "Can't save a workflow state with more than one step " + "in the queue") + state_manager.save( + self.__class__.__name__, + self._save_key, + step, + json.dumps(data)) + + def restore_state(self): + state_manager = getUtility(IWorkflowStateManager) + state = state_manager.restore(self.__class__.__name__, self._save_key) + if state is not None: + self._next.clear() + if state.step: + self._next.append(state.step) + if state.data is not None: + for attr, value in json.loads(state.data).items(): + setattr(self, attr, value) + + +class SubscriptionWorkflow(Workflow): + """Workflow of a subscription request.""" + + _save_attributes = ["pre_verified", "pre_confirmed", "pre_approved"] + _initial_state = ["verification_check"] + + def __init__(self, mlist, subscriber, + pre_verified, pre_confirmed, pre_approved): + super(SubscriptionWorkflow, self).__init__() + self.mlist = mlist + # The subscriber must be either an IUser or IAddress. + if IAddress.providedBy(subscriber): + self.address = subscriber + self.user = self.address.user + elif IUser.providedBy(subscriber): + self.address = subscriber.preferred_address + self.user = subscriber + self.subscriber = subscriber + self.pre_verified = pre_verified + self.pre_confirmed = pre_confirmed + self.pre_approved = pre_approved + # State saving + self._save_key = "{}:{}".format(self.mlist.list_id, self.address.email) + + def _maybe_set_preferred_address(self): + if self.user is None: + # The address has no linked user so create one, link it, and set + # the user's preferred address. + assert self.address is not None, 'No address or user' + self.user = getUtility(IUserManager).make_user(self.address.email) + self.user.preferred_address = self.address + elif self.user.preferred_address is None: + assert self.address is not None, 'No address or user' + # The address has a linked user, but no preferred address is set + # yet. This is required, so use the address. + self.user.preferred_address = self.address + + def _step_verification_check(self): + if self.address.verified_on is not None: + # The address is already verified. Give the user a preferred + # address if it doesn't already have one. We may still have to do + # a subscription confirmation check. See below. + self._maybe_set_preferred_address() + else: + # The address is not yet verified. Maybe we're pre-verifying it. + # If so, we also want to give the user a preferred address if it + # doesn't already have one. We may still have to do a + # subscription confirmation check. See below. + if self.pre_verified: + self.address.verified_on = now() + self._maybe_set_preferred_address() + else: + # Since the address was not already verified, and not + # pre-verified, we have to send a confirmation check, which + # doubles as a verification step. Skip to that now. + self._next.append("send_confirmation") + return + self._next.append("confirmation_check") + + def _step_confirmation_check(self): + # Must the user confirm their subscription request? If the policy is + # open subscriptions, then we need neither confirmation nor moderator + # approval, so just subscribe them now. + if self.mlist.subscription_policy == SubscriptionPolicy.open: + self._next.append("do_subscription") + elif self.pre_confirmed: + # No confirmation is necessary. We can skip to seeing whether a + # moderator confirmation is necessary. + self._next.append("moderation_check") + else: + self._next.append("send_confirmation") + + def _step_send_confirmation(self): + self._next.append("moderation_check") + self.save_state() + self._next.clear() # stop iteration until we get confirmation + # XXX: create the Pendable, send the ConfirmationNeededEvent + # (see Registrar.register) + + def _step_moderation_check(self): + # Does the moderator need to approve the subscription request? + if not self.pre_approved and self.mlist.subscription_policy in ( + SubscriptionPolicy.moderate, + SubscriptionPolicy.confirm_then_moderate): + self._next.append("get_moderator_approval") + else: + # The moderator does not need to approve the subscription, so go + # ahead and do that now. + self._next.append("do_subscription") + + def _step_get_moderator_approval(self): + # In order to get the moderator's approval, we need to hold the + # subscription request in the database + request = RequestRecord( + self.address.email, self.subscriber.display_name, + DeliveryMode.regular, 'en') + hold_subscription(self.mlist, request) + + def _step_do_subscription(self): + # We can immediately subscribe the user to the mailing list. + self.mlist.subscribe(self.subscriber) + + @implementer(ISubscriptionService) class SubscriptionService: """Subscription services for the REST API.""" @@ -168,7 +340,6 @@ class SubscriptionService: delete_member(mlist, email, False, False) - def handle_ListDeletingEvent(event): """Delete a mailing list's members when the list is being deleted.""" diff --git a/src/mailman/app/tests/test_subscriptions.py b/src/mailman/app/tests/test_subscriptions.py index 8ba5f52ff..b19dc5398 100644 --- a/src/mailman/app/tests/test_subscriptions.py +++ b/src/mailman/app/tests/test_subscriptions.py @@ -18,7 +18,8 @@ """Tests for the subscription service.""" __all__ = [ - 'TestJoin' + 'TestJoin', + 'TestSubscriptionWorkflow', ] @@ -26,11 +27,17 @@ import uuid import unittest from mailman.app.lifecycle import create_list +from mailman.app.subscriptions import Workflow, SubscriptionWorkflow from mailman.interfaces.address import InvalidEmailAddressError from mailman.interfaces.member import MemberRole, MissingPreferredAddressError +from mailman.interfaces.requests import IListRequests, RequestType from mailman.interfaces.subscriptions import ( MissingUserError, ISubscriptionService) from mailman.testing.layers import ConfigLayer +from mailman.interfaces.mailinglist import SubscriptionPolicy +from mailman.interfaces.usermanager import IUserManager +from mailman.utilities.datetime import now +from mock import Mock from zope.component import getUtility @@ -65,3 +72,118 @@ class TestJoin(unittest.TestCase): self._service.join, 'test.example.com', anne.user.user_id, role=MemberRole.owner) + + + +class TestWorkflow(unittest.TestCase): + layer = ConfigLayer + + def setUp(self): + self.workflow = Workflow() + self.workflow._test_attribute = "test-value" + self.workflow._step_test = Mock() + self.workflow._next.append("test") + + def test_iter_steps(self): + next(self.workflow) + self.assertTrue(self.workflow._step_test.called) + self.assertEqual(len(self.workflow._next), 0) + try: + next(self.workflow) + except StopIteration: + pass + else: + self.fail() + + def test_save_restore(self): + self.workflow.save_state() + # Now create a new instance and restore + new_workflow = Workflow() + self.assertEqual(len(new_workflow._next), 0) + self.assertFalse(hasattr(new_workflow, "_test_attribute")) + new_workflow.restore_state() + self.assertEqual(len(new_workflow._next), 1) + self.assertEqual(new_workflow._next[0], "test") + self.assertEqual(self.workflow._test_attribute, "test-value") + + def test_save_restore_no_next_step(self): + self.workflow._next.clear() + self.workflow.save_state() + # Now create a new instance and restore + new_workflow = Workflow() + new_workflow._next.append("test") + new_workflow.restore_state() + self.assertEqual(len(new_workflow._next), 0) + + + +class TestSubscriptionWorkflow(unittest.TestCase): + layer = ConfigLayer + + def setUp(self): + self._mlist = create_list('test@example.com') + self._anne = 'anne@example.com' + self._user_manager = getUtility(IUserManager) + + def test_preverified_address_joins_open_list(self): + # The mailing list has an open subscription policy, so the subscriber + # becomes a member with no human intervention. + self._mlist.subscription_policy = SubscriptionPolicy.open + anne = self._user_manager.create_address(self._anne, 'Anne Person') + self.assertIsNone(anne.verified_on) + self.assertIsNone(anne.user) + self.assertIsNone(self._mlist.subscribers.get_member(self._anne)) + workflow = SubscriptionWorkflow( + self._mlist, anne, + pre_verified=True, pre_confirmed=False, pre_approved=False) + # Run the state machine to the end. The result is that her address + # will be verified, linked to a user, and subscribed to the mailing + # list. + list(workflow) + self.assertIsNotNone(anne.verified_on) + self.assertIsNotNone(anne.user) + self.assertIsNotNone(self._mlist.subscribers.get_member(self._anne)) + + def test_verified_address_joins_moderated_list(self): + # The mailing list is moderated but the subscriber is not a verified + # address and the subscription request is not pre-verified. + # A confirmation email must be sent, it will serve as the verification + # email too. + anne = self._user_manager.create_address(self._anne, 'Anne Person') + request_db = IListRequests(self._mlist) + def _do_check(): + anne.verified_on = now() + self.assertIsNone(self._mlist.subscribers.get_member(self._anne)) + workflow = SubscriptionWorkflow( + self._mlist, anne, + pre_verified=False, pre_confirmed=True, pre_approved=False) + # Run the state machine to the end. + list(workflow) + # Look in the requests db + requests = list(request_db.of_type(RequestType.subscription)) + self.assertEqual(len(requests), 1) + self.assertEqual(requests[0].key, anne.email) + request_db.delete_request(requests[0].id) + self._mlist.subscription_policy = SubscriptionPolicy.moderate + _do_check() + self._mlist.subscription_policy = \ + SubscriptionPolicy.confirm_then_moderate + _do_check() + + def test_confirmation_required(self): + # Tests subscriptions where user confirmation is required + self._mlist.subscription_policy = \ + SubscriptionPolicy.confirm_then_moderate + anne = self._user_manager.create_address(self._anne, 'Anne Person') + self.assertIsNone(self._mlist.subscribers.get_member(self._anne)) + workflow = SubscriptionWorkflow( + self._mlist, anne, + pre_verified=True, pre_confirmed=False, pre_approved=True) + # Run the state machine to the end. + list(workflow) + # A confirmation request must be pending + # TODO: test it + # Now restore and re-run the state machine as if we got the confirmation + workflow.restore_state() + list(workflow) + self.assertIsNotNone(self._mlist.subscribers.get_member(self._anne)) |
