Commit a11e089c authored by Barry Warsaw's avatar Barry Warsaw

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.
parent ea1d7f36
Pipeline #619933 passed with stage
# 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()
......@@ -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
......@@ -62,6 +62,15 @@ class Members:
dest='output_filename', metavar='FILENAME',
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',
......@@ -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>'))
......@@ -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)
[email protected]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='[email protected]')
<Member: Anne Person <[email protected]> on test1@example.com
>>> subscribe(ant, 'Anne', email='[email protected]')
<Member: Anne Person <[email protected]> on ant@example.com
as MemberRole.member>
>>> subscribe(mlist1, 'Bart', email='[email protected]')
<Member: Bart Person <[email protected]> on test1@example.com
>>> subscribe(ant, 'Bart', email='[email protected]')
<Member: Bart Person <[email protected]> on ant@example.com
as MemberRole.member>
>>> command.process(args)
Anne Person <[email protected]>
......@@ -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='[email protected]')
<Member: Anne Person <[email protected]> on test1@example.com
>>> subscribe(ant, 'Anne', email='[email protected]')
<Member: Anne Person <[email protected]> on ant@example.com
as MemberRole.member>
>>> command.process(args)
Anne Person <[email protected]>
......@@ -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 <[email protected]>
Anne Person <[email protected]>
Bart Person <[email protected]>
>>> 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('[email protected]')
>>> member = ant.members.get_member('[email protected]')
>>> member.preferences.delivery_mode = DeliveryMode.plaintext_digests
>>> command.process(args)
Anne Person <[email protected]>
......@@ -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('[email protected]')
>>> member = ant.members.get_member('[email protected]')
>>> 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('[email protected]')
>>> member = ant.members.get_member('[email protected]')
>>> member.preferences.delivery_status = DeliveryStatus.by_moderator
>>> member = mlist1.members.get_member('[email protected]')
>>> member = ant.members.get_member('[email protected]')
>>> member.preferences.delivery_status = DeliveryStatus.by_user
>>> member = subscribe(mlist1, 'Cris', email='[email protected]')
>>> member = subscribe(ant, 'Cris', email='[email protected]')
>>> member.preferences.delivery_status = DeliveryStatus.unknown
>>> member = subscribe(mlist1, 'Dave', email='[email protected]')
>>> member = subscribe(ant, 'Dave', email='[email protected]')
>>> member.preferences.delivery_status = DeliveryStatus.enabled
>>> member = subscribe(mlist1, 'Elle', email='[email protected]')
>>> member = subscribe(ant, 'Elle', email='[email protected]')
>>> 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('[email protected]')
>>> import os
>>> path = os.path.join(config.VAR_DIR, 'addresses.txt')
>>> with open(path, 'w') as fp:
>>> bee = create_list('[email protected]')
>>> with NamedTemporaryFile('w', buffering=1, encoding='utf-8') as fp:
... for address in ('[email protected]',
... 'Bart Person <[email protected]>',
... '[email protected] (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'))
[email protected]
Bart Person <[email protected]>
Cate Person <[email protected]>
......@@ -227,15 +222,17 @@ taken from standard input.
... '[email protected] (Fred Person)',
... ):
... print(address, file=fp)
>>> args.input_filename = '-'
>>> filepos = fp.seek(0)
>>> import sys
>>> sys.stdin = fp
>>> args.input_filename = '-'
>>> command.process(args)
>>> sys.stdin = sys.__stdin__
>>> dump_list(mlist2.members.addresses, key=attrgetter('email'))
>>> try:
... stdin = sys.stdin
... sys.stdin = fp
... command.process(args)
... finally:
... sys.stdin = stdin
>>> dump_list(bee.members.addresses, key=attrgetter('email'))
[email protected]
Bart Person <[email protected]>
Cate Person <[email protected]>
......@@ -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 ('[email protected]',
... '# [email protected]',
... ' ',
......@@ -254,10 +251,10 @@ Blank lines and lines that begin with '#' are ignored.
... '[email protected]',
... ):
... 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'))
[email protected]
Bart Person <[email protected]>
Cate Person <[email protected]>
......@@ -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 ('[email protected]',
... '[email protected]',
... '[email protected]',
... ):
... print(address, file=fp)
>>> command.process(args)
... args.input_filename = fp.name
... command.process(args)
Already subscribed (skipping): [email protected]
Already subscribed (skipping): [email protected]
>>> dump_list(mlist2.members.addresses, key=attrgetter('email'))
>>> dump_list(bee.members.addresses, key=attrgetter('email'))
[email protected]
Bart Person <[email protected]>
Cate Person <[email protected]>
......
# 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('[email protected]')
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 <[email protected]>\n')
self.assertEqual(lines[1], 'Bart Person <[email protected]>\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 <aper[email protected]>\n')
self.assertEqual(lines[1], 'Bart Person <[email protected]>\n')
self.assertEqual(lines[2], 'Cate Person <[email protected]>\n')
self.assertEqual(lines[3], 'Dave Person <[email protected]>\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 <[email protected]>\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 <[email protected]>', 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 <[email protected]>\n'
)
......@@ -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"
......
......@@ -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
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment