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
1=== modified file 'src/mailman/app/registrar.py'
2--- src/mailman/app/registrar.py 2015-03-28 20:00:24 +0000
3+++ src/mailman/app/registrar.py 2015-04-07 01:46:33 +0000
4@@ -162,7 +162,7 @@
5 confirm_url = mlist.domain.confirm_url(event.token)
6 email_address = event.pendable['email']
7 domain_name = mlist.domain.mail_host
8- contact_address = mlist.domain.contact_address
9+ contact_address = mlist.owner_address
10 # Send a verification email to the address.
11 template = getUtility(ITemplateLoader).get(
12 'mailman:///{0}/{1}/confirm.txt'.format(
13
14=== modified file 'src/mailman/commands/docs/create.rst'
15--- src/mailman/commands/docs/create.rst 2014-04-28 15:23:35 +0000
16+++ src/mailman/commands/docs/create.rst 2015-04-07 01:46:33 +0000
17@@ -44,8 +44,7 @@
18
19 >>> from mailman.interfaces.domain import IDomainManager
20 >>> getUtility(IDomainManager).get('example.xx')
21- <Domain example.xx, base_url: http://example.xx,
22- contact_address: postmaster@example.xx>
23+ <Domain example.xx, base_url: http://example.xx>
24
25 You can also create mailing lists in existing domains without the
26 auto-creation flag.
27
28=== modified file 'src/mailman/commands/docs/membership.rst'
29--- src/mailman/commands/docs/membership.rst 2014-12-13 15:55:57 +0000
30+++ src/mailman/commands/docs/membership.rst 2015-04-07 01:46:33 +0000
31@@ -127,7 +127,7 @@
32 message. If you think you are being maliciously subscribed to the list, or
33 have any other questions, you may contact
34 <BLANKLINE>
35- postmaster@example.com
36+ alpha-owner@example.com
37 <BLANKLINE>
38
39 Once Anne confirms her registration, she will be made a member of the mailing
40
41=== modified file 'src/mailman/commands/tests/test_lists.py'
42--- src/mailman/commands/tests/test_lists.py 2015-03-14 01:16:51 +0000
43+++ src/mailman/commands/tests/test_lists.py 2015-04-07 01:46:33 +0000
44@@ -48,7 +48,7 @@
45 # LP: #1166911 - non-matching lists were returned.
46 getUtility(IDomainManager).add(
47 'example.net', 'An example domain.',
48- 'http://lists.example.net', 'postmaster@example.net')
49+ 'http://lists.example.net')
50 create_list('test1@example.com')
51 create_list('test2@example.com')
52 # Only this one should show up.
53
54=== added file 'src/mailman/database/alembic/versions/46e92facee7_add_serverowner_domainowner.py'
55--- src/mailman/database/alembic/versions/46e92facee7_add_serverowner_domainowner.py 1970-01-01 00:00:00 +0000
56+++ src/mailman/database/alembic/versions/46e92facee7_add_serverowner_domainowner.py 2015-04-07 01:46:33 +0000
57@@ -0,0 +1,56 @@
58+# Copyright (C) 2015 by the Free Software Foundation, Inc.
59+#
60+# This file is part of GNU Mailman.
61+#
62+# GNU Mailman is free software: you can redistribute it and/or modify it under
63+# the terms of the GNU General Public License as published by the Free
64+# Software Foundation, either version 3 of the License, or (at your option)
65+# any later version.
66+#
67+# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
68+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
69+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
70+# more details.
71+#
72+# You should have received a copy of the GNU General Public License along with
73+# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
74+
75+"""add_serverowner_domainowner
76+
77+Revision ID: 46e92facee7
78+Revises: 33e1f5f6fa8
79+Create Date: 2015-03-20 16:01:25.007242
80+
81+"""
82+
83+# Revision identifiers, used by Alembic.
84+revision = '46e92facee7'
85+down_revision = '33e1f5f6fa8'
86+
87+from alembic import op
88+import sqlalchemy as sa
89+
90+
91+def upgrade():
92+ op.create_table(
93+ 'domain_owner',
94+ sa.Column('user_id', sa.Integer(), nullable=False),
95+ sa.Column('domain_id', sa.Integer(), nullable=False),
96+ sa.ForeignKeyConstraint(['domain_id'], ['domain.id'], ),
97+ sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
98+ sa.PrimaryKeyConstraint('user_id', 'domain_id')
99+ )
100+ op.add_column(
101+ 'user',
102+ sa.Column('is_server_owner', sa.Boolean(), nullable=True))
103+ if op.get_bind().dialect.name != 'sqlite':
104+ op.drop_column('domain', 'contact_address')
105+
106+
107+def downgrade():
108+ if op.get_bind().dialect.name != 'sqlite':
109+ op.drop_column('user', 'is_server_owner')
110+ op.add_column(
111+ 'domain',
112+ sa.Column('contact_address', sa.VARCHAR(), nullable=True))
113+ op.drop_table('domain_owner')
114
115=== modified file 'src/mailman/interfaces/domain.py'
116--- src/mailman/interfaces/domain.py 2015-01-05 01:22:39 +0000
117+++ src/mailman/interfaces/domain.py 2015-04-07 01:46:33 +0000
118@@ -88,9 +88,8 @@
119 description = Attribute(
120 'The human readable description of the domain name.')
121
122- contact_address = Attribute("""\
123- The contact address for the human at this domain.
124- E.g. postmaster@example.com""")
125+ owners = Attribute("""\
126+ The relationship with the user database representing domain owners""")
127
128 mailing_lists = Attribute(
129 """All mailing lists for this domain.
130@@ -112,7 +111,7 @@
131 class IDomainManager(Interface):
132 """The manager of domains."""
133
134- def add(mail_host, description=None, base_url=None, contact_address=None):
135+ def add(mail_host, description=None, base_url=None, owners=None):
136 """Add a new domain.
137
138 :param mail_host: The email host name for the domain.
139@@ -123,11 +122,10 @@
140 interface of the domain. If not given, it defaults to
141 http://`mail_host`/
142 :type base_url: string
143- :param contact_address: The email contact address for the human
144- managing the domain. If not given, defaults to
145- postmaster@`mail_host`
146- :type contact_address: string
147- :return: The new domain object
148+ :param owners: Sequence of owners of the domain, defaults to None,
149+ meaning the domain does not have owners.
150+ :type owners: sequence of `IUser` or string emails.
151+ :return: The new domain object.
152 :rtype: `IDomain`
153 :raises `BadDomainSpecificationError`: when the `mail_host` is
154 already registered.
155
156=== modified file 'src/mailman/model/docs/domains.rst'
157--- src/mailman/model/docs/domains.rst 2014-12-13 18:26:05 +0000
158+++ src/mailman/model/docs/domains.rst 2015-04-07 01:46:33 +0000
159@@ -14,12 +14,16 @@
160 ::
161
162 >>> from operator import attrgetter
163- >>> def show_domains():
164+ >>> def show_domains(*, with_owners=False):
165 ... if len(manager) == 0:
166 ... print('no domains')
167 ... return
168 ... for domain in sorted(manager, key=attrgetter('mail_host')):
169 ... print(domain)
170+ ... owners = sorted(owner.addresses[0].email
171+ ... for owner in domain.owners)
172+ ... for owner in owners:
173+ ... print('- owner:', owner)
174
175 >>> show_domains()
176 no domains
177@@ -28,17 +32,14 @@
178 is the only required piece. The other parts are inferred from that.
179
180 >>> manager.add('example.org')
181- <Domain example.org, base_url: http://example.org,
182- contact_address: postmaster@example.org>
183+ <Domain example.org, base_url: http://example.org>
184 >>> show_domains()
185- <Domain example.org, base_url: http://example.org,
186- contact_address: postmaster@example.org>
187+ <Domain example.org, base_url: http://example.org>
188
189 We can remove domains too.
190
191 >>> manager.remove('example.org')
192- <Domain example.org, base_url: http://example.org,
193- contact_address: postmaster@example.org>
194+ <Domain example.org, base_url: http://example.org>
195 >>> show_domains()
196 no domains
197
198@@ -46,30 +47,39 @@
199 web interface for the domain.
200
201 >>> manager.add('example.com', base_url='https://mail.example.com')
202- <Domain example.com, base_url: https://mail.example.com,
203- contact_address: postmaster@example.com>
204+ <Domain example.com, base_url: https://mail.example.com>
205 >>> show_domains()
206- <Domain example.com, base_url: https://mail.example.com,
207- contact_address: postmaster@example.com>
208+ <Domain example.com, base_url: https://mail.example.com>
209
210-Domains can have explicit descriptions and contact addresses.
211+Domains can have explicit descriptions, and can be created with one or more
212+owners.
213 ::
214
215 >>> manager.add(
216 ... 'example.net',
217 ... base_url='http://lists.example.net',
218- ... contact_address='postmaster@example.com',
219- ... description='The example domain')
220- <Domain example.net, The example domain,
221- base_url: http://lists.example.net,
222- contact_address: postmaster@example.com>
223-
224- >>> show_domains()
225- <Domain example.com, base_url: https://mail.example.com,
226- contact_address: postmaster@example.com>
227- <Domain example.net, The example domain,
228- base_url: http://lists.example.net,
229- contact_address: postmaster@example.com>
230+ ... description='The example domain',
231+ ... owners=['anne@example.com'])
232+ <Domain example.net, The example domain,
233+ base_url: http://lists.example.net>
234+
235+ >>> show_domains(with_owners=True)
236+ <Domain example.com, base_url: https://mail.example.com>
237+ <Domain example.net, The example domain,
238+ base_url: http://lists.example.net>
239+ - owner: anne@example.com
240+
241+Domains can have multiple owners, ideally one of the owners should have a
242+verified preferred address. However this is not checked right now and the
243+configuration's default contact address may be used as a fallback.
244+
245+ >>> net_domain = manager['example.net']
246+ >>> net_domain.add_owner('bart@example.org')
247+ >>> show_domains(with_owners=True)
248+ <Domain example.com, base_url: https://mail.example.com>
249+ <Domain example.net, The example domain, base_url: http://lists.example.net>
250+ - owner: anne@example.com
251+ - owner: bart@example.org
252
253 Domains can list all associated mailing lists with the mailing_lists property.
254 ::
255@@ -105,8 +115,7 @@
256
257 >>> print(manager['example.net'])
258 <Domain example.net, The example domain,
259- base_url: http://lists.example.net,
260- contact_address: postmaster@example.com>
261+ base_url: http://lists.example.net>
262
263 As with dictionaries, you can also get the domain. If the domain does not
264 exist, ``None`` or a default is returned.
265@@ -114,8 +123,7 @@
266
267 >>> print(manager.get('example.net'))
268 <Domain example.net, The example domain,
269- base_url: http://lists.example.net,
270- contact_address: postmaster@example.com>
271+ base_url: http://lists.example.net>
272
273 >>> print(manager.get('doesnotexist.com'))
274 None
275
276=== modified file 'src/mailman/model/docs/registration.rst'
277--- src/mailman/model/docs/registration.rst 2015-03-28 20:00:24 +0000
278+++ src/mailman/model/docs/registration.rst 2015-04-07 01:46:33 +0000
279@@ -120,7 +120,7 @@
280 message. If you think you are being maliciously subscribed to the list,
281 or have any other questions, you may contact
282 <BLANKLINE>
283- postmaster@example.com
284+ alpha-owner@example.com
285 <BLANKLINE>
286 >>> dump_msgdata(items[0].msgdata)
287 _parsemsg : False
288
289=== modified file 'src/mailman/model/docs/users.rst'
290--- src/mailman/model/docs/users.rst 2014-12-21 22:59:06 +0000
291+++ src/mailman/model/docs/users.rst 2015-04-07 01:46:33 +0000
292@@ -295,4 +295,19 @@
293 zperson@example.org xtest_2.example.com MemberRole.owner
294
295
296+Server owners
297+=============
298+
299+Some users are server owners. Zoe is not yet a server owner.
300+
301+ >>> user_1.is_server_owner
302+ False
303+
304+So, let's make her one.
305+
306+ >>> user_1.is_server_owner = True
307+ >>> user_1.is_server_owner
308+ True
309+
310+
311 .. _`usermanager.txt`: usermanager.html
312
313=== modified file 'src/mailman/model/domain.py'
314--- src/mailman/model/domain.py 2015-01-05 01:40:47 +0000
315+++ src/mailman/model/domain.py 2015-04-07 01:46:33 +0000
316@@ -28,11 +28,15 @@
317 from mailman.interfaces.domain import (
318 BadDomainSpecificationError, DomainCreatedEvent, DomainCreatingEvent,
319 DomainDeletedEvent, DomainDeletingEvent, IDomain, IDomainManager)
320+from mailman.interfaces.user import IUser
321+from mailman.interfaces.usermanager import IUserManager
322 from mailman.model.mailinglist import MailingList
323 from urllib.parse import urljoin, urlparse
324 from sqlalchemy import Column, Integer, Unicode
325+from sqlalchemy.orm import relationship
326 from zope.event import notify
327 from zope.interface import implementer
328+from zope.component import getUtility
329
330
331
332
333@@ -44,15 +48,17 @@
334
335 id = Column(Integer, primary_key=True)
336
337- mail_host = Column(Unicode) # TODO: add index?
338+ mail_host = Column(Unicode)
339 base_url = Column(Unicode)
340 description = Column(Unicode)
341- contact_address = Column(Unicode)
342+ owners = relationship('User',
343+ secondary='domain_owner',
344+ backref='domains')
345
346 def __init__(self, mail_host,
347 description=None,
348 base_url=None,
349- contact_address=None):
350+ owners=None):
351 """Create and register a domain.
352
353 :param mail_host: The host name for the email interface.
354@@ -63,18 +69,16 @@
355 scheme. If not given, it will be constructed from the
356 `mail_host` using the http protocol.
357 :type base_url: string
358- :param contact_address: The email address to contact a human for this
359- domain. If not given, postmaster@`mail_host` will be used.
360- :type contact_address: string
361+ :param owners: Optional owners of this domain.
362+ :type owners: sequence of `IUser` or string emails.
363 """
364 self.mail_host = mail_host
365 self.base_url = (base_url
366 if base_url is not None
367 else 'http://' + mail_host)
368 self.description = description
369- self.contact_address = (contact_address
370- if contact_address is not None
371- else 'postmaster@' + mail_host)
372+ if owners is not None:
373+ self.add_owners(owners)
374
375 @property
376 def url_host(self):
377@@ -103,12 +107,35 @@
378 def __repr__(self):
379 """repr(a_domain)"""
380 if self.description is None:
381- return ('<Domain {0.mail_host}, base_url: {0.base_url}, '
382- 'contact_address: {0.contact_address}>').format(self)
383+ return ('<Domain {0.mail_host}, base_url: {0.base_url}>').format(
384+ self)
385 else:
386 return ('<Domain {0.mail_host}, {0.description}, '
387- 'base_url: {0.base_url}, '
388- 'contact_address: {0.contact_address}>').format(self)
389+ 'base_url: {0.base_url}>').format(self)
390+
391+ def add_owner(self, owner):
392+ """See `IDomain`."""
393+ user_manager = getUtility(IUserManager)
394+ if IUser.providedBy(owner):
395+ user = owner
396+ else:
397+ user = user_manager.get_user(owner)
398+ # BAW 2015-04-06: Make sure this path is tested.
399+ if user is None:
400+ user = user_manager.create_user(owner)
401+ self.owners.append(user)
402+
403+ def add_owners(self, owners):
404+ """See `IDomain`."""
405+ # BAW 2015-04-06: This should probably be more efficient by inlining
406+ # add_owner().
407+ for owner in owners:
408+ self.add_owner(owner)
409+
410+ def remove_owner(self, owner):
411+ """See `IDomain`."""
412+ user_manager = getUtility(IUserManager)
413+ self.owners.remove(user_manager.get_user(owner))
414
415
416
417
418@@ -121,7 +148,7 @@
419 mail_host,
420 description=None,
421 base_url=None,
422- contact_address=None):
423+ owners=None):
424 """See `IDomainManager`."""
425 # Be sure the mail_host is not already registered. This is probably
426 # a constraint that should (also) be maintained in the database.
427@@ -129,7 +156,7 @@
428 raise BadDomainSpecificationError(
429 'Duplicate email host: %s' % mail_host)
430 notify(DomainCreatingEvent(mail_host))
431- domain = Domain(mail_host, description, base_url, contact_address)
432+ domain = Domain(mail_host, description, base_url, owners)
433 store.add(domain)
434 notify(DomainCreatedEvent(domain))
435 return domain
436
437=== modified file 'src/mailman/model/tests/test_domain.py'
438--- src/mailman/model/tests/test_domain.py 2015-01-05 01:22:39 +0000
439+++ src/mailman/model/tests/test_domain.py 2015-04-07 01:46:33 +0000
440@@ -30,6 +30,7 @@
441 DomainCreatedEvent, DomainCreatingEvent, DomainDeletedEvent,
442 DomainDeletingEvent, IDomainManager)
443 from mailman.interfaces.listmanager import IListManager
444+from mailman.interfaces.usermanager import IUserManager
445 from mailman.testing.helpers import event_subscribers
446 from mailman.testing.layers import ConfigLayer
447 from zope.component import getUtility
448@@ -78,6 +79,98 @@
449 # Trying to delete a missing domain gives you a KeyError.
450 self.assertRaises(KeyError, self._manager.remove, 'doesnotexist.com')
451
452+ def test_domain_creation_no_default_owners(self):
453+ # If a domain is created without owners, then it has none.
454+ domain = self._manager.add('example.org')
455+ self.assertEqual(len(domain.owners), 0)
456+
457+ def test_domain_creation_with_owner(self):
458+ # You can create a new domain with a single owner.
459+ domain = self._manager.add('example.org', owners=['anne@example.org'])
460+ self.assertEqual(len(domain.owners), 1)
461+ self.assertEqual(domain.owners[0].addresses[0].email,
462+ 'anne@example.org')
463+
464+ def test_domain_creation_with_owners(self):
465+ # You can create a new domain with multiple owners.
466+ domain = self._manager.add(
467+ 'example.org', owners=['anne@example.org',
468+ 'bart@example.net'])
469+ self.assertEqual(len(domain.owners), 2)
470+ self.assertEqual(
471+ sorted(owner.addresses[0].email for owner in domain.owners),
472+ ['anne@example.org', 'bart@example.net'])
473+
474+ def test_domain_creation_creates_new_users(self):
475+ # Domain creation with existing users does not create new users, but
476+ # any user which doesn't yet exist (and is linked to the given
477+ # address), gets created.
478+ user_manager = getUtility(IUserManager)
479+ user_manager.make_user('anne@example.com')
480+ user_manager.make_user('bart@example.com')
481+ domain = self._manager.add(
482+ 'example.org', owners=['anne@example.com',
483+ 'bart@example.com',
484+ 'cris@example.com'])
485+ self.assertEqual(len(domain.owners), 3)
486+ self.assertEqual(
487+ sorted(owner.addresses[0].email for owner in domain.owners),
488+ ['anne@example.com', 'bart@example.com', 'cris@example.com'])
489+ # Now cris exists as a user.
490+ self.assertIsNotNone(user_manager.get_user('cris@example.com'))
491+
492+ def test_domain_creation_with_users(self):
493+ # Domains can be created with IUser objects.
494+ user_manager = getUtility(IUserManager)
495+ anne = user_manager.make_user('anne@example.com')
496+ bart = user_manager.make_user('bart@example.com')
497+ domain = self._manager.add('example.org', owners=[anne, bart])
498+ self.assertEqual(len(domain.owners), 2)
499+ self.assertEqual(
500+ sorted(owner.addresses[0].email for owner in domain.owners),
501+ ['anne@example.com', 'bart@example.com'])
502+ def sort_key(owner):
503+ return owner.addresses[0].email
504+ self.assertEqual(sorted(domain.owners, key=sort_key), [anne, bart])
505+
506+ def test_add_domain_owner(self):
507+ # Domain owners can be added after the domain is created.
508+ domain = self._manager.add('example.org')
509+ self.assertEqual(len(domain.owners), 0)
510+ domain.add_owner('anne@example.org')
511+ self.assertEqual(len(domain.owners), 1)
512+ self.assertEqual(domain.owners[0].addresses[0].email,
513+ 'anne@example.org')
514+
515+ def test_add_multiple_domain_owners(self):
516+ # Multiple domain owners can be added after the domain is created.
517+ domain = self._manager.add('example.org')
518+ self.assertEqual(len(domain.owners), 0)
519+ domain.add_owners(['anne@example.org', 'bart@example.net'])
520+ self.assertEqual(len(domain.owners), 2)
521+ self.assertEqual([owner.addresses[0].email for owner in domain.owners],
522+ ['anne@example.org', 'bart@example.net'])
523+
524+ def test_remove_domain_owner(self):
525+ # Domain onwers can be removed.
526+ domain = self._manager.add(
527+ 'example.org', owners=['anne@example.org',
528+ 'bart@example.net'])
529+ domain.remove_owner('anne@example.org')
530+ self.assertEqual(len(domain.owners), 1)
531+ self.assertEqual([owner.addresses[0].email for owner in domain.owners],
532+ ['bart@example.net'])
533+
534+ def test_remove_missing_owner(self):
535+ # Users which aren't owners can't be removed.
536+ domain = self._manager.add(
537+ 'example.org', owners=['anne@example.org',
538+ 'bart@example.net'])
539+ self.assertRaises(ValueError, domain.remove_owner, 'cris@example.org')
540+ self.assertEqual(len(domain.owners), 2)
541+ self.assertEqual([owner.addresses[0].email for owner in domain.owners],
542+ ['anne@example.org', 'bart@example.net'])
543+
544
545
546
547 class TestDomainLifecycleEvents(unittest.TestCase):
548
549=== modified file 'src/mailman/model/user.py'
550--- src/mailman/model/user.py 2015-03-20 16:38:00 +0000
551+++ src/mailman/model/user.py 2015-04-07 01:46:33 +0000
552@@ -18,6 +18,7 @@
553 """Model for users."""
554
555 __all__ = [
556+ 'DomainOwner',
557 'User',
558 ]
559
560@@ -34,7 +35,7 @@
561 from mailman.model.roster import Memberships
562 from mailman.utilities.datetime import factory as date_factory
563 from mailman.utilities.uid import UniqueIDFactory
564-from sqlalchemy import Column, DateTime, ForeignKey, Integer, Unicode
565+from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, Unicode
566 from sqlalchemy.orm import relationship, backref
567 from zope.event import notify
568 from zope.interface import implementer
569@@ -55,6 +56,7 @@
570 _password = Column('password', Unicode)
571 _user_id = Column(UUID, index=True)
572 _created_on = Column(DateTime)
573+ is_server_owner = Column(Boolean, default=False)
574
575 addresses = relationship(
576 'Address', backref='user',
577@@ -176,3 +178,13 @@
578 @property
579 def memberships(self):
580 return Memberships(self)
581+
582+
583+
584
585+class DomainOwner(Model):
586+ """Internal table for associating domains to their owners."""
587+
588+ __tablename__ = 'domain_owner'
589+
590+ user_id = Column(Integer, ForeignKey('user.id'), primary_key=True)
591+ domain_id = Column(Integer, ForeignKey('domain.id'), primary_key=True)
592
593=== modified file 'src/mailman/rest/docs/addresses.rst'
594--- src/mailman/rest/docs/addresses.rst 2015-02-14 01:35:35 +0000
595+++ src/mailman/rest/docs/addresses.rst 2015-04-07 01:46:33 +0000
596@@ -190,6 +190,7 @@
597 created_on: 2005-08-01T07:49:23
598 display_name: Cris X. Person
599 http_etag: "..."
600+ is_server_owner: False
601 password: ...
602 self_link: http://localhost:9001/3.0/users/1
603 user_id: 1
604
605=== modified file 'src/mailman/rest/docs/domains.rst'
606--- src/mailman/rest/docs/domains.rst 2014-12-16 01:01:53 +0000
607+++ src/mailman/rest/docs/domains.rst 2015-04-07 01:46:33 +0000
608@@ -28,15 +28,12 @@
609
610 >>> domain_manager.add(
611 ... 'example.com', 'An example domain', 'http://lists.example.com')
612- <Domain example.com, An example domain,
613- base_url: http://lists.example.com,
614- contact_address: postmaster@example.com>
615+ <Domain example.com, An example domain, base_url: http://lists.example.com>
616 >>> transaction.commit()
617
618 >>> dump_json('http://localhost:9001/3.0/domains')
619 entry 0:
620 base_url: http://lists.example.com
621- contact_address: postmaster@example.com
622 description: An example domain
623 http_etag: "..."
624 mail_host: example.com
625@@ -51,24 +48,18 @@
626
627 >>> domain_manager.add(
628 ... 'example.org',
629- ... base_url='http://mail.example.org',
630- ... contact_address='listmaster@example.org')
631- <Domain example.org, base_url: http://mail.example.org,
632- contact_address: listmaster@example.org>
633+ ... base_url='http://mail.example.org')
634+ <Domain example.org, base_url: http://mail.example.org>
635 >>> domain_manager.add(
636 ... 'lists.example.net',
637 ... 'Porkmasters',
638- ... 'http://example.net',
639- ... 'porkmaster@example.net')
640- <Domain lists.example.net, Porkmasters,
641- base_url: http://example.net,
642- contact_address: porkmaster@example.net>
643+ ... 'http://example.net')
644+ <Domain lists.example.net, Porkmasters, base_url: http://example.net>
645 >>> transaction.commit()
646
647 >>> dump_json('http://localhost:9001/3.0/domains')
648 entry 0:
649 base_url: http://lists.example.com
650- contact_address: postmaster@example.com
651 description: An example domain
652 http_etag: "..."
653 mail_host: example.com
654@@ -76,7 +67,6 @@
655 url_host: lists.example.com
656 entry 1:
657 base_url: http://mail.example.org
658- contact_address: listmaster@example.org
659 description: None
660 http_etag: "..."
661 mail_host: example.org
662@@ -84,7 +74,6 @@
663 url_host: mail.example.org
664 entry 2:
665 base_url: http://example.net
666- contact_address: porkmaster@example.net
667 description: Porkmasters
668 http_etag: "..."
669 mail_host: lists.example.net
670@@ -103,7 +92,6 @@
671
672 >>> dump_json('http://localhost:9001/3.0/domains/lists.example.net')
673 base_url: http://example.net
674- contact_address: porkmaster@example.net
675 description: Porkmasters
676 http_etag: "..."
677 mail_host: lists.example.net
678@@ -165,7 +153,6 @@
679
680 >>> dump_json('http://localhost:9001/3.0/domains/lists.example.com')
681 base_url: http://lists.example.com
682- contact_address: postmaster@lists.example.com
683 description: None
684 http_etag: "..."
685 mail_host: lists.example.com
686@@ -176,9 +163,7 @@
687 ::
688
689 >>> domain_manager['lists.example.com']
690- <Domain lists.example.com,
691- base_url: http://lists.example.com,
692- contact_address: postmaster@lists.example.com>
693+ <Domain lists.example.com, base_url: http://lists.example.com>
694
695 # Unlock the database.
696 >>> transaction.abort()
697@@ -190,8 +175,7 @@
698 >>> dump_json('http://localhost:9001/3.0/domains', {
699 ... 'mail_host': 'my.example.com',
700 ... 'description': 'My new domain',
701- ... 'base_url': 'http://allmy.example.com',
702- ... 'contact_address': 'helpme@example.com'
703+ ... 'base_url': 'http://allmy.example.com'
704 ... })
705 content-length: 0
706 date: ...
707@@ -200,7 +184,6 @@
708
709 >>> dump_json('http://localhost:9001/3.0/domains/my.example.com')
710 base_url: http://allmy.example.com
711- contact_address: helpme@example.com
712 description: My new domain
713 http_etag: "..."
714 mail_host: my.example.com
715@@ -208,9 +191,7 @@
716 url_host: allmy.example.com
717
718 >>> domain_manager['my.example.com']
719- <Domain my.example.com, My new domain,
720- base_url: http://allmy.example.com,
721- contact_address: helpme@example.com>
722+ <Domain my.example.com, My new domain, base_url: http://allmy.example.com>
723
724 # Unlock the database.
725 >>> transaction.abort()
726@@ -229,4 +210,92 @@
727 status: 204
728
729
730+Domain owners
731+=============
732+
733+Domains can have owners. By posting some addresses to the owners resource,
734+you can add some domain owners. Currently our domain has no owners:
735+
736+ >>> dump_json('http://localhost:9001/3.0/domains/my.example.com/owners')
737+ http_etag: ...
738+ start: 0
739+ total_size: 0
740+
741+Anne and Bart volunteer to be a domain owners.
742+::
743+
744+ >>> dump_json('http://localhost:9001/3.0/domains/my.example.com/owners', (
745+ ... ('owner', 'anne@example.com'), ('owner', 'bart@example.com')
746+ ... ))
747+ content-length: 0
748+ date: ...
749+ server: ...
750+ status: 204
751+
752+ >>> dump_json('http://localhost:9001/3.0/domains/my.example.com/owners')
753+ entry 0:
754+ created_on: 2005-08-01T07:49:23
755+ http_etag: ...
756+ is_server_owner: False
757+ self_link: http://localhost:9001/3.0/users/1
758+ user_id: 1
759+ entry 1:
760+ created_on: 2005-08-01T07:49:23
761+ http_etag: ...
762+ is_server_owner: False
763+ self_link: http://localhost:9001/3.0/users/2
764+ user_id: 2
765+ http_etag: ...
766+ start: 0
767+ total_size: 2
768+
769+We can delete all the domain owners.
770+
771+ >>> dump_json('http://localhost:9001/3.0/domains/my.example.com/owners',
772+ ... method='DELETE')
773+ content-length: 0
774+ date: ...
775+ server: ...
776+ status: 204
777+
778+Now there are no owners.
779+
780+ >>> dump_json('http://localhost:9001/3.0/domains/my.example.com/owners')
781+ http_etag: ...
782+ start: 0
783+ total_size: 0
784+
785+New domains can be created with owners.
786+
787+ >>> dump_json('http://localhost:9001/3.0/domains', (
788+ ... ('mail_host', 'your.example.com'),
789+ ... ('owner', 'anne@example.com'),
790+ ... ('owner', 'bart@example.com'),
791+ ... ))
792+ content-length: 0
793+ date: ...
794+ location: http://localhost:9001/3.0/domains/your.example.com
795+ server: ...
796+ status: 201
797+
798+The new domain has the expected owners.
799+
800+ >>> dump_json('http://localhost:9001/3.0/domains/your.example.com/owners')
801+ entry 0:
802+ created_on: 2005-08-01T07:49:23
803+ http_etag: ...
804+ is_server_owner: False
805+ self_link: http://localhost:9001/3.0/users/1
806+ user_id: 1
807+ entry 1:
808+ created_on: 2005-08-01T07:49:23
809+ http_etag: ...
810+ is_server_owner: False
811+ self_link: http://localhost:9001/3.0/users/2
812+ user_id: 2
813+ http_etag: ...
814+ start: 0
815+ total_size: 2
816+
817+
818 .. _Domains: ../../model/docs/domains.html
819
820=== modified file 'src/mailman/rest/docs/users.rst'
821--- src/mailman/rest/docs/users.rst 2014-12-22 18:40:30 +0000
822+++ src/mailman/rest/docs/users.rst 2015-04-07 01:46:33 +0000
823@@ -34,6 +34,7 @@
824 created_on: 2005-08-01T07:49:23
825 display_name: Anne Person
826 http_etag: "..."
827+ is_server_owner: False
828 self_link: http://localhost:9001/3.0/users/1
829 user_id: 1
830 http_etag: "..."
831@@ -50,11 +51,13 @@
832 created_on: 2005-08-01T07:49:23
833 display_name: Anne Person
834 http_etag: "..."
835+ is_server_owner: False
836 self_link: http://localhost:9001/3.0/users/1
837 user_id: 1
838 entry 1:
839 created_on: 2005-08-01T07:49:23
840 http_etag: "..."
841+ is_server_owner: False
842 self_link: http://localhost:9001/3.0/users/2
843 user_id: 2
844 http_etag: "..."
845@@ -76,6 +79,7 @@
846 created_on: 2005-08-01T07:49:23
847 display_name: Anne Person
848 http_etag: "..."
849+ is_server_owner: False
850 self_link: http://localhost:9001/3.0/users/1
851 user_id: 1
852 http_etag: "..."
853@@ -86,6 +90,7 @@
854 entry 0:
855 created_on: 2005-08-01T07:49:23
856 http_etag: "..."
857+ is_server_owner: False
858 self_link: http://localhost:9001/3.0/users/2
859 user_id: 2
860 http_etag: "..."
861@@ -120,6 +125,7 @@
862 >>> dump_json('http://localhost:9001/3.0/users/3')
863 created_on: 2005-08-01T07:49:23
864 http_etag: "..."
865+ is_server_owner: False
866 password: {plaintext}...
867 self_link: http://localhost:9001/3.0/users/3
868 user_id: 3
869@@ -131,6 +137,7 @@
870 >>> dump_json('http://localhost:9001/3.0/users/cris@example.com')
871 created_on: 2005-08-01T07:49:23
872 http_etag: "..."
873+ is_server_owner: False
874 password: {plaintext}...
875 self_link: http://localhost:9001/3.0/users/3
876 user_id: 3
877@@ -158,6 +165,7 @@
878 created_on: 2005-08-01T07:49:23
879 display_name: Dave Person
880 http_etag: "..."
881+ is_server_owner: False
882 password: {plaintext}...
883 self_link: http://localhost:9001/3.0/users/4
884 user_id: 4
885@@ -190,6 +198,7 @@
886 created_on: 2005-08-01T07:49:23
887 display_name: Elly Person
888 http_etag: "..."
889+ is_server_owner: False
890 password: {plaintext}supersekrit
891 self_link: http://localhost:9001/3.0/users/5
892 user_id: 5
893@@ -214,6 +223,7 @@
894 created_on: 2005-08-01T07:49:23
895 display_name: David Person
896 http_etag: "..."
897+ is_server_owner: False
898 password: {plaintext}...
899 self_link: http://localhost:9001/3.0/users/4
900 user_id: 4
901@@ -238,6 +248,7 @@
902 created_on: 2005-08-01T07:49:23
903 display_name: David Person
904 http_etag: "..."
905+ is_server_owner: False
906 password: {plaintext}clockwork angels
907 self_link: http://localhost:9001/3.0/users/4
908 user_id: 4
909@@ -246,8 +257,9 @@
910 resource.
911
912 >>> dump_json('http://localhost:9001/3.0/users/4', {
913+ ... 'cleartext_password': 'the garden',
914 ... 'display_name': 'David Personhood',
915- ... 'cleartext_password': 'the garden',
916+ ... 'is_server_owner': False,
917 ... }, method='PUT')
918 content-length: 0
919 date: ...
920@@ -260,6 +272,7 @@
921 created_on: 2005-08-01T07:49:23
922 display_name: David Personhood
923 http_etag: "..."
924+ is_server_owner: False
925 password: {plaintext}the garden
926 self_link: http://localhost:9001/3.0/users/4
927 user_id: 4
928@@ -343,6 +356,7 @@
929 created_on: 2005-08-01T07:49:23
930 display_name: Fred Person
931 http_etag: "..."
932+ is_server_owner: False
933 self_link: http://localhost:9001/3.0/users/6
934 user_id: 6
935
936@@ -350,6 +364,7 @@
937 created_on: 2005-08-01T07:49:23
938 display_name: Fred Person
939 http_etag: "..."
940+ is_server_owner: False
941 self_link: http://localhost:9001/3.0/users/6
942 user_id: 6
943
944@@ -357,6 +372,7 @@
945 created_on: 2005-08-01T07:49:23
946 display_name: Fred Person
947 http_etag: "..."
948+ is_server_owner: False
949 self_link: http://localhost:9001/3.0/users/6
950 user_id: 6
951
952@@ -364,6 +380,7 @@
953 created_on: 2005-08-01T07:49:23
954 display_name: Fred Person
955 http_etag: "..."
956+ is_server_owner: False
957 self_link: http://localhost:9001/3.0/users/6
958 user_id: 6
959
960@@ -382,6 +399,7 @@
961 created_on: 2005-08-01T07:49:23
962 display_name: Elly Person
963 http_etag: "..."
964+ is_server_owner: False
965 password: {plaintext}supersekrit
966 self_link: http://localhost:9001/3.0/users/5
967 user_id: 5
968@@ -399,3 +417,82 @@
969 date: ...
970 server: ...
971 status: 204
972+
973+
974+Server owners
975+=============
976+
977+Users can be designated as server owners. Elly is not currently a server
978+owner.
979+
980+ >>> dump_json('http://localhost:9001/3.0/users/5')
981+ created_on: 2005-08-01T07:49:23
982+ display_name: Elly Person
983+ http_etag: "..."
984+ is_server_owner: False
985+ password: {plaintext}supersekrit
986+ self_link: http://localhost:9001/3.0/users/5
987+ user_id: 5
988+
989+Let's make her a server owner.
990+::
991+
992+ >>> dump_json('http://localhost:9001/3.0/users/5', {
993+ ... 'is_server_owner': True,
994+ ... }, method='PATCH')
995+ content-length: 0
996+ date: ...
997+ server: ...
998+ status: 204
999+
1000+ >>> dump_json('http://localhost:9001/3.0/users/5')
1001+ created_on: 2005-08-01T07:49:23
1002+ display_name: Elly Person
1003+ http_etag: "..."
1004+ is_server_owner: True
1005+ password: {plaintext}supersekrit
1006+ self_link: http://localhost:9001/3.0/users/5
1007+ user_id: 5
1008+
1009+Elly later retires as server owner.
1010+::
1011+
1012+ >>> dump_json('http://localhost:9001/3.0/users/5', {
1013+ ... 'is_server_owner': False,
1014+ ... }, method='PATCH')
1015+ content-length: 0
1016+ date: ...
1017+ server: ...
1018+ status: 204
1019+
1020+ >>> dump_json('http://localhost:9001/3.0/users/5')
1021+ created_on: 2005-08-01T07:49:23
1022+ display_name: Elly Person
1023+ http_etag: "..."
1024+ is_server_owner: False
1025+ password: {plaintext}...
1026+ self_link: http://localhost:9001/3.0/users/5
1027+ user_id: 5
1028+
1029+Gwen, a new users, takes over as a server owner.
1030+::
1031+
1032+ >>> dump_json('http://localhost:9001/3.0/users', {
1033+ ... 'display_name': 'Gwen Person',
1034+ ... 'email': 'gwen@example.com',
1035+ ... 'is_server_owner': True,
1036+ ... })
1037+ content-length: 0
1038+ date: ...
1039+ location: http://localhost:9001/3.0/users/7
1040+ server: ...
1041+ status: 201
1042+
1043+ >>> dump_json('http://localhost:9001/3.0/users/7')
1044+ created_on: 2005-08-01T07:49:23
1045+ display_name: Gwen Person
1046+ http_etag: "..."
1047+ is_server_owner: True
1048+ password: {plaintext}...
1049+ self_link: http://localhost:9001/3.0/users/7
1050+ user_id: 7
1051
1052=== modified file 'src/mailman/rest/domains.py'
1053--- src/mailman/rest/domains.py 2015-01-05 01:40:47 +0000
1054+++ src/mailman/rest/domains.py 2015-04-07 01:46:33 +0000
1055@@ -29,7 +29,8 @@
1056 BadRequest, CollectionMixin, NotFound, bad_request, child, created, etag,
1057 no_content, not_found, okay, path_to)
1058 from mailman.rest.lists import ListsForDomain
1059-from mailman.rest.validator import Validator
1060+from mailman.rest.users import OwnersForDomain
1061+from mailman.rest.validator import Validator, list_of_strings_validator
1062 from zope.component import getUtility
1063
1064
1065@@ -41,7 +42,6 @@
1066 """See `CollectionMixin`."""
1067 return dict(
1068 base_url=domain.base_url,
1069- contact_address=domain.contact_address,
1070 description=domain.description,
1071 mail_host=domain.mail_host,
1072 self_link=path_to('domains/{0}'.format(domain.mail_host)),
1073@@ -88,6 +88,17 @@
1074 else:
1075 return BadRequest(), []
1076
1077+ @child()
1078+ def owners(self, request, segments):
1079+ """/domains/<domain>/owners"""
1080+ if len(segments) == 0:
1081+ domain = getUtility(IDomainManager).get(self._domain)
1082+ if domain is None:
1083+ return NotFound()
1084+ return OwnersForDomain(domain)
1085+ else:
1086+ return BadRequest(), []
1087+
1088
1089 class AllDomains(_DomainBase):
1090 """The domains."""
1091@@ -99,12 +110,18 @@
1092 validator = Validator(mail_host=str,
1093 description=str,
1094 base_url=str,
1095- contact_address=str,
1096- _optional=('description', 'base_url',
1097- 'contact_address'))
1098- domain = domain_manager.add(**validator(request))
1099- except BadDomainSpecificationError:
1100- bad_request(response, b'Domain exists')
1101+ owner=list_of_strings_validator,
1102+ _optional=(
1103+ 'description', 'base_url', 'owner'))
1104+ values = validator(request)
1105+ # For consistency, owners are passed in as multiple `owner` keys,
1106+ # but .add() requires an `owners` keyword. Match impedence.
1107+ owners = values.pop('owner', None)
1108+ if owners is not None:
1109+ values['owners'] = owners
1110+ domain = domain_manager.add(**values)
1111+ except BadDomainSpecificationError as error:
1112+ bad_request(response, str(error))
1113 except ValueError as error:
1114 bad_request(response, str(error))
1115 else:
1116
1117=== modified file 'src/mailman/rest/listconf.py'
1118--- src/mailman/rest/listconf.py 2015-01-05 01:40:47 +0000
1119+++ src/mailman/rest/listconf.py 2015-04-07 01:46:33 +0000
1120@@ -32,7 +32,8 @@
1121 from mailman.interfaces.mailinglist import IAcceptableAliasSet, ReplyToMunging
1122 from mailman.rest.helpers import (
1123 GetterSetter, bad_request, etag, no_content, okay)
1124-from mailman.rest.validator import PatchValidator, Validator, enum_validator
1125+from mailman.rest.validator import (
1126+ PatchValidator, Validator, enum_validator, list_of_strings_validator)
1127
1128
1129
1130
1131@@ -72,14 +73,6 @@
1132 raise ValueError('Unknown pipeline: {}'.format(pipeline_name))
1133
1134
1135-def list_of_str(values):
1136- """Turn a list of things into a list of unicodes."""
1137- for value in values:
1138- if not isinstance(value, str):
1139- raise ValueError('Expected str, got {!r}'.format(value))
1140- return values
1141-
1142-
1143
1144
1145 # This is the list of IMailingList attributes that are exposed through the
1146 # REST API. The values of the keys are the GetterSetter instance holding the
1147@@ -96,7 +89,7 @@
1148 # (e.g. datetimes, timedeltas, enums).
1149
1150 ATTRIBUTES = dict(
1151- acceptable_aliases=AcceptableAliases(list_of_str),
1152+ acceptable_aliases=AcceptableAliases(list_of_strings_validator),
1153 admin_immed_notify=GetterSetter(as_boolean),
1154 admin_notify_mchanges=GetterSetter(as_boolean),
1155 administrivia=GetterSetter(as_boolean),
1156
1157=== modified file 'src/mailman/rest/tests/test_domains.py'
1158--- src/mailman/rest/tests/test_domains.py 2015-01-05 01:40:47 +0000
1159+++ src/mailman/rest/tests/test_domains.py 2015-04-07 01:46:33 +0000
1160@@ -18,6 +18,7 @@
1161 """REST domain tests."""
1162
1163 __all__ = [
1164+ 'TestDomainOwners',
1165 'TestDomains',
1166 ]
1167
1168@@ -41,6 +42,17 @@
1169 with transaction():
1170 self._mlist = create_list('test@example.com')
1171
1172+ def test_create_domains(self):
1173+ """Test Create domain via REST"""
1174+ data = {'mail_host': 'example.org',
1175+ 'description': 'Example domain',
1176+ 'base_url': 'http://example.org',
1177+ 'owners': ['someone@example.com',
1178+ 'secondowner@example.com',]}
1179+ content, response = call_api('http://localhost:9001/3.0/domains',
1180+ data, method="POST")
1181+ self.assertEqual(response.status, 201)
1182+
1183 def test_bogus_endpoint_extension(self):
1184 # /domains/<domain>/lists/<anything> is not a valid endpoint.
1185 with self.assertRaises(HTTPError) as cm:
1186@@ -87,3 +99,45 @@
1187 call_api('http://localhost:9001/3.0/domains/example.com',
1188 method='DELETE')
1189 self.assertEqual(cm.exception.code, 404)
1190+
1191+
1192+
1193
1194+class TestDomainOwners(unittest.TestCase):
1195+ layer = RESTLayer
1196+
1197+ def test_get_missing_domain_owners(self):
1198+ # Try to get the owners of a missing domain.
1199+ with self.assertRaises(HTTPError) as cm:
1200+ call_api('http://localhost:9001/3.0/domains/example.net/owners')
1201+ self.assertEqual(cm.exception.code, 404)
1202+
1203+ def test_post_to_missing_domain_owners(self):
1204+ # Try to add owners to a missing domain.
1205+ with self.assertRaises(HTTPError) as cm:
1206+ call_api('http://localhost:9001/3.0/domains/example.net/owners', (
1207+ ('owner', 'dave@example.com'), ('owner', 'elle@example.com'),
1208+ ))
1209+ self.assertEqual(cm.exception.code, 404)
1210+
1211+ def test_delete_missing_domain_owners(self):
1212+ # Try to delete the owners of a missing domain.
1213+ with self.assertRaises(HTTPError) as cm:
1214+ call_api('http://localhost:9001/3.0/domains/example.net/owners',
1215+ method='DELETE')
1216+ self.assertEqual(cm.exception.code, 404)
1217+
1218+ def test_bad_post(self):
1219+ # Send POST data with an invalid attribute.
1220+ with self.assertRaises(HTTPError) as cm:
1221+ call_api('http://localhost:9001/3.0/domains/example.com/owners', (
1222+ ('guy', 'dave@example.com'), ('gal', 'elle@example.com'),
1223+ ))
1224+ self.assertEqual(cm.exception.code, 400)
1225+
1226+ def test_bad_delete(self):
1227+ # Send DELETE with any data.
1228+ with self.assertRaises(HTTPError) as cm:
1229+ call_api('http://localhost:9001/3.0/domains/example.com/owners', {
1230+ 'owner': 'dave@example.com',
1231+ }, method='DELETE')
1232+ self.assertEqual(cm.exception.code, 400)
1233
1234=== modified file 'src/mailman/rest/users.py'
1235--- src/mailman/rest/users.py 2015-03-20 16:38:00 +0000
1236+++ src/mailman/rest/users.py 2015-04-07 01:46:33 +0000
1237@@ -22,6 +22,7 @@
1238 'AddressUser',
1239 'AllUsers',
1240 'Login',
1241+ 'OwnersForDomain',
1242 ]
1243
1244
1245@@ -37,7 +38,8 @@
1246 conflict, created, etag, forbidden, no_content, not_found, okay, paginate,
1247 path_to)
1248 from mailman.rest.preferences import Preferences
1249-from mailman.rest.validator import PatchValidator, Validator
1250+from mailman.rest.validator import (
1251+ PatchValidator, Validator, list_of_strings_validator)
1252 from passlib.utils import generate_password as generate
1253 from uuid import UUID
1254 from zope.component import getUtility
1255@@ -47,27 +49,42 @@
1256 # Attributes of a user which can be changed via the REST API.
1257 class PasswordEncrypterGetterSetter(GetterSetter):
1258 def __init__(self):
1259- super(PasswordEncrypterGetterSetter, self).__init__(
1260- config.password_context.encrypt)
1261+ super().__init__(config.password_context.encrypt)
1262 def get(self, obj, attribute):
1263 assert attribute == 'cleartext_password'
1264- super(PasswordEncrypterGetterSetter, self).get(obj, 'password')
1265+ super().get(obj, 'password')
1266 def put(self, obj, attribute, value):
1267 assert attribute == 'cleartext_password'
1268- super(PasswordEncrypterGetterSetter, self).put(obj, 'password', value)
1269+ super().put(obj, 'password', value)
1270+
1271+
1272+class ListOfDomainOwners(GetterSetter):
1273+ def get(self, domain, attribute):
1274+ assert attribute == 'owner', (
1275+ 'Unexpected attribute: {}'.format(attribute))
1276+ def sort_key(owner):
1277+ return owner.addresses[0].email
1278+ return sorted(domain.owners, key=sort_key)
1279+
1280+ def put(self, domain, attribute, value):
1281+ assert attribute == 'owner', (
1282+ 'Unexpected attribute: {}'.format(attribute))
1283+ domain.add_owners(value)
1284
1285
1286 ATTRIBUTES = dict(
1287+ cleartext_password=PasswordEncrypterGetterSetter(),
1288 display_name=GetterSetter(str),
1289- cleartext_password=PasswordEncrypterGetterSetter(),
1290+ is_server_owner=GetterSetter(as_boolean),
1291 )
1292
1293
1294 CREATION_FIELDS = dict(
1295+ display_name=str,
1296 email=str,
1297- display_name=str,
1298+ is_server_owner=bool,
1299 password=str,
1300- _optional=('display_name', 'password'),
1301+ _optional=('display_name', 'password', 'is_server_owner'),
1302 )
1303
1304
1305@@ -78,6 +95,7 @@
1306 # strip that out (if it exists), then create the user, adding the password
1307 # after the fact if successful.
1308 password = arguments.pop('password', None)
1309+ is_server_owner = arguments.pop('is_server_owner', False)
1310 try:
1311 user = getUtility(IUserManager).create_user(**arguments)
1312 except ExistingAddressError as error:
1313@@ -88,6 +106,7 @@
1314 # This will have to be reset since it cannot be retrieved.
1315 password = generate(int(config.passwords.password_length))
1316 user.password = config.password_context.encrypt(password)
1317+ user.is_server_owner = is_server_owner
1318 location = path_to('users/{}'.format(user.user_id.int))
1319 created(response, location)
1320 return user
1321@@ -105,10 +124,11 @@
1322 # but we serialize its integer equivalent.
1323 user_id = user.user_id.int
1324 resource = dict(
1325- user_id=user_id,
1326 created_on=user.created_on,
1327+ is_server_owner=user.is_server_owner,
1328 self_link=path_to('users/{}'.format(user_id)),
1329- )
1330+ user_id=user_id,
1331+ )
1332 # Add the password attribute, only if the user has a password. Same
1333 # with the real name. These could be None or the empty string.
1334 if user.password:
1335@@ -293,7 +313,8 @@
1336 del fields['email']
1337 fields['user_id'] = int
1338 fields['auto_create'] = as_boolean
1339- fields['_optional'] = fields['_optional'] + ('user_id', 'auto_create')
1340+ fields['_optional'] = fields['_optional'] + (
1341+ 'user_id', 'auto_create', 'is_server_owner')
1342 try:
1343 validator = Validator(**fields)
1344 arguments = validator(request)
1345@@ -328,7 +349,8 @@
1346 # Process post data and check for an existing user.
1347 fields = CREATION_FIELDS.copy()
1348 fields['user_id'] = int
1349- fields['_optional'] = fields['_optional'] + ('user_id', 'email')
1350+ fields['_optional'] = fields['_optional'] + (
1351+ 'user_id', 'email', 'is_server_owner')
1352 try:
1353 validator = Validator(**fields)
1354 arguments = validator(request)
1355@@ -377,3 +399,56 @@
1356 no_content(response)
1357 else:
1358 forbidden(response)
1359+
1360+
1361+
1362
1363+class OwnersForDomain(_UserBase):
1364+ """Owners for a particular domain."""
1365+
1366+ def __init__(self, domain):
1367+ self._domain = domain
1368+
1369+ def on_get(self, request, response):
1370+ """/domains/<domain>/owners"""
1371+ if self._domain is None:
1372+ not_found(response)
1373+ return
1374+ resource = self._make_collection(request)
1375+ okay(response, etag(resource))
1376+
1377+ def on_post(self, request, response):
1378+ """POST to /domains/<domain>/owners """
1379+ if self._domain is None:
1380+ not_found(response)
1381+ return
1382+ validator = Validator(
1383+ owner=ListOfDomainOwners(list_of_strings_validator))
1384+ try:
1385+ validator.update(self._domain, request)
1386+ except ValueError as error:
1387+ bad_request(response, str(error))
1388+ return
1389+ return no_content(response)
1390+
1391+ def on_delete(self, request, response):
1392+ """DELETE to /domains/<domain>/owners"""
1393+ if self._domain is None:
1394+ not_found(response)
1395+ try:
1396+ # No arguments.
1397+ Validator()(request)
1398+ except ValueError as error:
1399+ bad_request(response, str(error))
1400+ return
1401+ owner_email = [
1402+ owner.addresses[0].email
1403+ for owner in self._domain.owners
1404+ ]
1405+ for email in owner_email:
1406+ self._domain.remove_owner(email)
1407+ return no_content(response)
1408+
1409+ @paginate
1410+ def _get_collection(self, request):
1411+ """See `CollectionMixin`."""
1412+ return list(self._domain.owners)
1413
1414=== modified file 'src/mailman/rest/validator.py'
1415--- src/mailman/rest/validator.py 2015-01-05 01:22:39 +0000
1416+++ src/mailman/rest/validator.py 2015-04-07 01:46:33 +0000
1417@@ -22,6 +22,7 @@
1418 'Validator',
1419 'enum_validator',
1420 'language_validator',
1421+ 'list_of_strings_validator',
1422 'subscriber_validator',
1423 ]
1424
1425@@ -66,6 +67,14 @@
1426 return getUtility(ILanguageManager)[code]
1427
1428
1429+def list_of_strings_validator(values):
1430+ """Turn a list of things into a list of unicodes."""
1431+ for value in values:
1432+ if not isinstance(value, str):
1433+ raise ValueError('Expected str, got {!r}'.format(value))
1434+ return values
1435+
1436+
1437
1438
1439 class Validator:
1440 """A validator of parameter input."""
1441
1442=== modified file 'src/mailman/testing/layers.py'
1443--- src/mailman/testing/layers.py 2015-01-05 01:22:39 +0000
1444+++ src/mailman/testing/layers.py 2015-04-07 01:46:33 +0000
1445@@ -200,7 +200,7 @@
1446 with transaction():
1447 getUtility(IDomainManager).add(
1448 'example.com', 'An example domain.',
1449- 'http://lists.example.com', 'postmaster@example.com')
1450+ 'http://lists.example.com')
1451
1452 @classmethod
1453 def testTearDown(cls):