Merge lp:~barry/mailman/lp1423756 into lp:mailman

Proposed by Barry Warsaw
Status: Merged
Merged at revision: 7316
Proposed branch: lp:~barry/mailman/lp1423756
Merge into: lp:mailman
Diff against target: 1444 lines (+641/-118)
21 files modified
src/mailman/app/registrar.py (+1/-1)
src/mailman/commands/docs/create.rst (+1/-2)
src/mailman/commands/docs/membership.rst (+1/-1)
src/mailman/commands/tests/test_lists.py (+1/-1)
src/mailman/database/alembic/versions/46e92facee7_add_serverowner_domainowner.py (+56/-0)
src/mailman/interfaces/domain.py (+7/-9)
src/mailman/model/docs/domains.rst (+36/-28)
src/mailman/model/docs/registration.rst (+1/-1)
src/mailman/model/docs/users.rst (+15/-0)
src/mailman/model/domain.py (+42/-15)
src/mailman/model/tests/test_domain.py (+93/-0)
src/mailman/model/user.py (+13/-1)
src/mailman/rest/docs/addresses.rst (+1/-0)
src/mailman/rest/docs/domains.rst (+96/-27)
src/mailman/rest/docs/users.rst (+98/-1)
src/mailman/rest/domains.py (+25/-8)
src/mailman/rest/listconf.py (+3/-10)
src/mailman/rest/tests/test_domains.py (+54/-0)
src/mailman/rest/users.py (+87/-12)
src/mailman/rest/validator.py (+9/-0)
src/mailman/testing/layers.py (+1/-1)
To merge this branch: bzr merge lp:~barry/mailman/lp1423756
Reviewer Review Type Date Requested Status
Mailman Coders Pending
Review via email: mp+255318@code.launchpad.net

Description of the change

Mega-merge of Abhilash's branch, with fixes.

