Barry Warsaw has proposed merging lp:~barry/mailman/lp1423756 into lp:mailman.

Requested reviews:
  Mailman Coders (mailman-coders)
Related bugs:
  Bug #1423756 in GNU Mailman: "Ability to define user as domainowner or 
serverowner "
  https://bugs.launchpad.net/mailman/+bug/1423756

For more details, see:
https://code.launchpad.net/~barry/mailman/lp1423756/+merge/255318

Mega-merge of Abhilash's branch, with fixes.
-- 
Your team Mailman Coders is requested to review the proposed merge of 
lp:~barry/mailman/lp1423756 into lp:mailman.
=== modified file 'src/mailman/app/registrar.py'
--- src/mailman/app/registrar.py	2015-03-28 20:00:24 +0000
+++ src/mailman/app/registrar.py	2015-04-07 01:46:33 +0000
@@ -162,7 +162,7 @@
     confirm_url = mlist.domain.confirm_url(event.token)
     email_address = event.pendable['email']
     domain_name = mlist.domain.mail_host
-    contact_address = mlist.domain.contact_address
+    contact_address = mlist.owner_address
     # Send a verification email to the address.
     template = getUtility(ITemplateLoader).get(
         'mailman:///{0}/{1}/confirm.txt'.format(

=== modified file 'src/mailman/commands/docs/create.rst'
--- src/mailman/commands/docs/create.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/commands/docs/create.rst	2015-04-07 01:46:33 +0000
@@ -44,8 +44,7 @@
 
     >>> from mailman.interfaces.domain import IDomainManager
     >>> getUtility(IDomainManager).get('example.xx')
-    <Domain example.xx, base_url: http://example.xx,
-            contact_address: [email protected]>
+    <Domain example.xx, base_url: http://example.xx>
 
 You can also create mailing lists in existing domains without the
 auto-creation flag.

=== modified file 'src/mailman/commands/docs/membership.rst'
--- src/mailman/commands/docs/membership.rst	2014-12-13 15:55:57 +0000
+++ src/mailman/commands/docs/membership.rst	2015-04-07 01:46:33 +0000
@@ -127,7 +127,7 @@
     message.  If you think you are being maliciously subscribed to the list, or
     have any other questions, you may contact
     <BLANKLINE>
-        [email protected]
+        [email protected]
     <BLANKLINE>
 
 Once Anne confirms her registration, she will be made a member of the mailing

=== modified file 'src/mailman/commands/tests/test_lists.py'
--- src/mailman/commands/tests/test_lists.py	2015-03-14 01:16:51 +0000
+++ src/mailman/commands/tests/test_lists.py	2015-04-07 01:46:33 +0000
@@ -48,7 +48,7 @@
         # LP: #1166911 - non-matching lists were returned.
         getUtility(IDomainManager).add(
             'example.net', 'An example domain.',
-            'http://lists.example.net', '[email protected]')
+            'http://lists.example.net')
         create_list('[email protected]')
         create_list('[email protected]')
         # Only this one should show up.

=== added file 'src/mailman/database/alembic/versions/46e92facee7_add_serverowner_domainowner.py'
--- src/mailman/database/alembic/versions/46e92facee7_add_serverowner_domainowner.py	1970-01-01 00:00:00 +0000
+++ src/mailman/database/alembic/versions/46e92facee7_add_serverowner_domainowner.py	2015-04-07 01:46:33 +0000
@@ -0,0 +1,56 @@
+# 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/>.
+
+"""add_serverowner_domainowner
+
+Revision ID: 46e92facee7
+Revises: 33e1f5f6fa8
+Create Date: 2015-03-20 16:01:25.007242
+
+"""
+
+# Revision identifiers, used by Alembic.
+revision = '46e92facee7'
+down_revision = '33e1f5f6fa8'
+
+from alembic import op
+import sqlalchemy as sa
+
+
+def upgrade():
+    op.create_table(
+        'domain_owner',
+        sa.Column('user_id', sa.Integer(), nullable=False),
+        sa.Column('domain_id', sa.Integer(), nullable=False),
+        sa.ForeignKeyConstraint(['domain_id'], ['domain.id'], ),
+        sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
+        sa.PrimaryKeyConstraint('user_id', 'domain_id')
+        )
+    op.add_column(
+        'user',
+        sa.Column('is_server_owner', sa.Boolean(), nullable=True))
+    if op.get_bind().dialect.name != 'sqlite':
+        op.drop_column('domain', 'contact_address')
+
+
+def downgrade():
+    if op.get_bind().dialect.name != 'sqlite':
+        op.drop_column('user', 'is_server_owner')
+        op.add_column(
+            'domain',
+            sa.Column('contact_address', sa.VARCHAR(), nullable=True))
+    op.drop_table('domain_owner')

=== modified file 'src/mailman/interfaces/domain.py'
--- src/mailman/interfaces/domain.py	2015-01-05 01:22:39 +0000
+++ src/mailman/interfaces/domain.py	2015-04-07 01:46:33 +0000
@@ -88,9 +88,8 @@
     description = Attribute(
         'The human readable description of the domain name.')
 
-    contact_address = Attribute("""\
-    The contact address for the human at this domain.
-    E.g. [email protected]""")
+    owners = Attribute("""\
+        The relationship with the user database representing domain owners""")
 
     mailing_lists = Attribute(
         """All mailing lists for this domain.
@@ -112,7 +111,7 @@
 class IDomainManager(Interface):
     """The manager of domains."""
 
-    def add(mail_host, description=None, base_url=None, contact_address=None):
+    def add(mail_host, description=None, base_url=None, owners=None):
         """Add a new domain.
 
         :param mail_host: The email host name for the domain.
@@ -123,11 +122,10 @@
             interface of the domain.  If not given, it defaults to
             http://`mail_host`/
         :type base_url: string
-        :param contact_address: The email contact address for the human
-            managing the domain.  If not given, defaults to
-            postmaster@`mail_host`
-        :type contact_address: string
-        :return: The new domain object
+        :param owners: Sequence of owners of the domain, defaults to None,
+            meaning the domain does not have owners.
+        :type owners: sequence of `IUser` or string emails.
+        :return: The new domain object.
         :rtype: `IDomain`
         :raises `BadDomainSpecificationError`: when the `mail_host` is
             already registered.

=== modified file 'src/mailman/model/docs/domains.rst'
--- src/mailman/model/docs/domains.rst	2014-12-13 18:26:05 +0000
+++ src/mailman/model/docs/domains.rst	2015-04-07 01:46:33 +0000
@@ -14,12 +14,16 @@
 ::
 
     >>> from operator import attrgetter
-    >>> def show_domains():
+    >>> def show_domains(*, with_owners=False):
     ...     if len(manager) == 0:
     ...         print('no domains')
     ...         return
     ...     for domain in sorted(manager, key=attrgetter('mail_host')):
     ...         print(domain)
+    ...     owners = sorted(owner.addresses[0].email
+    ...                     for owner in domain.owners)
+    ...     for owner in owners:
+    ...         print('- owner:', owner)
 
     >>> show_domains()
     no domains
@@ -28,17 +32,14 @@
 is the only required piece.  The other parts are inferred from that.
 
     >>> manager.add('example.org')
-    <Domain example.org, base_url: http://example.org,
-            contact_address: [email protected]>
+    <Domain example.org, base_url: http://example.org>
     >>> show_domains()
-    <Domain example.org, base_url: http://example.org,
-            contact_address: [email protected]>
+    <Domain example.org, base_url: http://example.org>
 
 We can remove domains too.
 
     >>> manager.remove('example.org')
-    <Domain example.org, base_url: http://example.org,
-            contact_address: [email protected]>
+    <Domain example.org, base_url: http://example.org>
     >>> show_domains()
     no domains
 
@@ -46,30 +47,39 @@
 web interface for the domain.
 
     >>> manager.add('example.com', base_url='https://mail.example.com')
-    <Domain example.com, base_url: https://mail.example.com,
-            contact_address: [email protected]>
+    <Domain example.com, base_url: https://mail.example.com>
     >>> show_domains()
-    <Domain example.com, base_url: https://mail.example.com,
-            contact_address: [email protected]>
+    <Domain example.com, base_url: https://mail.example.com>
 
-Domains can have explicit descriptions and contact addresses.
+Domains can have explicit descriptions, and can be created with one or more
+owners.
 ::
 
     >>> manager.add(
     ...     'example.net',
     ...     base_url='http://lists.example.net',
-    ...     contact_address='[email protected]',
-    ...     description='The example domain')
-    <Domain example.net, The example domain,
-            base_url: http://lists.example.net,
-            contact_address: [email protected]>
-
-    >>> show_domains()
-    <Domain example.com, base_url: https://mail.example.com,
-            contact_address: [email protected]>
-    <Domain example.net, The example domain,
-            base_url: http://lists.example.net,
-            contact_address: [email protected]>
+    ...     description='The example domain',
+    ...     owners=['[email protected]'])
+    <Domain example.net, The example domain,
+            base_url: http://lists.example.net>
+
+    >>> show_domains(with_owners=True)
+    <Domain example.com, base_url: https://mail.example.com>
+    <Domain example.net, The example domain,
+            base_url: http://lists.example.net>
+    - owner: [email protected]
+
+Domains can have multiple owners, ideally one of the owners should have a
+verified preferred address.  However this is not checked right now and the
+configuration's default contact address may be used as a fallback.
+
+   >>> net_domain = manager['example.net']
+   >>> net_domain.add_owner('[email protected]')
+   >>> show_domains(with_owners=True)
+   <Domain example.com, base_url: https://mail.example.com>
+   <Domain example.net, The example domain, base_url: http://lists.example.net>
+   - owner: [email protected]
+   - owner: [email protected]
 
 Domains can list all associated mailing lists with the mailing_lists property.
 ::
@@ -105,8 +115,7 @@
 
     >>> print(manager['example.net'])
     <Domain example.net, The example domain,
-            base_url: http://lists.example.net,
-            contact_address: [email protected]>
+            base_url: http://lists.example.net>
 
 As with dictionaries, you can also get the domain.  If the domain does not
 exist, ``None`` or a default is returned.
@@ -114,8 +123,7 @@
 
     >>> print(manager.get('example.net'))
     <Domain example.net, The example domain,
-            base_url: http://lists.example.net,
-            contact_address: [email protected]>
+            base_url: http://lists.example.net>
 
     >>> print(manager.get('doesnotexist.com'))
     None

=== modified file 'src/mailman/model/docs/registration.rst'
--- src/mailman/model/docs/registration.rst	2015-03-28 20:00:24 +0000
+++ src/mailman/model/docs/registration.rst	2015-04-07 01:46:33 +0000
@@ -120,7 +120,7 @@
     message.  If you think you are being maliciously subscribed to the list,
     or have any other questions, you may contact
     <BLANKLINE>
-        [email protected]
+        [email protected]
     <BLANKLINE>
     >>> dump_msgdata(items[0].msgdata)
     _parsemsg           : False

=== modified file 'src/mailman/model/docs/users.rst'
--- src/mailman/model/docs/users.rst	2014-12-21 22:59:06 +0000
+++ src/mailman/model/docs/users.rst	2015-04-07 01:46:33 +0000
@@ -295,4 +295,19 @@
     [email protected] xtest_2.example.com MemberRole.owner
 
 
+Server owners
+=============
+
+Some users are server owners.  Zoe is not yet a server owner.
+
+    >>> user_1.is_server_owner
+    False
+
+So, let's make her one.
+
+    >>> user_1.is_server_owner = True
+    >>> user_1.is_server_owner
+    True
+
+
 .. _`usermanager.txt`: usermanager.html

=== modified file 'src/mailman/model/domain.py'
--- src/mailman/model/domain.py	2015-01-05 01:40:47 +0000
+++ src/mailman/model/domain.py	2015-04-07 01:46:33 +0000
@@ -28,11 +28,15 @@
 from mailman.interfaces.domain import (
     BadDomainSpecificationError, DomainCreatedEvent, DomainCreatingEvent,
     DomainDeletedEvent, DomainDeletingEvent, IDomain, IDomainManager)
+from mailman.interfaces.user import IUser
+from mailman.interfaces.usermanager import IUserManager
 from mailman.model.mailinglist import MailingList
 from urllib.parse import urljoin, urlparse
 from sqlalchemy import Column, Integer, Unicode
+from sqlalchemy.orm import relationship
 from zope.event import notify
 from zope.interface import implementer
+from zope.component import getUtility
 
 
 
@@ -44,15 +48,17 @@
 
     id = Column(Integer, primary_key=True)
 
-    mail_host = Column(Unicode) # TODO: add index?
+    mail_host = Column(Unicode)
     base_url = Column(Unicode)
     description = Column(Unicode)
-    contact_address = Column(Unicode)
+    owners = relationship('User',
+                          secondary='domain_owner',
+                          backref='domains')
 
     def __init__(self, mail_host,
                  description=None,
                  base_url=None,
-                 contact_address=None):
+                 owners=None):
         """Create and register a domain.
 
         :param mail_host: The host name for the email interface.
@@ -63,18 +69,16 @@
             scheme.  If not given, it will be constructed from the
             `mail_host` using the http protocol.
         :type base_url: string
-        :param contact_address: The email address to contact a human for this
-            domain.  If not given, postmaster@`mail_host` will be used.
-        :type contact_address: string
+        :param owners: Optional owners of this domain.
+        :type owners: sequence of `IUser` or string emails.
         """
         self.mail_host = mail_host
         self.base_url = (base_url
                          if base_url is not None
                          else 'http://' + mail_host)
         self.description = description
