Status: | Merged |
---|---|
Merged at revision: | 7324 |
Proposed branch: | lp:~barry/mailman/requests |
Merge into: | lp:mailman |
Diff against target: |
1111 lines (+546/-349) 12 files modified
src/mailman/app/subscriptions.py (+4/-2) src/mailman/app/tests/test_subscriptions.py (+17/-0) src/mailman/interfaces/pending.py (+6/-0) src/mailman/interfaces/registrar.py (+14/-9) src/mailman/model/docs/pending.rst (+15/-4) src/mailman/model/pending.py (+5/-0) src/mailman/rest/docs/post-moderation.rst (+9/-177) src/mailman/rest/docs/sub-moderation.rst (+113/-0) src/mailman/rest/lists.py (+2/-1) src/mailman/rest/post_moderation.py (+6/-103) src/mailman/rest/sub_moderation.py (+138/-0) src/mailman/rest/tests/test_moderation.py (+217/-53) |
To merge this branch: | bzr merge lp:~barry/mailman/requests |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Mailman Coders | Pending | ||
Review via email: mp+256560@code.launchpad.net |
Commit message
Description of the change
Fix subscription holds exposure to REST.
To post a comment you must log in.
lp:~barry/mailman/requests
updated
- 7327. By Barry Warsaw
-
Check pointing new subscription moderation REST API.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'src/mailman/app/subscriptions.py' | |||
2 | --- src/mailman/app/subscriptions.py 2015-04-16 02:51:39 +0000 | |||
3 | +++ src/mailman/app/subscriptions.py 2015-04-17 15:30:55 +0000 | |||
4 | @@ -24,7 +24,6 @@ | |||
5 | 24 | ] | 24 | ] |
6 | 25 | 25 | ||
7 | 26 | 26 | ||
8 | 27 | |||
9 | 28 | import uuid | 27 | import uuid |
10 | 29 | import logging | 28 | import logging |
11 | 30 | 29 | ||
12 | @@ -169,7 +168,10 @@ | |||
13 | 169 | return | 168 | return |
14 | 170 | pendable = Pendable( | 169 | pendable = Pendable( |
15 | 171 | list_id=self.mlist.list_id, | 170 | list_id=self.mlist.list_id, |
17 | 172 | address=self.address.email, | 171 | email=self.address.email, |
18 | 172 | display_name=self.address.display_name, | ||
19 | 173 | when=now().replace(microsecond=0).isoformat(), | ||
20 | 174 | token_owner=token_owner.name, | ||
21 | 173 | ) | 175 | ) |
22 | 174 | self.token = getUtility(IPendings).add(pendable, timedelta(days=3650)) | 176 | self.token = getUtility(IPendings).add(pendable, timedelta(days=3650)) |
23 | 175 | 177 | ||
24 | 176 | 178 | ||
25 | === modified file 'src/mailman/app/tests/test_subscriptions.py' | |||
26 | --- src/mailman/app/tests/test_subscriptions.py 2015-04-16 14:42:40 +0000 | |||
27 | +++ src/mailman/app/tests/test_subscriptions.py 2015-04-17 15:30:55 +0000 | |||
28 | @@ -57,6 +57,23 @@ | |||
29 | 57 | self.assertEqual(workflow.token_owner, TokenOwner.no_one) | 57 | self.assertEqual(workflow.token_owner, TokenOwner.no_one) |
30 | 58 | self.assertIsNone(workflow.member) | 58 | self.assertIsNone(workflow.member) |
31 | 59 | 59 | ||
32 | 60 | def test_pended_data(self): | ||
33 | 61 | # There is a Pendable associated with the held request, and it has | ||
34 | 62 | # some data associated with it. | ||
35 | 63 | anne = self._user_manager.create_address(self._anne) | ||
36 | 64 | workflow = SubscriptionWorkflow(self._mlist, anne) | ||
37 | 65 | try: | ||
38 | 66 | workflow.run_thru('send_confirmation') | ||
39 | 67 | except StopIteration: | ||
40 | 68 | pass | ||
41 | 69 | self.assertIsNotNone(workflow.token) | ||
42 | 70 | pendable = getUtility(IPendings).confirm(workflow.token, expunge=False) | ||
43 | 71 | self.assertEqual(pendable['list_id'], 'test.example.com') | ||
44 | 72 | self.assertEqual(pendable['email'], 'anne@example.com') | ||
45 | 73 | self.assertEqual(pendable['display_name'], '') | ||
46 | 74 | self.assertEqual(pendable['when'], '2005-08-01T07:49:23') | ||
47 | 75 | self.assertEqual(pendable['token_owner'], 'subscriber') | ||
48 | 76 | |||
49 | 60 | def test_user_or_address_required(self): | 77 | def test_user_or_address_required(self): |
50 | 61 | # The `subscriber` attribute must be a user or address. | 78 | # The `subscriber` attribute must be a user or address. |
51 | 62 | workflow = SubscriptionWorkflow(self._mlist) | 79 | workflow = SubscriptionWorkflow(self._mlist) |
52 | 63 | 80 | ||
53 | === modified file 'src/mailman/interfaces/pending.py' | |||
54 | --- src/mailman/interfaces/pending.py 2015-04-15 14:05:35 +0000 | |||
55 | +++ src/mailman/interfaces/pending.py 2015-04-17 15:30:55 +0000 | |||
56 | @@ -95,4 +95,10 @@ | |||
57 | 95 | def evict(): | 95 | def evict(): |
58 | 96 | """Remove all pended items whose lifetime has expired.""" | 96 | """Remove all pended items whose lifetime has expired.""" |
59 | 97 | 97 | ||
60 | 98 | def __iter__(): | ||
61 | 99 | """An iterator over all pendables. | ||
62 | 100 | |||
63 | 101 | Each element is a 2-tuple of the form (token, dict). | ||
64 | 102 | """ | ||
65 | 103 | |||
66 | 98 | count = Attribute('The number of pendables in the pendings database.') | 104 | count = Attribute('The number of pendables in the pendings database.') |
67 | 99 | 105 | ||
68 | === modified file 'src/mailman/interfaces/registrar.py' | |||
69 | --- src/mailman/interfaces/registrar.py 2015-04-16 02:51:39 +0000 | |||
70 | +++ src/mailman/interfaces/registrar.py 2015-04-17 15:30:55 +0000 | |||
71 | @@ -75,12 +75,13 @@ | |||
72 | 75 | 75 | ||
73 | 76 | :param subscriber: The user or address to subscribe. | 76 | :param subscriber: The user or address to subscribe. |
74 | 77 | :type email: ``IUser`` or ``IAddress`` | 77 | :type email: ``IUser`` or ``IAddress`` |
81 | 78 | :return: None if the workflow completes with the member being | 78 | :return: A 3-tuple is returned where the first element is the token |
82 | 79 | subscribed. If the workflow is paused for user confirmation or | 79 | hash, the second element is a ``TokenOwner`, and the third element |
83 | 80 | moderator approval, a 3-tuple is returned where the first element | 80 | is the subscribed member. If the subscriber got subscribed |
84 | 81 | is a ``TokenOwner`` the second element is the token hash, and the | 81 | immediately, the token will be None and the member will be |
85 | 82 | third element is the subscribed member. | 82 | an ``IMember``. If the subscription got held, the token |
86 | 83 | :rtype: None or 2-tuple of (TokenOwner, str) | 83 | will be a hash and the member will be None. |
87 | 84 | :rtype: (str-or-None, ``TokenOwner``, ``IMember``-or-None) | ||
88 | 84 | :raises MembershipIsBannedError: when the address being subscribed | 85 | :raises MembershipIsBannedError: when the address being subscribed |
89 | 85 | appears in the global or list-centric bans. | 86 | appears in the global or list-centric bans. |
90 | 86 | """ | 87 | """ |
91 | @@ -94,9 +95,13 @@ | |||
92 | 94 | 95 | ||
93 | 95 | :param token: A token matching a workflow. | 96 | :param token: A token matching a workflow. |
94 | 96 | :type token: string | 97 | :type token: string |
98 | 97 | :return: The new token for any follow up confirmation, or None if the | 98 | :return: A 3-tuple is returned where the first element is the token |
99 | 98 | user was subscribed. | 99 | hash, the second element is a ``TokenOwner`, and the third element |
100 | 99 | :rtype: str or None | 100 | is the subscribed member. If the subscriber got subscribed |
101 | 101 | immediately, the token will be None and the member will be | ||
102 | 102 | an ``IMember``. If the subscription is still being held, the token | ||
103 | 103 | will be a hash and the member will be None. | ||
104 | 104 | :rtype: (str-or-None, ``TokenOwner``, ``IMember``-or-None) | ||
105 | 100 | :raises LookupError: when no workflow is associated with the token. | 105 | :raises LookupError: when no workflow is associated with the token. |
106 | 101 | """ | 106 | """ |
107 | 102 | 107 | ||
108 | 103 | 108 | ||
109 | === modified file 'src/mailman/model/docs/pending.rst' | |||
110 | --- src/mailman/model/docs/pending.rst 2015-04-15 14:05:35 +0000 | |||
111 | +++ src/mailman/model/docs/pending.rst 2015-04-17 15:30:55 +0000 | |||
112 | @@ -43,10 +43,9 @@ | |||
113 | 43 | >>> pendingdb.count | 43 | >>> pendingdb.count |
114 | 44 | 1 | 44 | 1 |
115 | 45 | 45 | ||
120 | 46 | There's not much you can do with tokens except to *confirm* them, which | 46 | You can *confirm* the pending, which means returning the `IPendable` structure |
121 | 47 | basically means returning the `IPendable` structure (as a dictionary) from the | 47 | (as a dictionary) from the database that matches the token. If the token |
122 | 48 | database that matches the token. If the token isn't in the database, None is | 48 | isn't in the database, None is returned. |
119 | 49 | returned. | ||
123 | 50 | 49 | ||
124 | 51 | >>> pendable = pendingdb.confirm(b'missing') | 50 | >>> pendable = pendingdb.confirm(b'missing') |
125 | 52 | >>> print(pendable) | 51 | >>> print(pendable) |
126 | @@ -83,6 +82,18 @@ | |||
127 | 83 | >>> print(pendingdb.confirm(token_1)) | 82 | >>> print(pendingdb.confirm(token_1)) |
128 | 84 | None | 83 | None |
129 | 85 | 84 | ||
130 | 85 | You can iterate over all the pendings in the database. | ||
131 | 86 | |||
132 | 87 | >>> pendables = list(pendingdb) | ||
133 | 88 | >>> def sort_key(item): | ||
134 | 89 | ... token, pendable = item | ||
135 | 90 | ... return pendable['type'] | ||
136 | 91 | >>> sorted_pendables = sorted(pendables, key=sort_key) | ||
137 | 92 | >>> for token, pendable in sorted_pendables: | ||
138 | 93 | ... print(pendable['type']) | ||
139 | 94 | three | ||
140 | 95 | two | ||
141 | 96 | |||
142 | 86 | An event can be given a lifetime when it is pended, otherwise it just uses a | 97 | An event can be given a lifetime when it is pended, otherwise it just uses a |
143 | 87 | default lifetime. | 98 | default lifetime. |
144 | 88 | 99 | ||
145 | 89 | 100 | ||
146 | === modified file 'src/mailman/model/pending.py' | |||
147 | --- src/mailman/model/pending.py 2015-04-15 14:05:35 +0000 | |||
148 | +++ src/mailman/model/pending.py 2015-04-17 15:30:55 +0000 | |||
149 | @@ -166,6 +166,11 @@ | |||
150 | 166 | store.delete(keyvalue) | 166 | store.delete(keyvalue) |
151 | 167 | store.delete(pending) | 167 | store.delete(pending) |
152 | 168 | 168 | ||
153 | 169 | @dbconnection | ||
154 | 170 | def __iter__(self, store): | ||
155 | 171 | for pending in store.query(Pended).all(): | ||
156 | 172 | yield pending.token, self.confirm(pending.token, expunge=False) | ||
157 | 173 | |||
158 | 169 | @property | 174 | @property |
159 | 170 | @dbconnection | 175 | @dbconnection |
160 | 171 | def count(self, store): | 176 | def count(self, store): |
161 | 172 | 177 | ||
162 | === renamed file 'src/mailman/rest/docs/moderation.rst' => 'src/mailman/rest/docs/post-moderation.rst' | |||
163 | --- src/mailman/rest/docs/moderation.rst 2015-03-29 20:30:30 +0000 | |||
164 | +++ src/mailman/rest/docs/post-moderation.rst 2015-04-17 15:30:55 +0000 | |||
165 | @@ -1,18 +1,13 @@ | |||
178 | 1 | ========== | 1 | =============== |
179 | 2 | Moderation | 2 | Post Moderation |
180 | 3 | ========== | 3 | =============== |
181 | 4 | 4 | ||
182 | 5 | There are two kinds of moderation tasks a list administrator may need to | 5 | Messages which are held for approval can be accepted, rejected, discarded, or |
183 | 6 | perform. Messages which are held for approval can be accepted, rejected, | 6 | deferred by the list moderators. |
184 | 7 | discarded, or deferred. Subscription (and sometimes unsubscription) requests | 7 | |
173 | 8 | can similarly be accepted, discarded, rejected, or deferred. | ||
174 | 9 | |||
175 | 10 | |||
176 | 11 | Message moderation | ||
177 | 12 | ================== | ||
185 | 13 | 8 | ||
186 | 14 | Viewing the list of held messages | 9 | Viewing the list of held messages |
188 | 15 | --------------------------------- | 10 | ================================= |
189 | 16 | 11 | ||
190 | 17 | Held messages can be moderated through the REST API. A mailing list starts | 12 | Held messages can be moderated through the REST API. A mailing list starts |
191 | 18 | with no held messages. | 13 | with no held messages. |
192 | @@ -90,7 +85,7 @@ | |||
193 | 90 | 85 | ||
194 | 91 | 86 | ||
195 | 92 | Disposing of held messages | 87 | Disposing of held messages |
197 | 93 | -------------------------- | 88 | ========================== |
198 | 94 | 89 | ||
199 | 95 | Individual messages can be moderated through the API by POSTing back to the | 90 | Individual messages can be moderated through the API by POSTing back to the |
200 | 96 | held message's resource. The POST data requires an action of one of the | 91 | held message's resource. The POST data requires an action of one of the |
201 | @@ -196,166 +191,3 @@ | |||
202 | 196 | 1 | 191 | 1 |
203 | 197 | >>> print(messages[0].msg['subject']) | 192 | >>> print(messages[0].msg['subject']) |
204 | 198 | Request to mailing list "Ant" rejected | 193 | Request to mailing list "Ant" rejected |
205 | 199 | |||
206 | 200 | |||
207 | 201 | Subscription moderation | ||
208 | 202 | ======================= | ||
209 | 203 | |||
210 | 204 | Viewing subscription requests | ||
211 | 205 | ----------------------------- | ||
212 | 206 | |||
213 | 207 | Subscription and unsubscription requests can be moderated via the REST API as | ||
214 | 208 | well. A mailing list starts with no pending subscription or unsubscription | ||
215 | 209 | requests. | ||
216 | 210 | |||
217 | 211 | >>> ant.admin_immed_notify = False | ||
218 | 212 | >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/requests') | ||
219 | 213 | http_etag: "..." | ||
220 | 214 | start: 0 | ||
221 | 215 | total_size: 0 | ||
222 | 216 | |||
223 | 217 | When Anne tries to subscribe to the Ant list, her subscription is held for | ||
224 | 218 | moderator approval. | ||
225 | 219 | :: | ||
226 | 220 | |||
227 | 221 | >>> from mailman.app.moderator import hold_subscription | ||
228 | 222 | >>> from mailman.interfaces.member import DeliveryMode | ||
229 | 223 | >>> from mailman.interfaces.subscriptions import RequestRecord | ||
230 | 224 | |||
231 | 225 | >>> sub_req_id = hold_subscription( | ||
232 | 226 | ... ant, RequestRecord('anne@example.com', 'Anne Person', | ||
233 | 227 | ... DeliveryMode.regular, 'en')) | ||
234 | 228 | >>> transaction.commit() | ||
235 | 229 | |||
236 | 230 | The subscription request is available from the mailing list. | ||
237 | 231 | |||
238 | 232 | >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/requests') | ||
239 | 233 | entry 0: | ||
240 | 234 | delivery_mode: regular | ||
241 | 235 | display_name: Anne Person | ||
242 | 236 | email: anne@example.com | ||
243 | 237 | http_etag: "..." | ||
244 | 238 | language: en | ||
245 | 239 | request_id: ... | ||
246 | 240 | type: subscription | ||
247 | 241 | when: 2005-08-01T07:49:23 | ||
248 | 242 | http_etag: "..." | ||
249 | 243 | start: 0 | ||
250 | 244 | total_size: 1 | ||
251 | 245 | |||
252 | 246 | |||
253 | 247 | Viewing unsubscription requests | ||
254 | 248 | ------------------------------- | ||
255 | 249 | |||
256 | 250 | Bart tries to leave a mailing list, but he may not be allowed to. | ||
257 | 251 | |||
258 | 252 | >>> from mailman.testing.helpers import subscribe | ||
259 | 253 | >>> from mailman.app.moderator import hold_unsubscription | ||
260 | 254 | >>> bart = subscribe(ant, 'Bart', email='bart@example.com') | ||
261 | 255 | >>> unsub_req_id = hold_unsubscription(ant, 'bart@example.com') | ||
262 | 256 | >>> transaction.commit() | ||
263 | 257 | |||
264 | 258 | The unsubscription request is also available from the mailing list. | ||
265 | 259 | |||
266 | 260 | >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/requests') | ||
267 | 261 | entry 0: | ||
268 | 262 | delivery_mode: regular | ||
269 | 263 | display_name: Anne Person | ||
270 | 264 | email: anne@example.com | ||
271 | 265 | http_etag: "..." | ||
272 | 266 | language: en | ||
273 | 267 | request_id: ... | ||
274 | 268 | type: subscription | ||
275 | 269 | when: 2005-08-01T07:49:23 | ||
276 | 270 | entry 1: | ||
277 | 271 | email: bart@example.com | ||
278 | 272 | http_etag: "..." | ||
279 | 273 | request_id: ... | ||
280 | 274 | type: unsubscription | ||
281 | 275 | http_etag: "..." | ||
282 | 276 | start: 0 | ||
283 | 277 | total_size: 2 | ||
284 | 278 | |||
285 | 279 | |||
286 | 280 | Viewing individual requests | ||
287 | 281 | --------------------------- | ||
288 | 282 | |||
289 | 283 | You can view an individual membership change request by providing the | ||
290 | 284 | request id. Anne's subscription request looks like this. | ||
291 | 285 | |||
292 | 286 | >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/' | ||
293 | 287 | ... 'requests/{}'.format(sub_req_id)) | ||
294 | 288 | delivery_mode: regular | ||
295 | 289 | display_name: Anne Person | ||
296 | 290 | email: anne@example.com | ||
297 | 291 | http_etag: "..." | ||
298 | 292 | language: en | ||
299 | 293 | request_id: ... | ||
300 | 294 | type: subscription | ||
301 | 295 | when: 2005-08-01T07:49:23 | ||
302 | 296 | |||
303 | 297 | Bart's unsubscription request looks like this. | ||
304 | 298 | |||
305 | 299 | >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/' | ||
306 | 300 | ... 'requests/{}'.format(unsub_req_id)) | ||
307 | 301 | email: bart@example.com | ||
308 | 302 | http_etag: "..." | ||
309 | 303 | request_id: ... | ||
310 | 304 | type: unsubscription | ||
311 | 305 | |||
312 | 306 | |||
313 | 307 | Disposing of subscription requests | ||
314 | 308 | ---------------------------------- | ||
315 | 309 | |||
316 | 310 | Similar to held messages, you can dispose of held subscription and | ||
317 | 311 | unsubscription requests by POSTing back to the request's resource. The POST | ||
318 | 312 | data requires an action of one of the following: | ||
319 | 313 | |||
320 | 314 | * discard - throw the request away. | ||
321 | 315 | * reject - the request is denied and a notification is sent to the email | ||
322 | 316 | address requesting the membership change. | ||
323 | 317 | * defer - defer any action on this membership change (continue to hold it). | ||
324 | 318 | * accept - accept the membership change. | ||
325 | 319 | |||
326 | 320 | Anne's subscription request is accepted. | ||
327 | 321 | |||
328 | 322 | >>> dump_json('http://localhost:9001/3.0/lists/' | ||
329 | 323 | ... 'ant@example.com/requests/{}'.format(sub_req_id), | ||
330 | 324 | ... {'action': 'accept'}) | ||
331 | 325 | content-length: 0 | ||
332 | 326 | date: ... | ||
333 | 327 | server: ... | ||
334 | 328 | status: 204 | ||
335 | 329 | |||
336 | 330 | Anne is now a member of the mailing list. | ||
337 | 331 | |||
338 | 332 | >>> transaction.abort() | ||
339 | 333 | >>> ant.members.get_member('anne@example.com') | ||
340 | 334 | <Member: Anne Person <anne@example.com> on ant@example.com | ||
341 | 335 | as MemberRole.member> | ||
342 | 336 | >>> transaction.abort() | ||
343 | 337 | |||
344 | 338 | Bart's unsubscription request is discarded. | ||
345 | 339 | |||
346 | 340 | >>> dump_json('http://localhost:9001/3.0/lists/' | ||
347 | 341 | ... 'ant@example.com/requests/{}'.format(unsub_req_id), | ||
348 | 342 | ... {'action': 'discard'}) | ||
349 | 343 | content-length: 0 | ||
350 | 344 | date: ... | ||
351 | 345 | server: ... | ||
352 | 346 | status: 204 | ||
353 | 347 | |||
354 | 348 | Bart is still a member of the mailing list. | ||
355 | 349 | |||
356 | 350 | >>> transaction.abort() | ||
357 | 351 | >>> print(ant.members.get_member('bart@example.com')) | ||
358 | 352 | <Member: Bart Person <bart@example.com> on ant@example.com | ||
359 | 353 | as MemberRole.member> | ||
360 | 354 | >>> transaction.abort() | ||
361 | 355 | |||
362 | 356 | There are no more membership change requests. | ||
363 | 357 | |||
364 | 358 | >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/requests') | ||
365 | 359 | http_etag: "..." | ||
366 | 360 | start: 0 | ||
367 | 361 | total_size: 0 | ||
368 | 362 | 194 | ||
369 | === added file 'src/mailman/rest/docs/sub-moderation.rst' | |||
370 | --- src/mailman/rest/docs/sub-moderation.rst 1970-01-01 00:00:00 +0000 | |||
371 | +++ src/mailman/rest/docs/sub-moderation.rst 2015-04-17 15:30:55 +0000 | |||
372 | @@ -0,0 +1,113 @@ | |||
373 | 1 | ========================= | ||
374 | 2 | Subscription moderation | ||
375 | 3 | ========================= | ||
376 | 4 | |||
377 | 5 | Subscription (and sometimes unsubscription) requests can similarly be | ||
378 | 6 | accepted, discarded, rejected, or deferred by the list moderators. | ||
379 | 7 | |||
380 | 8 | |||
381 | 9 | Viewing subscription requests | ||
382 | 10 | ============================= | ||
383 | 11 | |||
384 | 12 | A mailing list starts with no pending subscription or unsubscription requests. | ||
385 | 13 | |||
386 | 14 | >>> ant = create_list('ant@example.com') | ||
387 | 15 | >>> ant.admin_immed_notify = False | ||
388 | 16 | >>> from mailman.interfaces.mailinglist import SubscriptionPolicy | ||
389 | 17 | >>> ant.subscription_policy = SubscriptionPolicy.moderate | ||
390 | 18 | >>> transaction.commit() | ||
391 | 19 | >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/requests') | ||
392 | 20 | http_etag: "..." | ||
393 | 21 | start: 0 | ||
394 | 22 | total_size: 0 | ||
395 | 23 | |||
396 | 24 | When Anne tries to subscribe to the Ant list, her subscription is held for | ||
397 | 25 | moderator approval. | ||
398 | 26 | |||
399 | 27 | >>> from mailman.interfaces.registrar import IRegistrar | ||
400 | 28 | >>> from mailman.interfaces.usermanager import IUserManager | ||
401 | 29 | >>> from zope.component import getUtility | ||
402 | 30 | >>> registrar = IRegistrar(ant) | ||
403 | 31 | >>> manager = getUtility(IUserManager) | ||
404 | 32 | >>> anne = manager.create_address('anne@example.com', 'Anne Person') | ||
405 | 33 | >>> token, token_owner, member = registrar.register( | ||
406 | 34 | ... anne, pre_verified=True, pre_confirmed=True) | ||
407 | 35 | >>> print(member) | ||
408 | 36 | None | ||
409 | 37 | |||
410 | 38 | The message is being held for moderator approval. | ||
411 | 39 | |||
412 | 40 | >>> print(token_owner.name) | ||
413 | 41 | moderator | ||
414 | 42 | |||
415 | 43 | The subscription request can be viewed in the REST API. | ||
416 | 44 | |||
417 | 45 | >>> transaction.commit() | ||
418 | 46 | >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/requests') | ||
419 | 47 | entry 0: | ||
420 | 48 | display_name: Anne Person | ||
421 | 49 | email: anne@example.com | ||
422 | 50 | http_etag: "..." | ||
423 | 51 | list_id: ant.example.com | ||
424 | 52 | token: ... | ||
425 | 53 | token_owner: moderator | ||
426 | 54 | when: 2005-08-01T07:49:23 | ||
427 | 55 | http_etag: "..." | ||
428 | 56 | start: 0 | ||
429 | 57 | total_size: 1 | ||
430 | 58 | |||
431 | 59 | |||
432 | 60 | Viewing individual requests | ||
433 | 61 | =========================== | ||
434 | 62 | |||
435 | 63 | You can view an individual membership change request by providing the token | ||
436 | 64 | (a.k.a. request id). Anne's subscription request looks like this. | ||
437 | 65 | |||
438 | 66 | >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/' | ||
439 | 67 | ... 'requests/{}'.format(token)) | ||
440 | 68 | delivery_mode: regular | ||
441 | 69 | display_name: Anne Person | ||
442 | 70 | email: anne@example.com | ||
443 | 71 | http_etag: "..." | ||
444 | 72 | language: en | ||
445 | 73 | request_id: ... | ||
446 | 74 | type: subscription | ||
447 | 75 | when: 2005-08-01T07:49:23 | ||
448 | 76 | |||
449 | 77 | |||
450 | 78 | Disposing of subscription requests | ||
451 | 79 | ================================== | ||
452 | 80 | |||
453 | 81 | Moderators can dispose of held subscription requests by POSTing back to the | ||
454 | 82 | request's resource. The POST data requires an action of one of the following: | ||
455 | 83 | |||
456 | 84 | * discard - throw the request away. | ||
457 | 85 | * reject - the request is denied and a notification is sent to the email | ||
458 | 86 | address requesting the membership change. | ||
459 | 87 | * defer - defer any action on this membership change (continue to hold it). | ||
460 | 88 | * accept - accept the membership change. | ||
461 | 89 | |||
462 | 90 | Anne's subscription request is accepted. | ||
463 | 91 | |||
464 | 92 | >>> dump_json('http://localhost:9001/3.0/lists/' | ||
465 | 93 | ... 'ant@example.com/requests/{}'.format(token), | ||
466 | 94 | ... {'action': 'accept'}) | ||
467 | 95 | content-length: 0 | ||
468 | 96 | date: ... | ||
469 | 97 | server: ... | ||
470 | 98 | status: 204 | ||
471 | 99 | |||
472 | 100 | Anne is now a member of the mailing list. | ||
473 | 101 | |||
474 | 102 | >>> transaction.abort() | ||
475 | 103 | >>> ant.members.get_member('anne@example.com') | ||
476 | 104 | <Member: Anne Person <anne@example.com> on ant@example.com | ||
477 | 105 | as MemberRole.member> | ||
478 | 106 | >>> transaction.abort() | ||
479 | 107 | |||
480 | 108 | There are no more membership change requests. | ||
481 | 109 | |||
482 | 110 | >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/requests') | ||
483 | 111 | http_etag: "..." | ||
484 | 112 | start: 0 | ||
485 | 113 | total_size: 0 | ||
486 | 0 | 114 | ||
487 | === modified file 'src/mailman/rest/lists.py' | |||
488 | --- src/mailman/rest/lists.py 2015-04-06 23:07:42 +0000 | |||
489 | +++ src/mailman/rest/lists.py 2015-04-17 15:30:55 +0000 | |||
490 | @@ -42,7 +42,8 @@ | |||
491 | 42 | CollectionMixin, GetterSetter, NotFound, bad_request, child, created, | 42 | CollectionMixin, GetterSetter, NotFound, bad_request, child, created, |
492 | 43 | etag, no_content, not_found, okay, paginate, path_to) | 43 | etag, no_content, not_found, okay, paginate, path_to) |
493 | 44 | from mailman.rest.members import AMember, MemberCollection | 44 | from mailman.rest.members import AMember, MemberCollection |
495 | 45 | from mailman.rest.moderation import HeldMessages, SubscriptionRequests | 45 | from mailman.rest.post_moderation import HeldMessages |
496 | 46 | from mailman.rest.sub_moderation import SubscriptionRequests | ||
497 | 46 | from mailman.rest.validator import Validator | 47 | from mailman.rest.validator import Validator |
498 | 47 | from operator import attrgetter | 48 | from operator import attrgetter |
499 | 48 | from zope.component import getUtility | 49 | from zope.component import getUtility |
500 | 49 | 50 | ||
501 | === renamed file 'src/mailman/rest/moderation.py' => 'src/mailman/rest/post_moderation.py' | |||
502 | --- src/mailman/rest/moderation.py 2015-01-05 01:22:39 +0000 | |||
503 | +++ src/mailman/rest/post_moderation.py 2015-04-17 15:30:55 +0000 | |||
504 | @@ -15,18 +15,15 @@ | |||
505 | 15 | # You should have received a copy of the GNU General Public License along with | 15 | # You should have received a copy of the GNU General Public License along with |
506 | 16 | # GNU Mailman. If not, see <http://www.gnu.org/licenses/>. | 16 | # GNU Mailman. If not, see <http://www.gnu.org/licenses/>. |
507 | 17 | 17 | ||
509 | 18 | """REST API for Message moderation.""" | 18 | """REST API for held message moderation.""" |
510 | 19 | 19 | ||
511 | 20 | __all__ = [ | 20 | __all__ = [ |
512 | 21 | 'HeldMessage', | 21 | 'HeldMessage', |
513 | 22 | 'HeldMessages', | 22 | 'HeldMessages', |
514 | 23 | 'MembershipChangeRequest', | ||
515 | 24 | 'SubscriptionRequests', | ||
516 | 25 | ] | 23 | ] |
517 | 26 | 24 | ||
518 | 27 | 25 | ||
521 | 28 | from mailman.app.moderator import ( | 26 | from mailman.app.moderator import handle_message |
520 | 29 | handle_message, handle_subscription, handle_unsubscription) | ||
522 | 30 | from mailman.interfaces.action import Action | 27 | from mailman.interfaces.action import Action |
523 | 31 | from mailman.interfaces.messages import IMessageStore | 28 | from mailman.interfaces.messages import IMessageStore |
524 | 32 | from mailman.interfaces.requests import IListRequests, RequestType | 29 | from mailman.interfaces.requests import IListRequests, RequestType |
525 | @@ -36,16 +33,11 @@ | |||
526 | 36 | from zope.component import getUtility | 33 | from zope.component import getUtility |
527 | 37 | 34 | ||
528 | 38 | 35 | ||
529 | 39 | HELD_MESSAGE_REQUESTS = (RequestType.held_message,) | ||
530 | 40 | MEMBERSHIP_CHANGE_REQUESTS = (RequestType.subscription, | ||
531 | 41 | RequestType.unsubscription) | ||
532 | 42 | |||
533 | 43 | |||
534 | 44 | 36 | ||
535 | 45 | 37 | ||
536 | 46 | class _ModerationBase: | 38 | class _ModerationBase: |
537 | 47 | """Common base class.""" | 39 | """Common base class.""" |
538 | 48 | 40 | ||
540 | 49 | def _make_resource(self, request_id, expected_request_types): | 41 | def _make_resource(self, request_id): |
541 | 50 | requests = IListRequests(self._mlist) | 42 | requests = IListRequests(self._mlist) |
542 | 51 | results = requests.get_request(request_id) | 43 | results = requests.get_request(request_id) |
543 | 52 | if results is None: | 44 | if results is None: |
544 | @@ -57,9 +49,9 @@ | |||
545 | 57 | # Check for a matching request type, and insert the type name into the | 49 | # Check for a matching request type, and insert the type name into the |
546 | 58 | # resource. | 50 | # resource. |
547 | 59 | request_type = RequestType[resource.pop('_request_type')] | 51 | request_type = RequestType[resource.pop('_request_type')] |
549 | 60 | if request_type not in expected_request_types: | 52 | if request_type is not RequestType.held_message: |
550 | 61 | return None | 53 | return None |
552 | 62 | resource['type'] = request_type.name | 54 | resource['type'] = RequestType.held_message.name |
553 | 63 | # This key isn't what you think it is. Usually, it's the Pendable | 55 | # This key isn't what you think it is. Usually, it's the Pendable |
554 | 64 | # record's row id, which isn't helpful at all. If it's not there, | 56 | # record's row id, which isn't helpful at all. If it's not there, |
555 | 65 | # that's fine too. | 57 | # that's fine too. |
556 | @@ -72,8 +64,7 @@ | |||
557 | 72 | """Held messages are a little different.""" | 64 | """Held messages are a little different.""" |
558 | 73 | 65 | ||
559 | 74 | def _make_resource(self, request_id): | 66 | def _make_resource(self, request_id): |
562 | 75 | resource = super(_HeldMessageBase, self)._make_resource( | 67 | resource = super(_HeldMessageBase, self)._make_resource(request_id) |
561 | 76 | request_id, HELD_MESSAGE_REQUESTS) | ||
563 | 77 | if resource is None: | 68 | if resource is None: |
564 | 78 | return None | 69 | return None |
565 | 79 | # Grab the message and insert its text representation into the | 70 | # Grab the message and insert its text representation into the |
566 | @@ -162,91 +153,3 @@ | |||
567 | 162 | @child(r'^(?P<id>[^/]+)') | 153 | @child(r'^(?P<id>[^/]+)') |
568 | 163 | def message(self, request, segments, **kw): | 154 | def message(self, request, segments, **kw): |
569 | 164 | return HeldMessage(self._mlist, kw['id']) | 155 | return HeldMessage(self._mlist, kw['id']) |
570 | 165 | |||
571 | 166 | |||
572 | 167 | |||
573 | 168 | 156 | ||
574 | 169 | class MembershipChangeRequest(_ModerationBase): | ||
575 | 170 | """Resource for moderating a membership change.""" | ||
576 | 171 | |||
577 | 172 | def __init__(self, mlist, request_id): | ||
578 | 173 | self._mlist = mlist | ||
579 | 174 | self._request_id = request_id | ||
580 | 175 | |||
581 | 176 | def on_get(self, request, response): | ||
582 | 177 | try: | ||
583 | 178 | request_id = int(self._request_id) | ||
584 | 179 | except ValueError: | ||
585 | 180 | bad_request(response) | ||
586 | 181 | return | ||
587 | 182 | resource = self._make_resource(request_id, MEMBERSHIP_CHANGE_REQUESTS) | ||
588 | 183 | if resource is None: | ||
589 | 184 | not_found(response) | ||
590 | 185 | else: | ||
591 | 186 | # Remove unnecessary keys. | ||
592 | 187 | del resource['key'] | ||
593 | 188 | okay(response, etag(resource)) | ||
594 | 189 | |||
595 | 190 | def on_post(self, request, response): | ||
596 | 191 | try: | ||
597 | 192 | validator = Validator(action=enum_validator(Action)) | ||
598 | 193 | arguments = validator(request) | ||
599 | 194 | except ValueError as error: | ||
600 | 195 | bad_request(response, str(error)) | ||
601 | 196 | return | ||
602 | 197 | requests = IListRequests(self._mlist) | ||
603 | 198 | try: | ||
604 | 199 | request_id = int(self._request_id) | ||
605 | 200 | except ValueError: | ||
606 | 201 | bad_request(response) | ||
607 | 202 | return | ||
608 | 203 | results = requests.get_request(request_id) | ||
609 | 204 | if results is None: | ||
610 | 205 | not_found(response) | ||
611 | 206 | return | ||
612 | 207 | key, data = results | ||
613 | 208 | try: | ||
614 | 209 | request_type = RequestType[data['_request_type']] | ||
615 | 210 | except ValueError: | ||
616 | 211 | bad_request(response) | ||
617 | 212 | return | ||
618 | 213 | if request_type is RequestType.subscription: | ||
619 | 214 | handle_subscription(self._mlist, request_id, **arguments) | ||
620 | 215 | elif request_type is RequestType.unsubscription: | ||
621 | 216 | handle_unsubscription(self._mlist, request_id, **arguments) | ||
622 | 217 | else: | ||
623 | 218 | bad_request(response) | ||
624 | 219 | return | ||
625 | 220 | no_content(response) | ||
626 | 221 | |||
627 | 222 | |||
628 | 223 | class SubscriptionRequests(_ModerationBase, CollectionMixin): | ||
629 | 224 | """Resource for membership change requests.""" | ||
630 | 225 | |||
631 | 226 | def __init__(self, mlist): | ||
632 | 227 | self._mlist = mlist | ||
633 | 228 | self._requests = None | ||
634 | 229 | |||
635 | 230 | def _resource_as_dict(self, request): | ||
636 | 231 | """See `CollectionMixin`.""" | ||
637 | 232 | resource = self._make_resource(request.id, MEMBERSHIP_CHANGE_REQUESTS) | ||
638 | 233 | # Remove unnecessary keys. | ||
639 | 234 | del resource['key'] | ||
640 | 235 | return resource | ||
641 | 236 | |||
642 | 237 | def _get_collection(self, request): | ||
643 | 238 | requests = IListRequests(self._mlist) | ||
644 | 239 | self._requests = requests | ||
645 | 240 | items = [] | ||
646 | 241 | for request_type in MEMBERSHIP_CHANGE_REQUESTS: | ||
647 | 242 | for request in requests.of_type(request_type): | ||
648 | 243 | items.append(request) | ||
649 | 244 | return items | ||
650 | 245 | |||
651 | 246 | def on_get(self, request, response): | ||
652 | 247 | """/lists/listname/requests""" | ||
653 | 248 | resource = self._make_collection(request) | ||
654 | 249 | okay(response, etag(resource)) | ||
655 | 250 | |||
656 | 251 | @child(r'^(?P<id>[^/]+)') | ||
657 | 252 | def subscription(self, request, segments, **kw): | ||
658 | 253 | return MembershipChangeRequest(self._mlist, kw['id']) | ||
659 | 254 | 157 | ||
660 | === added file 'src/mailman/rest/sub_moderation.py' | |||
661 | --- src/mailman/rest/sub_moderation.py 1970-01-01 00:00:00 +0000 | |||
662 | +++ src/mailman/rest/sub_moderation.py 2015-04-17 15:30:55 +0000 | |||
663 | @@ -0,0 +1,138 @@ | |||
664 | 1 | # Copyright (C) 2012-2015 by the Free Software Foundation, Inc. | ||
665 | 2 | # | ||
666 | 3 | # This file is part of GNU Mailman. | ||
667 | 4 | # | ||
668 | 5 | # GNU Mailman is free software: you can redistribute it and/or modify it under | ||
669 | 6 | # the terms of the GNU General Public License as published by the Free | ||
670 | 7 | # Software Foundation, either version 3 of the License, or (at your option) | ||
671 | 8 | # any later version. | ||
672 | 9 | # | ||
673 | 10 | # GNU Mailman is distributed in the hope that it will be useful, but WITHOUT | ||
674 | 11 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or | ||
675 | 12 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for | ||
676 | 13 | # more details. | ||
677 | 14 | # | ||
678 | 15 | # You should have received a copy of the GNU General Public License along with | ||
679 | 16 | # GNU Mailman. If not, see <http://www.gnu.org/licenses/>. | ||
680 | 17 | |||
681 | 18 | """REST API for held subscription requests.""" | ||
682 | 19 | |||
683 | 20 | __all__ = [ | ||
684 | 21 | 'SubscriptionRequests', | ||
685 | 22 | ] | ||
686 | 23 | |||
687 | 24 | |||
688 | 25 | from mailman.interfaces.action import Action | ||
689 | 26 | from mailman.interfaces.pending import IPendings | ||
690 | 27 | from mailman.interfaces.registrar import IRegistrar | ||
691 | 28 | from mailman.rest.helpers import ( | ||
692 | 29 | CollectionMixin, bad_request, child, etag, no_content, not_found, okay) | ||
693 | 30 | from mailman.rest.validator import Validator, enum_validator | ||
694 | 31 | from zope.component import getUtility | ||
695 | 32 | |||
696 | 33 | |||
697 | 34 | |||
698 | 0 | 35 | ||
699 | 36 | class _ModerationBase: | ||
700 | 37 | """Common base class.""" | ||
701 | 38 | |||
702 | 39 | def __init__(self): | ||
703 | 40 | self._pendings = getUtility(IPendings) | ||
704 | 41 | |||
705 | 42 | def _resource_as_dict(self, token): | ||
706 | 43 | pendable = self._pendings.confirm(token, expunge=False) | ||
707 | 44 | if pendable is None: | ||
708 | 45 | # This token isn't in the database. | ||
709 | 46 | raise LookupError | ||
710 | 47 | resource = dict(token=token) | ||
711 | 48 | resource.update(pendable) | ||
712 | 49 | return resource | ||
713 | 50 | |||
714 | 51 | |||
715 | 52 | |||
716 | 1 | 53 | ||
717 | 54 | class IndividualRequest(_ModerationBase): | ||
718 | 55 | """Resource for moderating a membership change.""" | ||
719 | 56 | |||
720 | 57 | def __init__(self, mlist, token): | ||
721 | 58 | super().__init__() | ||
722 | 59 | self._mlist = mlist | ||
723 | 60 | self._registrar = IRegistrar(self._mlist) | ||
724 | 61 | self._token = token | ||
725 | 62 | |||
726 | 63 | def on_get(self, request, response): | ||
727 | 64 | # Get the pended record associated with this token, if it exists in | ||
728 | 65 | # the pending table. | ||
729 | 66 | try: | ||
730 | 67 | resource = self._resource_as_dict(self._token) | ||
731 | 68 | except LookupError: | ||
732 | 69 | not_found(response) | ||
733 | 70 | return | ||
734 | 71 | okay(response, etag(resource)) | ||
735 | 72 | |||
736 | 73 | def on_post(self, request, response): | ||
737 | 74 | try: | ||
738 | 75 | validator = Validator(action=enum_validator(Action)) | ||
739 | 76 | arguments = validator(request) | ||
740 | 77 | except ValueError as error: | ||
741 | 78 | bad_request(response, str(error)) | ||
742 | 79 | return | ||
743 | 80 | action = arguments['action'] | ||
744 | 81 | if action is Action.defer: | ||
745 | 82 | # At least see if the token is in the database. | ||
746 | 83 | pendable = self._pendings.confirm(self._token, expunge=False) | ||
747 | 84 | if pendable is None: | ||
748 | 85 | not_found(response) | ||
749 | 86 | else: | ||
750 | 87 | no_content(response) | ||
751 | 88 | elif action is Action.accept: | ||
752 | 89 | try: | ||
753 | 90 | self._registrar.confirm(self._token) | ||
754 | 91 | except LookupError: | ||
755 | 92 | not_found(response) | ||
756 | 93 | else: | ||
757 | 94 | no_content(response) | ||
758 | 95 | elif action is Action.discard: | ||
759 | 96 | # At least see if the token is in the database. | ||
760 | 97 | pendable = self._pendings.confirm(self._token, expunge=True) | ||
761 | 98 | if pendable is None: | ||
762 | 99 | not_found(response) | ||
763 | 100 | else: | ||
764 | 101 | no_content(response) | ||
765 | 102 | elif action is Action.reject: | ||
766 | 103 | # XXX | ||
767 | 104 | no_content(response) | ||
768 | 105 | |||
769 | 106 | |||
770 | 107 | |||
771 | 2 | 108 | ||
772 | 109 | class SubscriptionRequests(_ModerationBase, CollectionMixin): | ||
773 | 110 | """Resource for membership change requests.""" | ||
774 | 111 | |||
775 | 112 | def __init__(self, mlist): | ||
776 | 113 | super().__init__() | ||
777 | 114 | self._mlist = mlist | ||
778 | 115 | |||
779 | 116 | def _get_collection(self, request): | ||
780 | 117 | # There's currently no better way to query the pendings database for | ||
781 | 118 | # all the entries that are associated with subscription holds on this | ||
782 | 119 | # mailing list. Brute force iterating over all the pendables. | ||
783 | 120 | collection = [] | ||
784 | 121 | for token, pendable in getUtility(IPendings): | ||
785 | 122 | if 'token_owner' not in pendable: | ||
786 | 123 | # This isn't a subscription hold. | ||
787 | 124 | continue | ||
788 | 125 | list_id = pendable.get('list_id') | ||
789 | 126 | if list_id != self._mlist.list_id: | ||
790 | 127 | # Either there isn't a list_id field, in which case it can't | ||
791 | 128 | # be a subscription hold, or this is a hold for some other | ||
792 | 129 | # mailing list. | ||
793 | 130 | continue | ||
794 | 131 | collection.append(token) | ||
795 | 132 | return collection | ||
796 | 133 | |||
797 | 134 | def on_get(self, request, response): | ||
798 | 135 | """/lists/listname/requests""" | ||
799 | 136 | resource = self._make_collection(request) | ||
800 | 137 | okay(response, etag(resource)) | ||
801 | 138 | |||
802 | 139 | @child(r'^(?P<token>[^/]+)') | ||
803 | 140 | def subscription(self, request, segments, **kw): | ||
804 | 141 | return IndividualRequest(self._mlist, kw['token']) | ||
805 | 3 | 142 | ||
806 | === modified file 'src/mailman/rest/tests/test_moderation.py' | |||
807 | --- src/mailman/rest/tests/test_moderation.py 2015-03-29 20:30:30 +0000 | |||
808 | +++ src/mailman/rest/tests/test_moderation.py 2015-04-17 15:30:55 +0000 | |||
809 | @@ -18,26 +18,28 @@ | |||
810 | 18 | """REST moderation tests.""" | 18 | """REST moderation tests.""" |
811 | 19 | 19 | ||
812 | 20 | __all__ = [ | 20 | __all__ = [ |
814 | 21 | 'TestModeration', | 21 | 'TestPostModeration', |
815 | 22 | 'TestSubscriptionModeration', | ||
816 | 22 | ] | 23 | ] |
817 | 23 | 24 | ||
818 | 24 | 25 | ||
819 | 25 | import unittest | 26 | import unittest |
820 | 26 | 27 | ||
821 | 27 | from mailman.app.lifecycle import create_list | 28 | from mailman.app.lifecycle import create_list |
824 | 28 | from mailman.app.moderator import hold_message, hold_subscription | 29 | from mailman.app.moderator import hold_message |
823 | 29 | from mailman.config import config | ||
825 | 30 | from mailman.database.transaction import transaction | 30 | from mailman.database.transaction import transaction |
828 | 31 | from mailman.interfaces.member import DeliveryMode | 31 | from mailman.interfaces.registrar import IRegistrar |
829 | 32 | from mailman.interfaces.subscriptions import RequestRecord | 32 | from mailman.interfaces.usermanager import IUserManager |
830 | 33 | from mailman.testing.helpers import ( | 33 | from mailman.testing.helpers import ( |
832 | 34 | call_api, specialized_message_from_string as mfs) | 34 | call_api, get_queue_messages, specialized_message_from_string as mfs) |
833 | 35 | from mailman.testing.layers import RESTLayer | 35 | from mailman.testing.layers import RESTLayer |
834 | 36 | from mailman.utilities.datetime import now | ||
835 | 36 | from urllib.error import HTTPError | 37 | from urllib.error import HTTPError |
836 | 38 | from zope.component import getUtility | ||
837 | 37 | 39 | ||
838 | 38 | 40 | ||
839 | 39 | 41 | ||
840 | 40 | 42 | ||
842 | 41 | class TestModeration(unittest.TestCase): | 43 | class TestPostModeration(unittest.TestCase): |
843 | 42 | layer = RESTLayer | 44 | layer = RESTLayer |
844 | 43 | 45 | ||
845 | 44 | def setUp(self): | 46 | def setUp(self): |
846 | @@ -71,24 +73,6 @@ | |||
847 | 71 | call_api('http://localhost:9001/3.0/lists/ant@example.com/held/99') | 73 | call_api('http://localhost:9001/3.0/lists/ant@example.com/held/99') |
848 | 72 | self.assertEqual(cm.exception.code, 404) | 74 | self.assertEqual(cm.exception.code, 404) |
849 | 73 | 75 | ||
850 | 74 | def test_subscription_request_as_held_message(self): | ||
851 | 75 | # Provide the request id of a subscription request using the held | ||
852 | 76 | # message API returns a not-found even though the request id is | ||
853 | 77 | # in the database. | ||
854 | 78 | held_id = hold_message(self._mlist, self._msg) | ||
855 | 79 | subscribe_id = hold_subscription( | ||
856 | 80 | self._mlist, | ||
857 | 81 | RequestRecord('bperson@example.net', 'Bart Person', | ||
858 | 82 | DeliveryMode.regular, 'en')) | ||
859 | 83 | config.db.store.commit() | ||
860 | 84 | url = 'http://localhost:9001/3.0/lists/ant@example.com/held/{0}' | ||
861 | 85 | with self.assertRaises(HTTPError) as cm: | ||
862 | 86 | call_api(url.format(subscribe_id)) | ||
863 | 87 | self.assertEqual(cm.exception.code, 404) | ||
864 | 88 | # But using the held_id returns a valid response. | ||
865 | 89 | response, content = call_api(url.format(held_id)) | ||
866 | 90 | self.assertEqual(response['message_id'], '<alpha>') | ||
867 | 91 | |||
868 | 92 | def test_bad_held_message_action(self): | 76 | def test_bad_held_message_action(self): |
869 | 93 | # POSTing to a held message with a bad action. | 77 | # POSTing to a held message with a bad action. |
870 | 94 | held_id = hold_message(self._mlist, self._msg) | 78 | held_id = hold_message(self._mlist, self._msg) |
871 | @@ -99,34 +83,6 @@ | |||
872 | 99 | self.assertEqual(cm.exception.msg, | 83 | self.assertEqual(cm.exception.msg, |
873 | 100 | b'Cannot convert parameters: action') | 84 | b'Cannot convert parameters: action') |
874 | 101 | 85 | ||
875 | 102 | def test_bad_subscription_request_id(self): | ||
876 | 103 | # Bad request when request_id is not an integer. | ||
877 | 104 | with self.assertRaises(HTTPError) as cm: | ||
878 | 105 | call_api('http://localhost:9001/3.0/lists/ant@example.com/' | ||
879 | 106 | 'requests/bogus') | ||
880 | 107 | self.assertEqual(cm.exception.code, 400) | ||
881 | 108 | |||
882 | 109 | def test_missing_subscription_request_id(self): | ||
883 | 110 | # Bad request when the request_id is not in the database. | ||
884 | 111 | with self.assertRaises(HTTPError) as cm: | ||
885 | 112 | call_api('http://localhost:9001/3.0/lists/ant@example.com/' | ||
886 | 113 | 'requests/99') | ||
887 | 114 | self.assertEqual(cm.exception.code, 404) | ||
888 | 115 | |||
889 | 116 | def test_bad_subscription_action(self): | ||
890 | 117 | # POSTing to a held message with a bad action. | ||
891 | 118 | held_id = hold_subscription( | ||
892 | 119 | self._mlist, | ||
893 | 120 | RequestRecord('cperson@example.net', 'Cris Person', | ||
894 | 121 | DeliveryMode.regular, 'en')) | ||
895 | 122 | config.db.store.commit() | ||
896 | 123 | url = 'http://localhost:9001/3.0/lists/ant@example.com/requests/{0}' | ||
897 | 124 | with self.assertRaises(HTTPError) as cm: | ||
898 | 125 | call_api(url.format(held_id), {'action': 'bogus'}) | ||
899 | 126 | self.assertEqual(cm.exception.code, 400) | ||
900 | 127 | self.assertEqual(cm.exception.msg, | ||
901 | 128 | b'Cannot convert parameters: action') | ||
902 | 129 | |||
903 | 130 | def test_discard(self): | 86 | def test_discard(self): |
904 | 131 | # Discarding a message removes it from the moderation queue. | 87 | # Discarding a message removes it from the moderation queue. |
905 | 132 | with transaction(): | 88 | with transaction(): |
906 | @@ -139,3 +95,211 @@ | |||
907 | 139 | with self.assertRaises(HTTPError) as cm: | 95 | with self.assertRaises(HTTPError) as cm: |
908 | 140 | call_api(url, dict(action='discard')) | 96 | call_api(url, dict(action='discard')) |
909 | 141 | self.assertEqual(cm.exception.code, 404) | 97 | self.assertEqual(cm.exception.code, 404) |
910 | 98 | |||
911 | 99 | |||
912 | 100 | |||
913 | 142 | 101 | ||
914 | 102 | class TestSubscriptionModeration(unittest.TestCase): | ||
915 | 103 | layer = RESTLayer | ||
916 | 104 | |||
917 | 105 | def setUp(self): | ||
918 | 106 | with transaction(): | ||
919 | 107 | self._mlist = create_list('ant@example.com') | ||
920 | 108 | self._registrar = IRegistrar(self._mlist) | ||
921 | 109 | manager = getUtility(IUserManager) | ||
922 | 110 | self._anne = manager.create_address( | ||
923 | 111 | 'anne@example.com', 'Anne Person') | ||
924 | 112 | self._bart = manager.make_user( | ||
925 | 113 | 'bart@example.com', 'Bart Person') | ||
926 | 114 | preferred = list(self._bart.addresses)[0] | ||
927 | 115 | preferred.verified_on = now() | ||
928 | 116 | self._bart.preferred_address = preferred | ||
929 | 117 | |||
930 | 118 | def test_no_such_list(self): | ||
931 | 119 | # Try to get the requests of a nonexistent list. | ||
932 | 120 | with self.assertRaises(HTTPError) as cm: | ||
933 | 121 | call_api('http://localhost:9001/3.0/lists/bee@example.com/' | ||
934 | 122 | 'requests') | ||
935 | 123 | self.assertEqual(cm.exception.code, 404) | ||
936 | 124 | |||
937 | 125 | def test_no_such_subscription_token(self): | ||
938 | 126 | # Bad request when the token is not in the database. | ||
939 | 127 | with self.assertRaises(HTTPError) as cm: | ||
940 | 128 | call_api('http://localhost:9001/3.0/lists/ant@example.com/' | ||
941 | 129 | 'requests/missing') | ||
942 | 130 | self.assertEqual(cm.exception.code, 404) | ||
943 | 131 | |||
944 | 132 | def test_bad_subscription_action(self): | ||
945 | 133 | # POSTing to a held message with a bad action. | ||
946 | 134 | token, token_owner, member = self._registrar.register(self._anne) | ||
947 | 135 | # Anne's subscription request got held. | ||
948 | 136 | self.assertIsNone(member) | ||
949 | 137 | # Let's try to handle her request, but with a bogus action. | ||
950 | 138 | url = 'http://localhost:9001/3.0/lists/ant@example.com/requests/{}' | ||
951 | 139 | with self.assertRaises(HTTPError) as cm: | ||
952 | 140 | call_api(url.format(token), dict( | ||
953 | 141 | action='bogus', | ||
954 | 142 | )) | ||
955 | 143 | self.assertEqual(cm.exception.code, 400) | ||
956 | 144 | self.assertEqual(cm.exception.msg, | ||
957 | 145 | b'Cannot convert parameters: action') | ||
958 | 146 | |||
959 | 147 | def test_list_held_requests(self): | ||
960 | 148 | # We can view all the held requests. | ||
961 | 149 | with transaction(): | ||
962 | 150 | token_1, token_owner, member = self._registrar.register(self._anne) | ||
963 | 151 | # Anne's subscription request got held. | ||
964 | 152 | self.assertIsNotNone(token_1) | ||
965 | 153 | self.assertIsNone(member) | ||
966 | 154 | token_2, token_owner, member = self._registrar.register(self._bart) | ||
967 | 155 | self.assertIsNotNone(token_2) | ||
968 | 156 | self.assertIsNone(member) | ||
969 | 157 | content, response = call_api( | ||
970 | 158 | 'http://localhost:9001/3.0/lists/ant@example.com/requests') | ||
971 | 159 | self.assertEqual(response.status, 200) | ||
972 | 160 | self.assertEqual(content['total_size'], 2) | ||
973 | 161 | tokens = set(json['token'] for json in content['entries']) | ||
974 | 162 | self.assertEqual(tokens, {token_1, token_2}) | ||
975 | 163 | emails = set(json['email'] for json in content['entries']) | ||
976 | 164 | self.assertEqual(emails, {'anne@example.com', 'bart@example.com'}) | ||
977 | 165 | |||
978 | 166 | def test_individual_request(self): | ||
979 | 167 | # We can view an individual request. | ||
980 | 168 | with transaction(): | ||
981 | 169 | token, token_owner, member = self._registrar.register(self._anne) | ||
982 | 170 | # Anne's subscription request got held. | ||
983 | 171 | self.assertIsNotNone(token) | ||
984 | 172 | self.assertIsNone(member) | ||
985 | 173 | url = 'http://localhost:9001/3.0/lists/ant@example.com/requests/{}' | ||
986 | 174 | content, response = call_api(url.format(token)) | ||
987 | 175 | self.assertEqual(response.status, 200) | ||
988 | 176 | self.assertEqual(content['token'], token) | ||
989 | 177 | self.assertEqual(content['token_owner'], token_owner.name) | ||
990 | 178 | self.assertEqual(content['email'], 'anne@example.com') | ||
991 | 179 | |||
992 | 180 | def test_accept(self): | ||
993 | 181 | # POST to the request to accept it. | ||
994 | 182 | with transaction(): | ||
995 | 183 | token, token_owner, member = self._registrar.register(self._anne) | ||
996 | 184 | # Anne's subscription request got held. | ||
997 | 185 | self.assertIsNone(member) | ||
998 | 186 | url = 'http://localhost:9001/3.0/lists/ant@example.com/requests/{}' | ||
999 | 187 | content, response = call_api(url.format(token), dict( | ||
1000 | 188 | action='accept', | ||
1001 | 189 | )) | ||
1002 | 190 | self.assertEqual(response.status, 204) | ||
1003 | 191 | # Anne is a member. | ||
1004 | 192 | self.assertEqual( | ||
1005 | 193 | self._mlist.members.get_member('anne@example.com').address, | ||
1006 | 194 | self._anne) | ||
1007 | 195 | # The request URL no longer exists. | ||
1008 | 196 | with self.assertRaises(HTTPError) as cm: | ||
1009 | 197 | call_api(url.format(token), dict( | ||
1010 | 198 | action='accept', | ||
1011 | 199 | )) | ||
1012 | 200 | self.assertEqual(cm.exception.code, 404) | ||
1013 | 201 | |||
1014 | 202 | def test_accept_bad_token(self): | ||
1015 | 203 | # Try to accept a request with a bogus token. | ||
1016 | 204 | with self.assertRaises(HTTPError) as cm: | ||
1017 | 205 | call_api('http://localhost:9001/3.0/lists/ant@example.com' | ||
1018 | 206 | '/requests/bogus', | ||
1019 | 207 | dict(action='accept')) | ||
1020 | 208 | self.assertEqual(cm.exception.code, 404) | ||
1021 | 209 | |||
1022 | 210 | def test_discard(self): | ||
1023 | 211 | # POST to the request to discard it. | ||
1024 | 212 | with transaction(): | ||
1025 | 213 | token, token_owner, member = self._registrar.register(self._anne) | ||
1026 | 214 | # Anne's subscription request got held. | ||
1027 | 215 | self.assertIsNone(member) | ||
1028 | 216 | url = 'http://localhost:9001/3.0/lists/ant@example.com/requests/{}' | ||
1029 | 217 | content, response = call_api(url.format(token), dict( | ||
1030 | 218 | action='discard', | ||
1031 | 219 | )) | ||
1032 | 220 | self.assertEqual(response.status, 204) | ||
1033 | 221 | # Anne is not a member. | ||
1034 | 222 | self.assertIsNone(self._mlist.members.get_member('anne@example.com')) | ||
1035 | 223 | # The request URL no longer exists. | ||
1036 | 224 | with self.assertRaises(HTTPError) as cm: | ||
1037 | 225 | call_api(url.format(token), dict( | ||
1038 | 226 | action='discard', | ||
1039 | 227 | )) | ||
1040 | 228 | self.assertEqual(cm.exception.code, 404) | ||
1041 | 229 | |||
1042 | 230 | def test_defer(self): | ||
1043 | 231 | # Defer the decision for some other moderator. | ||
1044 | 232 | with transaction(): | ||
1045 | 233 | token, token_owner, member = self._registrar.register(self._anne) | ||
1046 | 234 | # Anne's subscription request got held. | ||
1047 | 235 | self.assertIsNone(member) | ||
1048 | 236 | url = 'http://localhost:9001/3.0/lists/ant@example.com/requests/{}' | ||
1049 | 237 | content, response = call_api(url.format(token), dict( | ||
1050 | 238 | action='defer', | ||
1051 | 239 | )) | ||
1052 | 240 | self.assertEqual(response.status, 204) | ||
1053 | 241 | # Anne is not a member. | ||
1054 | 242 | self.assertIsNone(self._mlist.members.get_member('anne@example.com')) | ||
1055 | 243 | # The request URL still exists. | ||
1056 | 244 | content, response = call_api(url.format(token), dict( | ||
1057 | 245 | action='defer', | ||
1058 | 246 | )) | ||
1059 | 247 | self.assertEqual(response.status, 204) | ||
1060 | 248 | # And now we can accept it. | ||
1061 | 249 | content, response = call_api(url.format(token), dict( | ||
1062 | 250 | action='accept', | ||
1063 | 251 | )) | ||
1064 | 252 | self.assertEqual(response.status, 204) | ||
1065 | 253 | # Anne is a member. | ||
1066 | 254 | self.assertEqual( | ||
1067 | 255 | self._mlist.members.get_member('anne@example.com').address, | ||
1068 | 256 | self._anne) | ||
1069 | 257 | # The request URL no longer exists. | ||
1070 | 258 | with self.assertRaises(HTTPError) as cm: | ||
1071 | 259 | call_api(url.format(token), dict( | ||
1072 | 260 | action='accept', | ||
1073 | 261 | )) | ||
1074 | 262 | self.assertEqual(cm.exception.code, 404) | ||
1075 | 263 | |||
1076 | 264 | def test_defer_bad_token(self): | ||
1077 | 265 | # Try to accept a request with a bogus token. | ||
1078 | 266 | with self.assertRaises(HTTPError) as cm: | ||
1079 | 267 | call_api('http://localhost:9001/3.0/lists/ant@example.com' | ||
1080 | 268 | '/requests/bogus', | ||
1081 | 269 | dict(action='defer')) | ||
1082 | 270 | self.assertEqual(cm.exception.code, 404) | ||
1083 | 271 | |||
1084 | 272 | def test_reject(self): | ||
1085 | 273 | # POST to the request to reject it. This leaves a bounce message in | ||
1086 | 274 | # the virgin queue. | ||
1087 | 275 | with transaction(): | ||
1088 | 276 | token, token_owner, member = self._registrar.register(self._anne) | ||
1089 | 277 | # Anne's subscription request got held. | ||
1090 | 278 | self.assertIsNone(member) | ||
1091 | 279 | # There are currently no messages in the virgin queue. | ||
1092 | 280 | items = get_queue_messages('virgin') | ||
1093 | 281 | self.assertEqual(len(items), 0) | ||
1094 | 282 | url = 'http://localhost:9001/3.0/lists/ant@example.com/requests/{}' | ||
1095 | 283 | content, response = call_api(url.format(token), dict( | ||
1096 | 284 | action='reject', | ||
1097 | 285 | )) | ||
1098 | 286 | self.assertEqual(response.status, 204) | ||
1099 | 287 | # Anne is not a member. | ||
1100 | 288 | self.assertIsNone(self._mlist.members.get_member('anne@example.com')) | ||
1101 | 289 | # The request URL no longer exists. | ||
1102 | 290 | with self.assertRaises(HTTPError) as cm: | ||
1103 | 291 | call_api(url.format(token), dict( | ||
1104 | 292 | action='reject', | ||
1105 | 293 | )) | ||
1106 | 294 | self.assertEqual(cm.exception.code, 404) | ||
1107 | 295 | # And the rejection message is now in the virgin queue. | ||
1108 | 296 | items = get_queue_messages('virgin') | ||
1109 | 297 | self.assertEqual(len(items), 1) | ||
1110 | 298 | self.assertEqual(str(items[0].msg), '') | ||
1111 | 299 | |||
1112 | 300 | def test_reject_bad_token(self): | ||
1113 | 301 | # Try to accept a request with a bogus token. | ||
1114 | 302 | with self.assertRaises(HTTPError) as cm: | ||
1115 | 303 | call_api('http://localhost:9001/3.0/lists/ant@example.com' | ||
1116 | 304 | '/requests/bogus', | ||
1117 | 305 | dict(action='reject')) | ||
1118 | 306 | self.assertEqual(cm.exception.code, 404) |