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

Proposed by Barry Warsaw
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
Reviewer Review Type Date Requested Status
Mailman Coders Pending
Review via email: mp+256560@code.launchpad.net

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