summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--port_me/list_owners.py90
-rw-r--r--src/mailman/commands/cli_members.py101
-rw-r--r--src/mailman/commands/docs/members.rst97
-rw-r--r--src/mailman/commands/tests/test_members.py153
-rw-r--r--src/mailman/docs/NEWS.rst3
-rw-r--r--src/mailman/model/roster.py2
6 files changed, 270 insertions, 176 deletions
diff --git a/port_me/list_owners.py b/port_me/list_owners.py
deleted file mode 100644
index 5b5fca2bf..000000000
--- a/port_me/list_owners.py
+++ /dev/null
@@ -1,90 +0,0 @@
-# Copyright (C) 2002-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/>.
-
-import sys
-import optparse
-
-from zope.component import getUtility
-
-from mailman.MailList import MailList
-from mailman.core.i18n import _
-from mailman.initialize import initialize
-from mailman.interfaces.listmanager import IListManager
-from mailman.version import MAILMAN_VERSION
-
-
-
-def parseargs():
- parser = optparse.OptionParser(version=MAILMAN_VERSION,
- usage=_("""\
-%prog [options] [listname ...]
-
-List the owners of a mailing list, or all mailing lists if no list names are
-given."""))
- parser.add_option('-w', '--with-listnames',
- default=False, action='store_true',
- help=_("""\
-Group the owners by list names and include the list names in the output.
-Otherwise, the owners will be sorted and uniquified based on the email
-address."""))
- parser.add_option('-m', '--moderators',
- default=False, action='store_true',
- help=_('Include the list moderators in the output.'))
- parser.add_option('-C', '--config',
- help=_('Alternative configuration file to use'))
- opts, args = parser.parse_args()
- return parser, opts, args
-
-
-
-def main():
- parser, opts, args = parseargs()
- initialize(opts.config)
-
- list_manager = getUtility(IListManager)
- listnames = set(args or list_manager.names)
- bylist = {}
-
- for listname in listnames:
- mlist = list_manager.get(listname)
- addrs = [addr.address for addr in mlist.owners.addresses]
- if opts.moderators:
- addrs.extend([addr.address for addr in mlist.moderators.addresses])
- bylist[listname] = addrs
-
- if opts.with_listnames:
- for listname in listnames:
- unique = set()
- for addr in bylist[listname]:
- unique.add(addr)
- keys = list(unique)
- keys.sort()
- print listname
- for k in keys:
- print '\t', k
- else:
- unique = set()
- for listname in listnames:
- for addr in bylist[listname]:
- unique.add(addr)
- for k in sorted(unique):
- print k
-
-
-
-if __name__ == '__main__':
- main()
diff --git a/src/mailman/commands/cli_members.py b/src/mailman/commands/cli_members.py
index ccacbeeb8..2e6224f71 100644
--- a/src/mailman/commands/cli_members.py
+++ b/src/mailman/commands/cli_members.py
@@ -23,8 +23,8 @@ __all__ = [
import sys
-import codecs
+from contextlib import ExitStack
from email.utils import formataddr, parseaddr
from mailman.app.membership import add_member
from mailman.core.i18n import _
@@ -32,7 +32,7 @@ from mailman.database.transaction import transactional
from mailman.interfaces.command import ICLISubCommand
from mailman.interfaces.listmanager import IListManager
from mailman.interfaces.member import (
- AlreadySubscribedError, DeliveryMode, DeliveryStatus)
+ AlreadySubscribedError, DeliveryMode, DeliveryStatus, MemberRole)
from mailman.interfaces.subscriptions import RequestRecord
from operator import attrgetter
from zope.component import getUtility
@@ -63,6 +63,15 @@ class Members:
help=_("""Display output to FILENAME instead of stdout. FILENAME
can be '-' to indicate standard output."""))
command_parser.add_argument(
+ '-R', '--role',
+ default=None, metavar='ROLE',
+ choices=('any', 'owner', 'moderator', 'nonmember', 'member',
+ 'administrator'),
+ help=_("""Display only members with a given ROLE. The role may be
+ 'any', 'member', 'nonmember', 'owner', 'moderator', or
+ 'administrator' (i.e. owners and moderators). If not
+ given, then delivery members are used. """))
+ command_parser.add_argument(
'-r', '--regular',
default=None, action='store_true',
help=_('Display only regular delivery members.'))
@@ -89,20 +98,26 @@ class Members:
was disabled for unknown (legacy) reasons."""))
# Required positional argument.
command_parser.add_argument(
- 'listname', metavar='LISTNAME', nargs=1,
+ 'list', metavar='LIST', nargs=1,
help=_("""\
- The 'fully qualified list name', i.e. the posting address of the
- mailing list. It must be a valid email address and the domain
- must be registered with Mailman. List names are forced to lower
- case."""))
+ The list to operate on. This can be the fully qualified list
+ name', i.e. the posting address of the mailing list or the
+ List-ID."""))
+ command_parser.epilog = _(
+ """Display a mailing list's members, with filtering along various
+ criteria.""")
def process(self, args):
"""See `ICLISubCommand`."""
- assert len(args.listname) == 1, 'Missing mailing list name'
- fqdn_listname = args.listname[0]
- mlist = getUtility(IListManager).get(fqdn_listname)
+ assert len(args.list) == 1, 'Missing mailing list name'
+ list_spec = args.list[0]
+ list_manager = getUtility(IListManager)
+ if '@' in list_spec:
+ mlist = list_manager.get(list_spec)
+ else:
+ mlist = list_manager.get_by_list_id(list_spec)
if mlist is None:
- self.parser.error(_('No such list: $fqdn_listname'))
+ self.parser.error(_('No such list: $list_spec'))
if args.input_filename is None:
self.display_members(mlist, args)
else:
@@ -116,10 +131,6 @@ class Members:
:param args: The command line arguments.
:type args: `argparse.Namespace`
"""
- if args.output_filename == '-' or args.output_filename is None:
- fp = sys.stdout
- else:
- fp = codecs.open(args.output_filename, 'w', 'utf-8')
if args.digest == 'any':
digest_types = [DeliveryMode.plaintext_digests,
DeliveryMode.mime_digests,
@@ -129,6 +140,7 @@ class Members:
else:
# Don't filter on digest type.
pass
+
if args.nomail is None:
# Don't filter on delivery status.
pass
@@ -146,31 +158,49 @@ class Members:
DeliveryStatus.by_moderator,
DeliveryStatus.unknown]
else:
- raise AssertionError('Unknown delivery status: %s' % args.nomail)
- try:
- addresses = list(mlist.members.addresses)
+ status = args.nomail
+ self.parser.error(_('Unknown delivery status: $status'))
+
+ if args.role is None:
+ # By default, filter on members.
+ roster = mlist.members
+ elif args.role == 'administrator':
+ roster = mlist.administrators
+ elif args.role == 'any':
+ roster = mlist.subscribers
+ else:
+ try:
+ roster = mlist.get_roster(MemberRole[args.role])
+ except KeyError:
+ role = args.role
+ self.parser.error(_('Unknown member role: $role'))
+
+ with ExitStack() as resources:
+ if args.output_filename == '-' or args.output_filename is None:
+ fp = sys.stdout
+ else:
+ fp = resources.enter_context(
+ open(args.output_filename, 'w', encoding='utf-8'))
+ addresses = list(roster.addresses)
if len(addresses) == 0:
- print(mlist.fqdn_listname, 'has no members', file=fp)
+ print(_('$mlist.list_id has no members'), file=fp)
return
for address in sorted(addresses, key=attrgetter('email')):
if args.regular:
- member = mlist.members.get_member(address.email)
+ member = roster.get_member(address.email)
if member.delivery_mode != DeliveryMode.regular:
continue
if args.digest is not None:
- member = mlist.members.get_member(address.email)
+ member = roster.get_member(address.email)
if member.delivery_mode not in digest_types:
continue
if args.nomail is not None:
- member = mlist.members.get_member(address.email)
+ member = roster.get_member(address.email)
if member.delivery_status not in status_types:
continue
print(
formataddr((address.display_name, address.original_email)),
file=fp)
- finally:
- if fp is not sys.stdout:
- fp.close()
@transactional
def add_members(self, mlist, args):
@@ -181,11 +211,12 @@ class Members:
:param args: The command line arguments.
:type args: `argparse.Namespace`
"""
- if args.input_filename == '-':
- fp = sys.stdin
- else:
- fp = codecs.open(args.input_filename, 'r', 'utf-8')
- try:
+ with ExitStack() as resources:
+ if args.input_filename == '-':
+ fp = sys.stdin
+ else:
+ fp = resources.enter_context(
+ open(args.input_filename, 'r', encoding='utf-8'))
for line in fp:
# Ignore blank lines and lines that start with a '#'.
if line.startswith('#') or len(line.strip()) == 0:
@@ -200,8 +231,8 @@ class Members:
except AlreadySubscribedError:
# It's okay if the address is already subscribed, just
# print a warning and continue.
- print('Already subscribed (skipping):',
- email, display_name)
- finally:
- if fp is not sys.stdin:
- fp.close()
+ if not display_name:
+ print(_('Already subscribed (skipping): $email'))
+ else:
+ print(_('Already subscribed (skipping): '
+ '$display_name <$email>'))
diff --git a/src/mailman/commands/docs/members.rst b/src/mailman/commands/docs/members.rst
index 86e5c3ceb..c40afb122 100644
--- a/src/mailman/commands/docs/members.rst
+++ b/src/mailman/commands/docs/members.rst
@@ -6,15 +6,16 @@ The ``mailman members`` command allows a site administrator to display, add,
and remove members from a mailing list.
::
- >>> mlist1 = create_list('test1@example.com')
+ >>> ant = create_list('ant@example.com')
>>> class FakeArgs:
... input_filename = None
... output_filename = None
- ... listname = []
+ ... list = []
... regular = False
... digest = None
... nomail = None
+ ... role = None
>>> args = FakeArgs()
>>> from mailman.commands.cli_members import Members
@@ -27,19 +28,18 @@ Listing members
You can list all the members of a mailing list by calling the command with no
options. To start with, there are no members of the mailing list.
- >>> args.listname = [mlist1.fqdn_listname]
+ >>> args.list = ['ant.example.com']
>>> command.process(args)
- test1@example.com has no members
+ ant.example.com has no members
Once the mailing list add some members, they will be displayed.
-::
>>> from mailman.testing.helpers import subscribe
- >>> subscribe(mlist1, 'Anne', email='anne@example.com')
- <Member: Anne Person <anne@example.com> on test1@example.com
+ >>> subscribe(ant, 'Anne', email='anne@example.com')
+ <Member: Anne Person <anne@example.com> on ant@example.com
as MemberRole.member>
- >>> subscribe(mlist1, 'Bart', email='bart@example.com')
- <Member: Bart Person <bart@example.com> on test1@example.com
+ >>> subscribe(ant, 'Bart', email='bart@example.com')
+ <Member: Bart Person <bart@example.com> on ant@example.com
as MemberRole.member>
>>> command.process(args)
Anne Person <anne@example.com>
@@ -48,8 +48,8 @@ Once the mailing list add some members, they will be displayed.
Members are displayed in alphabetical order based on their address.
::
- >>> subscribe(mlist1, 'Anne', email='anne@aaaxample.com')
- <Member: Anne Person <anne@aaaxample.com> on test1@example.com
+ >>> subscribe(ant, 'Anne', email='anne@aaaxample.com')
+ <Member: Anne Person <anne@aaaxample.com> on ant@example.com
as MemberRole.member>
>>> command.process(args)
Anne Person <anne@aaaxample.com>
@@ -58,17 +58,15 @@ Members are displayed in alphabetical order based on their address.
You can also output this list to a file.
- >>> from tempfile import mkstemp
- >>> fd, args.output_filename = mkstemp()
- >>> import os
- >>> os.close(fd)
- >>> command.process(args)
- >>> with open(args.output_filename) as fp:
- ... print(fp.read())
+ >>> from tempfile import NamedTemporaryFile
+ >>> with NamedTemporaryFile() as outfp:
+ ... args.output_filename = outfp.name
+ ... command.process(args)
+ ... with open(args.output_filename) as infp:
+ ... print(infp.read())
Anne Person <anne@aaaxample.com>
Anne Person <anne@example.com>
Bart Person <bart@example.com>
- >>> os.remove(args.output_filename)
>>> args.output_filename = None
The output file can also be standard out.
@@ -88,7 +86,7 @@ You can limit output to just the regular non-digest members...
>>> from mailman.interfaces.member import DeliveryMode
>>> args.regular = True
- >>> member = mlist1.members.get_member('anne@example.com')
+ >>> member = ant.members.get_member('anne@example.com')
>>> member.preferences.delivery_mode = DeliveryMode.plaintext_digests
>>> command.process(args)
Anne Person <anne@aaaxample.com>
@@ -97,7 +95,7 @@ You can limit output to just the regular non-digest members...
...or just the digest members. Furthermore, you can either display all digest
members...
- >>> member = mlist1.members.get_member('anne@aaaxample.com')
+ >>> member = ant.members.get_member('anne@aaaxample.com')
>>> member.preferences.delivery_mode = DeliveryMode.mime_digests
>>> args.regular = False
>>> args.digest = 'any'
@@ -132,16 +130,16 @@ status is enabled...
>>> from mailman.interfaces.member import DeliveryStatus
- >>> member = mlist1.members.get_member('anne@aaaxample.com')
+ >>> member = ant.members.get_member('anne@aaaxample.com')
>>> member.preferences.delivery_status = DeliveryStatus.by_moderator
- >>> member = mlist1.members.get_member('bart@example.com')
+ >>> member = ant.members.get_member('bart@example.com')
>>> member.preferences.delivery_status = DeliveryStatus.by_user
- >>> member = subscribe(mlist1, 'Cris', email='cris@example.com')
+ >>> member = subscribe(ant, 'Cris', email='cris@example.com')
>>> member.preferences.delivery_status = DeliveryStatus.unknown
- >>> member = subscribe(mlist1, 'Dave', email='dave@example.com')
+ >>> member = subscribe(ant, 'Dave', email='dave@example.com')
>>> member.preferences.delivery_status = DeliveryStatus.enabled
- >>> member = subscribe(mlist1, 'Elle', email='elle@example.com')
+ >>> member = subscribe(ant, 'Elle', email='elle@example.com')
>>> member.preferences.delivery_status = DeliveryStatus.by_bounces
>>> args.nomail = 'enabled'
@@ -195,23 +193,20 @@ need a file containing email addresses and full names that can be parsed by
``email.utils.parseaddr()``.
::
- >>> mlist2 = create_list('test2@example.com')
-
- >>> import os
- >>> path = os.path.join(config.VAR_DIR, 'addresses.txt')
- >>> with open(path, 'w') as fp:
+ >>> bee = create_list('bee@example.com')
+ >>> with NamedTemporaryFile('w', buffering=1, encoding='utf-8') as fp:
... for address in ('aperson@example.com',
... 'Bart Person <bperson@example.com>',
... 'cperson@example.com (Cate Person)',
... ):
... print(address, file=fp)
-
- >>> args.input_filename = path
- >>> args.listname = [mlist2.fqdn_listname]
- >>> command.process(args)
+ ... fp.flush()
+ ... args.input_filename = fp.name
+ ... args.list = ['bee.example.com']
+ ... command.process(args)
>>> from operator import attrgetter
- >>> dump_list(mlist2.members.addresses, key=attrgetter('email'))
+ >>> dump_list(bee.members.addresses, key=attrgetter('email'))
aperson@example.com
Bart Person <bperson@example.com>
Cate Person <cperson@example.com>
@@ -227,15 +222,17 @@ taken from standard input.
... 'fperson@example.com (Fred Person)',
... ):
... print(address, file=fp)
+ >>> args.input_filename = '-'
>>> filepos = fp.seek(0)
>>> import sys
- >>> sys.stdin = fp
+ >>> try:
+ ... stdin = sys.stdin
+ ... sys.stdin = fp
+ ... command.process(args)
+ ... finally:
+ ... sys.stdin = stdin
- >>> args.input_filename = '-'
- >>> command.process(args)
- >>> sys.stdin = sys.__stdin__
-
- >>> dump_list(mlist2.members.addresses, key=attrgetter('email'))
+ >>> dump_list(bee.members.addresses, key=attrgetter('email'))
aperson@example.com
Bart Person <bperson@example.com>
Cate Person <cperson@example.com>
@@ -246,7 +243,7 @@ taken from standard input.
Blank lines and lines that begin with '#' are ignored.
::
- >>> with open(path, 'w') as fp:
+ >>> with NamedTemporaryFile('w', buffering=1, encoding='utf-8') as fp:
... for address in ('gperson@example.com',
... '# hperson@example.com',
... ' ',
@@ -254,10 +251,10 @@ Blank lines and lines that begin with '#' are ignored.
... 'iperson@example.com',
... ):
... print(address, file=fp)
+ ... args.input_filename = fp.name
+ ... command.process(args)
- >>> args.input_filename = path
- >>> command.process(args)
- >>> dump_list(mlist2.members.addresses, key=attrgetter('email'))
+ >>> dump_list(bee.members.addresses, key=attrgetter('email'))
aperson@example.com
Bart Person <bperson@example.com>
Cate Person <cperson@example.com>
@@ -271,18 +268,18 @@ Addresses which are already subscribed are ignored, although a warning is
printed.
::
- >>> with open(path, 'w') as fp:
+ >>> with NamedTemporaryFile('w', buffering=1, encoding='utf-8') as fp:
... for address in ('gperson@example.com',
... 'aperson@example.com',
... 'jperson@example.com',
... ):
... print(address, file=fp)
-
- >>> command.process(args)
+ ... args.input_filename = fp.name
+ ... command.process(args)
Already subscribed (skipping): gperson@example.com
Already subscribed (skipping): aperson@example.com
- >>> dump_list(mlist2.members.addresses, key=attrgetter('email'))
+ >>> dump_list(bee.members.addresses, key=attrgetter('email'))
aperson@example.com
Bart Person <bperson@example.com>
Cate Person <cperson@example.com>
diff --git a/src/mailman/commands/tests/test_members.py b/src/mailman/commands/tests/test_members.py
new file mode 100644
index 000000000..5afa2ca83
--- /dev/null
+++ b/src/mailman/commands/tests/test_members.py
@@ -0,0 +1,153 @@
+# 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 `mailman members` command."""
+
+__all__ = [
+ 'TestCLIMembers',
+ ]
+
+
+import sys
+import unittest
+
+from functools import partial
+from io import StringIO
+from mailman.app.lifecycle import create_list
+from mailman.commands.cli_members import Members
+from mailman.interfaces.member import MemberRole
+from mailman.testing.helpers import subscribe
+from mailman.testing.layers import ConfigLayer
+from tempfile import NamedTemporaryFile
+from unittest.mock import patch
+
+
+
+class FakeArgs:
+ input_filename = None
+ output_filename = None
+ role = None
+ regular = None
+ digest = None
+ nomail = None
+ list = None
+
+
+class FakeParser:
+ def __init__(self):
+ self.message = None
+
+ def error(self, message):
+ self.message = message
+ sys.exit(1)
+
+
+
+class TestCLIMembers(unittest.TestCase):
+ layer = ConfigLayer
+
+ def setUp(self):
+ self._mlist = create_list('ant@example.com')
+ self.command = Members()
+ self.command.parser = FakeParser()
+ self.args = FakeArgs()
+
+ def test_no_such_list(self):
+ self.args.list = ['bee.example.com']
+ with self.assertRaises(SystemExit):
+ self.command.process(self.args)
+ self.assertEqual(self.command.parser.message,
+ 'No such list: bee.example.com')
+
+ def test_bad_delivery_status(self):
+ self.args.list = ['ant.example.com']
+ self.args.nomail = 'bogus'
+ with self.assertRaises(SystemExit):
+ self.command.process(self.args)
+ self.assertEqual(self.command.parser.message,
+ 'Unknown delivery status: bogus')
+
+ def test_role_administrator(self):
+ subscribe(self._mlist, 'Anne', role=MemberRole.owner)
+ subscribe(self._mlist, 'Bart', role=MemberRole.moderator)
+ subscribe(self._mlist, 'Cate', role=MemberRole.nonmember)
+ subscribe(self._mlist, 'Dave', role=MemberRole.member)
+ self.args.list = ['ant.example.com']
+ self.args.role = 'administrator'
+ with NamedTemporaryFile('w', encoding='utf-8') as outfp:
+ self.args.output_filename = outfp.name
+ self.command.process(self.args)
+ with open(outfp.name, 'r', encoding='utf-8') as infp:
+ lines = infp.readlines()
+ self.assertEqual(len(lines), 2)
+ self.assertEqual(lines[0], 'Anne Person <aperson@example.com>\n')
+ self.assertEqual(lines[1], 'Bart Person <bperson@example.com>\n')
+
+ def test_role_any(self):
+ subscribe(self._mlist, 'Anne', role=MemberRole.owner)
+ subscribe(self._mlist, 'Bart', role=MemberRole.moderator)
+ subscribe(self._mlist, 'Cate', role=MemberRole.nonmember)
+ subscribe(self._mlist, 'Dave', role=MemberRole.member)
+ self.args.list = ['ant.example.com']
+ self.args.role = 'any'
+ with NamedTemporaryFile('w', encoding='utf-8') as outfp:
+ self.args.output_filename = outfp.name
+ self.command.process(self.args)
+ with open(outfp.name, 'r', encoding='utf-8') as infp:
+ lines = infp.readlines()
+ self.assertEqual(len(lines), 4)
+ self.assertEqual(lines[0], 'Anne Person <aperson@example.com>\n')
+ self.assertEqual(lines[1], 'Bart Person <bperson@example.com>\n')
+ self.assertEqual(lines[2], 'Cate Person <cperson@example.com>\n')
+ self.assertEqual(lines[3], 'Dave Person <dperson@example.com>\n')
+
+ def test_role_moderator(self):
+ subscribe(self._mlist, 'Anne', role=MemberRole.owner)
+ subscribe(self._mlist, 'Bart', role=MemberRole.moderator)
+ subscribe(self._mlist, 'Cate', role=MemberRole.nonmember)
+ subscribe(self._mlist, 'Dave', role=MemberRole.member)
+ self.args.list = ['ant.example.com']
+ self.args.role = 'moderator'
+ with NamedTemporaryFile('w', encoding='utf-8') as outfp:
+ self.args.output_filename = outfp.name
+ self.command.process(self.args)
+ with open(outfp.name, 'r', encoding='utf-8') as infp:
+ lines = infp.readlines()
+ self.assertEqual(len(lines), 1)
+ self.assertEqual(lines[0], 'Bart Person <bperson@example.com>\n')
+
+ def test_bad_role(self):
+ self.args.list = ['ant.example.com']
+ self.args.role = 'bogus'
+ with self.assertRaises(SystemExit):
+ self.command.process(self.args)
+ self.assertEqual(self.command.parser.message,
+ 'Unknown member role: bogus')
+
+ def test_already_subscribed_with_display_name(self):
+ subscribe(self._mlist, 'Anne')
+ outfp = StringIO()
+ with NamedTemporaryFile('w', buffering=1, encoding='utf-8') as infp:
+ print('Anne Person <aperson@example.com>', file=infp)
+ self.args.list = ['ant.example.com']
+ self.args.input_filename = infp.name
+ with patch('builtins.print', partial(print, file=outfp)):
+ self.command.process(self.args)
+ self.assertEqual(
+ outfp.getvalue(),
+ 'Already subscribed (skipping): Anne Person <aperson@example.com>\n'
+ )
diff --git a/src/mailman/docs/NEWS.rst b/src/mailman/docs/NEWS.rst
index e8ac58c68..848106a86 100644
--- a/src/mailman/docs/NEWS.rst
+++ b/src/mailman/docs/NEWS.rst
@@ -144,6 +144,9 @@ Other
* The mailing list "data directory" has been renamed. Instead of using the
fqdn listname, the subdirectory inside ``[paths]list_data_dir`` now uses
the List-ID.
+ * The ``mailman members`` command can now be used to display members based on
+ subscription roles. Also, the positional "list" argument can now accept
+ list names or list-ids.
3.0.0 -- "Show Don't Tell"
diff --git a/src/mailman/model/roster.py b/src/mailman/model/roster.py
index 16f463199..47369b640 100644
--- a/src/mailman/model/roster.py
+++ b/src/mailman/model/roster.py
@@ -39,7 +39,7 @@ from mailman.interfaces.member import DeliveryMode, MemberRole
from mailman.interfaces.roster import IRoster
from mailman.model.address import Address
from mailman.model.member import Member
-from sqlalchemy import and_, or_
+from sqlalchemy import or_
from zope.interface import implementer