summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/mailman/app/registrar.py2
-rw-r--r--src/mailman/archiving/mailarchive.py1
-rw-r--r--src/mailman/archiving/mhonarc.py3
-rw-r--r--src/mailman/archiving/prototype.py2
-rw-r--r--src/mailman/commands/docs/create.rst3
-rw-r--r--src/mailman/commands/docs/membership.rst2
-rw-r--r--src/mailman/commands/tests/test_lists.py2
-rw-r--r--src/mailman/database/alembic/versions/46e92facee7_add_serverowner_domainowner.py56
-rw-r--r--src/mailman/docs/NEWS.rst15
-rw-r--r--src/mailman/interfaces/domain.py16
-rw-r--r--src/mailman/model/docs/domains.rst58
-rw-r--r--src/mailman/model/docs/registration.rst2
-rw-r--r--src/mailman/model/docs/users.rst15
-rw-r--r--src/mailman/model/domain.py57
-rw-r--r--src/mailman/model/tests/test_domain.py93
-rw-r--r--src/mailman/model/user.py14
-rw-r--r--src/mailman/rest/docs/addresses.rst1
-rw-r--r--src/mailman/rest/docs/domains.rst123
-rw-r--r--src/mailman/rest/docs/users.rst99
-rw-r--r--src/mailman/rest/domains.py33
-rw-r--r--src/mailman/rest/listconf.py13
-rw-r--r--src/mailman/rest/tests/test_domains.py55
-rw-r--r--src/mailman/rest/users.py99
-rw-r--r--src/mailman/rest/validator.py9
-rw-r--r--src/mailman/testing/layers.py2
25 files changed, 660 insertions, 115 deletions
diff --git a/src/mailman/app/registrar.py b/src/mailman/app/registrar.py
index 2544e233c..9272b95d7 100644
--- a/src/mailman/app/registrar.py
+++ b/src/mailman/app/registrar.py
@@ -162,7 +162,7 @@ def handle_ConfirmationNeededEvent(event):
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(
diff --git a/src/mailman/archiving/mailarchive.py b/src/mailman/archiving/mailarchive.py
index b7f3847f0..c5dd8c8e5 100644
--- a/src/mailman/archiving/mailarchive.py
+++ b/src/mailman/archiving/mailarchive.py
@@ -74,3 +74,4 @@ class MailArchive:
msg,
listid=mlist.list_id,
recipients=[self.recipient])
+ return None
diff --git a/src/mailman/archiving/mhonarc.py b/src/mailman/archiving/mhonarc.py
index ff8baf079..8d19c6f64 100644
--- a/src/mailman/archiving/mhonarc.py
+++ b/src/mailman/archiving/mhonarc.py
@@ -87,3 +87,6 @@ class MHonArc:
(msg['message-id'], proc.returncode))
log.info(stdout)
log.error(stderr)
+ # Can we get more information, such as the url to the message just
+ # archived, out of MHonArc?
+ return None
diff --git a/src/mailman/archiving/prototype.py b/src/mailman/archiving/prototype.py
index b0328211e..b5df11f78 100644
--- a/src/mailman/archiving/prototype.py
+++ b/src/mailman/archiving/prototype.py
@@ -115,3 +115,5 @@ class Prototype:
message.get('message-id', 'n/a')))
finally:
lock.unlock(unconditionally=True)
+ # Can we get return the URL of the archived message?
+ return None
diff --git a/src/mailman/commands/docs/create.rst b/src/mailman/commands/docs/create.rst
index bec4ea8b6..1a5d2a3ab 100644
--- a/src/mailman/commands/docs/create.rst
+++ b/src/mailman/commands/docs/create.rst
@@ -44,8 +44,7 @@ Now both the domain and the mailing list exist in the database.
>>> from mailman.interfaces.domain import IDomainManager
>>> getUtility(IDomainManager).get('example.xx')
- <Domain example.xx, base_url: http://example.xx,
- contact_address: postmaster@example.xx>
+ <Domain example.xx, base_url: http://example.xx>
You can also create mailing lists in existing domains without the
auto-creation flag.
diff --git a/src/mailman/commands/docs/membership.rst b/src/mailman/commands/docs/membership.rst
index a260e930a..bdebba8f7 100644
--- a/src/mailman/commands/docs/membership.rst
+++ b/src/mailman/commands/docs/membership.rst
@@ -127,7 +127,7 @@ Mailman has sent her the confirmation message.
message. If you think you are being maliciously subscribed to the list, or
have any other questions, you may contact
<BLANKLINE>
- postmaster@example.com
+ alpha-owner@example.com
<BLANKLINE>
Once Anne confirms her registration, she will be made a member of the mailing
diff --git a/src/mailman/commands/tests/test_lists.py b/src/mailman/commands/tests/test_lists.py
index dad15eec8..229e7c96d 100644
--- a/src/mailman/commands/tests/test_lists.py
+++ b/src/mailman/commands/tests/test_lists.py
@@ -48,7 +48,7 @@ class TestLists(unittest.TestCase):
# LP: #1166911 - non-matching lists were returned.
getUtility(IDomainManager).add(
'example.net', 'An example domain.',
- 'http://lists.example.net', 'postmaster@example.net')
+ 'http://lists.example.net')
create_list('test1@example.com')
create_list('test2@example.com')
# Only this one should show up.
diff --git a/src/mailman/database/alembic/versions/46e92facee7_add_serverowner_domainowner.py b/src/mailman/database/alembic/versions/46e92facee7_add_serverowner_domainowner.py
new file mode 100644
index 000000000..fc489cae5
--- /dev/null
+++ b/src/mailman/database/alembic/versions/46e92facee7_add_serverowner_domainowner.py
@@ -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')
diff --git a/src/mailman/docs/NEWS.rst b/src/mailman/docs/NEWS.rst
index 191e667a9..68ebe6143 100644
--- a/src/mailman/docs/NEWS.rst
+++ b/src/mailman/docs/NEWS.rst
@@ -12,6 +12,14 @@ Here is a history of user visible changes to Mailman.
===============================
(2015-XX-XX)
+Architecture
+------------
+ * Domains now have a list of owners, which are ``IUser`` objects, instead of
+ the single ``contact_address`` they used to have. ``IUser`` objects now
+ also have a ``is_server_owner`` flag (defaulting to False) to indicate
+ whether they have superuser privileges. Give by Abhliash Raj, with fixes
+ and refinements by Barry Warsaw. (LP: #1423756)
+
Bugs
----
* Fix calculation of default configuration file to use when the ``$var_dir``
@@ -30,6 +38,8 @@ Bugs
Given by Abhishek. (LP: #1418276)
* Be sure a mailing list's acceptable aliases are deleted when the mailing
list itself is deleted. (LP: #1432239)
+ * The built-in example ``IArchiver`` implementations now explicitly return
+ None. (LP: #1203359)
Configuration
-------------
@@ -62,6 +72,11 @@ REST
``<api>/reserved/uids/orphans``. Note that *no guarantees* of API
stability will ever be made for resources under ``reserved``.
(LP: #1420083)
+ * Domains can now optionally be created with owners; domain owners can be
+ added after the fact; domain owners can be deleted. Also, users now have
+ an ``is_server_owner`` flag as part of their representation, which defaults
+ to False, and can be PUT and PATCH'd. Given by Abhilash Raj, with fixes
+ and refinements by Barry Warsaw. (LP: #1423756)
3.0 beta 5 -- "Carve Away The Stone"
diff --git a/src/mailman/interfaces/domain.py b/src/mailman/interfaces/domain.py
index 3ebfb0d6e..33d835325 100644
--- a/src/mailman/interfaces/domain.py
+++ b/src/mailman/interfaces/domain.py
@@ -88,9 +88,8 @@ class IDomain(Interface):
description = Attribute(
'The human readable description of the domain name.')
- contact_address = Attribute("""\
- The contact address for the human at this domain.
- E.g. postmaster@example.com""")
+ 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 IDomain(Interface):
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 @@ class IDomainManager(Interface):
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.
diff --git a/src/mailman/model/docs/domains.rst b/src/mailman/model/docs/domains.rst
index abb594a62..c0d65cee7 100644
--- a/src/mailman/model/docs/domains.rst
+++ b/src/mailman/model/docs/domains.rst
@@ -14,12 +14,16 @@ Domains are how Mailman interacts with email host names and web host names.
::
>>> 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 @@ Adding a domain requires some basic information, of which the email host name
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: postmaster@example.org>
+ <Domain example.org, base_url: http://example.org>
>>> show_domains()
- <Domain example.org, base_url: http://example.org,
- contact_address: postmaster@example.org>
+ <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: postmaster@example.org>
+ <Domain example.org, base_url: http://example.org>
>>> show_domains()
no domains
@@ -46,30 +47,39 @@ Sometimes the email host name is different than the base url for hitting the
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: postmaster@example.com>
+ <Domain example.com, base_url: https://mail.example.com>
>>> show_domains()
- <Domain example.com, base_url: https://mail.example.com,
- contact_address: postmaster@example.com>
+ <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='postmaster@example.com',
- ... description='The example domain')
+ ... description='The example domain',
+ ... owners=['anne@example.com'])
<Domain example.net, The example domain,
- base_url: http://lists.example.net,
- contact_address: postmaster@example.com>
+ base_url: http://lists.example.net>
- >>> show_domains()
- <Domain example.com, base_url: https://mail.example.com,
- contact_address: postmaster@example.com>
+ >>> 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,
- contact_address: postmaster@example.com>
+ base_url: http://lists.example.net>
+ - owner: anne@example.com
+
+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('bart@example.org')
+ >>> 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: anne@example.com
+ - owner: bart@example.org
Domains can list all associated mailing lists with the mailing_lists property.
::
@@ -105,8 +115,7 @@ In the global domain manager, domains are indexed by their email host name.
>>> print(manager['example.net'])
<Domain example.net, The example domain,
- base_url: http://lists.example.net,
- contact_address: postmaster@example.com>
+ 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 @@ exist, ``None`` or a default is returned.
>>> print(manager.get('example.net'))
<Domain example.net, The example domain,
- base_url: http://lists.example.net,
- contact_address: postmaster@example.com>
+ base_url: http://lists.example.net>
>>> print(manager.get('doesnotexist.com'))
None
diff --git a/src/mailman/model/docs/registration.rst b/src/mailman/model/docs/registration.rst
index 47f9f951d..2d2aa8ec7 100644
--- a/src/mailman/model/docs/registration.rst
+++ b/src/mailman/model/docs/registration.rst
@@ -120,7 +120,7 @@ message is sent to the user in order to verify the registered address.
message. If you think you are being maliciously subscribed to the list,
or have any other questions, you may contact
<BLANKLINE>
- postmaster@example.com
+ alpha-owner@example.com
<BLANKLINE>
>>> dump_msgdata(items[0].msgdata)
_parsemsg : False
diff --git a/src/mailman/model/docs/users.rst b/src/mailman/model/docs/users.rst
index 0b926d6a7..0d6a0f368 100644
--- a/src/mailman/model/docs/users.rst
+++ b/src/mailman/model/docs/users.rst
@@ -295,4 +295,19 @@ membership role.
zperson@example.org 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
diff --git a/src/mailman/model/domain.py b/src/mailman/model/domain.py
index 9e627c119..40298c719 100644
--- a/src/mailman/model/domain.py
+++ b/src/mailman/model/domain.py
@@ -28,11 +28,15 @@ from mailman.database.transaction import dbconnection
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 @@ class Domain(Model):
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 @@ class Domain(Model):
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 @@ class Domain(Model):
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 @@ class DomainManager:
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 @@ class DomainManager:
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
diff --git a/src/mailman/model/tests/test_domain.py b/src/mailman/model/tests/test_domain.py
index b4a6dd75c..afde6cd53 100644
--- a/src/mailman/model/tests/test_domain.py
+++ b/src/mailman/model/tests/test_domain.py
@@ -30,6 +30,7 @@ from mailman.interfaces.domain import (
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 @@ class TestDomainManager(unittest.TestCase):
# 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=['anne@example.org'])
+ self.assertEqual(len(domain.owners), 1)
+ self.assertEqual(domain.owners[0].addresses[0].email,
+ 'anne@example.org')
+
+ def test_domain_creation_with_owners(self):
+ # You can create a new domain with multiple owners.
+ domain = self._manager.add(
+ 'example.org', owners=['anne@example.org',
+ 'bart@example.net'])
+ self.assertEqual(len(domain.owners), 2)
+ self.assertEqual(
+ sorted(owner.addresses[0].email for owner in domain.owners),
+ ['anne@example.org', 'bart@example.net'])
+
+ 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('anne@example.com')
+ user_manager.make_user('bart@example.com')
+ domain = self._manager.add(
+ 'example.org', owners=['anne@example.com',
+ 'bart@example.com',
+ 'cris@example.com'])
+ self.assertEqual(len(domain.owners), 3)
+ self.assertEqual(
+ sorted(owner.addresses[0].email for owner in domain.owners),
+ ['anne@example.com', 'bart@example.com', 'cris@example.com'])
+ # Now cris exists as a user.
+ self.assertIsNotNone(user_manager.get_user('cris@example.com'))
+
+ def test_domain_creation_with_users(self):
+ # Domains can be created with IUser objects.
+ user_manager = getUtility(IUserManager)
+ anne = user_manager.make_user('anne@example.com')
+ bart = user_manager.make_user('bart@example.com')
+ 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),
+ ['anne@example.com', 'bart@example.com'])
+ 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('anne@example.org')
+ self.assertEqual(len(domain.owners), 1)
+ self.assertEqual(domain.owners[0].addresses[0].email,
+ 'anne@example.org')
+
+ 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(['anne@example.org', 'bart@example.net'])
+ self.assertEqual(len(domain.owners), 2)
+ self.assertEqual([owner.addresses[0].email for owner in domain.owners],
+ ['anne@example.org', 'bart@example.net'])
+
+ def test_remove_domain_owner(self):
+ # Domain onwers can be removed.
+ domain = self._manager.add(
+ 'example.org', owners=['anne@example.org',
+ 'bart@example.net'])
+ domain.remove_owner('anne@example.org')
+ self.assertEqual(len(domain.owners), 1)
+ self.assertEqual([owner.addresses[0].email for owner in domain.owners],
+ ['bart@example.net'])
+
+ def test_remove_missing_owner(self):
+ # Users which aren't owners can't be removed.
+ domain = self._manager.add(
+ 'example.org', owners=['anne@example.org',
+ 'bart@example.net'])
+ self.assertRaises(ValueError, domain.remove_owner, 'cris@example.org')
+ self.assertEqual(len(domain.owners), 2)
+ self.assertEqual([owner.addresses[0].email for owner in domain.owners],
+ ['anne@example.org', 'bart@example.net'])
+
class TestDomainLifecycleEvents(unittest.TestCase):
diff --git a/src/mailman/model/user.py b/src/mailman/model/user.py
index 66197d72e..f6aedd132 100644
--- a/src/mailman/model/user.py
+++ b/src/mailman/model/user.py
@@ -18,6 +18,7 @@
"""Model for users."""
__all__ = [
+ 'DomainOwner',
'User',
]
@@ -34,7 +35,7 @@ from mailman.model.preferences import Preferences
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 @@ class User(Model):
_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 @@ class User(Model):
@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)
diff --git a/src/mailman/rest/docs/addresses.rst b/src/mailman/rest/docs/addresses.rst
index fd3520be9..f70b64a39 100644
--- a/src/mailman/rest/docs/addresses.rst
+++ b/src/mailman/rest/docs/addresses.rst
@@ -190,6 +190,7 @@ representation:
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
diff --git a/src/mailman/rest/docs/domains.rst b/src/mailman/rest/docs/domains.rst
index a78dacd85..34e3b9a18 100644
--- a/src/mailman/rest/docs/domains.rst
+++ b/src/mailman/rest/docs/domains.rst
@@ -28,15 +28,12 @@ Once a domain is added, it is accessible through the API.
>>> 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: postmaster@example.com>
+ <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: postmaster@example.com
description: An example domain
http_etag: "..."
mail_host: example.com
@@ -51,24 +48,18 @@ At the top level, all domains are returned as separate entries.
>>> domain_manager.add(
... 'example.org',
- ... base_url='http://mail.example.org',
- ... contact_address='listmaster@example.org')
- <Domain example.org, base_url: http://mail.example.org,
- contact_address: listmaster@example.org>
+ ... 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',
- ... 'porkmaster@example.net')
- <Domain lists.example.net, Porkmasters,
- base_url: http://example.net,
- contact_address: porkmaster@example.net>
+ ... '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: postmaster@example.com
description: An example domain
http_etag: "..."
mail_host: example.com
@@ -76,7 +67,6 @@ At the top level, all domains are returned as separate entries.
url_host: lists.example.com
entry 1:
base_url: http://mail.example.org
- contact_address: listmaster@example.org
description: None
http_etag: "..."
mail_host: example.org
@@ -84,7 +74,6 @@ At the top level, all domains are returned as separate entries.
url_host: mail.example.org
entry 2:
base_url: http://example.net
- contact_address: porkmaster@example.net
description: Porkmasters
http_etag: "..."
mail_host: lists.example.net
@@ -103,7 +92,6 @@ The information for a single domain is available by following one of the
>>> dump_json('http://localhost:9001/3.0/domains/lists.example.net')
base_url: http://example.net
- contact_address: porkmaster@example.net
description: Porkmasters
http_etag: "..."
mail_host: lists.example.net
@@ -165,7 +153,6 @@ Now the web service knows about our new domain.
>>> dump_json('http://localhost:9001/3.0/domains/lists.example.com')
base_url: http://lists.example.com
- contact_address: postmaster@lists.example.com
description: None
http_etag: "..."
mail_host: lists.example.com
@@ -176,9 +163,7 @@ And the new domain is in our database.
::
>>> domain_manager['lists.example.com']
- <Domain lists.example.com,
- base_url: http://lists.example.com,
- contact_address: postmaster@lists.example.com>
+ <Domain lists.example.com, base_url: http://lists.example.com>
# Unlock the database.
>>> transaction.abort()
@@ -190,8 +175,7 @@ address.
>>> 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': 'helpme@example.com'
+ ... 'base_url': 'http://allmy.example.com'
... })
content-length: 0
date: ...
@@ -200,7 +184,6 @@ address.
>>> dump_json('http://localhost:9001/3.0/domains/my.example.com')
base_url: http://allmy.example.com
- contact_address: helpme@example.com
description: My new domain
http_etag: "..."
mail_host: my.example.com
@@ -208,9 +191,7 @@ address.
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: helpme@example.com>
+ <Domain my.example.com, My new domain, base_url: http://allmy.example.com>
# Unlock the database.
>>> transaction.abort()
@@ -229,4 +210,92 @@ Domains can also be deleted via the API.
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', 'anne@example.com'), ('owner', 'bart@example.com')
+ ... ))
+ 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', 'anne@example.com'),
+ ... ('owner', 'bart@example.com'),
+ ... ))
+ 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
diff --git a/src/mailman/rest/docs/users.rst b/src/mailman/rest/docs/users.rst
index 824492333..13390a00f 100644
--- a/src/mailman/rest/docs/users.rst
+++ b/src/mailman/rest/docs/users.rst
@@ -34,6 +34,7 @@ Anne's user record is returned as an entry into the collection of all users.
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 @@ returned in the REST API.
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 @@ page.
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 @@ page.
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 @@ one was assigned to her.
>>> 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 @@ address.
>>> dump_json('http://localhost:9001/3.0/users/cris@example.com')
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 @@ Dave's user record includes his display name.
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 @@ because it has the hash algorithm prefix (i.e. the *{plaintext}* marker).
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 @@ Dave's display name has been updated.
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 @@ addition of the algorithm prefix.
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 @@ You can change both the display name and the password by PUTing the full
resource.
>>> dump_json('http://localhost:9001/3.0/users/4', {
- ... 'display_name': 'David Personhood',
... 'cleartext_password': 'the garden',
+ ... 'display_name': 'David Personhood',
+ ... 'is_server_owner': False,
... }, method='PUT')
content-length: 0
date: ...
@@ -260,6 +272,7 @@ Dave's user record has been updated.
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 @@ addresses can be used to look up Fred's user record.
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 @@ addresses can be used to look up Fred's user record.
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 @@ addresses can be used to look up Fred's user record.
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 @@ addresses can be used to look up Fred's user record.
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 @@ password is hashed and getting her user record returns the hashed password.
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 @@ This time, Elly successfully logs into Mailman.
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': 'gwen@example.com',
+ ... '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
diff --git a/src/mailman/rest/domains.py b/src/mailman/rest/domains.py
index 345e8327d..bf6fc5ca5 100644
--- a/src/mailman/rest/domains.py
+++ b/src/mailman/rest/domains.py
@@ -29,7 +29,8 @@ from mailman.rest.helpers import (
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 @@ class _DomainBase(CollectionMixin):
"""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 @@ class ADomain(_DomainBase):
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 @@ class AllDomains(_DomainBase):
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:
diff --git a/src/mailman/rest/listconf.py b/src/mailman/rest/listconf.py
index 92d1169d4..04dea996f 100644
--- a/src/mailman/rest/listconf.py
+++ b/src/mailman/rest/listconf.py
@@ -33,7 +33,8 @@ from mailman.interfaces.mailinglist import (
IAcceptableAliasSet, ReplyToMunging, SubscriptionPolicy)
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)
@@ -73,14 +74,6 @@ def pipeline_validator(pipeline_name):
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
@@ -97,7 +90,7 @@ def list_of_str(values):
# (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),
diff --git a/src/mailman/rest/tests/test_domains.py b/src/mailman/rest/tests/test_domains.py
index bf53c8e70..716ded580 100644
--- a/src/mailman/rest/tests/test_domains.py
+++ b/src/mailman/rest/tests/test_domains.py
@@ -18,6 +18,7 @@
"""REST domain tests."""
__all__ = [
+ 'TestDomainOwners',
'TestDomains',
]
@@ -41,6 +42,18 @@ class TestDomains(unittest.TestCase):
with transaction():
self._mlist = create_list('test@example.com')
+ def test_create_domains(self):
+ # Create a domain with owners.
+ data = dict(
+ mail_host='example.org',
+ description='Example domain',
+ base_url='http://example.org',
+ owner=['someone@example.com', 'secondowner@example.com'],
+ )
+ 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 +100,45 @@ class TestDomains(unittest.TestCase):
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', 'dave@example.com'), ('owner', 'elle@example.com'),
+ ))
+ 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', 'dave@example.com'), ('gal', 'elle@example.com'),
+ ))
+ 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': 'dave@example.com',
+ }, method='DELETE')
+ self.assertEqual(cm.exception.code, 400)
diff --git a/src/mailman/rest/users.py b/src/mailman/rest/users.py
index a912b6129..7b1ec8040 100644
--- a/src/mailman/rest/users.py
+++ b/src/mailman/rest/users.py
@@ -22,6 +22,7 @@ __all__ = [
'AddressUser',
'AllUsers',
'Login',
+ 'OwnersForDomain',
]
@@ -37,7 +38,8 @@ from mailman.rest.helpers import (
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 @@ from zope.component import getUtility
# 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(
- display_name=GetterSetter(str),
cleartext_password=PasswordEncrypterGetterSetter(),
+ display_name=GetterSetter(str),
+ is_server_owner=GetterSetter(as_boolean),
)
CREATION_FIELDS = dict(
- email=str,
display_name=str,
+ email=str,
+ is_server_owner=bool,
password=str,
- _optional=('display_name', 'password'),
+ _optional=('display_name', 'password', 'is_server_owner'),
)
@@ -78,6 +95,7 @@ def create_user(arguments, response):
# 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 @@ def create_user(arguments, response):
# 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 @@ class _UserBase(CollectionMixin):
# 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 @@ class AddressUser(_UserBase):
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 @@ class AddressUser(_UserBase):
# 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 @@ class Login:
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)
diff --git a/src/mailman/rest/validator.py b/src/mailman/rest/validator.py
index 867991a36..d09886e36 100644
--- a/src/mailman/rest/validator.py
+++ b/src/mailman/rest/validator.py
@@ -22,6 +22,7 @@ __all__ = [
'Validator',
'enum_validator',
'language_validator',
+ 'list_of_strings_validator',
'subscriber_validator',
]
@@ -66,6 +67,14 @@ def language_validator(code):
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."""
diff --git a/src/mailman/testing/layers.py b/src/mailman/testing/layers.py
index 8618f39d3..3328efefc 100644
--- a/src/mailman/testing/layers.py
+++ b/src/mailman/testing/layers.py
@@ -200,7 +200,7 @@ class ConfigLayer(MockAndMonkeyLayer):
with transaction():
getUtility(IDomainManager).add(
'example.com', 'An example domain.',
- 'http://lists.example.com', 'postmaster@example.com')
+ 'http://lists.example.com')
@classmethod
def testTearDown(cls):