diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/mailman/app/subscriptions.py | 124 | ||||
| -rw-r--r-- | src/mailman/app/tests/test_subscriptions.py | 96 | ||||
| -rw-r--r-- | src/mailman/app/tests/test_workflow.py | 126 | ||||
| -rw-r--r-- | src/mailman/app/workflow.py | 102 | ||||
| -rw-r--r-- | src/mailman/config/configure.zcml | 5 | ||||
| -rw-r--r-- | src/mailman/database/alembic/versions/16c2b25c7b_list_subscription_policy.py | 41 | ||||
| -rw-r--r-- | src/mailman/database/alembic/versions/2bb9b382198_workflow_state_table.py | 28 | ||||
| -rw-r--r-- | src/mailman/interfaces/mailinglist.py | 16 | ||||
| -rw-r--r-- | src/mailman/interfaces/workflow.py | 66 | ||||
| -rw-r--r-- | src/mailman/model/mailinglist.py | 3 | ||||
| -rw-r--r-- | src/mailman/model/roster.py | 4 | ||||
| -rw-r--r-- | src/mailman/model/tests/test_workflow.py | 110 | ||||
| -rw-r--r-- | src/mailman/model/workflow.py | 65 | ||||
| -rw-r--r-- | src/mailman/rest/docs/listconf.rst | 3 | ||||
| -rw-r--r-- | src/mailman/rest/listconf.py | 4 | ||||
| -rw-r--r-- | src/mailman/rest/tests/test_listconf.py | 1 | ||||
| -rw-r--r-- | src/mailman/styles/base.py | 4 | ||||
| -rw-r--r-- | src/mailman/utilities/importer.py | 3 | ||||
| -rw-r--r-- | src/mailman/utilities/tests/test_import.py | 27 |
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): |