To post a comment you must log in.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== 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 @@
162 confirm_url = mlist.domain.confirm_url(event.token)162 confirm_url = mlist.domain.confirm_url(event.token)
163 email_address = event.pendable['email']163 email_address = event.pendable['email']
164 domain_name = mlist.domain.mail_host164 domain_name = mlist.domain.mail_host
165 contact_address = mlist.domain.contact_address165 contact_address = mlist.owner_address
166 # Send a verification email to the address.166 # Send a verification email to the address.
167 template = getUtility(ITemplateLoader).get(167 template = getUtility(ITemplateLoader).get(
168 'mailman:///{0}/{1}/confirm.txt'.format(168 'mailman:///{0}/{1}/confirm.txt'.format(
169169
=== 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 @@
4444
45 >>> from mailman.interfaces.domain import IDomainManager45 >>> from mailman.interfaces.domain import IDomainManager
46 >>> getUtility(IDomainManager).get('example.xx')46 >>> getUtility(IDomainManager).get('example.xx')
47 <Domain example.xx, base_url: http://example.xx,47 <Domain example.xx, base_url: http://example.xx>
48 contact_address: postmaster@example.xx>
4948
50You can also create mailing lists in existing domains without the49You can also create mailing lists in existing domains without the
51auto-creation flag.50auto-creation flag.
5251
=== 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 @@
127 message. If you think you are being maliciously subscribed to the list, or127 message. If you think you are being maliciously subscribed to the list, or
128 have any other questions, you may contact128 have any other questions, you may contact
129 <BLANKLINE>129 <BLANKLINE>
130 postmaster@example.com130 alpha-owner@example.com
131 <BLANKLINE>131 <BLANKLINE>
132132
133Once Anne confirms her registration, she will be made a member of the mailing133Once Anne confirms her registration, she will be made a member of the mailing
134134
=== 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 @@
48 # LP: #1166911 - non-matching lists were returned.48 # LP: #1166911 - non-matching lists were returned.
49 getUtility(IDomainManager).add(49 getUtility(IDomainManager).add(
50 'example.net', 'An example domain.',50 'example.net', 'An example domain.',
51 'http://lists.example.net', 'postmaster@example.net')51 'http://lists.example.net')
52 create_list('test1@example.com')52 create_list('test1@example.com')
53 create_list('test2@example.com')53 create_list('test2@example.com')
54 # Only this one should show up.54 # Only this one should show up.
5555
=== 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 @@
1# Copyright (C) 2015 by the Free Software Foundation, Inc.
2#
3# This file is part of GNU Mailman.
4#
5# GNU Mailman is free software: you can redistribute it and/or modify it under
6# the terms of the GNU General Public License as published by the Free
7# Software Foundation, either version 3 of the License, or (at your option)
8# any later version.
9#
10# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
11# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
13# more details.
14#
15# You should have received a copy of the GNU General Public License along with
16# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
17
18"""add_serverowner_domainowner
19
20Revision ID: 46e92facee7
21Revises: 33e1f5f6fa8
22Create Date: 2015-03-20 16:01:25.007242
23
24"""
25
26# Revision identifiers, used by Alembic.
27revision = '46e92facee7'
28down_revision = '33e1f5f6fa8'
29
30from alembic import op
31import sqlalchemy as sa
32
33
34def upgrade():
35 op.create_table(
36 'domain_owner',
37 sa.Column('user_id', sa.Integer(), nullable=False),
38 sa.Column('domain_id', sa.Integer(), nullable=False),
39 sa.ForeignKeyConstraint(['domain_id'], ['domain.id'], ),
40 sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
41 sa.PrimaryKeyConstraint('user_id', 'domain_id')
42 )
43 op.add_column(
44 'user',
45 sa.Column('is_server_owner', sa.Boolean(), nullable=True))
46 if op.get_bind().dialect.name != 'sqlite':
47 op.drop_column('domain', 'contact_address')
48
49
50def downgrade():
51 if op.get_bind().dialect.name != 'sqlite':
52 op.drop_column('user', 'is_server_owner')
53 op.add_column(
54 'domain',
55 sa.Column('contact_address', sa.VARCHAR(), nullable=True))
56 op.drop_table('domain_owner')
057
=== 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 @@
88 description = Attribute(88 description = Attribute(
89 'The human readable description of the domain name.')89 'The human readable description of the domain name.')
9090
91 contact_address = Attribute("""\91 owners = Attribute("""\
92 The contact address for the human at this domain.92 The relationship with the user database representing domain owners""")
93 E.g. postmaster@example.com""")
9493
95 mailing_lists = Attribute(94 mailing_lists = Attribute(
96 """All mailing lists for this domain.95 """All mailing lists for this domain.
@@ -112,7 +111,7 @@
112class IDomainManager(Interface):111class IDomainManager(Interface):
113 """The manager of domains."""112 """The manager of domains."""
114113
115 def add(mail_host, description=None, base_url=None, contact_address=None):114 def add(mail_host, description=None, base_url=None, owners=None):
116 """Add a new domain.115 """Add a new domain.
117116
118 :param mail_host: The email host name for the domain.117 :param mail_host: The email host name for the domain.
@@ -123,11 +122,10 @@
123 interface of the domain. If not given, it defaults to122 interface of the domain. If not given, it defaults to
124 http://`mail_host`/123 http://`mail_host`/
125 :type base_url: string124 :type base_url: string
126 :param contact_address: The email contact address for the human125 :param owners: Sequence of owners of the domain, defaults to None,
127 managing the domain. If not given, defaults to126 meaning the domain does not have owners.
128 postmaster@`mail_host`127 :type owners: sequence of `IUser` or string emails.
129 :type contact_address: string128 :return: The new domain object.
130 :return: The new domain object
131 :rtype: `IDomain`129 :rtype: `IDomain`
132 :raises `BadDomainSpecificationError`: when the `mail_host` is130 :raises `BadDomainSpecificationError`: when the `mail_host` is
133 already registered.131 already registered.
134132
=== 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 @@
14::14::
1515
16 >>> from operator import attrgetter16 >>> from operator import attrgetter
17 >>> def show_domains():17 >>> def show_domains(*, with_owners=False):
18 ... if len(manager) == 0:18 ... if len(manager) == 0:
19 ... print('no domains')19 ... print('no domains')
20 ... return20 ... return
21 ... for domain in sorted(manager, key=attrgetter('mail_host')):21 ... for domain in sorted(manager, key=attrgetter('mail_host')):
22 ... print(domain)22 ... print(domain)
23 ... owners = sorted(owner.addresses[0].email
24 ... for owner in domain.owners)
25 ... for owner in owners:
26 ... print('- owner:', owner)
2327
24 >>> show_domains()28 >>> show_domains()
25 no domains29 no domains
@@ -28,17 +32,14 @@
28is the only required piece. The other parts are inferred from that.32is the only required piece. The other parts are inferred from that.
2933
30 >>> manager.add('example.org')34 >>> manager.add('example.org')
31 <Domain example.org, base_url: http://example.org,35 <Domain example.org, base_url: http://example.org>
32 contact_address: postmaster@example.org>
33 >>> show_domains()36 >>> show_domains()
34 <Domain example.org, base_url: http://example.org,37 <Domain example.org, base_url: http://example.org>
35 contact_address: postmaster@example.org>
3638
37We can remove domains too.39We can remove domains too.
3840
39 >>> manager.remove('example.org')41 >>> manager.remove('example.org')
40 <Domain example.org, base_url: http://example.org,42 <Domain example.org, base_url: http://example.org>
41 contact_address: postmaster@example.org>
42 >>> show_domains()43 >>> show_domains()
43 no domains44 no domains
4445
@@ -46,30 +47,39 @@
46web interface for the domain.47web interface for the domain.
4748
48 >>> manager.add('example.com', base_url='https://mail.example.com')49 >>> manager.add('example.com', base_url='https://mail.example.com')
49 <Domain example.com, base_url: https://mail.example.com,50 <Domain example.com, base_url: https://mail.example.com>
50 contact_address: postmaster@example.com>
51 >>> show_domains()51 >>> show_domains()
52 <Domain example.com, base_url: https://mail.example.com,52 <Domain example.com, base_url: https://mail.example.com>
53 contact_address: postmaster@example.com>
5453
55Domains can have explicit descriptions and contact addresses.54Domains can have explicit descriptions, and can be created with one or more
55owners.
56::56::
5757
58 >>> manager.add(58 >>> manager.add(
59 ... 'example.net',59 ... 'example.net',
60 ... base_url='http://lists.example.net',60 ... base_url='http://lists.example.net',
61 ... contact_address='postmaster@example.com',61 ... description='The example domain',
62 ... description='The example domain')62 ... owners=['anne@example.com'])
63 <Domain example.net, The example domain,63 <Domain example.net, The example domain,
64 base_url: http://lists.example.net,64 base_url: http://lists.example.net>
65 contact_address: postmaster@example.com>65
6666 >>> show_domains(with_owners=True)
67 >>> show_domains()67 <Domain example.com, base_url: https://mail.example.com>
68 <Domain example.com, base_url: https://mail.example.com,68 <Domain example.net, The example domain,
69 contact_address: postmaster@example.com>69 base_url: http://lists.example.net>
70 <Domain example.net, The example domain,70 - owner: anne@example.com
71 base_url: http://lists.example.net,71
72 contact_address: postmaster@example.com>72Domains can have multiple owners, ideally one of the owners should have a
73verified preferred address. However this is not checked right now and the
74configuration's default contact address may be used as a fallback.
75
76 >>> net_domain = manager['example.net']
77 >>> net_domain.add_owner('bart@example.org')
78 >>> show_domains(with_owners=True)
79 <Domain example.com, base_url: https://mail.example.com>
80 <Domain example.net, The example domain, base_url: http://lists.example.net>
81 - owner: anne@example.com
82 - owner: bart@example.org
7383
74Domains can list all associated mailing lists with the mailing_lists property.84Domains can list all associated mailing lists with the mailing_lists property.
75::85::
@@ -105,8 +115,7 @@
105115
106 >>> print(manager['example.net'])116 >>> print(manager['example.net'])
107 <Domain example.net, The example domain,117 <Domain example.net, The example domain,
108 base_url: http://lists.example.net,118 base_url: http://lists.example.net>
109 contact_address: postmaster@example.com>
110119
111As with dictionaries, you can also get the domain. If the domain does not120As with dictionaries, you can also get the domain. If the domain does not
112exist, ``None`` or a default is returned.121exist, ``None`` or a default is returned.
@@ -114,8 +123,7 @@
114123
115 >>> print(manager.get('example.net'))124 >>> print(manager.get('example.net'))
116 <Domain example.net, The example domain,125 <Domain example.net, The example domain,
117 base_url: http://lists.example.net,126 base_url: http://lists.example.net>
118 contact_address: postmaster@example.com>
119127
120 >>> print(manager.get('doesnotexist.com'))128 >>> print(manager.get('doesnotexist.com'))
121 None129 None
122130
=== 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 @@
120 message. If you think you are being maliciously subscribed to the list,120 message. If you think you are being maliciously subscribed to the list,
121 or have any other questions, you may contact121 or have any other questions, you may contact
122 <BLANKLINE>122 <BLANKLINE>
123 postmaster@example.com123 alpha-owner@example.com
124 <BLANKLINE>124 <BLANKLINE>
125 >>> dump_msgdata(items[0].msgdata)125 >>> dump_msgdata(items[0].msgdata)
126 _parsemsg : False126 _parsemsg : False
127127
=== 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 @@
295 zperson@example.org xtest_2.example.com MemberRole.owner295 zperson@example.org xtest_2.example.com MemberRole.owner
296296
297297
298Server owners
299=============
300
301Some users are server owners. Zoe is not yet a server owner.
302
303 >>> user_1.is_server_owner
304 False
305
306So, let's make her one.
307
308 >>> user_1.is_server_owner = True
309 >>> user_1.is_server_owner
310 True
311
312
298.. _`usermanager.txt`: usermanager.html313.. _`usermanager.txt`: usermanager.html
299314
=== 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 @@
28from mailman.interfaces.domain import (28from mailman.interfaces.domain import (
29 BadDomainSpecificationError, DomainCreatedEvent, DomainCreatingEvent,29 BadDomainSpecificationError, DomainCreatedEvent, DomainCreatingEvent,
30 DomainDeletedEvent, DomainDeletingEvent, IDomain, IDomainManager)30 DomainDeletedEvent, DomainDeletingEvent, IDomain, IDomainManager)
31from mailman.interfaces.user import IUser
32from mailman.interfaces.usermanager import IUserManager
31from mailman.model.mailinglist import MailingList33from mailman.model.mailinglist import MailingList
32from urllib.parse import urljoin, urlparse34from urllib.parse import urljoin, urlparse
33from sqlalchemy import Column, Integer, Unicode35from sqlalchemy import Column, Integer, Unicode
36from sqlalchemy.orm import relationship
34from zope.event import notify37from zope.event import notify
35from zope.interface import implementer38from zope.interface import implementer
39from zope.component import getUtility
3640
3741
3842
3943
@@ -44,15 +48,17 @@
4448
45 id = Column(Integer, primary_key=True)49 id = Column(Integer, primary_key=True)
4650
47 mail_host = Column(Unicode) # TODO: add index?51 mail_host = Column(Unicode)
48 base_url = Column(Unicode)52 base_url = Column(Unicode)
49 description = Column(Unicode)53 description = Column(Unicode)
50 contact_address = Column(Unicode)54 owners = relationship('User',
55 secondary='domain_owner',
56 backref='domains')
5157
52 def __init__(self, mail_host,58 def __init__(self, mail_host,
53 description=None,59 description=None,
54 base_url=None,60 base_url=None,
55 contact_address=None):61 owners=None):
56 """Create and register a domain.62 """Create and register a domain.
5763
58 :param mail_host: The host name for the email interface.64 :param mail_host: The host name for the email interface.
@@ -63,18 +69,16 @@
63 scheme. If not given, it will be constructed from the69 scheme. If not given, it will be constructed from the
64 `mail_host` using the http protocol.70 `mail_host` using the http protocol.
65 :type base_url: string71 :type base_url: string
66 :param contact_address: The email address to contact a human for this72 :param owners: Optional owners of this domain.
67 domain. If not given, postmaster@`mail_host` will be used.73 :type owners: sequence of `IUser` or string emails.
68 :type contact_address: string
69 """74 """
70 self.mail_host = mail_host75 self.mail_host = mail_host
71 self.base_url = (base_url76 self.base_url = (base_url
72 if base_url is not None77 if base_url is not None
73 else 'http://' + mail_host)78 else 'http://' + mail_host)
74 self.description = description79 self.description = description
75 self.contact_address = (contact_address80 if owners is not None:
76 if contact_address is not None81 self.add_owners(owners)
77 else 'postmaster@' + mail_host)
7882
79 @property83 @property
80 def url_host(self):84 def url_host(self):
@@ -103,12 +107,35 @@
103 def __repr__(self):107 def __repr__(self):
104 """repr(a_domain)"""108 """repr(a_domain)"""
105 if self.description is None:109 if self.description is None:
106 return ('<Domain {0.mail_host}, base_url: {0.base_url}, '110 return ('<Domain {0.mail_host}, base_url: {0.base_url}>').format(
107 'contact_address: {0.contact_address}>').format(self)111 self)
108 else:112 else:
109 return ('<Domain {0.mail_host}, {0.description}, '113 return ('<Domain {0.mail_host}, {0.description}, '
110 'base_url: {0.base_url}, '114 'base_url: {0.base_url}>').format(self)
111 'contact_address: {0.contact_address}>').format(self)115
116 def add_owner(self, owner):
117 """See `IDomain`."""
118 user_manager = getUtility(IUserManager)
119 if IUser.providedBy(owner):
120 user = owner
121 else:
122 user = user_manager.get_user(owner)
123 # BAW 2015-04-06: Make sure this path is tested.
124 if user is None:
125 user = user_manager.create_user(owner)
126 self.owners.append(user)
127
128 def add_owners(self, owners):
129 """See `IDomain`."""
130 # BAW 2015-04-06: This should probably be more efficient by inlining
131 # add_owner().
132 for owner in owners:
133 self.add_owner(owner)
134
135 def remove_owner(self, owner):
136 """See `IDomain`."""
137 user_manager = getUtility(IUserManager)
138 self.owners.remove(user_manager.get_user(owner))
112139
113140
114141
115142
@@ -121,7 +148,7 @@
121 mail_host,148 mail_host,
122 description=None,149 description=None,
123 base_url=None,150 base_url=None,
124 contact_address=None):151 owners=None):
125 """See `IDomainManager`."""152 """See `IDomainManager`."""
126 # Be sure the mail_host is not already registered. This is probably153 # Be sure the mail_host is not already registered. This is probably
127 # a constraint that should (also) be maintained in the database.154 # a constraint that should (also) be maintained in the database.
@@ -129,7 +156,7 @@
129 raise BadDomainSpecificationError(156 raise BadDomainSpecificationError(
130 'Duplicate email host: %s' % mail_host)157 'Duplicate email host: %s' % mail_host)
131 notify(DomainCreatingEvent(mail_host))158 notify(DomainCreatingEvent(mail_host))
132 domain = Domain(mail_host, description, base_url, contact_address)159 domain = Domain(mail_host, description, base_url, owners)
133 store.add(domain)160 store.add(domain)
134 notify(DomainCreatedEvent(domain))161 notify(DomainCreatedEvent(domain))
135 return domain162 return domain
136163
=== 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 @@
30 DomainCreatedEvent, DomainCreatingEvent, DomainDeletedEvent,30 DomainCreatedEvent, DomainCreatingEvent, DomainDeletedEvent,
31 DomainDeletingEvent, IDomainManager)31 DomainDeletingEvent, IDomainManager)
32from mailman.interfaces.listmanager import IListManager32from mailman.interfaces.listmanager import IListManager
33from mailman.interfaces.usermanager import IUserManager
33from mailman.testing.helpers import event_subscribers34from mailman.testing.helpers import event_subscribers
34from mailman.testing.layers import ConfigLayer35from mailman.testing.layers import ConfigLayer
35from zope.component import getUtility36from zope.component import getUtility
@@ -78,6 +79,98 @@
78 # Trying to delete a missing domain gives you a KeyError.79 # Trying to delete a missing domain gives you a KeyError.
79 self.assertRaises(KeyError, self._manager.remove, 'doesnotexist.com')80 self.assertRaises(KeyError, self._manager.remove, 'doesnotexist.com')
8081
82 def test_domain_creation_no_default_owners(self):
83 # If a domain is created without owners, then it has none.
84 domain = self._manager.add('example.org')
85 self.assertEqual(len(domain.owners), 0)
86
87 def test_domain_creation_with_owner(self):
88 # You can create a new domain with a single owner.
89 domain = self._manager.add('example.org', owners=['anne@example.org'])
90 self.assertEqual(len(domain.owners), 1)
91 self.assertEqual(domain.owners[0].addresses[0].email,
92 'anne@example.org')
93
94 def test_domain_creation_with_owners(self):
95 # You can create a new domain with multiple owners.
96 domain = self._manager.add(
97 'example.org', owners=['anne@example.org',
98 'bart@example.net'])
99 self.assertEqual(len(domain.owners), 2)
100 self.assertEqual(
101 sorted(owner.addresses[0].email for owner in domain.owners),
102 ['anne@example.org', 'bart@example.net'])
103
104 def test_domain_creation_creates_new_users(self):
105 # Domain creation with existing users does not create new users, but
106 # any user which doesn't yet exist (and is linked to the given
107 # address), gets created.
108 user_manager = getUtility(IUserManager)
109 user_manager.make_user('anne@example.com')
110 user_manager.make_user('bart@example.com')
111 domain = self._manager.add(
112 'example.org', owners=['anne@example.com',
113 'bart@example.com',
114 'cris@example.com'])
115 self.assertEqual(len(domain.owners), 3)
116 self.assertEqual(
117 sorted(owner.addresses[0].email for owner in domain.owners),
118 ['anne@example.com', 'bart@example.com', 'cris@example.com'])
119 # Now cris exists as a user.
120 self.assertIsNotNone(user_manager.get_user('cris@example.com'))
121
122 def test_domain_creation_with_users(self):
123 # Domains can be created with IUser objects.
124 user_manager = getUtility(IUserManager)
125 anne = user_manager.make_user('anne@example.com')
126 bart = user_manager.make_user('bart@example.com')
127 domain = self._manager.add('example.org', owners=[anne, bart])
128 self.assertEqual(len(domain.owners), 2)
129 self.assertEqual(
130 sorted(owner.addresses[0].email for owner in domain.owners),
131 ['anne@example.com', 'bart@example.com'])
132 def sort_key(owner):
133 return owner.addresses[0].email
134 self.assertEqual(sorted(domain.owners, key=sort_key), [anne, bart])
135
136 def test_add_domain_owner(self):
137 # Domain owners can be added after the domain is created.
138 domain = self._manager.add('example.org')
139 self.assertEqual(len(domain.owners), 0)
140 domain.add_owner('anne@example.org')
141 self.assertEqual(len(domain.owners), 1)
142 self.assertEqual(domain.owners[0].addresses[0].email,
143 'anne@example.org')
144
145 def test_add_multiple_domain_owners(self):
146 # Multiple domain owners can be added after the domain is created.
147 domain = self._manager.add('example.org')
148 self.assertEqual(len(domain.owners), 0)
149 domain.add_owners(['anne@example.org', 'bart@example.net'])
150 self.assertEqual(len(domain.owners), 2)
151 self.assertEqual([owner.addresses[0].email for owner in domain.owners],
152 ['anne@example.org', 'bart@example.net'])
153
154 def test_remove_domain_owner(self):
155 # Domain onwers can be removed.
156 domain = self._manager.add(
157 'example.org', owners=['anne@example.org',
158 'bart@example.net'])
159 domain.remove_owner('anne@example.org')
160 self.assertEqual(len(domain.owners), 1)
161 self.assertEqual([owner.addresses[0].email for owner in domain.owners],
162 ['bart@example.net'])
163
164 def test_remove_missing_owner(self):
165 # Users which aren't owners can't be removed.
166 domain = self._manager.add(
167 'example.org', owners=['anne@example.org',
168 'bart@example.net'])
169 self.assertRaises(ValueError, domain.remove_owner, 'cris@example.org')
170 self.assertEqual(len(domain.owners), 2)
171 self.assertEqual([owner.addresses[0].email for owner in domain.owners],
172 ['anne@example.org', 'bart@example.net'])
173
81174
82175
83176
84class TestDomainLifecycleEvents(unittest.TestCase):177class TestDomainLifecycleEvents(unittest.TestCase):
85178
=== 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 @@
18"""Model for users."""18"""Model for users."""
1919
20__all__ = [20__all__ = [
21 'DomainOwner',
21 'User',22 'User',
22 ]23 ]
2324
@@ -34,7 +35,7 @@
34from mailman.model.roster import Memberships35from mailman.model.roster import Memberships
35from mailman.utilities.datetime import factory as date_factory36from mailman.utilities.datetime import factory as date_factory
36from mailman.utilities.uid import UniqueIDFactory37from mailman.utilities.uid import UniqueIDFactory
37from sqlalchemy import Column, DateTime, ForeignKey, Integer, Unicode38from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, Unicode
38from sqlalchemy.orm import relationship, backref39from sqlalchemy.orm import relationship, backref
39from zope.event import notify40from zope.event import notify
40from zope.interface import implementer41from zope.interface import implementer
@@ -55,6 +56,7 @@
55 _password = Column('password', Unicode)56 _password = Column('password', Unicode)
56 _user_id = Column(UUID, index=True)57 _user_id = Column(UUID, index=True)
57 _created_on = Column(DateTime)58 _created_on = Column(DateTime)
59 is_server_owner = Column(Boolean, default=False)
5860
59 addresses = relationship(61 addresses = relationship(
60 'Address', backref='user',62 'Address', backref='user',
@@ -176,3 +178,13 @@
176 @property178 @property
177 def memberships(self):179 def memberships(self):
178 return Memberships(self)180 return Memberships(self)
181
182
183
179184
185class DomainOwner(Model):
186 """Internal table for associating domains to their owners."""
187
188 __tablename__ = 'domain_owner'
189
190 user_id = Column(Integer, ForeignKey('user.id'), primary_key=True)
191 domain_id = Column(Integer, ForeignKey('domain.id'), primary_key=True)
180192
=== 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 @@
190 created_on: 2005-08-01T07:49:23190 created_on: 2005-08-01T07:49:23
191 display_name: Cris X. Person191 display_name: Cris X. Person
192 http_etag: "..."192 http_etag: "..."
193 is_server_owner: False
193 password: ...194 password: ...
194 self_link: http://localhost:9001/3.0/users/1195 self_link: http://localhost:9001/3.0/users/1
195 user_id: 1196 user_id: 1
196197
=== 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 @@
2828
29 >>> domain_manager.add(29 >>> domain_manager.add(
30 ... 'example.com', 'An example domain', 'http://lists.example.com')30 ... 'example.com', 'An example domain', 'http://lists.example.com')
31 <Domain example.com, An example domain,31 <Domain example.com, An example domain, base_url: http://lists.example.com>
32 base_url: http://lists.example.com,
33 contact_address: postmaster@example.com>
34 >>> transaction.commit()32 >>> transaction.commit()
3533
36 >>> dump_json('http://localhost:9001/3.0/domains')34 >>> dump_json('http://localhost:9001/3.0/domains')
37 entry 0:35 entry 0:
38 base_url: http://lists.example.com36 base_url: http://lists.example.com
39 contact_address: postmaster@example.com
40 description: An example domain37 description: An example domain
41 http_etag: "..."38 http_etag: "..."
42 mail_host: example.com39 mail_host: example.com
@@ -51,24 +48,18 @@
5148
52 >>> domain_manager.add(49 >>> domain_manager.add(
53 ... 'example.org',50 ... 'example.org',
54 ... base_url='http://mail.example.org',51 ... base_url='http://mail.example.org')
55 ... contact_address='listmaster@example.org')52 <Domain example.org, base_url: http://mail.example.org>
56 <Domain example.org, base_url: http://mail.example.org,
57 contact_address: listmaster@example.org>
58 >>> domain_manager.add(53 >>> domain_manager.add(
59 ... 'lists.example.net',54 ... 'lists.example.net',
60 ... 'Porkmasters',55 ... 'Porkmasters',
61 ... 'http://example.net',56 ... 'http://example.net')
62 ... 'porkmaster@example.net')57 <Domain lists.example.net, Porkmasters, base_url: http://example.net>
63 <Domain lists.example.net, Porkmasters,
64 base_url: http://example.net,
65 contact_address: porkmaster@example.net>
66 >>> transaction.commit()58 >>> transaction.commit()
6759
68 >>> dump_json('http://localhost:9001/3.0/domains')60 >>> dump_json('http://localhost:9001/3.0/domains')
69 entry 0:61 entry 0:
70 base_url: http://lists.example.com62 base_url: http://lists.example.com
71 contact_address: postmaster@example.com
72 description: An example domain63 description: An example domain
73 http_etag: "..."64 http_etag: "..."
74 mail_host: example.com65 mail_host: example.com
@@ -76,7 +67,6 @@
76 url_host: lists.example.com67 url_host: lists.example.com
77 entry 1:68 entry 1:
78 base_url: http://mail.example.org69 base_url: http://mail.example.org
79 contact_address: listmaster@example.org
80 description: None70 description: None
81 http_etag: "..."71 http_etag: "..."
82 mail_host: example.org72 mail_host: example.org
@@ -84,7 +74,6 @@
84 url_host: mail.example.org74 url_host: mail.example.org
85 entry 2:75 entry 2:
86 base_url: http://example.net76 base_url: http://example.net
87 contact_address: porkmaster@example.net
88 description: Porkmasters77 description: Porkmasters
89 http_etag: "..."78 http_etag: "..."
90 mail_host: lists.example.net79 mail_host: lists.example.net
@@ -103,7 +92,6 @@
10392
104 >>> dump_json('http://localhost:9001/3.0/domains/lists.example.net')93 >>> dump_json('http://localhost:9001/3.0/domains/lists.example.net')
105 base_url: http://example.net94 base_url: http://example.net
106 contact_address: porkmaster@example.net
107 description: Porkmasters95 description: Porkmasters
108 http_etag: "..."96 http_etag: "..."
109 mail_host: lists.example.net97 mail_host: lists.example.net
@@ -165,7 +153,6 @@
165153
166 >>> dump_json('http://localhost:9001/3.0/domains/lists.example.com')154 >>> dump_json('http://localhost:9001/3.0/domains/lists.example.com')
167 base_url: http://lists.example.com155 base_url: http://lists.example.com
168 contact_address: postmaster@lists.example.com
169 description: None156 description: None
170 http_etag: "..."157 http_etag: "..."
171 mail_host: lists.example.com158 mail_host: lists.example.com
@@ -176,9 +163,7 @@
176::163::
177164
178 >>> domain_manager['lists.example.com']165 >>> domain_manager['lists.example.com']
179 <Domain lists.example.com,166 <Domain lists.example.com, base_url: http://lists.example.com>
180 base_url: http://lists.example.com,
181 contact_address: postmaster@lists.example.com>
182167
183 # Unlock the database.168 # Unlock the database.
184 >>> transaction.abort()169 >>> transaction.abort()
@@ -190,8 +175,7 @@
190 >>> dump_json('http://localhost:9001/3.0/domains', {175 >>> dump_json('http://localhost:9001/3.0/domains', {
191 ... 'mail_host': 'my.example.com',176 ... 'mail_host': 'my.example.com',
192 ... 'description': 'My new domain',177 ... 'description': 'My new domain',
193 ... 'base_url': 'http://allmy.example.com',178 ... 'base_url': 'http://allmy.example.com'
194 ... 'contact_address': 'helpme@example.com'
195 ... })179 ... })
196 content-length: 0180 content-length: 0
197 date: ...181 date: ...
@@ -200,7 +184,6 @@
200184
201 >>> dump_json('http://localhost:9001/3.0/domains/my.example.com')185 >>> dump_json('http://localhost:9001/3.0/domains/my.example.com')
202 base_url: http://allmy.example.com186 base_url: http://allmy.example.com
203 contact_address: helpme@example.com
204 description: My new domain187 description: My new domain
205 http_etag: "..."188 http_etag: "..."
206 mail_host: my.example.com189 mail_host: my.example.com
@@ -208,9 +191,7 @@
208 url_host: allmy.example.com191 url_host: allmy.example.com
209192
210 >>> domain_manager['my.example.com']193 >>> domain_manager['my.example.com']
211 <Domain my.example.com, My new domain,194 <Domain my.example.com, My new domain, base_url: http://allmy.example.com>
212 base_url: http://allmy.example.com,
213 contact_address: helpme@example.com>
214195
215 # Unlock the database.196 # Unlock the database.
216 >>> transaction.abort()197 >>> transaction.abort()
@@ -229,4 +210,92 @@
229 status: 204210 status: 204
230211
231212
213Domain owners
214=============
215
216Domains can have owners. By posting some addresses to the owners resource,
217you can add some domain owners. Currently our domain has no owners:
218
219 >>> dump_json('http://localhost:9001/3.0/domains/my.example.com/owners')
220 http_etag: ...
221 start: 0
222 total_size: 0
223
224Anne and Bart volunteer to be a domain owners.
225::
226
227 >>> dump_json('http://localhost:9001/3.0/domains/my.example.com/owners', (
228 ... ('owner', 'anne@example.com'), ('owner', 'bart@example.com')
229 ... ))
230 content-length: 0
231 date: ...
232 server: ...
233 status: 204
234
235 >>> dump_json('http://localhost:9001/3.0/domains/my.example.com/owners')
236 entry 0:
237 created_on: 2005-08-01T07:49:23
238 http_etag: ...
239 is_server_owner: False
240 self_link: http://localhost:9001/3.0/users/1
241 user_id: 1
242 entry 1:
243 created_on: 2005-08-01T07:49:23
244 http_etag: ...
245 is_server_owner: False
246 self_link: http://localhost:9001/3.0/users/2
247 user_id: 2
248 http_etag: ...
249 start: 0
250 total_size: 2
251
252We can delete all the domain owners.
253
254 >>> dump_json('http://localhost:9001/3.0/domains/my.example.com/owners',
255 ... method='DELETE')
256 content-length: 0
257 date: ...
258 server: ...
259 status: 204
260
261Now there are no owners.
262
263 >>> dump_json('http://localhost:9001/3.0/domains/my.example.com/owners')
264 http_etag: ...
265 start: 0
266 total_size: 0
267
268New domains can be created with owners.
269
270 >>> dump_json('http://localhost:9001/3.0/domains', (
271 ... ('mail_host', 'your.example.com'),
272 ... ('owner', 'anne@example.com'),
273 ... ('owner', 'bart@example.com'),
274 ... ))
275 content-length: 0
276 date: ...
277 location: http://localhost:9001/3.0/domains/your.example.com
278 server: ...
279 status: 201
280
281The new domain has the expected owners.
282
283 >>> dump_json('http://localhost:9001/3.0/domains/your.example.com/owners')
284 entry 0:
285 created_on: 2005-08-01T07:49:23
286 http_etag: ...
287 is_server_owner: False
288 self_link: http://localhost:9001/3.0/users/1
289 user_id: 1
290 entry 1:
291 created_on: 2005-08-01T07:49:23
292 http_etag: ...
293 is_server_owner: False
294 self_link: http://localhost:9001/3.0/users/2
295 user_id: 2
296 http_etag: ...
297 start: 0
298 total_size: 2
299
300
232.. _Domains: ../../model/docs/domains.html301.. _Domains: ../../model/docs/domains.html
233302
=== 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 @@
34 created_on: 2005-08-01T07:49:2334 created_on: 2005-08-01T07:49:23
35 display_name: Anne Person35 display_name: Anne Person
36 http_etag: "..."36 http_etag: "..."
37 is_server_owner: False
37 self_link: http://localhost:9001/3.0/users/138 self_link: http://localhost:9001/3.0/users/1
38 user_id: 139 user_id: 1
39 http_etag: "..."40 http_etag: "..."
@@ -50,11 +51,13 @@
50 created_on: 2005-08-01T07:49:2351 created_on: 2005-08-01T07:49:23
51 display_name: Anne Person52 display_name: Anne Person
52 http_etag: "..."53 http_etag: "..."
54 is_server_owner: False
53 self_link: http://localhost:9001/3.0/users/155 self_link: http://localhost:9001/3.0/users/1
54 user_id: 156 user_id: 1
55 entry 1:57 entry 1:
56 created_on: 2005-08-01T07:49:2358 created_on: 2005-08-01T07:49:23
57 http_etag: "..."59 http_etag: "..."
60 is_server_owner: False
58 self_link: http://localhost:9001/3.0/users/261 self_link: http://localhost:9001/3.0/users/2
59 user_id: 262 user_id: 2
60 http_etag: "..."63 http_etag: "..."
@@ -76,6 +79,7 @@
76 created_on: 2005-08-01T07:49:2379 created_on: 2005-08-01T07:49:23
77 display_name: Anne Person80 display_name: Anne Person
78 http_etag: "..."81 http_etag: "..."
82 is_server_owner: False
79 self_link: http://localhost:9001/3.0/users/183 self_link: http://localhost:9001/3.0/users/1
80 user_id: 184 user_id: 1
81 http_etag: "..."85 http_etag: "..."
@@ -86,6 +90,7 @@
86 entry 0:90 entry 0:
87 created_on: 2005-08-01T07:49:2391 created_on: 2005-08-01T07:49:23
88 http_etag: "..."92 http_etag: "..."
93 is_server_owner: False
89 self_link: http://localhost:9001/3.0/users/294 self_link: http://localhost:9001/3.0/users/2
90 user_id: 295 user_id: 2
91 http_etag: "..."96 http_etag: "..."
@@ -120,6 +125,7 @@
120 >>> dump_json('http://localhost:9001/3.0/users/3')125 >>> dump_json('http://localhost:9001/3.0/users/3')
121 created_on: 2005-08-01T07:49:23126 created_on: 2005-08-01T07:49:23
122 http_etag: "..."127 http_etag: "..."
128 is_server_owner: False
123 password: {plaintext}...129 password: {plaintext}...
124 self_link: http://localhost:9001/3.0/users/3130 self_link: http://localhost:9001/3.0/users/3
125 user_id: 3131 user_id: 3
@@ -131,6 +137,7 @@
131 >>> dump_json('http://localhost:9001/3.0/users/cris@example.com')137 >>> dump_json('http://localhost:9001/3.0/users/cris@example.com')
132 created_on: 2005-08-01T07:49:23138 created_on: 2005-08-01T07:49:23
133 http_etag: "..."139 http_etag: "..."
140 is_server_owner: False
134 password: {plaintext}...141 password: {plaintext}...
135 self_link: http://localhost:9001/3.0/users/3142 self_link: http://localhost:9001/3.0/users/3
136 user_id: 3143 user_id: 3
@@ -158,6 +165,7 @@
158 created_on: 2005-08-01T07:49:23165 created_on: 2005-08-01T07:49:23
159 display_name: Dave Person166 display_name: Dave Person
160 http_etag: "..."167 http_etag: "..."
168 is_server_owner: False
161 password: {plaintext}...169 password: {plaintext}...
162 self_link: http://localhost:9001/3.0/users/4170 self_link: http://localhost:9001/3.0/users/4
163 user_id: 4171 user_id: 4
@@ -190,6 +198,7 @@
190 created_on: 2005-08-01T07:49:23198 created_on: 2005-08-01T07:49:23
191 display_name: Elly Person199 display_name: Elly Person
192 http_etag: "..."200 http_etag: "..."
201 is_server_owner: False
193 password: {plaintext}supersekrit202 password: {plaintext}supersekrit
194 self_link: http://localhost:9001/3.0/users/5203 self_link: http://localhost:9001/3.0/users/5
195 user_id: 5204 user_id: 5
@@ -214,6 +223,7 @@
214 created_on: 2005-08-01T07:49:23223 created_on: 2005-08-01T07:49:23
215 display_name: David Person224 display_name: David Person
216 http_etag: "..."225 http_etag: "..."
226 is_server_owner: False
217 password: {plaintext}...227 password: {plaintext}...
218 self_link: http://localhost:9001/3.0/users/4228 self_link: http://localhost:9001/3.0/users/4
219 user_id: 4229 user_id: 4
@@ -238,6 +248,7 @@
238 created_on: 2005-08-01T07:49:23248 created_on: 2005-08-01T07:49:23
239 display_name: David Person249 display_name: David Person
240 http_etag: "..."250 http_etag: "..."
251 is_server_owner: False
241 password: {plaintext}clockwork angels252 password: {plaintext}clockwork angels
242 self_link: http://localhost:9001/3.0/users/4253 self_link: http://localhost:9001/3.0/users/4
243 user_id: 4254 user_id: 4
@@ -246,8 +257,9 @@
246resource.257resource.
247258
248 >>> dump_json('http://localhost:9001/3.0/users/4', {259 >>> dump_json('http://localhost:9001/3.0/users/4', {
260 ... 'cleartext_password': 'the garden',
249 ... 'display_name': 'David Personhood',261 ... 'display_name': 'David Personhood',
250 ... 'cleartext_password': 'the garden',262 ... 'is_server_owner': False,
251 ... }, method='PUT')263 ... }, method='PUT')
252 content-length: 0264 content-length: 0
253 date: ...265 date: ...
@@ -260,6 +272,7 @@
260 created_on: 2005-08-01T07:49:23272 created_on: 2005-08-01T07:49:23
261 display_name: David Personhood273 display_name: David Personhood
262 http_etag: "..."274 http_etag: "..."
275 is_server_owner: False
263 password: {plaintext}the garden276 password: {plaintext}the garden
264 self_link: http://localhost:9001/3.0/users/4277 self_link: http://localhost:9001/3.0/users/4
265 user_id: 4278 user_id: 4
@@ -343,6 +356,7 @@
343 created_on: 2005-08-01T07:49:23356 created_on: 2005-08-01T07:49:23
344 display_name: Fred Person357 display_name: Fred Person
345 http_etag: "..."358 http_etag: "..."
359 is_server_owner: False
346 self_link: http://localhost:9001/3.0/users/6360 self_link: http://localhost:9001/3.0/users/6
347 user_id: 6361 user_id: 6
348362
@@ -350,6 +364,7 @@
350 created_on: 2005-08-01T07:49:23364 created_on: 2005-08-01T07:49:23
351 display_name: Fred Person365 display_name: Fred Person
352 http_etag: "..."366 http_etag: "..."
367 is_server_owner: False
353 self_link: http://localhost:9001/3.0/users/6368 self_link: http://localhost:9001/3.0/users/6
354 user_id: 6369 user_id: 6
355370
@@ -357,6 +372,7 @@
357 created_on: 2005-08-01T07:49:23372 created_on: 2005-08-01T07:49:23
358 display_name: Fred Person373 display_name: Fred Person
359 http_etag: "..."374 http_etag: "..."
375 is_server_owner: False
360 self_link: http://localhost:9001/3.0/users/6376 self_link: http://localhost:9001/3.0/users/6
361 user_id: 6377 user_id: 6
362378
@@ -364,6 +380,7 @@
364 created_on: 2005-08-01T07:49:23380 created_on: 2005-08-01T07:49:23
365 display_name: Fred Person381 display_name: Fred Person
366 http_etag: "..."382 http_etag: "..."
383 is_server_owner: False
367 self_link: http://localhost:9001/3.0/users/6384 self_link: http://localhost:9001/3.0/users/6
368 user_id: 6385 user_id: 6
369386
@@ -382,6 +399,7 @@
382 created_on: 2005-08-01T07:49:23399 created_on: 2005-08-01T07:49:23
383 display_name: Elly Person400 display_name: Elly Person
384 http_etag: "..."401 http_etag: "..."
402 is_server_owner: False
385 password: {plaintext}supersekrit403 password: {plaintext}supersekrit
386 self_link: http://localhost:9001/3.0/users/5404 self_link: http://localhost:9001/3.0/users/5
387 user_id: 5405 user_id: 5
@@ -399,3 +417,82 @@
399 date: ...417 date: ...
400 server: ...418 server: ...
401 status: 204419 status: 204
420
421
422Server owners
423=============
424
425Users can be designated as server owners. Elly is not currently a server
426owner.
427
428 >>> dump_json('http://localhost:9001/3.0/users/5')
429 created_on: 2005-08-01T07:49:23
430 display_name: Elly Person
431 http_etag: "..."
432 is_server_owner: False
433 password: {plaintext}supersekrit
434 self_link: http://localhost:9001/3.0/users/5
435 user_id: 5
436
437Let's make her a server owner.
438::
439
440 >>> dump_json('http://localhost:9001/3.0/users/5', {
441 ... 'is_server_owner': True,
442 ... }, method='PATCH')
443 content-length: 0
444 date: ...
445 server: ...
446 status: 204
447
448 >>> dump_json('http://localhost:9001/3.0/users/5')
449 created_on: 2005-08-01T07:49:23
450 display_name: Elly Person
451 http_etag: "..."
452 is_server_owner: True
453 password: {plaintext}supersekrit
454 self_link: http://localhost:9001/3.0/users/5
455 user_id: 5
456
457Elly later retires as server owner.
458::
459
460 >>> dump_json('http://localhost:9001/3.0/users/5', {
461 ... 'is_server_owner': False,
462 ... }, method='PATCH')
463 content-length: 0
464 date: ...
465 server: ...
466 status: 204
467
468 >>> dump_json('http://localhost:9001/3.0/users/5')
469 created_on: 2005-08-01T07:49:23
470 display_name: Elly Person
471 http_etag: "..."
472 is_server_owner: False
473 password: {plaintext}...
474 self_link: http://localhost:9001/3.0/users/5
475 user_id: 5
476
477Gwen, a new users, takes over as a server owner.
478::
479
480 >>> dump_json('http://localhost:9001/3.0/users', {
481 ... 'display_name': 'Gwen Person',
482 ... 'email': 'gwen@example.com',
483 ... 'is_server_owner': True,
484 ... })
485 content-length: 0
486 date: ...
487 location: http://localhost:9001/3.0/users/7
488 server: ...
489 status: 201
490
491 >>> dump_json('http://localhost:9001/3.0/users/7')
492 created_on: 2005-08-01T07:49:23
493 display_name: Gwen Person
494 http_etag: "..."
495 is_server_owner: True
496 password: {plaintext}...
497 self_link: http://localhost:9001/3.0/users/7
498 user_id: 7
402499
=== 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 @@
29 BadRequest, CollectionMixin, NotFound, bad_request, child, created, etag,29 BadRequest, CollectionMixin, NotFound, bad_request, child, created, etag,
30 no_content, not_found, okay, path_to)30 no_content, not_found, okay, path_to)
31from mailman.rest.lists import ListsForDomain31from mailman.rest.lists import ListsForDomain
32from mailman.rest.validator import Validator32from mailman.rest.users import OwnersForDomain
33from mailman.rest.validator import Validator, list_of_strings_validator
33from zope.component import getUtility34from zope.component import getUtility
3435
3536
@@ -41,7 +42,6 @@
41 """See `CollectionMixin`."""42 """See `CollectionMixin`."""
42 return dict(43 return dict(
43 base_url=domain.base_url,44 base_url=domain.base_url,
44 contact_address=domain.contact_address,
45 description=domain.description,45 description=domain.description,
46 mail_host=domain.mail_host,46 mail_host=domain.mail_host,
47 self_link=path_to('domains/{0}'.format(domain.mail_host)),47 self_link=path_to('domains/{0}'.format(domain.mail_host)),
@@ -88,6 +88,17 @@
88 else:88 else:
89 return BadRequest(), []89 return BadRequest(), []
9090
91 @child()
92 def owners(self, request, segments):
93 """/domains/<domain>/owners"""
94 if len(segments) == 0:
95 domain = getUtility(IDomainManager).get(self._domain)
96 if domain is None:
97 return NotFound()
98 return OwnersForDomain(domain)
99 else:
100 return BadRequest(), []
101
91102
92class AllDomains(_DomainBase):103class AllDomains(_DomainBase):
93 """The domains."""104 """The domains."""
@@ -99,12 +110,18 @@
99 validator = Validator(mail_host=str,110 validator = Validator(mail_host=str,
100 description=str,111 description=str,
101 base_url=str,112 base_url=str,
102 contact_address=str,113 owner=list_of_strings_validator,
103 _optional=('description', 'base_url',114 _optional=(
104 'contact_address'))115 'description', 'base_url', 'owner'))
105 domain = domain_manager.add(**validator(request))116 values = validator(request)
106 except BadDomainSpecificationError:117 # For consistency, owners are passed in as multiple `owner` keys,
107 bad_request(response, b'Domain exists')118 # but .add() requires an `owners` keyword. Match impedence.
119 owners = values.pop('owner', None)
120 if owners is not None:
121 values['owners'] = owners
122 domain = domain_manager.add(**values)
123 except BadDomainSpecificationError as error:
124 bad_request(response, str(error))
108 except ValueError as error:125 except ValueError as error:
109 bad_request(response, str(error))126 bad_request(response, str(error))
110 else:127 else:
111128
=== 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 @@
32from mailman.interfaces.mailinglist import IAcceptableAliasSet, ReplyToMunging32from mailman.interfaces.mailinglist import IAcceptableAliasSet, ReplyToMunging
33from mailman.rest.helpers import (33from mailman.rest.helpers import (
34 GetterSetter, bad_request, etag, no_content, okay)34 GetterSetter, bad_request, etag, no_content, okay)
35from mailman.rest.validator import PatchValidator, Validator, enum_validator35from mailman.rest.validator import (
36 PatchValidator, Validator, enum_validator, list_of_strings_validator)
3637
3738
3839
3940
@@ -72,14 +73,6 @@
72 raise ValueError('Unknown pipeline: {}'.format(pipeline_name))73 raise ValueError('Unknown pipeline: {}'.format(pipeline_name))
7374
7475
75def list_of_str(values):
76 """Turn a list of things into a list of unicodes."""
77 for value in values:
78 if not isinstance(value, str):
79 raise ValueError('Expected str, got {!r}'.format(value))
80 return values
81
82
8376
8477
85# This is the list of IMailingList attributes that are exposed through the78# This is the list of IMailingList attributes that are exposed through the
86# REST API. The values of the keys are the GetterSetter instance holding the79# REST API. The values of the keys are the GetterSetter instance holding the
@@ -96,7 +89,7 @@
96# (e.g. datetimes, timedeltas, enums).89# (e.g. datetimes, timedeltas, enums).
9790
98ATTRIBUTES = dict(91ATTRIBUTES = dict(
99 acceptable_aliases=AcceptableAliases(list_of_str),92 acceptable_aliases=AcceptableAliases(list_of_strings_validator),
100 admin_immed_notify=GetterSetter(as_boolean),93 admin_immed_notify=GetterSetter(as_boolean),
101 admin_notify_mchanges=GetterSetter(as_boolean),94 admin_notify_mchanges=GetterSetter(as_boolean),
102 administrivia=GetterSetter(as_boolean),95 administrivia=GetterSetter(as_boolean),
10396
=== 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 @@
18"""REST domain tests."""18"""REST domain tests."""
1919
20__all__ = [20__all__ = [
21 'TestDomainOwners',
21 'TestDomains',22 'TestDomains',
22 ]23 ]
2324
@@ -41,6 +42,17 @@
41 with transaction():42 with transaction():
42 self._mlist = create_list('test@example.com')43 self._mlist = create_list('test@example.com')
4344
45 def test_create_domains(self):
46 """Test Create domain via REST"""
47 data = {'mail_host': 'example.org',
48 'description': 'Example domain',
49 'base_url': 'http://example.org',
50 'owners': ['someone@example.com',
51 'secondowner@example.com',]}
52 content, response = call_api('http://localhost:9001/3.0/domains',
53 data, method="POST")
54 self.assertEqual(response.status, 201)
55
44 def test_bogus_endpoint_extension(self):56 def test_bogus_endpoint_extension(self):
45 # /domains/<domain>/lists/<anything> is not a valid endpoint.57 # /domains/<domain>/lists/<anything> is not a valid endpoint.
46 with self.assertRaises(HTTPError) as cm:58 with self.assertRaises(HTTPError) as cm:
@@ -87,3 +99,45 @@
87 call_api('http://localhost:9001/3.0/domains/example.com',99 call_api('http://localhost:9001/3.0/domains/example.com',
88 method='DELETE')100 method='DELETE')
89 self.assertEqual(cm.exception.code, 404)101 self.assertEqual(cm.exception.code, 404)
102
103
104
90105
106class TestDomainOwners(unittest.TestCase):
107 layer = RESTLayer
108
109 def test_get_missing_domain_owners(self):
110 # Try to get the owners of a missing domain.
111 with self.assertRaises(HTTPError) as cm:
112 call_api('http://localhost:9001/3.0/domains/example.net/owners')
113 self.assertEqual(cm.exception.code, 404)
114
115 def test_post_to_missing_domain_owners(self):
116 # Try to add owners to a missing domain.
117 with self.assertRaises(HTTPError) as cm:
118 call_api('http://localhost:9001/3.0/domains/example.net/owners', (
119 ('owner', 'dave@example.com'), ('owner', 'elle@example.com'),
120 ))
121 self.assertEqual(cm.exception.code, 404)
122
123 def test_delete_missing_domain_owners(self):
124 # Try to delete the owners of a missing domain.
125 with self.assertRaises(HTTPError) as cm:
126 call_api('http://localhost:9001/3.0/domains/example.net/owners',
127 method='DELETE')
128 self.assertEqual(cm.exception.code, 404)
129
130 def test_bad_post(self):
131 # Send POST data with an invalid attribute.
132 with self.assertRaises(HTTPError) as cm:
133 call_api('http://localhost:9001/3.0/domains/example.com/owners', (
134 ('guy', 'dave@example.com'), ('gal', 'elle@example.com'),
135 ))
136 self.assertEqual(cm.exception.code, 400)
137
138 def test_bad_delete(self):
139 # Send DELETE with any data.
140 with self.assertRaises(HTTPError) as cm:
141 call_api('http://localhost:9001/3.0/domains/example.com/owners', {
142 'owner': 'dave@example.com',
143 }, method='DELETE')
144 self.assertEqual(cm.exception.code, 400)
91145
=== 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 @@
22 'AddressUser',22 'AddressUser',
23 'AllUsers',23 'AllUsers',
24 'Login',24 'Login',
25 'OwnersForDomain',
25 ]26 ]
2627
2728
@@ -37,7 +38,8 @@
37 conflict, created, etag, forbidden, no_content, not_found, okay, paginate,38 conflict, created, etag, forbidden, no_content, not_found, okay, paginate,
38 path_to)39 path_to)
39from mailman.rest.preferences import Preferences40from mailman.rest.preferences import Preferences
40from mailman.rest.validator import PatchValidator, Validator41from mailman.rest.validator import (
42 PatchValidator, Validator, list_of_strings_validator)
41from passlib.utils import generate_password as generate43from passlib.utils import generate_password as generate
42from uuid import UUID44from uuid import UUID
43from zope.component import getUtility45from zope.component import getUtility
@@ -47,27 +49,42 @@
47# Attributes of a user which can be changed via the REST API.49# Attributes of a user which can be changed via the REST API.
48class PasswordEncrypterGetterSetter(GetterSetter):50class PasswordEncrypterGetterSetter(GetterSetter):
49 def __init__(self):51 def __init__(self):
50 super(PasswordEncrypterGetterSetter, self).__init__(52 super().__init__(config.password_context.encrypt)
51 config.password_context.encrypt)
52 def get(self, obj, attribute):53 def get(self, obj, attribute):
53 assert attribute == 'cleartext_password'54 assert attribute == 'cleartext_password'
54 super(PasswordEncrypterGetterSetter, self).get(obj, 'password')55 super().get(obj, 'password')
55 def put(self, obj, attribute, value):56 def put(self, obj, attribute, value):
56 assert attribute == 'cleartext_password'57 assert attribute == 'cleartext_password'
57 super(PasswordEncrypterGetterSetter, self).put(obj, 'password', value)58 super().put(obj, 'password', value)
59
60
61class ListOfDomainOwners(GetterSetter):
62 def get(self, domain, attribute):
63 assert attribute == 'owner', (
64 'Unexpected attribute: {}'.format(attribute))
65 def sort_key(owner):
66 return owner.addresses[0].email
67 return sorted(domain.owners, key=sort_key)
68
69 def put(self, domain, attribute, value):
70 assert attribute == 'owner', (
71 'Unexpected attribute: {}'.format(attribute))
72 domain.add_owners(value)
5873
5974
60ATTRIBUTES = dict(75ATTRIBUTES = dict(
76 cleartext_password=PasswordEncrypterGetterSetter(),
61 display_name=GetterSetter(str),77 display_name=GetterSetter(str),
62 cleartext_password=PasswordEncrypterGetterSetter(),78 is_server_owner=GetterSetter(as_boolean),
63 )79 )
6480
6581
66CREATION_FIELDS = dict(82CREATION_FIELDS = dict(
83 display_name=str,
67 email=str,84 email=str,
68 display_name=str,85 is_server_owner=bool,
69 password=str,86 password=str,
70 _optional=('display_name', 'password'),87 _optional=('display_name', 'password', 'is_server_owner'),
71 )88 )
7289
7390
@@ -78,6 +95,7 @@
78 # strip that out (if it exists), then create the user, adding the password95 # strip that out (if it exists), then create the user, adding the password
79 # after the fact if successful.96 # after the fact if successful.
80 password = arguments.pop('password', None)97 password = arguments.pop('password', None)
98 is_server_owner = arguments.pop('is_server_owner', False)
81 try:99 try:
82 user = getUtility(IUserManager).create_user(**arguments)100 user = getUtility(IUserManager).create_user(**arguments)
83 except ExistingAddressError as error:101 except ExistingAddressError as error:
@@ -88,6 +106,7 @@
88 # This will have to be reset since it cannot be retrieved.106 # This will have to be reset since it cannot be retrieved.
89 password = generate(int(config.passwords.password_length))107 password = generate(int(config.passwords.password_length))
90 user.password = config.password_context.encrypt(password)108 user.password = config.password_context.encrypt(password)
109 user.is_server_owner = is_server_owner
91 location = path_to('users/{}'.format(user.user_id.int))110 location = path_to('users/{}'.format(user.user_id.int))
92 created(response, location)111 created(response, location)
93 return user112 return user
@@ -105,10 +124,11 @@
105 # but we serialize its integer equivalent.124 # but we serialize its integer equivalent.
106 user_id = user.user_id.int125 user_id = user.user_id.int
107 resource = dict(126 resource = dict(
108 user_id=user_id,
109 created_on=user.created_on,127 created_on=user.created_on,
128 is_server_owner=user.is_server_owner,
110 self_link=path_to('users/{}'.format(user_id)),129 self_link=path_to('users/{}'.format(user_id)),
111 )130 user_id=user_id,
131 )
112 # Add the password attribute, only if the user has a password. Same132 # Add the password attribute, only if the user has a password. Same
113 # with the real name. These could be None or the empty string.133 # with the real name. These could be None or the empty string.
114 if user.password:134 if user.password:
@@ -293,7 +313,8 @@
293 del fields['email']313 del fields['email']
294 fields['user_id'] = int314 fields['user_id'] = int
295 fields['auto_create'] = as_boolean315 fields['auto_create'] = as_boolean
296 fields['_optional'] = fields['_optional'] + ('user_id', 'auto_create')316 fields['_optional'] = fields['_optional'] + (
317 'user_id', 'auto_create', 'is_server_owner')
297 try:318 try:
298 validator = Validator(**fields)319 validator = Validator(**fields)
299 arguments = validator(request)320 arguments = validator(request)
@@ -328,7 +349,8 @@
328 # Process post data and check for an existing user.349 # Process post data and check for an existing user.
329 fields = CREATION_FIELDS.copy()350 fields = CREATION_FIELDS.copy()
330 fields['user_id'] = int351 fields['user_id'] = int
331 fields['_optional'] = fields['_optional'] + ('user_id', 'email')352 fields['_optional'] = fields['_optional'] + (
353 'user_id', 'email', 'is_server_owner')
332 try:354 try:
333 validator = Validator(**fields)355 validator = Validator(**fields)
334 arguments = validator(request)356 arguments = validator(request)
@@ -377,3 +399,56 @@
377 no_content(response)399 no_content(response)
378 else:400 else:
379 forbidden(response)401 forbidden(response)
402
403
404
380405
406class OwnersForDomain(_UserBase):
407 """Owners for a particular domain."""
408
409 def __init__(self, domain):
410 self._domain = domain
411
412 def on_get(self, request, response):
413 """/domains/<domain>/owners"""
414 if self._domain is None:
415 not_found(response)
416 return
417 resource = self._make_collection(request)
418 okay(response, etag(resource))
419
420 def on_post(self, request, response):
421 """POST to /domains/<domain>/owners """
422 if self._domain is None:
423 not_found(response)
424 return
425 validator = Validator(
426 owner=ListOfDomainOwners(list_of_strings_validator))
427 try:
428 validator.update(self._domain, request)
429 except ValueError as error:
430 bad_request(response, str(error))
431 return
432 return no_content(response)
433
434 def on_delete(self, request, response):
435 """DELETE to /domains/<domain>/owners"""
436 if self._domain is None:
437 not_found(response)
438 try:
439 # No arguments.
440 Validator()(request)
441 except ValueError as error:
442 bad_request(response, str(error))
443 return
444 owner_email = [
445 owner.addresses[0].email
446 for owner in self._domain.owners
447 ]
448 for email in owner_email:
449 self._domain.remove_owner(email)
450 return no_content(response)
451
452 @paginate
453 def _get_collection(self, request):
454 """See `CollectionMixin`."""
455 return list(self._domain.owners)
381456
=== 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 @@
22 'Validator',22 'Validator',
23 'enum_validator',23 'enum_validator',
24 'language_validator',24 'language_validator',
25 'list_of_strings_validator',
25 'subscriber_validator',26 'subscriber_validator',
26 ]27 ]
2728
@@ -66,6 +67,14 @@
66 return getUtility(ILanguageManager)[code]67 return getUtility(ILanguageManager)[code]
6768
6869
70def list_of_strings_validator(values):
71 """Turn a list of things into a list of unicodes."""
72 for value in values:
73 if not isinstance(value, str):
74 raise ValueError('Expected str, got {!r}'.format(value))
75 return values
76
77
6978
7079
71class Validator:80class Validator:
72 """A validator of parameter input."""81 """A validator of parameter input."""
7382
=== 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 @@
200 with transaction():200 with transaction():
201 getUtility(IDomainManager).add(201 getUtility(IDomainManager).add(
202 'example.com', 'An example domain.',202 'example.com', 'An example domain.',
203 'http://lists.example.com', 'postmaster@example.com')203 'http://lists.example.com')
204204
205 @classmethod205 @classmethod
206 def testTearDown(cls):206 def testTearDown(cls):