summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/mailman/app/subscriptions.py124
-rw-r--r--src/mailman/app/tests/test_subscriptions.py96
-rw-r--r--src/mailman/app/tests/test_workflow.py126
-rw-r--r--src/mailman/app/workflow.py102
-rw-r--r--src/mailman/config/configure.zcml5
-rw-r--r--src/mailman/database/alembic/versions/16c2b25c7b_list_subscription_policy.py41
-rw-r--r--src/mailman/database/alembic/versions/2bb9b382198_workflow_state_table.py28
-rw-r--r--src/mailman/interfaces/mailinglist.py16
-rw-r--r--src/mailman/interfaces/workflow.py66
-rw-r--r--src/mailman/model/mailinglist.py3
-rw-r--r--src/mailman/model/roster.py4
-rw-r--r--src/mailman/model/tests/test_workflow.py110
-rw-r--r--src/mailman/model/workflow.py65
-rw-r--r--src/mailman/rest/docs/listconf.rst3
-rw-r--r--src/mailman/rest/listconf.py4
-rw-r--r--src/mailman/rest/tests/test_listconf.py1
-rw-r--r--src/mailman/styles/base.py4
-rw-r--r--src/mailman/utilities/importer.py3
-rw-r--r--src/mailman/utilities/tests/test_import.py27
19 files changed, 815 insertions, 13 deletions
diff --git a/src/mailman/app/subscriptions.py b/src/mailman/app/subscriptions.py
index cc363b30d..ebb4198bb 100644
--- a/src/mailman/app/subscriptions.py
+++ b/src/mailman/app/subscriptions.py
@@ -19,26 +19,33 @@
__all__ = [
'SubscriptionService',
+ 'SubscriptionWorkflow',
'handle_ListDeletingEvent',
]
-from operator import attrgetter
-from sqlalchemy import and_, or_
-from uuid import UUID
-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.app.workflow import Workflow
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.model.member import Member
+from mailman.utilities.datetime import now
+from operator import attrgetter
+from sqlalchemy import and_, or_
+from uuid import UUID
+from zope.component import getUtility
+from zope.interface import implementer
@@ -52,6 +59,113 @@ def _membership_sort_key(member):
+class SubscriptionWorkflow(Workflow):
+ """Workflow of a subscription request."""
+
+ INITIAL_STATE = 'verification_check'
+ SAVE_ATTRIBUTES = (
+ 'pre_approved',
+ 'pre_confirmed',
+ 'pre_verified',
+ )
+
+ def __init__(self, mlist, subscriber,
+ pre_verified, pre_confirmed, pre_approved):
+ super().__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
+ else:
+ raise AssertionError('subscriber is neither an IUser nor IAddress')
+ self.subscriber = subscriber
+ self.pre_verified = pre_verified
+ self.pre_confirmed = pre_confirmed
+ self.pre_approved = pre_approved
+
+ 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()
+ 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."""
diff --git a/src/mailman/app/tests/test_subscriptions.py b/src/mailman/app/tests/test_subscriptions.py
index 8ba5f52ff..d320284ce 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,16 @@ import uuid
import unittest
from mailman.app.lifecycle import create_list
+from mailman.app.subscriptions import 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 zope.component import getUtility
@@ -65,3 +71,91 @@ class TestJoin(unittest.TestCase):
self._service.join,
'test.example.com', anne.user.user_id,
role=MemberRole.owner)
+
+
+
+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_user_or_address_required(self):
+ # The `subscriber` attribute must be a user or address.
+ self.assertRaises(AssertionError, SubscriptionWorkflow,
+ self._mlist,
+ 'not a user', False, False, False)
+
+ def test_user_without_preferred_address_gets_one(self):
+ # When subscribing a user without a preferred address, the first step
+ # in the workflow is to give the user a preferred address.
+ anne = self._user_manager.create_user(self._anne)
+ self.assertIsNone(anne.preferred_address)
+ workflow = SubscriptionWorkflow(self._mlist, anne, False, False, False)
+ next(workflow)
+
+
+ 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()
+ list(workflow)
+ self.assertIsNotNone(self._mlist.subscribers.get_member(self._anne))
diff --git a/src/mailman/app/tests/test_workflow.py b/src/mailman/app/tests/test_workflow.py
new file mode 100644
index 000000000..0f70042af
--- /dev/null
+++ b/src/mailman/app/tests/test_workflow.py
@@ -0,0 +1,126 @@
+# Copyright (C) 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 <http://www.gnu.org/licenses/>.
+
+"""App-level workflow tests."""
+
+__all__ = [
+ 'TestWorkflow',
+ ]
+
+
+import unittest
+
+from mailman.app.workflow import Workflow
+from mailman.testing.layers import ConfigLayer
+
+
+class MyWorkflow(Workflow):
+ INITIAL_STATE = 'first'
+ SAVE_ATTRIBUTES = ('ant', 'bee', 'cat')
+
+ def __init__(self):
+ super().__init__()
+ self.token = 'test-workflow'
+ self.ant = 1
+ self.bee = 2
+ self.cat = 3
+ self.dog = 4
+
+ def _step_first(self):
+ self.push('second')
+ return 'one'
+
+ def _step_second(self):
+ self.push('third')
+ return 'two'
+
+ def _step_third(self):
+ return 'three'
+
+
+
+class TestWorkflow(unittest.TestCase):
+ layer = ConfigLayer
+
+ def setUp(self):
+ self._workflow = iter(MyWorkflow())
+
+ def test_basic_workflow(self):
+ # The work flows from one state to the next.
+ results = list(self._workflow)
+ self.assertEqual(results, ['one', 'two', 'three'])
+
+ def test_partial_workflow(self):
+ # You don't have to flow through every step.
+ results = next(self._workflow)
+ self.assertEqual(results, 'one')
+
+ def test_exhaust_workflow(self):
+ # Manually flow through a few steps, then consume the whole thing.
+ results = [next(self._workflow)]
+ results.extend(self._workflow)
+ self.assertEqual(results, ['one', 'two', 'three'])
+
+ def test_save_and_restore_workflow(self):
+ # Without running any steps, save and restore the workflow. Then
+ # consume the restored workflow.
+ self._workflow.save()
+ new_workflow = MyWorkflow()
+ new_workflow.restore()
+ results = list(new_workflow)
+ self.assertEqual(results, ['one', 'two', 'three'])
+
+ def test_save_and_restore_partial_workflow(self):
+ # After running a few steps, save and restore the workflow. Then
+ # consume the restored workflow.
+ next(self._workflow)
+ self._workflow.save()
+ new_workflow = MyWorkflow()
+ new_workflow.restore()
+ results = list(new_workflow)
+ self.assertEqual(results, ['two', 'three'])
+
+ def test_save_and_restore_exhausted_workflow(self):
+ # After consuming the entire workflow, save and restore it.
+ list(self._workflow)
+ self._workflow.save()
+ new_workflow = MyWorkflow()
+ new_workflow.restore()
+ results = list(new_workflow)
+ self.assertEqual(len(results), 0)
+
+ def test_save_and_restore_attributes(self):
+ # Saved attributes are restored.
+ self._workflow.ant = 9
+ self._workflow.bee = 8
+ self._workflow.cat = 7
+ # Don't save .dog.
+ self._workflow.save()
+ new_workflow = MyWorkflow()
+ new_workflow.restore()
+ self.assertEqual(new_workflow.ant, 9)
+ self.assertEqual(new_workflow.bee, 8)
+ self.assertEqual(new_workflow.cat, 7)
+ self.assertEqual(new_workflow.dog, 4)
+
+ def test_run_thru(self):
+ # Run all steps through the given one.
+ results = self._workflow.run_thru(second)
+ self.assertEqual(results, ['one', 'two'])
+
+ def test_run_until(self):
+ # Run until (but not including
diff --git a/src/mailman/app/workflow.py b/src/mailman/app/workflow.py
new file mode 100644
index 000000000..f9a951157
--- /dev/null
+++ b/src/mailman/app/workflow.py
@@ -0,0 +1,102 @@
+# Copyright (C) 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 <http://www.gnu.org/licenses/>.
+
+"""Generic workflow."""
+
+__all__ = [
+ 'Workflow',
+ ]
+
+
+import json
+import logging
+
+from collections import deque
+from mailman.interfaces.workflow import IWorkflowStateManager
+from zope.component import getUtility
+
+
+COMMASPACE = ', '
+log = logging.getLogger('mailman.error')
+
+
+
+class Workflow:
+ """Generic workflow."""
+
+ SAVE_ATTRIBUTES = ()
+ INITIAL_STATE = None
+
+ def __init__(self):
+ self.token = None
+ self._next = deque()
+ self.push(self.INITIAL_STATE)
+
+ def __iter__(self):
+ return self
+
+ def push(self, step):
+ self._next.append(step)
+
+ def _pop(self):
+ name = self._next.popleft()
+ step = getattr(self, '_step_{}'.format(name))
+ return name, step
+
+ def __next__(self):
+ try:
+ name, step = self._pop()
+ return step()
+ except IndexError:
+ raise StopIteration
+ except:
+ log.exception('deque: {}'.format(COMMASPACE.join(self._next)))
+ raise
+
+ def save(self):
+ assert self.token, 'Workflow token must be set'
+ 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. This is not
+ # an issue in practice, 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() 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.token,
+ step,
+ json.dumps(data))
+
+ def restore(self):
+ state_manager = getUtility(IWorkflowStateManager)
+ state = state_manager.restore(self.__class__.__name__, self.token)
+ 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)
diff --git a/src/mailman/config/configure.zcml b/src/mailman/config/configure.zcml
index 24061f0f0..1f2283b02 100644
--- a/src/mailman/config/configure.zcml
+++ b/src/mailman/config/configure.zcml
@@ -117,4 +117,9 @@
factory="mailman.app.templates.TemplateLoader"
/>
+ <utility
+ provides="mailman.interfaces.workflow.IWorkflowStateManager"
+ factory="mailman.model.workflow.WorkflowStateManager"
+ />
+
</configure>
diff --git a/src/mailman/database/alembic/versions/16c2b25c7b_list_subscription_policy.py b/src/mailman/database/alembic/versions/16c2b25c7b_list_subscription_policy.py
new file mode 100644
index 000000000..d5f8c3d96
--- /dev/null
+++ b/src/mailman/database/alembic/versions/16c2b25c7b_list_subscription_policy.py
@@ -0,0 +1,41 @@
+"""List subscription policy
+
+Revision ID: 16c2b25c7b
+Revises: 33e1f5f6fa8
+Create Date: 2015-03-21 11:00:44.634883
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = '16c2b25c7b'
+down_revision = '33e1f5f6fa8'
+
+from alembic import op
+import sqlalchemy as sa
+
+from mailman.database.types import Enum
+from mailman.interfaces.mailinglist import SubscriptionPolicy
+
+
+def upgrade():
+
+ ### Update the schema
+ op.add_column('mailinglist', sa.Column(
+ 'subscription_policy', Enum(SubscriptionPolicy), nullable=True))
+
+ ### Now migrate the data
+ # don't import the table definition from the models, it may break this
+ # migration when the model is updated in the future (see the Alembic doc)
+ mlist = sa.sql.table('mailinglist',
+ sa.sql.column('subscription_policy', Enum(SubscriptionPolicy))
+ )
+ # there were no enforced subscription policy before, so all lists are
+ # considered open
+ op.execute(mlist.update().values(
+ {'subscription_policy': op.inline_literal(SubscriptionPolicy.open)}))
+
+
+def downgrade():
+ if op.get_bind().dialect.name != 'sqlite':
+ # SQLite does not support dropping columns.
+ op.drop_column('mailinglist', 'subscription_policy')
diff --git a/src/mailman/database/alembic/versions/2bb9b382198_workflow_state_table.py b/src/mailman/database/alembic/versions/2bb9b382198_workflow_state_table.py
new file mode 100644
index 000000000..59cb1121e
--- /dev/null
+++ b/src/mailman/database/alembic/versions/2bb9b382198_workflow_state_table.py
@@ -0,0 +1,28 @@
+"""Workflow state table
+
+Revision ID: 2bb9b382198
+Revises: 16c2b25c7b
+Create Date: 2015-03-25 18:09:18.338790
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = '2bb9b382198'
+down_revision = '16c2b25c7b'
+
+from alembic import op
+import sqlalchemy as sa
+
+
+def upgrade():
+ op.create_table('workflowstate',
+ sa.Column('name', sa.Unicode(), nullable=False),
+ sa.Column('key', sa.Unicode(), nullable=False),
+ sa.Column('step', sa.Unicode(), nullable=True),
+ sa.Column('data', sa.Unicode(), nullable=True),
+ sa.PrimaryKeyConstraint('name', 'key')
+ )
+
+
+def downgrade():
+ op.drop_table('workflowstate')
diff --git a/src/mailman/interfaces/mailinglist.py b/src/mailman/interfaces/mailinglist.py
index 23d2fadf4..f112b2a11 100644
--- a/src/mailman/interfaces/mailinglist.py
+++ b/src/mailman/interfaces/mailinglist.py
@@ -25,6 +25,7 @@ __all__ = [
'IMailingList',
'Personalization',
'ReplyToMunging',
+ 'SubscriptionPolicy',
]
@@ -53,6 +54,18 @@ class ReplyToMunging(Enum):
explicit_header = 2
+class SubscriptionPolicy(Enum):
+ # Neither confirmation, nor moderator approval is required.
+ open = 0
+ # The user must confirm the subscription.
+ confirm = 1
+ # The moderator must approve the subscription.
+ moderate = 2
+ # The user must first confirm their subscription, and then if that is
+ # successful, the moderator must also approve it.
+ confirm_then_moderate = 3
+
+
class IMailingList(Interface):
"""A mailing list."""
@@ -234,6 +247,9 @@ class IMailingList(Interface):
deliver disabled or not, or of the type of digest they are to
receive.""")
+ subscription_policy = Attribute(
+ """The policy for subscribing new members to the list.""")
+
subscribers = Attribute(
"""An iterator over all IMembers subscribed to this list, with any
role.
diff --git a/src/mailman/interfaces/workflow.py b/src/mailman/interfaces/workflow.py
new file mode 100644
index 000000000..49fbc85d4
--- /dev/null
+++ b/src/mailman/interfaces/workflow.py
@@ -0,0 +1,66 @@
+# Copyright (C) 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 <http://www.gnu.org/licenses/>.
+
+"""Interfaces describing the state of a workflow."""
+
+__all__ = [
+ 'IWorkflowState',
+ 'IWorkflowStateManager',
+ ]
+
+
+from zope.interface import Attribute, Interface
+
+
+
+class IWorkflowState(Interface):
+ """The state of a workflow."""
+
+ name = Attribute('The name of the workflow.')
+
+ key = Attribute('A unique key identifying the workflow instance.')
+
+ step = Attribute("This workflow's next step.")
+
+ data = Attribute('Additional data (may be JSON-encoded).')
+
+
+
+class IWorkflowStateManager(Interface):
+ """The workflow states manager."""
+
+ def save(name, key, step, data=None):
+ """Save the state of a workflow.
+
+ :param name: The name of the workflow.
+ :type name: str
+ :param key: A unique key identifying this workflow instance.
+ :type key: str
+ :param step: The next step for this workflow.
+ :type step: str
+ :param data: Additional data (workflow-specific).
+ :type data: str
+ """
+
+ def restore(name, key):
+ """Get the saved state for a workflow or None if nothing was saved.
+
+ :param name: The name of the workflow.
+ :type name: str
+ :param key: A unique key identifying this workflow instance.
+ :type key: str
+ """
diff --git a/src/mailman/model/mailinglist.py b/src/mailman/model/mailinglist.py
index cef272437..f04c534e1 100644
--- a/src/mailman/model/mailinglist.py
+++ b/src/mailman/model/mailinglist.py
@@ -38,7 +38,7 @@ from mailman.interfaces.domain import IDomainManager
from mailman.interfaces.languages import ILanguageManager
from mailman.interfaces.mailinglist import (
IAcceptableAlias, IAcceptableAliasSet, IListArchiver, IListArchiverSet,
- IMailingList, Personalization, ReplyToMunging)
+ IMailingList, Personalization, ReplyToMunging, SubscriptionPolicy)
from mailman.interfaces.member import (
AlreadySubscribedError, MemberRole, MissingPreferredAddressError,
SubscriptionEvent)
@@ -183,6 +183,7 @@ class MailingList(Model):
send_goodbye_message = Column(Boolean)
send_welcome_message = Column(Boolean)
subject_prefix = Column(Unicode)
+ subscription_policy = Column(Enum(SubscriptionPolicy))
topics = Column(PickleType)
topics_bodylines_limit = Column(Integer)
topics_enabled = Column(Boolean)
diff --git a/src/mailman/model/roster.py b/src/mailman/model/roster.py
index 91211c665..ef24d896b 100644
--- a/src/mailman/model/roster.py
+++ b/src/mailman/model/roster.py
@@ -99,9 +99,7 @@ class AbstractRoster:
@dbconnection
def get_member(self, store, address):
"""See `IRoster`."""
- results = store.query(Member).filter(
- Member.list_id == self._mlist.list_id,
- Member.role == self.role,
+ results = self._query().filter(
Address.email == address,
Member.address_id == Address.id)
if results.count() == 0:
diff --git a/src/mailman/model/tests/test_workflow.py b/src/mailman/model/tests/test_workflow.py
new file mode 100644
index 000000000..b5e915df4
--- /dev/null
+++ b/src/mailman/model/tests/test_workflow.py
@@ -0,0 +1,110 @@
+# Copyright (C) 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 <http://www.gnu.org/licenses/>.
+
+"""Test the workflow model."""
+
+__all__ = [
+ 'TestWorkflow',
+ ]
+
+
+import unittest
+
+from mailman.interfaces.workflow import IWorkflowStateManager
+from mailman.testing.layers import ConfigLayer
+from zope.component import getUtility
+
+
+
+class TestWorkflow(unittest.TestCase):
+ layer = ConfigLayer
+
+ def setUp(self):
+ self._manager = getUtility(IWorkflowStateManager)
+
+ def test_save_restore_workflow(self):
+ # Save and restore a workflow.
+ name = 'ant'
+ key = 'bee'
+ step = 'cat'
+ data = 'dog'
+ self._manager.save(name, key, step, data)
+ workflow = self._manager.restore(name, key)
+ self.assertEqual(workflow.name, name)
+ self.assertEqual(workflow.key, key)
+ self.assertEqual(workflow.step, step)
+ self.assertEqual(workflow.data, data)
+
+ def test_save_restore_workflow_without_step(self):
+ # Save and restore a workflow that contains no step.
+ name = 'ant'
+ key = 'bee'
+ data = 'dog'
+ self._manager.save(name, key, data=data)
+ workflow = self._manager.restore(name, key)
+ self.assertEqual(workflow.name, name)
+ self.assertEqual(workflow.key, key)
+ self.assertIsNone(workflow.step)
+ self.assertEqual(workflow.data, data)
+
+ def test_save_restore_workflow_without_data(self):
+ # Save and restore a workflow that contains no data.
+ name = 'ant'
+ key = 'bee'
+ step = 'cat'
+ self._manager.save(name, key, step)
+ workflow = self._manager.restore(name, key)
+ self.assertEqual(workflow.name, name)
+ self.assertEqual(workflow.key, key)
+ self.assertEqual(workflow.step, step)
+ self.assertIsNone(workflow.data)
+
+ def test_save_restore_workflow_without_step_or_data(self):
+ # Save and restore a workflow that contains no step or data.
+ name = 'ant'
+ key = 'bee'
+ self._manager.save(name, key)
+ workflow = self._manager.restore(name, key)
+ self.assertEqual(workflow.name, name)
+ self.assertEqual(workflow.key, key)
+ self.assertIsNone(workflow.step)
+ self.assertIsNone(workflow.data)
+
+ def test_restore_workflow_with_no_matching_name(self):
+ # Try to restore a workflow that has no matching name in the database.
+ name = 'ant'
+ key = 'bee'
+ self._manager.save(name, key)
+ workflow = self._manager.restore('ewe', key)
+ self.assertIsNone(workflow)
+
+ def test_restore_workflow_with_no_matching_key(self):
+ # Try to restore a workflow that has no matching key in the database.
+ name = 'ant'
+ key = 'bee'
+ self._manager.save(name, key)
+ workflow = self._manager.restore(name, 'fly')
+ self.assertIsNone(workflow)
+
+ def test_restore_workflow_with_no_matching_key_or_name(self):
+ # Try to restore a workflow that has no matching key or name in the
+ # database.
+ name = 'ant'
+ key = 'bee'
+ self._manager.save(name, key)
+ workflow = self._manager.restore('ewe', 'fly')
+ self.assertIsNone(workflow)
diff --git a/src/mailman/model/workflow.py b/src/mailman/model/workflow.py
new file mode 100644
index 000000000..53c9f05ea
--- /dev/null
+++ b/src/mailman/model/workflow.py
@@ -0,0 +1,65 @@
+# Copyright (C) 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 <http://www.gnu.org/licenses/>.
+
+"""Model for workflow states."""
+
+__all__ = [
+ 'WorkflowState',
+ 'WorkflowStateManager',
+ ]
+
+
+from mailman.database.model import Model
+from mailman.database.transaction import dbconnection
+from mailman.interfaces.workflow import IWorkflowState, IWorkflowStateManager
+from sqlalchemy import Column, Unicode
+from zope.interface import implementer
+
+
+
+@implementer(IWorkflowState)
+class WorkflowState(Model):
+ """Workflow states."""
+
+ __tablename__ = 'workflowstate'
+
+ name = Column(Unicode, primary_key=True)
+ key = Column(Unicode, primary_key=True)
+ step = Column(Unicode)
+ data = Column(Unicode)
+
+
+
+@implementer(IWorkflowStateManager)
+class WorkflowStateManager:
+ """See `IWorkflowStateManager`."""
+
+ @dbconnection
+ def save(self, store, name, key, step=None, data=None):
+ """See `IWorkflowStateManager`."""
+ state = store.query(WorkflowState).get((name, key))
+ if state is None:
+ state = WorkflowState(name=name, key=key, step=step, data=data)
+ store.add(state)
+ else:
+ state.step = step
+ state.data = data
+
+ @dbconnection
+ def restore(self, store, name, key):
+ """See `IWorkflowStateManager`."""
+ return store.query(WorkflowState).get((name, key))
diff --git a/src/mailman/rest/docs/listconf.rst b/src/mailman/rest/docs/listconf.rst
index 841ab3c27..bcf4f856e 100644
--- a/src/mailman/rest/docs/listconf.rst
+++ b/src/mailman/rest/docs/listconf.rst
@@ -61,6 +61,7 @@ All readable attributes for a list are available on a sub-resource.
scheme: http
send_welcome_message: True
subject_prefix: [Ant]
+ subscription_policy: confirm
volume: 1
web_host: lists.example.com
welcome_message_uri: mailman:///welcome.txt
@@ -106,6 +107,7 @@ When using ``PUT``, all writable attributes must be included.
... reply_to_address='bee@example.com',
... send_welcome_message=False,
... subject_prefix='[ant]',
+ ... subscription_policy='moderate',
... welcome_message_uri='mailman:///welcome.txt',
... default_member_action='hold',
... default_nonmember_action='discard',
@@ -156,6 +158,7 @@ These values are changed permanently.
...
send_welcome_message: False
subject_prefix: [ant]
+ subscription_policy: moderate
...
welcome_message_uri: mailman:///welcome.txt
diff --git a/src/mailman/rest/listconf.py b/src/mailman/rest/listconf.py
index d618ce116..04dea996f 100644
--- a/src/mailman/rest/listconf.py
+++ b/src/mailman/rest/listconf.py
@@ -29,7 +29,8 @@ from mailman.core.errors import (
from mailman.interfaces.action import Action
from mailman.interfaces.archiver import ArchivePolicy
from mailman.interfaces.autorespond import ResponseAction
-from mailman.interfaces.mailinglist import IAcceptableAliasSet, ReplyToMunging
+from mailman.interfaces.mailinglist import (
+ IAcceptableAliasSet, ReplyToMunging, SubscriptionPolicy)
from mailman.rest.helpers import (
GetterSetter, bad_request, etag, no_content, okay)
from mailman.rest.validator import (
@@ -135,6 +136,7 @@ ATTRIBUTES = dict(
scheme=GetterSetter(None),
send_welcome_message=GetterSetter(as_boolean),
subject_prefix=GetterSetter(str),
+ subscription_policy=GetterSetter(enum_validator(SubscriptionPolicy)),
volume=GetterSetter(None),
web_host=GetterSetter(None),
welcome_message_uri=GetterSetter(str),
diff --git a/src/mailman/rest/tests/test_listconf.py b/src/mailman/rest/tests/test_listconf.py
index b0107b199..ddb43a8ea 100644
--- a/src/mailman/rest/tests/test_listconf.py
+++ b/src/mailman/rest/tests/test_listconf.py
@@ -79,6 +79,7 @@ class TestConfiguration(unittest.TestCase):
reply_to_address='bee@example.com',
send_welcome_message=False,
subject_prefix='[ant]',
+ subscription_policy='confirm_then_moderate',
welcome_message_uri='mailman:///welcome.txt',
default_member_action='hold',
default_nonmember_action='discard',
diff --git a/src/mailman/styles/base.py b/src/mailman/styles/base.py
index 50cddbc32..7a77af609 100644
--- a/src/mailman/styles/base.py
+++ b/src/mailman/styles/base.py
@@ -41,7 +41,8 @@ from mailman.interfaces.archiver import ArchivePolicy
from mailman.interfaces.autorespond import ResponseAction
from mailman.interfaces.bounce import UnrecognizedBounceDisposition
from mailman.interfaces.digests import DigestFrequency
-from mailman.interfaces.mailinglist import Personalization, ReplyToMunging
+from mailman.interfaces.mailinglist import (
+ Personalization, ReplyToMunging, SubscriptionPolicy)
from mailman.interfaces.nntp import NewsgroupModeration
@@ -75,6 +76,7 @@ class BasicOperation:
mlist.personalize = Personalization.none
mlist.default_member_action = Action.defer
mlist.default_nonmember_action = Action.hold
+ mlist.subscription_policy = SubscriptionPolicy.confirm
# Notify the administrator of pending requests and membership changes.
mlist.admin_immed_notify = True
mlist.admin_notify_mchanges = False
diff --git a/src/mailman/utilities/importer.py b/src/mailman/utilities/importer.py
index bb4273b12..66a23123c 100644
--- a/src/mailman/utilities/importer.py
+++ b/src/mailman/utilities/importer.py
@@ -41,6 +41,7 @@ from mailman.interfaces.digests import DigestFrequency
from mailman.interfaces.languages import ILanguageManager
from mailman.interfaces.mailinglist import IAcceptableAliasSet
from mailman.interfaces.mailinglist import Personalization, ReplyToMunging
+from mailman.interfaces.mailinglist import SubscriptionPolicy
from mailman.interfaces.member import DeliveryMode, DeliveryStatus, MemberRole
from mailman.interfaces.nntp import NewsgroupModeration
from mailman.interfaces.usermanager import IUserManager
@@ -178,6 +179,7 @@ TYPES = dict(
personalize=Personalization,
preferred_language=check_language_code,
reply_goes_to_list=ReplyToMunging,
+ subscription_policy=SubscriptionPolicy,
topics_enabled=bool,
)
@@ -202,6 +204,7 @@ NAME_MAPPINGS = dict(
real_name='display_name',
send_goodbye_msg='send_goodbye_message',
send_welcome_msg='send_welcome_message',
+ subscribe_policy='subscription_policy',
)
# These DateTime fields of the mailinglist table need a type conversion to
diff --git a/src/mailman/utilities/tests/test_import.py b/src/mailman/utilities/tests/test_import.py
index 938ef7d2e..59387dfa9 100644
--- a/src/mailman/utilities/tests/test_import.py
+++ b/src/mailman/utilities/tests/test_import.py
@@ -44,7 +44,8 @@ from mailman.interfaces.autorespond import ResponseAction
from mailman.interfaces.bans import IBanManager
from mailman.interfaces.bounce import UnrecognizedBounceDisposition
from mailman.interfaces.languages import ILanguageManager
-from mailman.interfaces.mailinglist import IAcceptableAliasSet
+from mailman.interfaces.mailinglist import (
+ IAcceptableAliasSet, SubscriptionPolicy)
from mailman.interfaces.member import DeliveryMode, DeliveryStatus
from mailman.interfaces.nntp import NewsgroupModeration
from mailman.interfaces.templates import ITemplateLoader
@@ -301,6 +302,30 @@ class TestBasicImport(unittest.TestCase):
self._import()
self.assertEqual(self._mlist.encode_ascii_prefixes, True)
+ def test_subscription_policy_open(self):
+ self._mlist.subscription_policy = SubscriptionPolicy.confirm
+ self._pckdict['subscribe_policy'] = 0
+ self._import()
+ self.assertEqual(self._mlist.subscription_policy, SubscriptionPolicy.open)
+
+ def test_subscription_policy_confirm(self):
+ self._mlist.subscription_policy = SubscriptionPolicy.open
+ self._pckdict['subscribe_policy'] = 1
+ self._import()
+ self.assertEqual(self._mlist.subscription_policy, SubscriptionPolicy.confirm)
+
+ def test_subscription_policy_moderate(self):
+ self._mlist.subscription_policy = SubscriptionPolicy.open
+ self._pckdict['subscribe_policy'] = 2
+ self._import()
+ self.assertEqual(self._mlist.subscription_policy, SubscriptionPolicy.moderate)
+
+ def test_subscription_policy_confirm_then_moderate(self):
+ self._mlist.subscription_policy = SubscriptionPolicy.open
+ self._pckdict['subscribe_policy'] = 3
+ self._import()
+ self.assertEqual(self._mlist.subscription_policy, SubscriptionPolicy.confirm_then_moderate)
+
class TestArchiveImport(unittest.TestCase):