-        self.contact_address = (contact_address
-                                if contact_address is not None
-                                else 'postmaster@' + mail_host)
+        if owners is not None:
+            self.add_owners(owners)
 
     @property
     def url_host(self):
@@ -103,12 +107,35 @@
     def __repr__(self):
         """repr(a_domain)"""
         if self.description is None:
-            return ('<Domain {0.mail_host}, base_url: {0.base_url}, '
-                    'contact_address: {0.contact_address}>').format(self)
+            return ('<Domain {0.mail_host}, base_url: {0.base_url}>').format(
+                self)
         else:
             return ('<Domain {0.mail_host}, {0.description}, '
-                    'base_url: {0.base_url}, '
-                    'contact_address: {0.contact_address}>').format(self)
+                    'base_url: {0.base_url}>').format(self)
+
+    def add_owner(self, owner):
+        """See `IDomain`."""
+        user_manager = getUtility(IUserManager)
+        if IUser.providedBy(owner):
+            user = owner
+        else:
+            user = user_manager.get_user(owner)
+        # BAW 2015-04-06: Make sure this path is tested.
+        if user is None:
+            user = user_manager.create_user(owner)
+        self.owners.append(user)
+
+    def add_owners(self, owners):
+        """See `IDomain`."""
+        # BAW 2015-04-06: This should probably be more efficient by inlining
+        # add_owner().
+        for owner in owners:
+            self.add_owner(owner)
+
+    def remove_owner(self, owner):
+        """See `IDomain`."""
+        user_manager = getUtility(IUserManager)
+        self.owners.remove(user_manager.get_user(owner))
 
 
 
