diff options
| author | Barry Warsaw | 2011-08-17 20:39:11 -0400 |
|---|---|---|
| committer | Barry Warsaw | 2011-08-17 20:39:11 -0400 |
| commit | 545735d6dc0efb76b88f3f0634966f97d833dc41 (patch) | |
| tree | a235c693656b2c42c239b674f81319f66f2df5a0 | |
| parent | 51319c7b0f1790b32ec360366ab3435b042363e2 (diff) | |
| parent | 7f32fdd8e82542122909f322d2e35b55511c32de (diff) | |
| download | mailman-545735d6dc0efb76b88f3f0634966f97d833dc41.tar.gz mailman-545735d6dc0efb76b88f3f0634966f97d833dc41.tar.zst mailman-545735d6dc0efb76b88f3f0634966f97d833dc41.zip | |
| -rw-r--r-- | src/mailman/app/docs/subscriptions.rst | 13 | ||||
| -rw-r--r-- | src/mailman/app/events.py | 39 | ||||
| -rw-r--r-- | src/mailman/app/moderator.py | 13 | ||||
| -rw-r--r-- | src/mailman/app/subscriptions.py | 81 | ||||
| -rw-r--r-- | src/mailman/chains/docs/moderation.rst (renamed from src/mailman/chains/docs/moderation.txt) | 0 | ||||
| -rw-r--r-- | src/mailman/core/initialize.py | 2 | ||||
| -rw-r--r-- | src/mailman/docs/NEWS.rst | 14 | ||||
| -rw-r--r-- | src/mailman/interfaces/listmanager.py | 32 | ||||
| -rw-r--r-- | src/mailman/interfaces/subscriptions.py | 2 | ||||
| -rw-r--r-- | src/mailman/model/docs/pending.rst (renamed from src/mailman/model/docs/pending.txt) | 0 | ||||
| -rw-r--r-- | src/mailman/model/listmanager.py | 10 | ||||
| -rw-r--r-- | src/mailman/model/tests/test_listmanager.py | 136 | ||||
| -rw-r--r-- | src/mailman/model/tests/test_user.py | 4 | ||||
| -rw-r--r-- | src/mailman/rest/docs/membership.rst | 37 | ||||
| -rw-r--r-- | src/mailman/rest/members.py | 9 | ||||
| -rw-r--r-- | src/mailman/testing/helpers.py | 1 |
16 files changed, 348 insertions, 45 deletions
diff --git a/src/mailman/app/docs/subscriptions.rst b/src/mailman/app/docs/subscriptions.rst index 378dd0a40..8291132ce 100644 --- a/src/mailman/app/docs/subscriptions.rst +++ b/src/mailman/app/docs/subscriptions.rst @@ -79,7 +79,7 @@ If you know the member id for a specific member, you can get that member. <Member: anne <anne@example.com> 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. At a minimum, you need the member's email address. +specific search criteria. :: >>> mlist2 = create_list('foo@example.com') @@ -121,6 +121,17 @@ Memberships can also be searched for by user id. <Member: anne <anne@example.com> on test@example.com as MemberRole.moderator>] +You can find all the memberships for a specific mailing list. + + >>> service.find_members(fqdn_listname='test@example.com') + [<Member: anne <anne@example.com> on test@example.com + as MemberRole.member>, + <Member: anne <anne@example.com> on test@example.com as MemberRole.owner>, + <Member: anne <anne@example.com> on test@example.com + as MemberRole.moderator>, + <Member: Bart Person <bart@example.com> on test@example.com + as MemberRole.member>] + You can find all the memberships for an address on a specific mailing list. >>> service.find_members('anne@example.com', 'test@example.com') diff --git a/src/mailman/app/events.py b/src/mailman/app/events.py new file mode 100644 index 000000000..ac318f6b2 --- /dev/null +++ b/src/mailman/app/events.py @@ -0,0 +1,39 @@ +# Copyright (C) 2011 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/>. + +"""Global events.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'initialize', + ] + + +from zope import event + +from mailman.app import moderator, subscriptions + + + +def initialize(): + """Initialize global event subscribers.""" + event.subscribers.extend([ + moderator.handle_ListDeletingEvent, + subscriptions.handle_ListDeletedEvent, + ]) diff --git a/src/mailman/app/moderator.py b/src/mailman/app/moderator.py index 1b9f21d2a..0ba6f492a 100644 --- a/src/mailman/app/moderator.py +++ b/src/mailman/app/moderator.py @@ -21,6 +21,7 @@ from __future__ import absolute_import, unicode_literals __metaclass__ = type __all__ = [ + 'handle_ListDeletingEvent', 'handle_message', 'handle_subscription', 'handle_unsubscription', @@ -43,6 +44,7 @@ from mailman.core.i18n import _ from mailman.email.message import UserNotification from mailman.interfaces.action import Action from mailman.interfaces.languages import ILanguageManager +from mailman.interfaces.listmanager import ListDeletingEvent from mailman.interfaces.member import ( AlreadySubscribedError, DeliveryMode, NotAMemberError) from mailman.interfaces.messages import IMessageStore @@ -355,3 +357,14 @@ def _refuse(mlist, request, recip, comment, origmsg=None, lang=None): subject = _('Request to mailing list "$realname" rejected') msg = UserNotification(recip, mlist.bounces_address, subject, text, lang) msg.send(mlist) + + + +def handle_ListDeletingEvent(event): + if not isinstance(event, ListDeletingEvent): + return + # Get the held requests database for the mailing list. Since the mailing + # list is about to get deleted, we can delete all associated requests. + requestsdb = getUtility(IRequests).get_list_requests(event.mailing_list) + for request in requestsdb.held_requests: + requestsdb.delete_request(request.id) diff --git a/src/mailman/app/subscriptions.py b/src/mailman/app/subscriptions.py index ab8c3d53c..f11494e75 100644 --- a/src/mailman/app/subscriptions.py +++ b/src/mailman/app/subscriptions.py @@ -22,6 +22,7 @@ from __future__ import absolute_import, unicode_literals __metaclass__ = type __all__ = [ 'SubscriptionService', + 'handle_ListDeleteEvent', ] @@ -34,7 +35,8 @@ from mailman.app.membership import add_member, delete_member from mailman.config import config from mailman.core.constants import system_preferences from mailman.interfaces.address import InvalidEmailAddressError -from mailman.interfaces.listmanager import IListManager, NoSuchListError +from mailman.interfaces.listmanager import ( + IListManager, ListDeletedEvent, NoSuchListError) from mailman.interfaces.member import DeliveryMode from mailman.interfaces.subscriptions import ( ISubscriptionService, MissingUserError) @@ -93,43 +95,43 @@ class SubscriptionService: assert members.count() == 1, 'Too many matching members' return members[0] - def find_members(self, subscriber, fqdn_listname=None, role=None): + def find_members(self, subscriber=None, fqdn_listname=None, role=None): """See `ISubscriptionService`.""" # If `subscriber` is a user id, then we'll search for all addresses # which are controlled by the user, otherwise we'll just search for # the given address. user_manager = getUtility(IUserManager) - query = None - if isinstance(subscriber, basestring): - # subscriber is an email address. - address = user_manager.get_address(subscriber) - user = user_manager.get_user(subscriber) - # This probably could be made more efficient. - if address is None or user is None: - return [] - or_clause = Or(Member.address_id == address.id, - Member.user_id == user.id) - else: - # subscriber is a user id. - user = user_manager.get_user_by_id(unicode(subscriber)) - address_ids = list(address.id for address in user.addresses - if address.id is not None) - if len(address_ids) == 0 or user is None: - return [] - or_clause = Or(Member.user_id == user.id, - Member.address_id.is_in(address_ids)) - # The rest is the same, based on the given criteria. - if fqdn_listname is None and role is None: - query = or_clause - elif fqdn_listname is None: - query = And(Member.role == role, or_clause) - elif role is None: - query = And(Member.mailing_list == fqdn_listname, or_clause) - else: - query = And(Member.mailing_list == fqdn_listname, - Member.role == role, - or_clause) - results = config.db.store.find(Member, query) + if subscriber is None and fqdn_listname is None and role is None: + return [] + # Querying for the subscriber is the most complicated part, because + # the parameter can either be an email address or a user id. + query = [] + if subscriber is not None: + if isinstance(subscriber, basestring): + # subscriber is an email address. + address = user_manager.get_address(subscriber) + user = user_manager.get_user(subscriber) + # This probably could be made more efficient. + if address is None or user is None: + return [] + query.append(Or(Member.address_id == address.id, + Member.user_id == user.id)) + else: + # subscriber is a user id. + user = user_manager.get_user_by_id(unicode(subscriber)) + address_ids = list(address.id for address in user.addresses + if address.id is not None) + if len(address_ids) == 0 or user is None: + return [] + query.append(Or(Member.user_id == user.id, + Member.address_id.is_in(address_ids))) + # Calculate the rest of the query expression, which will get And'd + # with the Or clause above (if there is one). + if fqdn_listname is not None: + query.append(Member.mailing_list == fqdn_listname) + if role is not None: + query.append(Member.role == role) + results = config.db.store.find(Member, And(*query)) return sorted(results, key=_membership_sort_key) def __iter__(self): @@ -177,3 +179,16 @@ class SubscriptionService: raise NoSuchListError(fqdn_listname) # XXX for now, no notification or user acknowledgement. delete_member(mlist, address, False, False) + + + +def handle_ListDeletedEvent(event): + """Delete a mailing list's members when the list is deleted.""" + + if not isinstance(event, ListDeletedEvent): + return + # Find all the members still associated with the mailing list. + members = getUtility(ISubscriptionService).find_members( + fqdn_listname=event.fqdn_listname) + for member in members: + member.unsubscribe() diff --git a/src/mailman/chains/docs/moderation.txt b/src/mailman/chains/docs/moderation.rst index d80f9bd2c..d80f9bd2c 100644 --- a/src/mailman/chains/docs/moderation.txt +++ b/src/mailman/chains/docs/moderation.rst diff --git a/src/mailman/core/initialize.py b/src/mailman/core/initialize.py index 148d1a150..bf0f8b542 100644 --- a/src/mailman/core/initialize.py +++ b/src/mailman/core/initialize.py @@ -147,6 +147,7 @@ def initialize_2(debug=False, propagate_logs=None): # Initialize the rules and chains. Do the imports here so as to avoid # circular imports. from mailman.app.commands import initialize as initialize_commands + from mailman.app.events import initialize as initialize_events from mailman.core.chains import initialize as initialize_chains from mailman.core.pipelines import initialize as initialize_pipelines from mailman.core.rules import initialize as initialize_rules @@ -155,6 +156,7 @@ def initialize_2(debug=False, propagate_logs=None): initialize_chains() initialize_pipelines() initialize_commands() + initialize_events() def initialize_3(): diff --git a/src/mailman/docs/NEWS.rst b/src/mailman/docs/NEWS.rst index 4a9e725bf..c2fa66905 100644 --- a/src/mailman/docs/NEWS.rst +++ b/src/mailman/docs/NEWS.rst @@ -19,6 +19,15 @@ Architecture * mailman.qrunner log is renamed to mailman.runner * master-qrunner.lck -> master.lck * master-qrunner.pid -> master.pid + * Four new events are created, and notifications are sent during mailing list + lifecycle changes: + - ListCreatingEvent - sent before the mailing list is created + - ListCreatedEvent - sent after the mailing list is created + - ListDeletingEvent - sent before the mailing list is deleted + - ListDeletedEvent - sent after the mailing list is deleted + * Using the above events, when a mailing list is deleted, all its members are + deleted, as well as all held message requests (but not the held messages + themselves). (LP: 827036) REST ---- @@ -26,9 +35,8 @@ REST for consistency. This changes the REST API for mailing list resources. (LP: #787599) * New REST resource http://.../members/find can be POSTed to in order to find - member records. Arguments are `subscriber` (email address to search for - - required), `fqdn_listname` (optional), and `role` (i.e. MemberRole - - optional). (LP: #799612) + member records. Optional arguments are `subscriber` (email address to + search for), `fqdn_listname`, and `role` (i.e. MemberRole). (LP: #799612) * Fixed /lists/<fqdn_listname>/<role>/<email> (LP: #825570) * Remove role plurals from /lists/<fqdn_listname/rosters/<role> * Fixed incorrect error code for /members/<bogus> (LP: #821020). Given by diff --git a/src/mailman/interfaces/listmanager.py b/src/mailman/interfaces/listmanager.py index 1efb4342f..c1b7721a9 100644 --- a/src/mailman/interfaces/listmanager.py +++ b/src/mailman/interfaces/listmanager.py @@ -23,6 +23,10 @@ __metaclass__ = type __all__ = [ 'IListManager', 'ListAlreadyExistsError', + 'ListCreatedEvent', + 'ListCreatingEvent', + 'ListDeletedEvent', + 'ListDeletingEvent', 'NoSuchListError', ] @@ -51,6 +55,34 @@ class NoSuchListError(MailmanError): return 'No such mailing list: {0.fqdn_listname}'.format(self) +class ListCreatingEvent: + """A mailing list is about to be created.""" + + def __init__(self, fqdn_listname): + self.fqdn_listname = fqdn_listname + + +class ListCreatedEvent: + """A mailing list was created.""" + + def __init__(self, mlist): + self.mailing_list = mlist + + +class ListDeletingEvent: + """A mailing list is about to be deleted.""" + + def __init__(self, mailing_list): + self.mailing_list = mailing_list + + +class ListDeletedEvent: + """A mailing list was deleted.""" + + def __init__(self, fqdn_listname): + self.fqdn_listname = fqdn_listname + + class IListManager(Interface): diff --git a/src/mailman/interfaces/subscriptions.py b/src/mailman/interfaces/subscriptions.py index c6bc47d5d..aa2e318f0 100644 --- a/src/mailman/interfaces/subscriptions.py +++ b/src/mailman/interfaces/subscriptions.py @@ -68,7 +68,7 @@ class ISubscriptionService(Interface): :rtype: `IMember` """ - def find_members(subscriber, fqdn_listname=None, role=None): + def find_members(subscriber=None, fqdn_listname=None, role=None): """Search for and return a specific member. The members are sorted first by fully-qualified mailing list name, diff --git a/src/mailman/model/docs/pending.txt b/src/mailman/model/docs/pending.rst index 707e8a7fc..707e8a7fc 100644 --- a/src/mailman/model/docs/pending.txt +++ b/src/mailman/model/docs/pending.rst diff --git a/src/mailman/model/listmanager.py b/src/mailman/model/listmanager.py index a7f302883..16185e670 100644 --- a/src/mailman/model/listmanager.py +++ b/src/mailman/model/listmanager.py @@ -27,11 +27,14 @@ __all__ = [ import datetime +from zope.event import notify from zope.interface import implements from mailman.config import config from mailman.interfaces.address import InvalidEmailAddressError -from mailman.interfaces.listmanager import IListManager, ListAlreadyExistsError +from mailman.interfaces.listmanager import ( + IListManager, ListAlreadyExistsError, ListCreatedEvent, ListCreatingEvent, + ListDeletedEvent, ListDeletingEvent) from mailman.model.mailinglist import MailingList @@ -46,6 +49,7 @@ class ListManager: listname, at, hostname = fqdn_listname.partition('@') if len(hostname) == 0: raise InvalidEmailAddressError(fqdn_listname) + notify(ListCreatingEvent(fqdn_listname)) mlist = config.db.store.find( MailingList, MailingList.list_name == listname, @@ -55,6 +59,7 @@ class ListManager: mlist = MailingList(fqdn_listname) mlist.created_at = datetime.datetime.now() config.db.store.add(mlist) + notify(ListCreatedEvent(mlist)) return mlist def get(self, fqdn_listname): @@ -70,7 +75,10 @@ class ListManager: def delete(self, mlist): """See `IListManager`.""" + fqdn_listname = mlist.fqdn_listname + notify(ListDeletingEvent(mlist)) config.db.store.remove(mlist) + notify(ListDeletedEvent(fqdn_listname)) @property def mailing_lists(self): diff --git a/src/mailman/model/tests/test_listmanager.py b/src/mailman/model/tests/test_listmanager.py new file mode 100644 index 000000000..8ea36d9b7 --- /dev/null +++ b/src/mailman/model/tests/test_listmanager.py @@ -0,0 +1,136 @@ +# Copyright (C) 2011 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 ListManager.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'test_suite', + ] + + +import unittest + +from zope.component import getUtility + +from mailman.app.lifecycle import create_list +from mailman.app.moderator import hold_message +from mailman.interfaces.listmanager import ( + IListManager, ListCreatedEvent, ListCreatingEvent, ListDeletedEvent, + ListDeletingEvent) +from mailman.interfaces.messages import IMessageStore +from mailman.interfaces.requests import IRequests +from mailman.interfaces.subscriptions import ISubscriptionService +from mailman.interfaces.usermanager import IUserManager +from mailman.testing.helpers import ( + event_subscribers, specialized_message_from_string) +from mailman.testing.layers import ConfigLayer + + + +class TestListManager(unittest.TestCase): + layer = ConfigLayer + + def setUp(self): + self._events = [] + + def _record_event(self, event): + self._events.append(event) + + def test_create_list_event(self): + # Test that creating a list in the list manager propagates the + # expected events. + with event_subscribers(self._record_event): + mlist = getUtility(IListManager).create('test@example.com') + self.assertEqual(len(self._events), 2) + self.assertTrue(isinstance(self._events[0], ListCreatingEvent)) + self.assertEqual(self._events[0].fqdn_listname, 'test@example.com') + self.assertTrue(isinstance(self._events[1], ListCreatedEvent)) + self.assertEqual(self._events[1].mailing_list, mlist) + + def test_delete_list_event(self): + # Test that deleting a list in the list manager propagates the + # expected event. + mlist = create_list('another@example.com') + with event_subscribers(self._record_event): + getUtility(IListManager).delete(mlist) + self.assertEqual(len(self._events), 2) + self.assertTrue(isinstance(self._events[0], ListDeletingEvent)) + self.assertEqual(self._events[0].mailing_list, mlist) + self.assertTrue(isinstance(self._events[1], ListDeletedEvent)) + self.assertEqual(self._events[1].fqdn_listname, 'another@example.com') + + + +class TestListLifecycleEvents(unittest.TestCase): + layer = ConfigLayer + + def setUp(self): + self._ant = create_list('ant@example.com') + self._bee = create_list('bee@example.com') + self._usermanager = getUtility(IUserManager) + + def test_members_are_deleted_when_mailing_list_is_deleted(self): + # When a mailing list with members is deleted, all the Member records + # are also deleted. + anne = self._usermanager.create_address('anne@example.com') + bart = self._usermanager.create_address('bart@example.com') + anne_ant = self._ant.subscribe(anne) + anne_bee = self._bee.subscribe(anne) + bart_ant = self._ant.subscribe(bart) + anne_ant_id = anne_ant.member_id + anne_bee_id = anne_bee.member_id + bart_ant_id = bart_ant.member_id + getUtility(IListManager).delete(self._ant) + service = getUtility(ISubscriptionService) + # We deleted the ant@example.com mailing list. Anne's and Bart's + # membership in this list should now be removed, but Anne's membership + # in bee@example.com should still exist. + self.assertEqual(service.get_member(anne_ant_id), None) + self.assertEqual(service.get_member(bart_ant_id), None) + self.assertEqual(service.get_member(anne_bee_id), anne_bee) + + def test_requests_are_deleted_when_mailing_list_is_deleted(self): + # When a mailing list is deleted, its requests database is deleted + # too, e.g. all its message hold requests (but not the messages + # themselves). + msg = specialized_message_from_string("""\ +From: anne@example.com +To: ant@example.com +Subject: Hold me +Message-ID: <argon> + +""") + request_id = hold_message(self._ant, msg) + getUtility(IListManager).delete(self._ant) + # This is a hack. ListRequests don't access self._mailinglist in + # their get_request() method. + requestsdb = getUtility(IRequests).get_list_requests(None) + request = requestsdb.get_request(request_id) + self.assertEqual(request, None) + saved_message = getUtility(IMessageStore).get_message_by_id('<argon>') + self.assertEqual(saved_message.as_string(), msg.as_string()) + + + +def test_suite(): + suite = unittest.TestSuite() + suite.addTest(unittest.makeSuite(TestListManager)) + suite.addTest(unittest.makeSuite(TestListLifecycleEvents)) + return suite diff --git a/src/mailman/model/tests/test_user.py b/src/mailman/model/tests/test_user.py index 072b2f7be..4112572bd 100644 --- a/src/mailman/model/tests/test_user.py +++ b/src/mailman/model/tests/test_user.py @@ -27,13 +27,13 @@ __all__ = [ import unittest +from zope.component import getUtility + from mailman.app.lifecycle import create_list from mailman.interfaces.usermanager import IUserManager from mailman.testing.layers import ConfigLayer from mailman.utilities.datetime import now -from zope.component import getUtility - class TestUser(unittest.TestCase): diff --git a/src/mailman/rest/docs/membership.rst b/src/mailman/rest/docs/membership.rst index 426b80acf..bed4f425d 100644 --- a/src/mailman/rest/docs/membership.rst +++ b/src/mailman/rest/docs/membership.rst @@ -308,6 +308,43 @@ example, we can search for all the memberships of a particular address. start: 0 total_size: 2 +Or, we can find all the memberships for a particular mailing list. + + >>> dump_json('http://localhost:9001/3.0/members/find', { + ... 'fqdn_listname': 'bee@example.com', + ... }) + entry 0: + address: aperson@example.com + fqdn_listname: bee@example.com + http_etag: ... + role: member + self_link: http://localhost:9001/3.0/members/3 + user: http://localhost:9001/3.0/users/3 + entry 1: + address: bperson@example.com + fqdn_listname: bee@example.com + http_etag: ... + role: member + self_link: http://localhost:9001/3.0/members/1 + user: http://localhost:9001/3.0/users/1 + entry 2: + address: cperson@example.com + fqdn_listname: bee@example.com + http_etag: ... + role: member + self_link: http://localhost:9001/3.0/members/2 + user: http://localhost:9001/3.0/users/2 + entry 3: + address: cperson@example.com + fqdn_listname: bee@example.com + http_etag: ... + role: owner + self_link: http://localhost:9001/3.0/members/7 + user: http://localhost:9001/3.0/users/2 + http_etag: "66836d0f23bed36fa9e0cda1e5dec7e5b0797743" + start: 0 + total_size: 4 + Or, we can find all the memberships for an address on a particular mailing list. diff --git a/src/mailman/rest/members.py b/src/mailman/rest/members.py index 0a4b99470..047c375bb 100644 --- a/src/mailman/rest/members.py +++ b/src/mailman/rest/members.py @@ -193,10 +193,11 @@ class FindMembers(_MemberBase): def find(self, request): """Find a member""" service = getUtility(ISubscriptionService) - validator = Validator(fqdn_listname=unicode, - subscriber=unicode, - role=enum_validator(MemberRole), - _optional=('fqdn_listname', 'role')) + validator = Validator( + fqdn_listname=unicode, + subscriber=unicode, + role=enum_validator(MemberRole), + _optional=('fqdn_listname', 'subscriber', 'role')) members = service.find_members(**validator(request)) # We can't just return the _FoundMembers instance, because # CollectionMixins have only a GET method, which is incompatible with diff --git a/src/mailman/testing/helpers.py b/src/mailman/testing/helpers.py index 1a168dd0c..144ae1ffb 100644 --- a/src/mailman/testing/helpers.py +++ b/src/mailman/testing/helpers.py @@ -29,6 +29,7 @@ __all__ = [ 'get_queue_messages', 'make_testable_runner', 'reset_the_world', + 'specialized_message_from_string', 'subscribe', 'wait_for_webservice', ] |
