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