@@ -121,7 +148,7 @@
             mail_host,
             description=None,
             base_url=None,
-            contact_address=None):
+            owners=None):
         """See `IDomainManager`."""
         # Be sure the mail_host is not already registered.  This is probably
         # a constraint that should (also) be maintained in the database.
@@ -129,7 +156,7 @@
             raise BadDomainSpecificationError(
                 'Duplicate email host: %s' % mail_host)
         notify(DomainCreatingEvent(mail_host))
-        domain = Domain(mail_host, description, base_url, contact_address)
+        domain = Domain(mail_host, description, base_url, owners)
         store.add(domain)
         notify(DomainCreatedEvent(domain))
         return domain

=== modified file 'src/mailman/model/tests/test_domain.py'
--- src/mailman/model/tests/test_domain.py	2015-01-05 01:22:39 +0000
+++ src/mailman/model/tests/test_domain.py	2015-04-07 01:46:33 +0000
@@ -30,6 +30,7 @@
     DomainCreatedEvent, DomainCreatingEvent, DomainDeletedEvent,
     DomainDeletingEvent, IDomainManager)
 from mailman.interfaces.listmanager import IListManager
+from mailman.interfaces.usermanager import IUserManager
 from mailman.testing.helpers import event_subscribers
 from mailman.testing.layers import ConfigLayer
 from zope.component import getUtility
@@ -78,6 +79,98 @@
         # Trying to delete a missing domain gives you a KeyError.
         self.assertRaises(KeyError, self._manager.remove, 'doesnotexist.com')
 
+    def test_domain_creation_no_default_owners(self):
+        # If a domain is created without owners, then it has none.
+        domain = self._manager.add('example.org')
+        self.assertEqual(len(domain.owners), 0)
+
+    def test_domain_creation_with_owner(self):
+        # You can create a new domain with a single owner.
+        domain = self._manager.add('example.org', owners=['[email protected]'])
+        self.assertEqual(len(domain.owners), 1)
+        self.assertEqual(domain.owners[0].addresses[0].email,
+                         '[email protected]')
+
+    def test_domain_creation_with_owners(self):
+        # You can create a new domain with multiple owners.
+        domain = self._manager.add(
+            'example.org', owners=['[email protected]',
+                                   '[email protected]'])
+        self.assertEqual(len(domain.owners), 2)
+        self.assertEqual(
+            sorted(owner.addresses[0].email for owner in domain.owners),
+            ['[email protected]', '[email protected]'])
+
+    def test_domain_creation_creates_new_users(self):
+        # Domain creation with existing users does not create new users, but
+        # any user which doesn't yet exist (and is linked to the given
+        # address), gets created.
+        user_manager = getUtility(IUserManager)
+        user_manager.make_user('[email protected]')
+        user_manager.make_user('[email protected]')
+        domain = self._manager.add(
+            'example.org', owners=['[email protected]',
+                                   '[email protected]',
+                                   '[email protected]'])
+        self.assertEqual(len(domain.owners), 3)
+        self.assertEqual(
+            sorted(owner.addresses[0].email for owner in domain.owners),
+            ['[email protected]', '[email protected]', '[email protected]'])
+        # Now cris exists as a user.
+        self.assertIsNotNone(user_manager.get_user('[email protected]'))
+
+    def test_domain_creation_with_users(self):
+        # Domains can be created with IUser objects.
+        user_manager = getUtility(IUserManager)
+        anne = user_manager.make_user('[email protected]')
+        bart = user_manager.make_user('[email protected]')
+        domain = self._manager.add('example.org', owners=[anne, bart])
+        self.assertEqual(len(domain.owners), 2)
+        self.assertEqual(
+            sorted(owner.addresses[0].email for owner in domain.owners),
+            ['[email protected]', '[email protected]'])
+        def sort_key(owner):
+            return owner.addresses[0].email
+        self.assertEqual(sorted(domain.owners, key=sort_key), [anne, bart])
+
+    def test_add_domain_owner(self):
+        # Domain owners can be added after the domain is created.
+        domain = self._manager.add('example.org')
+        self.assertEqual(len(domain.owners), 0)
+        domain.add_owner('[email protected]')
+        self.assertEqual(len(domain.owners), 1)
+        self.assertEqual(domain.owners[0].addresses[0].email,
+                         '[email protected]')
+
+    def test_add_multiple_domain_owners(self):
+        # Multiple domain owners can be added after the domain is created.
+        domain = self._manager.add('example.org')
+        self.assertEqual(len(domain.owners), 0)
+        domain.add_owners(['[email protected]', '[email protected]'])
+        self.assertEqual(len(domain.owners), 2)
+        self.assertEqual([owner.addresses[0].email for owner in domain.owners],
+                         ['[email protected]', '[email protected]'])
+
+    def test_remove_domain_owner(self):
+        # Domain onwers can be removed.
+        domain = self._manager.add(
+            'example.org', owners=['[email protected]',
+                                   '[email protected]'])
+        domain.remove_owner('[email protected]')
+        self.assertEqual(len(domain.owners), 1)
+        self.assertEqual([owner.addresses[0].email for owner in domain.owners],
+                         ['[email protected]'])
+
+    def test_remove_missing_owner(self):
+        # Users which aren't owners can't be removed.
+        domain = self._manager.add(
+            'example.org', owners=['[email protected]',
+                                   '[email protected]'])
+        self.assertRaises(ValueError, domain.remove_owner, '[email protected]')
+        self.assertEqual(len(domain.owners), 2)
+        self.assertEqual([owner.addresses[0].email for owner in domain.owners],
+                         ['[email protected]', '[email protected]'])
+
 
 
 class TestDomainLifecycleEvents(unittest.TestCase):

