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