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
=== modified file 'src/mailman/app/subscriptions.py'
--- src/mailman/app/subscriptions.py 2015-04-16 02:51:39 +0000
+++ src/mailman/app/subscriptions.py 2015-04-17 15:30:55 +0000
@@ -24,7 +24,6 @@
24 ]24 ]
2525
2626
27
28import uuid27import uuid
29import logging28import logging
3029
@@ -169,7 +168,10 @@
169 return168 return
170 pendable = Pendable(169 pendable = Pendable(
171 list_id=self.mlist.list_id,170 list_id=self.mlist.list_id,
172 address=self.address.email,171 email=self.address.email,
172 display_name=self.address.display_name,
173 when=now().replace(microsecond=0).isoformat(),
174 token_owner=token_owner.name,
173 )175 )
174 self.token = getUtility(IPendings).add(pendable, timedelta(days=3650))176 self.token = getUtility(IPendings).add(pendable, timedelta(days=3650))
175177
176178
=== modified file 'src/mailman/app/tests/test_subscriptions.py'
--- src/mailman/app/tests/test_subscriptions.py 2015-04-16 14:42:40 +0000
+++ src/mailman/app/tests/test_subscriptions.py 2015-04-17 15:30:55 +0000
@@ -57,6 +57,23 @@
57 self.assertEqual(workflow.token_owner, TokenOwner.no_one)57 self.assertEqual(workflow.token_owner, TokenOwner.no_one)
58 self.assertIsNone(workflow.member)58 self.assertIsNone(workflow.member)
5959
60 def test_pended_data(self):
61 # There is a Pendable associated with the held request, and it has
62 # some data associated with it.
63 anne = self._user_manager.create_address(self._anne)
64 workflow = SubscriptionWorkflow(self._mlist, anne)
65 try:
66 workflow.run_thru('send_confirmation')
67 except StopIteration:
68 pass
69 self.assertIsNotNone(workflow.token)
70 pendable = getUtility(IPendings).confirm(workflow.token, expunge=False)
71 self.assertEqual(pendable['list_id'], 'test.example.com')
72 self.assertEqual(pendable['email'], 'anne@example.com')
73 self.assertEqual(pendable['display_name'], '')
74 self.assertEqual(pendable['when'], '2005-08-01T07:49:23')
75 self.assertEqual(pendable['token_owner'], 'subscriber')
76
60 def test_user_or_address_required(self):77 def test_user_or_address_required(self):
61 # The `subscriber` attribute must be a user or address.78 # The `subscriber` attribute must be a user or address.
62 workflow = SubscriptionWorkflow(self._mlist)79 workflow = SubscriptionWorkflow(self._mlist)
6380
=== modified file 'src/mailman/interfaces/pending.py'
--- src/mailman/interfaces/pending.py 2015-04-15 14:05:35 +0000
+++ src/mailman/interfaces/pending.py 2015-04-17 15:30:55 +0000
@@ -95,4 +95,10 @@
95 def evict():95 def evict():
96 """Remove all pended items whose lifetime has expired."""96 """Remove all pended items whose lifetime has expired."""
9797
98 def __iter__():
99 """An iterator over all pendables.
100
101 Each element is a 2-tuple of the form (token, dict).
102 """
103
98 count = Attribute('The number of pendables in the pendings database.')104 count = Attribute('The number of pendables in the pendings database.')
99105
=== modified file 'src/mailman/interfaces/registrar.py'
--- src/mailman/interfaces/registrar.py 2015-04-16 02:51:39 +0000
+++ src/mailman/interfaces/registrar.py 2015-04-17 15:30:55 +0000
@@ -75,12 +75,13 @@
7575
76 :param subscriber: The user or address to subscribe.76 :param subscriber: The user or address to subscribe.
77 :type email: ``IUser`` or ``IAddress``77 :type email: ``IUser`` or ``IAddress``
78 :return: None if the workflow completes with the member being78 :return: A 3-tuple is returned where the first element is the token
79 subscribed. If the workflow is paused for user confirmation or79 hash, the second element is a ``TokenOwner`, and the third element
80 moderator approval, a 3-tuple is returned where the first element80 is the subscribed member. If the subscriber got subscribed
81 is a ``TokenOwner`` the second element is the token hash, and the81 immediately, the token will be None and the member will be
82 third element is the subscribed member.82 an ``IMember``. If the subscription got held, the token
83 :rtype: None or 2-tuple of (TokenOwner, str)83 will be a hash and the member will be None.
84 :rtype: (str-or-None, ``TokenOwner``, ``IMember``-or-None)
84 :raises MembershipIsBannedError: when the address being subscribed85 :raises MembershipIsBannedError: when the address being subscribed
85 appears in the global or list-centric bans.86 appears in the global or list-centric bans.
86 """87 """
@@ -94,9 +95,13 @@
9495
95 :param token: A token matching a workflow.96 :param token: A token matching a workflow.
96 :type token: string97 :type token: string
97 :return: The new token for any follow up confirmation, or None if the98 :return: A 3-tuple is returned where the first element is the token
98 user was subscribed.99 hash, the second element is a ``TokenOwner`, and the third element
99 :rtype: str or None100 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)
100 :raises LookupError: when no workflow is associated with the token.105 :raises LookupError: when no workflow is associated with the token.
101 """106 """
102107
103108
=== modified file 'src/mailman/model/docs/pending.rst'
--- src/mailman/model/docs/pending.rst 2015-04-15 14:05:35 +0000
+++ src/mailman/model/docs/pending.rst 2015-04-17 15:30:55 +0000
@@ -43,10 +43,9 @@
43 >>> pendingdb.count43 >>> pendingdb.count
44 144 1
4545
46There's not much you can do with tokens except to *confirm* them, which46You can *confirm* the pending, which means returning the `IPendable` structure
47basically means returning the `IPendable` structure (as a dictionary) from the47(as a dictionary) from the database that matches the token. If the token
48database that matches the token. If the token isn't in the database, None is48isn't in the database, None is returned.
49returned.
5049
51 >>> pendable = pendingdb.confirm(b'missing')50 >>> pendable = pendingdb.confirm(b'missing')
52 >>> print(pendable)51 >>> print(pendable)
@@ -83,6 +82,18 @@
83 >>> print(pendingdb.confirm(token_1))82 >>> print(pendingdb.confirm(token_1))
84 None83 None
8584
85You can iterate over all the pendings in the database.
86
87 >>> pendables = list(pendingdb)
88 >>> def sort_key(item):
89 ... token, pendable = item
90 ... return pendable['type']
91 >>> sorted_pendables = sorted(pendables, key=sort_key)
92 >>> for token, pendable in sorted_pendables:
93 ... print(pendable['type'])
94 three
95 two
96
86An event can be given a lifetime when it is pended, otherwise it just uses a97An event can be given a lifetime when it is pended, otherwise it just uses a
87default lifetime.98default lifetime.
8899
89100
=== modified file 'src/mailman/model/pending.py'
--- src/mailman/model/pending.py 2015-04-15 14:05:35 +0000
+++ src/mailman/model/pending.py 2015-04-17 15:30:55 +0000
@@ -166,6 +166,11 @@
166 store.delete(keyvalue)166 store.delete(keyvalue)
167 store.delete(pending)167 store.delete(pending)
168168
169 @dbconnection
170 def __iter__(self, store):
171 for pending in store.query(Pended).all():
172 yield pending.token, self.confirm(pending.token, expunge=False)
173
169 @property174 @property
170 @dbconnection175 @dbconnection
171 def count(self, store):176 def count(self, store):
172177
=== renamed file 'src/mailman/rest/docs/moderation.rst' => 'src/mailman/rest/docs/post-moderation.rst'
--- src/mailman/rest/docs/moderation.rst 2015-03-29 20:30:30 +0000
+++ src/mailman/rest/docs/post-moderation.rst 2015-04-17 15:30:55 +0000
@@ -1,18 +1,13 @@
1==========1===============
2Moderation2Post Moderation
3==========3===============
44
5There are two kinds of moderation tasks a list administrator may need to5Messages which are held for approval can be accepted, rejected, discarded, or
6perform. Messages which are held for approval can be accepted, rejected,6deferred by the list moderators.
7discarded, or deferred. Subscription (and sometimes unsubscription) requests7
8can similarly be accepted, discarded, rejected, or deferred.
9
10
11Message moderation
12==================
138
14Viewing the list of held messages9Viewing the list of held messages
15---------------------------------10=================================
1611
17Held messages can be moderated through the REST API. A mailing list starts12Held messages can be moderated through the REST API. A mailing list starts
18with no held messages.13with no held messages.
@@ -90,7 +85,7 @@
9085
9186
92Disposing of held messages87Disposing of held messages
93--------------------------88==========================
9489
95Individual messages can be moderated through the API by POSTing back to the90Individual messages can be moderated through the API by POSTing back to the
96held message's resource. The POST data requires an action of one of the91held message's resource. The POST data requires an action of one of the
@@ -196,166 +191,3 @@
196 1191 1
197 >>> print(messages[0].msg['subject'])192 >>> print(messages[0].msg['subject'])
198 Request to mailing list "Ant" rejected193 Request to mailing list "Ant" rejected
199
200
201Subscription moderation
202=======================
203
204Viewing subscription requests
205-----------------------------
206
207Subscription and unsubscription requests can be moderated via the REST API as
208well. A mailing list starts with no pending subscription or unsubscription
209requests.
210
211 >>> ant.admin_immed_notify = False
212 >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/requests')
213 http_etag: "..."
214 start: 0
215 total_size: 0
216
217When Anne tries to subscribe to the Ant list, her subscription is held for
218moderator approval.
219::
220
221 >>> from mailman.app.moderator import hold_subscription
222 >>> from mailman.interfaces.member import DeliveryMode
223 >>> from mailman.interfaces.subscriptions import RequestRecord
224
225 >>> sub_req_id = hold_subscription(
226 ... ant, RequestRecord('anne@example.com', 'Anne Person',
227 ... DeliveryMode.regular, 'en'))
228 >>> transaction.commit()
229
230The subscription request is available from the mailing list.
231
232 >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/requests')
233 entry 0:
234 delivery_mode: regular
235 display_name: Anne Person
236 email: anne@example.com
237 http_etag: "..."
238 language: en
239 request_id: ...
240 type: subscription
241 when: 2005-08-01T07:49:23
242 http_etag: "..."
243 start: 0
244 total_size: 1
245
246
247Viewing unsubscription requests
248-------------------------------
249
250Bart tries to leave a mailing list, but he may not be allowed to.
251
252 >>> from mailman.testing.helpers import subscribe
253 >>> from mailman.app.moderator import hold_unsubscription
254 >>> bart = subscribe(ant, 'Bart', email='bart@example.com')
255 >>> unsub_req_id = hold_unsubscription(ant, 'bart@example.com')
256 >>> transaction.commit()
257
258The unsubscription request is also available from the mailing list.
259
260 >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/requests')
261 entry 0:
262 delivery_mode: regular
263 display_name: Anne Person
264 email: anne@example.com
265 http_etag: "..."
266 language: en
267 request_id: ...
268 type: subscription
269 when: 2005-08-01T07:49:23
270 entry 1:
271 email: bart@example.com
272 http_etag: "..."
273 request_id: ...
274 type: unsubscription
275 http_etag: "..."
276 start: 0
277 total_size: 2
278
279
280Viewing individual requests
281---------------------------
282
283You can view an individual membership change request by providing the
284request id. Anne's subscription request looks like this.
285
286 >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/'
287 ... 'requests/{}'.format(sub_req_id))
288 delivery_mode: regular
289 display_name: Anne Person
290 email: anne@example.com
291 http_etag: "..."
292 language: en
293 request_id: ...
294 type: subscription
295 when: 2005-08-01T07:49:23
296
297Bart's unsubscription request looks like this.
298
299 >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/'
300 ... 'requests/{}'.format(unsub_req_id))
301 email: bart@example.com
302 http_etag: "..."
303 request_id: ...
304 type: unsubscription
305
306
307Disposing of subscription requests
308----------------------------------
309
310Similar to held messages, you can dispose of held subscription and
311unsubscription requests by POSTing back to the request's resource. The POST
312data requires an action of one of the following:
313
314 * discard - throw the request away.
315 * reject - the request is denied and a notification is sent to the email
316 address requesting the membership change.
317 * defer - defer any action on this membership change (continue to hold it).
318 * accept - accept the membership change.
319
320Anne's subscription request is accepted.
321
322 >>> dump_json('http://localhost:9001/3.0/lists/'
323 ... 'ant@example.com/requests/{}'.format(sub_req_id),
324 ... {'action': 'accept'})
325 content-length: 0
326 date: ...
327 server: ...
328 status: 204
329
330Anne is now a member of the mailing list.
331
332 >>> transaction.abort()
333 >>> ant.members.get_member('anne@example.com')
334 <Member: Anne Person <anne@example.com> on ant@example.com
335 as MemberRole.member>
336 >>> transaction.abort()
337
338Bart's unsubscription request is discarded.
339
340 >>> dump_json('http://localhost:9001/3.0/lists/'
341 ... 'ant@example.com/requests/{}'.format(unsub_req_id),
342 ... {'action': 'discard'})
343 content-length: 0
344 date: ...
345 server: ...
346 status: 204
347
348Bart is still a member of the mailing list.
349
350 >>> transaction.abort()
351 >>> print(ant.members.get_member('bart@example.com'))
352 <Member: Bart Person <bart@example.com> on ant@example.com
353 as MemberRole.member>
354 >>> transaction.abort()
355
356There are no more membership change requests.
357
358 >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/requests')
359 http_etag: "..."
360 start: 0
361 total_size: 0
362194
=== added file 'src/mailman/rest/docs/sub-moderation.rst'
--- src/mailman/rest/docs/sub-moderation.rst 1970-01-01 00:00:00 +0000
+++ src/mailman/rest/docs/sub-moderation.rst 2015-04-17 15:30:55 +0000
@@ -0,0 +1,113 @@
1=========================
2 Subscription moderation
3=========================
4
5Subscription (and sometimes unsubscription) requests can similarly be
6accepted, discarded, rejected, or deferred by the list moderators.
7
8
9Viewing subscription requests
10=============================
11
12A mailing list starts with no pending subscription or unsubscription requests.
13
14 >>> ant = create_list('ant@example.com')
15 >>> ant.admin_immed_notify = False
16 >>> from mailman.interfaces.mailinglist import SubscriptionPolicy
17 >>> ant.subscription_policy = SubscriptionPolicy.moderate
18 >>> transaction.commit()
19 >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/requests')
20 http_etag: "..."
21 start: 0
22 total_size: 0
23
24When Anne tries to subscribe to the Ant list, her subscription is held for
25moderator approval.
26
27 >>> from mailman.interfaces.registrar import IRegistrar
28 >>> from mailman.interfaces.usermanager import IUserManager
29 >>> from zope.component import getUtility
30 >>> registrar = IRegistrar(ant)
31 >>> manager = getUtility(IUserManager)
32 >>> anne = manager.create_address('anne@example.com', 'Anne Person')
33 >>> token, token_owner, member = registrar.register(
34 ... anne, pre_verified=True, pre_confirmed=True)
35 >>> print(member)
36 None
37
38The message is being held for moderator approval.
39
40 >>> print(token_owner.name)
41 moderator
42
43The subscription request can be viewed in the REST API.
44
45 >>> transaction.commit()
46 >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/requests')
47 entry 0:
48 display_name: Anne Person
49 email: anne@example.com
50 http_etag: "..."
51 list_id: ant.example.com
52 token: ...
53 token_owner: moderator
54 when: 2005-08-01T07:49:23
55 http_etag: "..."
56 start: 0
57 total_size: 1
58
59
60Viewing individual requests
61===========================
62
63You can view an individual membership change request by providing the token
64(a.k.a. request id). Anne's subscription request looks like this.
65
66 >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/'
67 ... 'requests/{}'.format(token))
68 delivery_mode: regular
69 display_name: Anne Person
70 email: anne@example.com
71 http_etag: "..."
72 language: en
73 request_id: ...
74 type: subscription
75 when: 2005-08-01T07:49:23
76
77
78Disposing of subscription requests
79==================================
80
81Moderators can dispose of held subscription requests by POSTing back to the
82request's resource. The POST data requires an action of one of the following:
83
84 * discard - throw the request away.
85 * reject - the request is denied and a notification is sent to the email
86 address requesting the membership change.
87 * defer - defer any action on this membership change (continue to hold it).
88 * accept - accept the membership change.
89
90Anne's subscription request is accepted.
91
92 >>> dump_json('http://localhost:9001/3.0/lists/'
93 ... 'ant@example.com/requests/{}'.format(token),
94 ... {'action': 'accept'})
95 content-length: 0
96 date: ...
97 server: ...
98 status: 204
99
100Anne is now a member of the mailing list.
101
102 >>> transaction.abort()
103 >>> ant.members.get_member('anne@example.com')
104 <Member: Anne Person <anne@example.com> on ant@example.com
105 as MemberRole.member>
106 >>> transaction.abort()
107
108There are no more membership change requests.
109
110 >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/requests')
111 http_etag: "..."
112 start: 0
113 total_size: 0
0114
=== modified file 'src/mailman/rest/lists.py'
--- src/mailman/rest/lists.py 2015-04-06 23:07:42 +0000
+++ src/mailman/rest/lists.py 2015-04-17 15:30:55 +0000
@@ -42,7 +42,8 @@
42 CollectionMixin, GetterSetter, NotFound, bad_request, child, created,42 CollectionMixin, GetterSetter, NotFound, bad_request, child, created,
43 etag, no_content, not_found, okay, paginate, path_to)43 etag, no_content, not_found, okay, paginate, path_to)
44from mailman.rest.members import AMember, MemberCollection44from mailman.rest.members import AMember, MemberCollection
45from mailman.rest.moderation import HeldMessages, SubscriptionRequests45from mailman.rest.post_moderation import HeldMessages
46from mailman.rest.sub_moderation import SubscriptionRequests
46from mailman.rest.validator import Validator47from mailman.rest.validator import Validator
47from operator import attrgetter48from operator import attrgetter
48from zope.component import getUtility49from zope.component import getUtility
4950
=== renamed file 'src/mailman/rest/moderation.py' => 'src/mailman/rest/post_moderation.py'
--- src/mailman/rest/moderation.py 2015-01-05 01:22:39 +0000
+++ src/mailman/rest/post_moderation.py 2015-04-17 15:30:55 +0000
@@ -15,18 +15,15 @@
15# You should have received a copy of the GNU General Public License along with15# You should have received a copy of the GNU General Public License along with
16# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.16# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
1717
18"""REST API for Message moderation."""18"""REST API for held message moderation."""
1919
20__all__ = [20__all__ = [
21 'HeldMessage',21 'HeldMessage',
22 'HeldMessages',22 'HeldMessages',
23 'MembershipChangeRequest',
24 'SubscriptionRequests',
25 ]23 ]
2624
2725
28from mailman.app.moderator import (26from mailman.app.moderator import handle_message
29 handle_message, handle_subscription, handle_unsubscription)
30from mailman.interfaces.action import Action27from mailman.interfaces.action import Action
31from mailman.interfaces.messages import IMessageStore28from mailman.interfaces.messages import IMessageStore
32from mailman.interfaces.requests import IListRequests, RequestType29from mailman.interfaces.requests import IListRequests, RequestType
@@ -36,16 +33,11 @@
36from zope.component import getUtility33from zope.component import getUtility
3734
3835
39HELD_MESSAGE_REQUESTS = (RequestType.held_message,)
40MEMBERSHIP_CHANGE_REQUESTS = (RequestType.subscription,
41 RequestType.unsubscription)
42
43
4436
4537
46class _ModerationBase:38class _ModerationBase:
47 """Common base class."""39 """Common base class."""
4840
49 def _make_resource(self, request_id, expected_request_types):41 def _make_resource(self, request_id):
50 requests = IListRequests(self._mlist)42 requests = IListRequests(self._mlist)
51 results = requests.get_request(request_id)43 results = requests.get_request(request_id)
52 if results is None:44 if results is None:
@@ -57,9 +49,9 @@
57 # Check for a matching request type, and insert the type name into the49 # Check for a matching request type, and insert the type name into the
58 # resource.50 # resource.
59 request_type = RequestType[resource.pop('_request_type')]51 request_type = RequestType[resource.pop('_request_type')]
60 if request_type not in expected_request_types:52 if request_type is not RequestType.held_message:
61 return None53 return None
62 resource['type'] = request_type.name54 resource['type'] = RequestType.held_message.name
63 # This key isn't what you think it is. Usually, it's the Pendable55 # This key isn't what you think it is. Usually, it's the Pendable
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,
65 # that's fine too.57 # that's fine too.
@@ -72,8 +64,7 @@
72 """Held messages are a little different."""64 """Held messages are a little different."""
7365
74 def _make_resource(self, request_id):66 def _make_resource(self, request_id):
75 resource = super(_HeldMessageBase, self)._make_resource(67 resource = super(_HeldMessageBase, self)._make_resource(request_id)
76 request_id, HELD_MESSAGE_REQUESTS)
77 if resource is None:68 if resource is None:
78 return None69 return None
79 # Grab the message and insert its text representation into the70 # Grab the message and insert its text representation into the
@@ -162,91 +153,3 @@
162 @child(r'^(?P<id>[^/]+)')153 @child(r'^(?P<id>[^/]+)')
163 def message(self, request, segments, **kw):154 def message(self, request, segments, **kw):
164 return HeldMessage(self._mlist, kw['id'])155 return HeldMessage(self._mlist, kw['id'])
165
166
167
168156
169class MembershipChangeRequest(_ModerationBase):
170 """Resource for moderating a membership change."""
171
172 def __init__(self, mlist, request_id):
173 self._mlist = mlist
174 self._request_id = request_id
175
176 def on_get(self, request, response):
177 try:
178 request_id = int(self._request_id)
179 except ValueError:
180 bad_request(response)
181 return
182 resource = self._make_resource(request_id, MEMBERSHIP_CHANGE_REQUESTS)
183 if resource is None:
184 not_found(response)
185 else:
186 # Remove unnecessary keys.
187 del resource['key']
188 okay(response, etag(resource))
189
190 def on_post(self, request, response):
191 try:
192 validator = Validator(action=enum_validator(Action))
193 arguments = validator(request)
194 except ValueError as error:
195 bad_request(response, str(error))
196 return
197 requests = IListRequests(self._mlist)
198 try:
199 request_id = int(self._request_id)
200 except ValueError:
201 bad_request(response)
202 return
203 results = requests.get_request(request_id)
204 if results is None:
205 not_found(response)
206 return
207 key, data = results
208 try:
209 request_type = RequestType[data['_request_type']]
210 except ValueError:
211 bad_request(response)
212 return
213 if request_type is RequestType.subscription:
214 handle_subscription(self._mlist, request_id, **arguments)
215 elif request_type is RequestType.unsubscription:
216 handle_unsubscription(self._mlist, request_id, **arguments)
217 else:
218 bad_request(response)
219 return
220 no_content(response)
221
222
223class SubscriptionRequests(_ModerationBase, CollectionMixin):
224 """Resource for membership change requests."""
225
226 def __init__(self, mlist):
227 self._mlist = mlist
228 self._requests = None
229
230 def _resource_as_dict(self, request):
231 """See `CollectionMixin`."""
232 resource = self._make_resource(request.id, MEMBERSHIP_CHANGE_REQUESTS)
233 # Remove unnecessary keys.
234 del resource['key']
235 return resource
236
237 def _get_collection(self, request):
238 requests = IListRequests(self._mlist)
239 self._requests = requests
240 items = []
241 for request_type in MEMBERSHIP_CHANGE_REQUESTS:
242 for request in requests.of_type(request_type):
243 items.append(request)
244 return items
245
246 def on_get(self, request, response):
247 """/lists/listname/requests"""
248 resource = self._make_collection(request)
249 okay(response, etag(resource))
250
251 @child(r'^(?P<id>[^/]+)')
252 def subscription(self, request, segments, **kw):
253 return MembershipChangeRequest(self._mlist, kw['id'])
254157
=== added file 'src/mailman/rest/sub_moderation.py'
--- src/mailman/rest/sub_moderation.py 1970-01-01 00:00:00 +0000
+++ src/mailman/rest/sub_moderation.py 2015-04-17 15:30:55 +0000
@@ -0,0 +1,138 @@
1# Copyright (C) 2012-2015 by the Free Software Foundation, Inc.
2#
3# This file is part of GNU Mailman.
4#
5# GNU Mailman is free software: you can redistribute it and/or modify it under
6# the terms of the GNU General Public License as published by the Free
7# Software Foundation, either version 3 of the License, or (at your option)
8# any later version.
9#
10# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
11# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
13# more details.
14#
15# You should have received a copy of the GNU General Public License along with
16# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
17
18"""REST API for held subscription requests."""
19
20__all__ = [
21 'SubscriptionRequests',
22 ]
23
24
25from mailman.interfaces.action import Action
26from mailman.interfaces.pending import IPendings
27from mailman.interfaces.registrar import IRegistrar
28from mailman.rest.helpers import (
29 CollectionMixin, bad_request, child, etag, no_content, not_found, okay)
30from mailman.rest.validator import Validator, enum_validator
31from zope.component import getUtility
32
33
34
035
36class _ModerationBase:
37 """Common base class."""
38
39 def __init__(self):
40 self._pendings = getUtility(IPendings)
41
42 def _resource_as_dict(self, token):
43 pendable = self._pendings.confirm(token, expunge=False)
44 if pendable is None:
45 # This token isn't in the database.
46 raise LookupError
47 resource = dict(token=token)
48 resource.update(pendable)
49 return resource
50
51
52
153
54class IndividualRequest(_ModerationBase):
55 """Resource for moderating a membership change."""
56
57 def __init__(self, mlist, token):
58 super().__init__()
59 self._mlist = mlist
60 self._registrar = IRegistrar(self._mlist)
61 self._token = token
62
63 def on_get(self, request, response):
64 # Get the pended record associated with this token, if it exists in
65 # the pending table.
66 try:
67 resource = self._resource_as_dict(self._token)
68 except LookupError:
69 not_found(response)
70 return
71 okay(response, etag(resource))
72
73 def on_post(self, request, response):
74 try:
75 validator = Validator(action=enum_validator(Action))
76 arguments = validator(request)
77 except ValueError as error:
78 bad_request(response, str(error))
79 return
80 action = arguments['action']
81 if action is Action.defer:
82 # At least see if the token is in the database.
83 pendable = self._pendings.confirm(self._token, expunge=False)
84 if pendable is None:
85 not_found(response)
86 else:
87 no_content(response)
88 elif action is Action.accept:
89 try:
90 self._registrar.confirm(self._token)
91 except LookupError:
92 not_found(response)
93 else:
94 no_content(response)
95 elif action is Action.discard:
96 # At least see if the token is in the database.
97 pendable = self._pendings.confirm(self._token, expunge=True)
98 if pendable is None:
99 not_found(response)
100 else:
101 no_content(response)
102 elif action is Action.reject:
103 # XXX
104 no_content(response)
105
106
107
2108
109class SubscriptionRequests(_ModerationBase, CollectionMixin):
110 """Resource for membership change requests."""
111
112 def __init__(self, mlist):
113 super().__init__()
114 self._mlist = mlist
115
116 def _get_collection(self, request):
117 # There's currently no better way to query the pendings database for
118 # all the entries that are associated with subscription holds on this
119 # mailing list. Brute force iterating over all the pendables.
120 collection = []
121 for token, pendable in getUtility(IPendings):
122 if 'token_owner' not in pendable:
123 # This isn't a subscription hold.
124 continue
125 list_id = pendable.get('list_id')
126 if list_id != self._mlist.list_id:
127 # Either there isn't a list_id field, in which case it can't
128 # be a subscription hold, or this is a hold for some other
129 # mailing list.
130 continue
131 collection.append(token)
132 return collection
133
134 def on_get(self, request, response):
135 """/lists/listname/requests"""
136 resource = self._make_collection(request)
137 okay(response, etag(resource))
138
139 @child(r'^(?P<token>[^/]+)')
140 def subscription(self, request, segments, **kw):
141 return IndividualRequest(self._mlist, kw['token'])
3142
=== modified file 'src/mailman/rest/tests/test_moderation.py'
--- src/mailman/rest/tests/test_moderation.py 2015-03-29 20:30:30 +0000
+++ src/mailman/rest/tests/test_moderation.py 2015-04-17 15:30:55 +0000
@@ -18,26 +18,28 @@
18"""REST moderation tests."""18"""REST moderation tests."""
1919
20__all__ = [20__all__ = [
21 'TestModeration',21 'TestPostModeration',
22 'TestSubscriptionModeration',
22 ]23 ]
2324
2425
25import unittest26import unittest
2627
27from mailman.app.lifecycle import create_list28from mailman.app.lifecycle import create_list
28from mailman.app.moderator import hold_message, hold_subscription29from mailman.app.moderator import hold_message
29from mailman.config import config
30from mailman.database.transaction import transaction30from mailman.database.transaction import transaction
31from mailman.interfaces.member import DeliveryMode31from mailman.interfaces.registrar import IRegistrar
32from mailman.interfaces.subscriptions import RequestRecord32from mailman.interfaces.usermanager import IUserManager
33from mailman.testing.helpers import (33from mailman.testing.helpers import (
34 call_api, specialized_message_from_string as mfs)34 call_api, get_queue_messages, specialized_message_from_string as mfs)
35from mailman.testing.layers import RESTLayer35from mailman.testing.layers import RESTLayer
36from mailman.utilities.datetime import now
36from urllib.error import HTTPError37from urllib.error import HTTPError
38from zope.component import getUtility
3739
3840
3941
4042
41class TestModeration(unittest.TestCase):43class TestPostModeration(unittest.TestCase):
42 layer = RESTLayer44 layer = RESTLayer
4345
44 def setUp(self):46 def setUp(self):
@@ -71,24 +73,6 @@
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')
72 self.assertEqual(cm.exception.code, 404)74 self.assertEqual(cm.exception.code, 404)
7375
74 def test_subscription_request_as_held_message(self):
75 # Provide the request id of a subscription request using the held
76 # message API returns a not-found even though the request id is
77 # in the database.
78 held_id = hold_message(self._mlist, self._msg)
79 subscribe_id = hold_subscription(
80 self._mlist,
81 RequestRecord('bperson@example.net', 'Bart Person',
82 DeliveryMode.regular, 'en'))
83 config.db.store.commit()
84 url = 'http://localhost:9001/3.0/lists/ant@example.com/held/{0}'
85 with self.assertRaises(HTTPError) as cm:
86 call_api(url.format(subscribe_id))
87 self.assertEqual(cm.exception.code, 404)
88 # But using the held_id returns a valid response.
89 response, content = call_api(url.format(held_id))
90 self.assertEqual(response['message_id'], '<alpha>')
91
92 def test_bad_held_message_action(self):76 def test_bad_held_message_action(self):
93 # POSTing to a held message with a bad action.77 # POSTing to a held message with a bad action.
94 held_id = hold_message(self._mlist, self._msg)78 held_id = hold_message(self._mlist, self._msg)
@@ -99,34 +83,6 @@
99 self.assertEqual(cm.exception.msg,83 self.assertEqual(cm.exception.msg,
100 b'Cannot convert parameters: action')84 b'Cannot convert parameters: action')
10185
102 def test_bad_subscription_request_id(self):
103 # Bad request when request_id is not an integer.
104 with self.assertRaises(HTTPError) as cm:
105 call_api('http://localhost:9001/3.0/lists/ant@example.com/'
106 'requests/bogus')
107 self.assertEqual(cm.exception.code, 400)
108
109 def test_missing_subscription_request_id(self):
110 # Bad request when the request_id is not in the database.
111 with self.assertRaises(HTTPError) as cm:
112 call_api('http://localhost:9001/3.0/lists/ant@example.com/'
113 'requests/99')
114 self.assertEqual(cm.exception.code, 404)
115
116 def test_bad_subscription_action(self):
117 # POSTing to a held message with a bad action.
118 held_id = hold_subscription(
119 self._mlist,
120 RequestRecord('cperson@example.net', 'Cris Person',
121 DeliveryMode.regular, 'en'))
122 config.db.store.commit()
123 url = 'http://localhost:9001/3.0/lists/ant@example.com/requests/{0}'
124 with self.assertRaises(HTTPError) as cm:
125 call_api(url.format(held_id), {'action': 'bogus'})
126 self.assertEqual(cm.exception.code, 400)
127 self.assertEqual(cm.exception.msg,
128 b'Cannot convert parameters: action')
129
130 def test_discard(self):86 def test_discard(self):
131 # Discarding a message removes it from the moderation queue.87 # Discarding a message removes it from the moderation queue.
132 with transaction():88 with transaction():
@@ -139,3 +95,211 @@
139 with self.assertRaises(HTTPError) as cm:95 with self.assertRaises(HTTPError) as cm:
140 call_api(url, dict(action='discard'))96 call_api(url, dict(action='discard'))
141 self.assertEqual(cm.exception.code, 404)97 self.assertEqual(cm.exception.code, 404)
98
99
100
142101
102class TestSubscriptionModeration(unittest.TestCase):
103 layer = RESTLayer
104
105 def setUp(self):
106 with transaction():
107 self._mlist = create_list('ant@example.com')
108 self._registrar = IRegistrar(self._mlist)
109 manager = getUtility(IUserManager)
110 self._anne = manager.create_address(
111 'anne@example.com', 'Anne Person')
112 self._bart = manager.make_user(
113 'bart@example.com', 'Bart Person')
114 preferred = list(self._bart.addresses)[0]
115 preferred.verified_on = now()
116 self._bart.preferred_address = preferred
117
118 def test_no_such_list(self):
119 # Try to get the requests of a nonexistent list.
120 with self.assertRaises(HTTPError) as cm:
121 call_api('http://localhost:9001/3.0/lists/bee@example.com/'
122 'requests')
123 self.assertEqual(cm.exception.code, 404)
124
125 def test_no_such_subscription_token(self):
126 # Bad request when the token is not in the database.
127 with self.assertRaises(HTTPError) as cm:
128 call_api('http://localhost:9001/3.0/lists/ant@example.com/'
129 'requests/missing')
130 self.assertEqual(cm.exception.code, 404)
131
132 def test_bad_subscription_action(self):
133 # POSTing to a held message with a bad action.
134 token, token_owner, member = self._registrar.register(self._anne)
135 # Anne's subscription request got held.
136 self.assertIsNone(member)
137 # Let's try to handle her request, but with a bogus action.
138 url = 'http://localhost:9001/3.0/lists/ant@example.com/requests/{}'
139 with self.assertRaises(HTTPError) as cm:
140 call_api(url.format(token), dict(
141 action='bogus',
142 ))
143 self.assertEqual(cm.exception.code, 400)
144 self.assertEqual(cm.exception.msg,
145 b'Cannot convert parameters: action')
146
147 def test_list_held_requests(self):
148 # We can view all the held requests.
149 with transaction():
150 token_1, token_owner, member = self._registrar.register(self._anne)
151 # Anne's subscription request got held.
152 self.assertIsNotNone(token_1)
153 self.assertIsNone(member)
154 token_2, token_owner, member = self._registrar.register(self._bart)
155 self.assertIsNotNone(token_2)
156 self.assertIsNone(member)
157 content, response = call_api(
158 'http://localhost:9001/3.0/lists/ant@example.com/requests')
159 self.assertEqual(response.status, 200)
160 self.assertEqual(content['total_size'], 2)
161 tokens = set(json['token'] for json in content['entries'])
162 self.assertEqual(tokens, {token_1, token_2})
163 emails = set(json['email'] for json in content['entries'])
164 self.assertEqual(emails, {'anne@example.com', 'bart@example.com'})
165
166 def test_individual_request(self):
167 # We can view an individual request.
168 with transaction():
169 token, token_owner, member = self._registrar.register(self._anne)
170 # Anne's subscription request got held.
171 self.assertIsNotNone(token)
172 self.assertIsNone(member)
173 url = 'http://localhost:9001/3.0/lists/ant@example.com/requests/{}'
174 content, response = call_api(url.format(token))
175 self.assertEqual(response.status, 200)
176 self.assertEqual(content['token'], token)
177 self.assertEqual(content['token_owner'], token_owner.name)
178 self.assertEqual(content['email'], 'anne@example.com')
179
180 def test_accept(self):
181 # POST to the request to accept it.
182 with transaction():
183 token, token_owner, member = self._registrar.register(self._anne)
184 # Anne's subscription request got held.
185 self.assertIsNone(member)
186 url = 'http://localhost:9001/3.0/lists/ant@example.com/requests/{}'
187 content, response = call_api(url.format(token), dict(
188 action='accept',
189 ))
190 self.assertEqual(response.status, 204)
191 # Anne is a member.
192 self.assertEqual(
193 self._mlist.members.get_member('anne@example.com').address,
194 self._anne)
195 # The request URL no longer exists.
196 with self.assertRaises(HTTPError) as cm:
197 call_api(url.format(token), dict(
198 action='accept',
199 ))
200 self.assertEqual(cm.exception.code, 404)
201
202 def test_accept_bad_token(self):
203 # Try to accept a request with a bogus token.
204 with self.assertRaises(HTTPError) as cm:
205 call_api('http://localhost:9001/3.0/lists/ant@example.com'
206 '/requests/bogus',
207 dict(action='accept'))
208 self.assertEqual(cm.exception.code, 404)
209
210 def test_discard(self):
211 # POST to the request to discard it.
212 with transaction():
213 token, token_owner, member = self._registrar.register(self._anne)
214 # Anne's subscription request got held.
215 self.assertIsNone(member)
216 url = 'http://localhost:9001/3.0/lists/ant@example.com/requests/{}'
217 content, response = call_api(url.format(token), dict(
218 action='discard',
219 ))
220 self.assertEqual(response.status, 204)
221 # Anne is not a member.
222 self.assertIsNone(self._mlist.members.get_member('anne@example.com'))
223 # The request URL no longer exists.
224 with self.assertRaises(HTTPError) as cm:
225 call_api(url.format(token), dict(
226 action='discard',
227 ))
228 self.assertEqual(cm.exception.code, 404)
229
230 def test_defer(self):
231 # Defer the decision for some other moderator.
232 with transaction():
233 token, token_owner, member = self._registrar.register(self._anne)
234 # Anne's subscription request got held.
235 self.assertIsNone(member)
236 url = 'http://localhost:9001/3.0/lists/ant@example.com/requests/{}'
237 content, response = call_api(url.format(token), dict(
238 action='defer',
239 ))
240 self.assertEqual(response.status, 204)
241 # Anne is not a member.
242 self.assertIsNone(self._mlist.members.get_member('anne@example.com'))
243 # The request URL still exists.
244 content, response = call_api(url.format(token), dict(
245 action='defer',
246 ))
247 self.assertEqual(response.status, 204)
248 # And now we can accept it.
249 content, response = call_api(url.format(token), dict(
250 action='accept',
251 ))
252 self.assertEqual(response.status, 204)
253 # Anne is a member.
254 self.assertEqual(
255 self._mlist.members.get_member('anne@example.com').address,
256 self._anne)
257 # The request URL no longer exists.
258 with self.assertRaises(HTTPError) as cm:
259 call_api(url.format(token), dict(
260 action='accept',
261 ))
262 self.assertEqual(cm.exception.code, 404)
263
264 def test_defer_bad_token(self):
265 # Try to accept a request with a bogus token.
266 with self.assertRaises(HTTPError) as cm:
267 call_api('http://localhost:9001/3.0/lists/ant@example.com'
268 '/requests/bogus',
269 dict(action='defer'))
270 self.assertEqual(cm.exception.code, 404)
271
272 def test_reject(self):
273 # POST to the request to reject it. This leaves a bounce message in
274 # the virgin queue.
275 with transaction():
276 token, token_owner, member = self._registrar.register(self._anne)
277 # Anne's subscription request got held.
278 self.assertIsNone(member)
279 # There are currently no messages in the virgin queue.
280 items = get_queue_messages('virgin')
281 self.assertEqual(len(items), 0)
282 url = 'http://localhost:9001/3.0/lists/ant@example.com/requests/{}'
283 content, response = call_api(url.format(token), dict(
284 action='reject',
285 ))
286 self.assertEqual(response.status, 204)
287 # Anne is not a member.
288 self.assertIsNone(self._mlist.members.get_member('anne@example.com'))
289 # The request URL no longer exists.
290 with self.assertRaises(HTTPError) as cm:
291 call_api(url.format(token), dict(
292 action='reject',
293 ))
294 self.assertEqual(cm.exception.code, 404)
295 # And the rejection message is now in the virgin queue.
296 items = get_queue_messages('virgin')
297 self.assertEqual(len(items), 1)
298 self.assertEqual(str(items[0].msg), '')
299
300 def test_reject_bad_token(self):
301 # Try to accept a request with a bogus token.
302 with self.assertRaises(HTTPError) as cm:
303 call_api('http://localhost:9001/3.0/lists/ant@example.com'
304 '/requests/bogus',
305 dict(action='reject'))
306 self.assertEqual(cm.exception.code, 404)