=== modified file 'src/mailman/model/user.py'
--- src/mailman/model/user.py	2015-03-20 16:38:00 +0000
+++ src/mailman/model/user.py	2015-04-07 01:46:33 +0000
@@ -18,6 +18,7 @@
 """Model for users."""
 
 __all__ = [
+    'DomainOwner',
     'User',
     ]
 
@@ -34,7 +35,7 @@
 from mailman.model.roster import Memberships
 from mailman.utilities.datetime import factory as date_factory
 from mailman.utilities.uid import UniqueIDFactory
-from sqlalchemy import Column, DateTime, ForeignKey, Integer, Unicode
+from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, Unicode
 from sqlalchemy.orm import relationship, backref
 from zope.event import notify
 from zope.interface import implementer
@@ -55,6 +56,7 @@
     _password = Column('password', Unicode)
     _user_id = Column(UUID, index=True)
     _created_on = Column(DateTime)
+    is_server_owner = Column(Boolean, default=False)
 
     addresses = relationship(
         'Address', backref='user',
@@ -176,3 +178,13 @@
     @property
     def memberships(self):
         return Memberships(self)
+
+
+
+class DomainOwner(Model):
+    """Internal table for associating domains to their owners."""
+
+    __tablename__ = 'domain_owner'
+
+    user_id = Column(Integer, ForeignKey('user.id'), primary_key=True)
+    domain_id = Column(Integer, ForeignKey('domain.id'), primary_key=True)

=== modified file 'src/mailman/rest/docs/addresses.rst'
--- src/mailman/rest/docs/addresses.rst	2015-02-14 01:35:35 +0000
+++ src/mailman/rest/docs/addresses.rst	2015-04-07 01:46:33 +0000
@@ -190,6 +190,7 @@
     created_on: 2005-08-01T07:49:23
     display_name: Cris X. Person
     http_etag: "..."
+    is_server_owner: False
     password: ...
     self_link: http://localhost:9001/3.0/users/1
     user_id: 1

=== modified file 'src/mailman/rest/docs/domains.rst'
--- src/mailman/rest/docs/domains.rst	2014-12-16 01:01:53 +0000
+++ src/mailman/rest/docs/domains.rst	2015-04-07 01:46:33 +0000
@@ -28,15 +28,12 @@
 
     >>> domain_manager.add(
     ...     'example.com', 'An example domain', 'http://lists.example.com')
-    <Domain example.com, An example domain,
-            base_url: http://lists.example.com,
-            contact_address: [email protected]>
+    <Domain example.com, An example domain, base_url: http://lists.example.com>
     >>> transaction.commit()
 
     >>> dump_json('http://localhost:9001/3.0/domains')
     entry 0:
         base_url: http://lists.example.com
-        contact_address: [email protected]
         description: An example domain
         http_etag: "..."
         mail_host: example.com
@@ -51,24 +48,18 @@
 
     >>> domain_manager.add(
     ...     'example.org',
-    ...     base_url='http://mail.example.org',
-    ...     contact_address='[email protected]')
-    <Domain example.org, base_url: http://mail.example.org,
-            contact_address: [email protected]>
+    ...     base_url='http://mail.example.org')
+    <Domain example.org, base_url: http://mail.example.org>
     >>> domain_manager.add(
     ...     'lists.example.net',
     ...     'Porkmasters',
-    ...     'http://example.net',
-    ...     '[email protected]')
-    <Domain lists.example.net, Porkmasters,
-            base_url: http://example.net,
-            contact_address: [email protected]>
+    ...     'http://example.net')
+    <Domain lists.example.net, Porkmasters, base_url: http://example.net>
     >>> transaction.commit()
 
     >>> dump_json('http://localhost:9001/3.0/domains')
     entry 0:
         base_url: http://lists.example.com
-        contact_address: [email protected]
         description: An example domain
         http_etag: "..."
         mail_host: example.com
@@ -76,7 +67,6 @@
         url_host: lists.example.com
     entry 1:
         base_url: http://mail.example.org
-        contact_address: [email protected]
         description: None
         http_etag: "..."
         mail_host: example.org
@@ -84,7 +74,6 @@
         url_host: mail.example.org
     entry 2:
         base_url: http://example.net
-        contact_address: [email protected]
         description: Porkmasters
         http_etag: "..."
         mail_host: lists.example.net
@@ -103,7 +92,6 @@
 
     >>> dump_json('http://localhost:9001/3.0/domains/lists.example.net')
     base_url: http://example.net
-    contact_address: [email protected]
     description: Porkmasters
     http_etag: "..."
     mail_host: lists.example.net
@@ -165,7 +153,6 @@
 
     >>> dump_json('http://localhost:9001/3.0/domains/lists.example.com')
     base_url: http://lists.example.com
-    contact_address: [email protected]
     description: None
     http_etag: "..."
     mail_host: lists.example.com
@@ -176,9 +163,7 @@
 ::
 
     >>> domain_manager['lists.example.com']
-    <Domain lists.example.com,
-            base_url: http://lists.example.com,
-            contact_address: [email protected]>
+    <Domain lists.example.com, base_url: http://lists.example.com>
 
     # Unlock the database.
     >>> transaction.abort()
@@ -190,8 +175,7 @@
     >>> dump_json('http://localhost:9001/3.0/domains', {
     ...           'mail_host': 'my.example.com',
     ...           'description': 'My new domain',
-    ...           'base_url': 'http://allmy.example.com',
-    ...           'contact_address': '[email protected]'
+    ...           'base_url': 'http://allmy.example.com'
     ...           })
     content-length: 0
     date: ...
@@ -200,7 +184,6 @@
 
     >>> dump_json('http://localhost:9001/3.0/domains/my.example.com')
     base_url: http://allmy.example.com
-    contact_address: [email protected]
     description: My new domain
     http_etag: "..."
     mail_host: my.example.com
@@ -208,9 +191,7 @@
     url_host: allmy.example.com
 
     >>> domain_manager['my.example.com']
-    <Domain my.example.com, My new domain,
-            base_url: http://allmy.example.com,
-            contact_address: [email protected]>
+    <Domain my.example.com, My new domain, base_url: http://allmy.example.com>
 
     # Unlock the database.
     >>> transaction.abort()
@@ -229,4 +210,92 @@
     status: 204
 
 
+Domain owners
+=============
+
+Domains can have owners.  By posting some addresses to the owners resource,
+you can add some domain owners.  Currently our domain has no owners:
+
+    >>> dump_json('http://localhost:9001/3.0/domains/my.example.com/owners')
+    http_etag: ...
+    start: 0
+    total_size: 0
+
+Anne and Bart volunteer to be a domain owners.
+::
+
+    >>> dump_json('http://localhost:9001/3.0/domains/my.example.com/owners', (
+    ...     ('owner', '[email protected]'), ('owner', '[email protected]')
+    ...     ))
+    content-length: 0
+    date: ...
+    server: ...
+    status: 204
+
+    >>> dump_json('http://localhost:9001/3.0/domains/my.example.com/owners')
+    entry 0:
+        created_on: 2005-08-01T07:49:23
+        http_etag: ...
+        is_server_owner: False
+        self_link: http://localhost:9001/3.0/users/1
+        user_id: 1
+    entry 1:
+        created_on: 2005-08-01T07:49:23
+        http_etag: ...
+        is_server_owner: False
+        self_link: http://localhost:9001/3.0/users/2
+        user_id: 2
+    http_etag: ...
+    start: 0
+    total_size: 2
+
+We can delete all the domain owners.
+
+    >>> dump_json('http://localhost:9001/3.0/domains/my.example.com/owners',
+    ...           method='DELETE')
+    content-length: 0
+    date: ...
+    server: ...
+    status: 204
+
+Now there are no owners.
+
+    >>> dump_json('http://localhost:9001/3.0/domains/my.example.com/owners')
+    http_etag: ...
+    start: 0
+    total_size: 0
+
+New domains can be created with owners.
+
+    >>> dump_json('http://localhost:9001/3.0/domains', (
+    ...           ('mail_host', 'your.example.com'),
+    ...           ('owner', '[email protected]'),
+    ...           ('owner', '[email protected]'),
+    ...           ))
+    content-length: 0
+    date: ...
+    location: http://localhost:9001/3.0/domains/your.example.com
+    server: ...
+    status: 201
+
+The new domain has the expected owners.
+
+    >>> dump_json('http://localhost:9001/3.0/domains/your.example.com/owners')
+    entry 0:
+        created_on: 2005-08-01T07:49:23
+        http_etag: ...
+        is_server_owner: False
+        self_link: http://localhost:9001/3.0/users/1
+        user_id: 1
+    entry 1:
+        created_on: 2005-08-01T07:49:23
+        http_etag: ...
+        is_server_owner: False
+        self_link: http://localhost:9001/3.0/users/2
+        user_id: 2
+    http_etag: ...
+    start: 0
+    total_size: 2
+
+
 .. _Domains: ../../model/docs/domains.html

=== modified file 'src/mailman/rest/docs/users.rst'
--- src/mailman/rest/docs/users.rst	2014-12-22 18:40:30 +0000
+++ src/mailman/rest/docs/users.rst	2015-04-07 01:46:33 +0000
@@ -34,6 +34,7 @@
         created_on: 2005-08-01T07:49:23
         display_name: Anne Person
         http_etag: "..."
+        is_server_owner: False
         self_link: http://localhost:9001/3.0/users/1
         user_id: 1
     http_etag: "..."
@@ -50,11 +51,13 @@
         created_on: 2005-08-01T07:49:23
         display_name: Anne Person
         http_etag: "..."
+        is_server_owner: False
         self_link: http://localhost:9001/3.0/users/1
         user_id: 1
     entry 1:
         created_on: 2005-08-01T07:49:23
         http_etag: "..."
+        is_server_owner: False
         self_link: http://localhost:9001/3.0/users/2
         user_id: 2
     http_etag: "..."
@@ -76,6 +79,7 @@
         created_on: 2005-08-01T07:49:23
         display_name: Anne Person
         http_etag: "..."
+        is_server_owner: False
         self_link: http://localhost:9001/3.0/users/1
         user_id: 1
     http_etag: "..."
@@ -86,6 +90,7 @@
     entry 0:
         created_on: 2005-08-01T07:49:23
         http_etag: "..."
+        is_server_owner: False
         self_link: http://localhost:9001/3.0/users/2
         user_id: 2
     http_etag: "..."
@@ -120,6 +125,7 @@
     >>> dump_json('http://localhost:9001/3.0/users/3')
     created_on: 2005-08-01T07:49:23
     http_etag: "..."
+    is_server_owner: False
     password: {plaintext}...
     self_link: http://localhost:9001/3.0/users/3
     user_id: 3
@@ -131,6 +137,7 @@
     >>> dump_json('http://localhost:9001/3.0/users/[email protected]')
     created_on: 2005-08-01T07:49:23
     http_etag: "..."
+    is_server_owner: False
     password: {plaintext}...
     self_link: http://localhost:9001/3.0/users/3
     user_id: 3
@@ -158,6 +165,7 @@
     created_on: 2005-08-01T07:49:23
     display_name: Dave Person
     http_etag: "..."
+    is_server_owner: False
     password: {plaintext}...
     self_link: http://localhost:9001/3.0/users/4
     user_id: 4
@@ -190,6 +198,7 @@
     created_on: 2005-08-01T07:49:23
     display_name: Elly Person
     http_etag: "..."
+    is_server_owner: False
     password: {plaintext}supersekrit
     self_link: http://localhost:9001/3.0/users/5
     user_id: 5
@@ -214,6 +223,7 @@
     created_on: 2005-08-01T07:49:23
     display_name: David Person
     http_etag: "..."
+    is_server_owner: False
     password: {plaintext}...
     self_link: http://localhost:9001/3.0/users/4
     user_id: 4
@@ -238,6 +248,7 @@
     created_on: 2005-08-01T07:49:23
     display_name: David Person
     http_etag: "..."
+    is_server_owner: False
     password: {plaintext}clockwork angels
     self_link: http://localhost:9001/3.0/users/4
     user_id: 4
@@ -246,8 +257,9 @@
 resource.
 
     >>> dump_json('http://localhost:9001/3.0/users/4', {
+    ...           'cleartext_password': 'the garden',
     ...           'display_name': 'David Personhood',
-    ...           'cleartext_password': 'the garden',
+    ...           'is_server_owner': False,
     ...           }, method='PUT')
     content-length: 0
     date: ...
@@ -260,6 +272,7 @@
     created_on: 2005-08-01T07:49:23
     display_name: David Personhood
     http_etag: "..."
+    is_server_owner: False
     password: {plaintext}the garden
     self_link: http://localhost:9001/3.0/users/4
     user_id: 4
@@ -343,6 +356,7 @@
     created_on: 2005-08-01T07:49:23
     display_name: Fred Person
     http_etag: "..."
+    is_server_owner: False
     self_link: http://localhost:9001/3.0/users/6
     user_id: 6
 
@@ -350,6 +364,7 @@
     created_on: 2005-08-01T07:49:23
     display_name: Fred Person
     http_etag: "..."
+    is_server_owner: False
     self_link: http://localhost:9001/3.0/users/6
     user_id: 6
 
@@ -357,6 +372,7 @@
     created_on: 2005-08-01T07:49:23
     display_name: Fred Person
     http_etag: "..."
+    is_server_owner: False
     self_link: http://localhost:9001/3.0/users/6
     user_id: 6
 
@@ -364,6 +380,7 @@
     created_on: 2005-08-01T07:49:23
     display_name: Fred Person
     http_etag: "..."
+    is_server_owner: False
     self_link: http://localhost:9001/3.0/users/6
     user_id: 6
 
@@ -382,6 +399,7 @@
     created_on: 2005-08-01T07:49:23
     display_name: Elly Person
     http_etag: "..."
+    is_server_owner: False
     password: {plaintext}supersekrit
     self_link: http://localhost:9001/3.0/users/5
     user_id: 5
@@ -399,3 +417,82 @@
     date: ...
     server: ...
     status: 204
+
+
+Server owners
+=============
+
+Users can be designated as server owners.  Elly is not currently a server
+owner.
+
+    >>> dump_json('http://localhost:9001/3.0/users/5')
+    created_on: 2005-08-01T07:49:23
+    display_name: Elly Person
+    http_etag: "..."
+    is_server_owner: False
+    password: {plaintext}supersekrit
+    self_link: http://localhost:9001/3.0/users/5
+    user_id: 5
+
+Let's make her a server owner.
+::
+
+    >>> dump_json('http://localhost:9001/3.0/users/5', {
+    ...           'is_server_owner': True,
+    ...           }, method='PATCH')
+    content-length: 0
+    date: ...
+    server: ...
+    status: 204
+
+    >>> dump_json('http://localhost:9001/3.0/users/5')
+    created_on: 2005-08-01T07:49:23
+    display_name: Elly Person
+    http_etag: "..."
+    is_server_owner: True
+    password: {plaintext}supersekrit
+    self_link: http://localhost:9001/3.0/users/5
+    user_id: 5
+
+Elly later retires as server owner.
+::
+
+    >>> dump_json('http://localhost:9001/3.0/users/5', {
+    ...           'is_server_owner': False,
+    ...           }, method='PATCH')
+    content-length: 0
+    date: ...
+    server: ...
+    status: 204
+
+    >>> dump_json('http://localhost:9001/3.0/users/5')
+    created_on: 2005-08-01T07:49:23
+    display_name: Elly Person
+    http_etag: "..."
+    is_server_owner: False
+    password: {plaintext}...
+    self_link: http://localhost:9001/3.0/users/5
+    user_id: 5
+
+Gwen, a new users, takes over as a server owner.
+::
+
+    >>> dump_json('http://localhost:9001/3.0/users', {
+    ...           'display_name': 'Gwen Person',
+    ...           'email': '[email protected]',
+    ...           'is_server_owner': True,
+    ...           })
+    content-length: 0
+    date: ...
+    location: http://localhost:9001/3.0/users/7
+    server: ...
+    status: 201
+
+    >>> dump_json('http://localhost:9001/3.0/users/7')
+    created_on: 2005-08-01T07:49:23
+    display_name: Gwen Person
+    http_etag: "..."
+    is_server_owner: True
+    password: {plaintext}...
+    self_link: http://localhost:9001/3.0/users/7
+    user_id: 7

=== modified file 'src/mailman/rest/domains.py'
--- src/mailman/rest/domains.py	2015-01-05 01:40:47 +0000
+++ src/mailman/rest/domains.py	2015-04-07 01:46:33 +0000
@@ -29,7 +29,8 @@
     BadRequest, CollectionMixin, NotFound, bad_request, child, created, etag,
     no_content, not_found, okay, path_to)
 from mailman.rest.lists import ListsForDomain
-from mailman.rest.validator import Validator
+from mailman.rest.users import OwnersForDomain
+from mailman.rest.validator import Validator, list_of_strings_validator
 from zope.component import getUtility
 
 
@@ -41,7 +42,6 @@
         """See `CollectionMixin`."""
         return dict(
             base_url=domain.base_url,
-            contact_address=domain.contact_address,
             description=domain.description,
             mail_host=domain.mail_host,
             self_link=path_to('domains/{0}'.format(domain.mail_host)),
@@ -88,6 +88,17 @@
         else:
             return BadRequest(), []
 
+    @child()
+    def owners(self, request, segments):
+        """/domains/<domain>/owners"""
+        if len(segments) == 0:
+            domain = getUtility(IDomainManager).get(self._domain)
+            if domain is None:
+                return NotFound()
+            return OwnersForDomain(domain)
+        else:
+            return BadRequest(), []
+
 
 class AllDomains(_DomainBase):
     """The domains."""
@@ -99,12 +110,18 @@
             validator = Validator(mail_host=str,
                                   description=str,
                                   base_url=str,
-                                  contact_address=str,
-                                  _optional=('description', 'base_url',
-                                             'contact_address'))
-            domain = domain_manager.add(**validator(request))
-        except BadDomainSpecificationError:
-            bad_request(response, b'Domain exists')
+                                  owner=list_of_strings_validator,
+                                  _optional=(
+                                      'description', 'base_url', 'owner'))
+            values = validator(request)
+            # For consistency, owners are passed in as multiple `owner` keys,
+            # but .add() requires an `owners` keyword.  Match impedence.
+            owners = values.pop('owner', None)
+            if owners is not None:
+                values['owners'] = owners
+            domain = domain_manager.add(**values)
+        except BadDomainSpecificationError as error:
+            bad_request(response, str(error))
         except ValueError as error:
             bad_request(response, str(error))
         else:

=== modified file 'src/mailman/rest/listconf.py'
--- src/mailman/rest/listconf.py	2015-01-05 01:40:47 +0000
+++ src/mailman/rest/listconf.py	2015-04-07 01:46:33 +0000
@@ -32,7 +32,8 @@
 from mailman.interfaces.mailinglist import IAcceptableAliasSet, ReplyToMunging
 from mailman.rest.helpers import (
     GetterSetter, bad_request, etag, no_content, okay)
-from mailman.rest.validator import PatchValidator, Validator, enum_validator
+from mailman.rest.validator import (
+    PatchValidator, Validator, enum_validator, list_of_strings_validator)
 
 
 
@@ -72,14 +73,6 @@
     raise ValueError('Unknown pipeline: {}'.format(pipeline_name))
 
 
-def list_of_str(values):
-    """Turn a list of things into a list of unicodes."""
-    for value in values:
-        if not isinstance(value, str):
-            raise ValueError('Expected str, got {!r}'.format(value))
-    return values
-
-
 
 # This is the list of IMailingList attributes that are exposed through the
 # REST API.  The values of the keys are the GetterSetter instance holding the
@@ -96,7 +89,7 @@
 # (e.g. datetimes, timedeltas, enums).
 
 ATTRIBUTES = dict(
-    acceptable_aliases=AcceptableAliases(list_of_str),
+    acceptable_aliases=AcceptableAliases(list_of_strings_validator),
     admin_immed_notify=GetterSetter(as_boolean),
     admin_notify_mchanges=GetterSetter(as_boolean),
     administrivia=GetterSetter(as_boolean),

=== modified file 'src/mailman/rest/tests/test_domains.py'
--- src/mailman/rest/tests/test_domains.py	2015-01-05 01:40:47 +0000
+++ src/mailman/rest/tests/test_domains.py	2015-04-07 01:46:33 +0000
@@ -18,6 +18,7 @@
 """REST domain tests."""
 
 __all__ = [
+    'TestDomainOwners',
     'TestDomains',
     ]
 
@@ -41,6 +42,17 @@
         with transaction():
             self._mlist = create_list('[email protected]')
 
+    def test_create_domains(self):
+        """Test Create domain via REST"""
+        data = {'mail_host': 'example.org',
+                'description': 'Example domain',
+                'base_url': 'http://example.org',
+                'owners': ['[email protected]',
+                           '[email protected]',]}
+        content, response = call_api('http://localhost:9001/3.0/domains',
+                                     data, method="POST")
+        self.assertEqual(response.status, 201)
+
     def test_bogus_endpoint_extension(self):
         # /domains/<domain>/lists/<anything> is not a valid endpoint.
         with self.assertRaises(HTTPError) as cm:
@@ -87,3 +99,45 @@
             call_api('http://localhost:9001/3.0/domains/example.com',
                      method='DELETE')
         self.assertEqual(cm.exception.code, 404)
+
+
+
+class TestDomainOwners(unittest.TestCase):
+    layer = RESTLayer
+
+    def test_get_missing_domain_owners(self):
+        # Try to get the owners of a missing domain.
+        with self.assertRaises(HTTPError) as cm:
+            call_api('http://localhost:9001/3.0/domains/example.net/owners')
+        self.assertEqual(cm.exception.code, 404)
+
+    def test_post_to_missing_domain_owners(self):
+        # Try to add owners to a missing domain.
+        with self.assertRaises(HTTPError) as cm:
+            call_api('http://localhost:9001/3.0/domains/example.net/owners', (
+                ('owner', '[email protected]'), ('owner', '[email protected]'),
+                ))
+        self.assertEqual(cm.exception.code, 404)
+
+    def test_delete_missing_domain_owners(self):
+        # Try to delete the owners of a missing domain.
+        with self.assertRaises(HTTPError) as cm:
+            call_api('http://localhost:9001/3.0/domains/example.net/owners',
+                     method='DELETE')
+        self.assertEqual(cm.exception.code, 404)
+
+    def test_bad_post(self):
+        # Send POST data with an invalid attribute.
+        with self.assertRaises(HTTPError) as cm:
+            call_api('http://localhost:9001/3.0/domains/example.com/owners', (
+                ('guy', '[email protected]'), ('gal', '[email protected]'),
+                ))
+        self.assertEqual(cm.exception.code, 400)
+
+    def test_bad_delete(self):
+        # Send DELETE with any data.
+        with self.assertRaises(HTTPError) as cm:
+            call_api('http://localhost:9001/3.0/domains/example.com/owners', {
+                'owner': '[email protected]',
+                }, method='DELETE')
+        self.assertEqual(cm.exception.code, 400)

=== modified file 'src/mailman/rest/users.py'
--- src/mailman/rest/users.py	2015-03-20 16:38:00 +0000
+++ src/mailman/rest/users.py	2015-04-07 01:46:33 +0000
@@ -22,6 +22,7 @@
     'AddressUser',
     'AllUsers',
     'Login',
+    'OwnersForDomain',
     ]
 
 
@@ -37,7 +38,8 @@
     conflict, created, etag, forbidden, no_content, not_found, okay, paginate,
     path_to)
 from mailman.rest.preferences import Preferences
-from mailman.rest.validator import PatchValidator, Validator
+from mailman.rest.validator import (
+    PatchValidator, Validator, list_of_strings_validator)
 from passlib.utils import generate_password as generate
 from uuid import UUID
 from zope.component import getUtility
@@ -47,27 +49,42 @@
 # Attributes of a user which can be changed via the REST API.
 class PasswordEncrypterGetterSetter(GetterSetter):
     def __init__(self):
-        super(PasswordEncrypterGetterSetter, self).__init__(
-            config.password_context.encrypt)
+        super().__init__(config.password_context.encrypt)
     def get(self, obj, attribute):
         assert attribute == 'cleartext_password'
-        super(PasswordEncrypterGetterSetter, self).get(obj, 'password')
+        super().get(obj, 'password')
     def put(self, obj, attribute, value):
         assert attribute == 'cleartext_password'
-        super(PasswordEncrypterGetterSetter, self).put(obj, 'password', value)
+        super().put(obj, 'password', value)
+
+
+class ListOfDomainOwners(GetterSetter):
+    def get(self, domain, attribute):
+        assert attribute == 'owner', (
+            'Unexpected attribute: {}'.format(attribute))
+        def sort_key(owner):
+            return owner.addresses[0].email
+        return sorted(domain.owners, key=sort_key)
+
+    def put(self, domain, attribute, value):
+        assert attribute == 'owner', (
+            'Unexpected attribute: {}'.format(attribute))
+        domain.add_owners(value)
 
 
 ATTRIBUTES = dict(
+    cleartext_password=PasswordEncrypterGetterSetter(),
     display_name=GetterSetter(str),
-    cleartext_password=PasswordEncrypterGetterSetter(),
+    is_server_owner=GetterSetter(as_boolean),
     )
 
 
 CREATION_FIELDS = dict(
+    display_name=str,
     email=str,
-    display_name=str,
+    is_server_owner=bool,
     password=str,
-    _optional=('display_name', 'password'),
+    _optional=('display_name', 'password', 'is_server_owner'),
     )
 
 
@@ -78,6 +95,7 @@
     # strip that out (if it exists), then create the user, adding the password
     # after the fact if successful.
     password = arguments.pop('password', None)
+    is_server_owner = arguments.pop('is_server_owner', False)
     try:
         user = getUtility(IUserManager).create_user(**arguments)
     except ExistingAddressError as error:
@@ -88,6 +106,7 @@
         # This will have to be reset since it cannot be retrieved.
         password = generate(int(config.passwords.password_length))
     user.password = config.password_context.encrypt(password)
+    user.is_server_owner = is_server_owner
     location = path_to('users/{}'.format(user.user_id.int))
     created(response, location)
     return user
@@ -105,10 +124,11 @@
         # but we serialize its integer equivalent.
         user_id = user.user_id.int
         resource = dict(
-            user_id=user_id,
             created_on=user.created_on,
+            is_server_owner=user.is_server_owner,
             self_link=path_to('users/{}'.format(user_id)),
-            )
+            user_id=user_id,
+        )
         # Add the password attribute, only if the user has a password.  Same
         # with the real name.  These could be None or the empty string.
         if user.password:
@@ -293,7 +313,8 @@
         del fields['email']
         fields['user_id'] = int
         fields['auto_create'] = as_boolean
-        fields['_optional'] = fields['_optional'] + ('user_id', 'auto_create')
+        fields['_optional'] = fields['_optional'] + (
+            'user_id', 'auto_create', 'is_server_owner')
         try:
             validator = Validator(**fields)
             arguments = validator(request)
@@ -328,7 +349,8 @@
         # Process post data and check for an existing user.
         fields = CREATION_FIELDS.copy()
         fields['user_id'] = int
-        fields['_optional'] = fields['_optional'] + ('user_id', 'email')
+        fields['_optional'] = fields['_optional'] + (
+            'user_id', 'email', 'is_server_owner')
         try:
             validator = Validator(**fields)
             arguments = validator(request)
@@ -377,3 +399,56 @@
             no_content(response)
         else:
             forbidden(response)
+
+
+
+class OwnersForDomain(_UserBase):
+    """Owners for a particular domain."""
+
+    def __init__(self, domain):
+        self._domain = domain
+
+    def on_get(self, request, response):
+        """/domains/<domain>/owners"""
+        if self._domain is None:
+            not_found(response)
+            return
+        resource = self._make_collection(request)
+        okay(response, etag(resource))
+
+    def on_post(self, request, response):
+        """POST to /domains/<domain>/owners """
+        if self._domain is None:
+            not_found(response)
+            return
+        validator = Validator(
+            owner=ListOfDomainOwners(list_of_strings_validator))
+        try:
+            validator.update(self._domain, request)
+        except ValueError as error:
+            bad_request(response, str(error))
+            return
+        return no_content(response)
+
+    def on_delete(self, request, response):
+        """DELETE to /domains/<domain>/owners"""
+        if self._domain is None:
+            not_found(response)
+        try:
+            # No arguments.
+            Validator()(request)
+        except ValueError as error:
+            bad_request(response, str(error))
+            return
+        owner_email = [
+            owner.addresses[0].email
+            for owner in self._domain.owners
+            ]
+        for email in owner_email:
+            self._domain.remove_owner(email)
+        return no_content(response)
+
+    @paginate
+    def _get_collection(self, request):
+        """See `CollectionMixin`."""
+        return list(self._domain.owners)

=== modified file 'src/mailman/rest/validator.py'
--- src/mailman/rest/validator.py	2015-01-05 01:22:39 +0000
+++ src/mailman/rest/validator.py	2015-04-07 01:46:33 +0000
@@ -22,6 +22,7 @@
     'Validator',
     'enum_validator',
     'language_validator',
+    'list_of_strings_validator',
     'subscriber_validator',
     ]
 
@@ -66,6 +67,14 @@
     return getUtility(ILanguageManager)[code]
 
 
+def list_of_strings_validator(values):
+    """Turn a list of things into a list of unicodes."""
+    for value in values:
+        if not isinstance(value, str):
+            raise ValueError('Expected str, got {!r}'.format(value))
+    return values
+
+
 
 class Validator:
     """A validator of parameter input."""

=== modified file 'src/mailman/testing/layers.py'
--- src/mailman/testing/layers.py	2015-01-05 01:22:39 +0000
+++ src/mailman/testing/layers.py	2015-04-07 01:46:33 +0000
@@ -200,7 +200,7 @@
         with transaction():
             getUtility(IDomainManager).add(
                 'example.com', 'An example domain.',
-                'http://lists.example.com', '[email protected]')
+                'http://lists.example.com')
 
     @classmethod
     def testTearDown(cls):

_______________________________________________
Mailman-coders mailing list
[email protected]
https://mail.python.org/mailman/listinfo/mailman-coders

Reply via email to