REST: allow setting a member's moderation_action to None
[mailman.git] / src / mailman / rest / members.py
blob197e2232b625f5ea8eac44a3ea10b4dc7357f9eb
1 # Copyright (C) 2010-2016 by the Free Software Foundation, Inc.
3 # This file is part of GNU Mailman.
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.
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.
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/>.
18 """REST for members."""
20 from mailman import public
21 from mailman.app.membership import add_member, delete_member
22 from mailman.interfaces.action import Action
23 from mailman.interfaces.address import IAddress
24 from mailman.interfaces.listmanager import IListManager
25 from mailman.interfaces.member import (
26 AlreadySubscribedError, DeliveryMode, MemberRole, MembershipError,
27 MembershipIsBannedError, MissingPreferredAddressError)
28 from mailman.interfaces.registrar import IRegistrar
29 from mailman.interfaces.subscriptions import (
30 ISubscriptionService, RequestRecord, SubscriptionPendingError, TokenOwner)
31 from mailman.interfaces.user import IUser, UnverifiedAddressError
32 from mailman.interfaces.usermanager import IUserManager
33 from mailman.rest.helpers import (
34 CollectionMixin, NotFound, accepted, bad_request, child, conflict,
35 created, etag, no_content, not_found, okay)
36 from mailman.rest.preferences import Preferences, ReadOnlyPreferences
37 from mailman.rest.validator import (
38 Validator, enum_validator, subscriber_validator)
39 from uuid import UUID
40 from zope.component import getUtility
43 class _MemberBase(CollectionMixin):
44 """Shared base class for member representations."""
46 def _resource_as_dict(self, member):
47 """See `CollectionMixin`."""
48 enum, dot, role = str(member.role).partition('.')
49 # The member will always have a member id and an address id. It will
50 # only have a user id if the address is linked to a user.
51 # E.g. nonmembers we've only seen via postings to lists they are not
52 # subscribed to will not have a user id. The user_id and the
53 # member_id are UUIDs. In API 3.0 we use the integer equivalent of
54 # the UID in the URL, but in API 3.1 we use the hex equivalent. See
55 # issue #121 for details.
56 member_id = self.api.from_uuid(member.member_id)
57 response = dict(
58 address=self.api.path_to(
59 'addresses/{}'.format(member.address.email)),
60 delivery_mode=member.delivery_mode,
61 email=member.address.email,
62 list_id=member.list_id,
63 member_id=member_id,
64 role=role,
65 self_link=self.api.path_to('members/{}'.format(member_id)),
67 # Add the moderation action if overriding the list's default.
68 if member.moderation_action is not None:
69 response['moderation_action'] = member.moderation_action
70 # Add the user link if there is one.
71 user = member.user
72 if user is not None:
73 user_id = self.api.from_uuid(user.user_id)
74 response['user'] = self.api.path_to('users/{}'.format(user_id))
75 return response
77 def _get_collection(self, request):
78 """See `CollectionMixin`."""
79 return list(getUtility(ISubscriptionService))
82 @public
83 class MemberCollection(_MemberBase):
84 """Abstract class for supporting submemberships.
86 This is used for example to return a resource representing all the
87 memberships of a mailing list, or all memberships for a specific email
88 address.
89 """
90 def _get_collection(self, request):
91 """See `CollectionMixin`."""
92 raise NotImplementedError
94 def on_get(self, request, response):
95 """roster/[members|owners|moderators]"""
96 resource = self._make_collection(request)
97 okay(response, etag(resource))
100 @public
101 class AMember(_MemberBase):
102 """A member."""
104 def __init__(self, member_id):
105 # The member_id is either the member's UUID or the string
106 # representation of the member's UUID.
107 service = getUtility(ISubscriptionService)
108 self._member_id = member_id
109 self._member = (None if member_id is None
110 else service.get_member(member_id))
112 def on_get(self, request, response):
113 """Return a single member end-point."""
114 if self._member is None:
115 not_found(response)
116 else:
117 okay(response, self._resource_as_json(self._member))
119 @child()
120 def preferences(self, context, segments):
121 """/members/<id>/preferences"""
122 if len(segments) != 0:
123 return NotFound(), []
124 if self._member is None:
125 return NotFound(), []
126 member_id = self.api.from_uuid(self._member_id)
127 child = Preferences(
128 self._member.preferences, 'members/{}'.format(member_id))
129 return child, []
131 @child()
132 def all(self, context, segments):
133 """/members/<id>/all/preferences"""
134 if len(segments) == 0:
135 return NotFound(), []
136 if self._member is None:
137 return NotFound(), []
138 member_id = self.api.from_uuid(self._member_id)
139 child = ReadOnlyPreferences(
140 self._member, 'members/{}/all'.format(member_id))
141 return child, []
143 def on_delete(self, request, response):
144 """Delete the member (i.e. unsubscribe)."""
145 # Leaving a list is a bit different than deleting a moderator or
146 # owner. Handle the former case first. For now too, we will not send
147 # an admin or user notification.
148 if self._member is None:
149 not_found(response)
150 return
151 mlist = getUtility(IListManager).get_by_list_id(self._member.list_id)
152 if self._member.role is MemberRole.member:
153 delete_member(mlist, self._member.address.email, False, False)
154 else:
155 self._member.unsubscribe()
156 no_content(response)
158 def on_patch(self, request, response):
159 """Patch the membership.
161 This is how subscription changes are done.
163 if self._member is None:
164 not_found(response)
165 return
166 try:
167 values = Validator(
168 address=str,
169 delivery_mode=enum_validator(DeliveryMode),
170 moderation_action=enum_validator(Action, allow_none=True),
171 _optional=('address', 'delivery_mode', 'moderation_action'),
172 )(request)
173 except ValueError as error:
174 bad_request(response, str(error))
175 return
176 if 'address' in values:
177 email = values['address']
178 address = getUtility(IUserManager).get_address(email)
179 if address is None:
180 bad_request(response, b'Address not registered')
181 return
182 try:
183 self._member.address = address
184 except (MembershipError, UnverifiedAddressError) as error:
185 bad_request(response, str(error))
186 return
187 if 'delivery_mode' in values:
188 self._member.preferences.delivery_mode = values['delivery_mode']
189 if 'moderation_action' in values:
190 self._member.moderation_action = values['moderation_action']
191 no_content(response)
194 @public
195 class AllMembers(_MemberBase):
196 """The members."""
198 def on_post(self, request, response):
199 """Create a new member."""
200 try:
201 validator = Validator(
202 list_id=str,
203 subscriber=subscriber_validator(self.api),
204 display_name=str,
205 delivery_mode=enum_validator(DeliveryMode),
206 role=enum_validator(MemberRole),
207 pre_verified=bool,
208 pre_confirmed=bool,
209 pre_approved=bool,
210 _optional=('delivery_mode', 'display_name', 'role',
211 'pre_verified', 'pre_confirmed', 'pre_approved'))
212 arguments = validator(request)
213 except ValueError as error:
214 bad_request(response, str(error))
215 return
216 # Dig the mailing list out of the arguments.
217 list_id = arguments.pop('list_id')
218 mlist = getUtility(IListManager).get_by_list_id(list_id)
219 if mlist is None:
220 bad_request(response, b'No such list')
221 return
222 # Figure out what kind of subscriber is being registered. Either it's
223 # a user via their preferred email address or it's an explicit address.
224 # If it's a UUID, then it must be associated with an existing user.
225 subscriber = arguments.pop('subscriber')
226 user_manager = getUtility(IUserManager)
227 # We use the display name if there is one.
228 display_name = arguments.pop('display_name', '')
229 if isinstance(subscriber, UUID):
230 user = user_manager.get_user_by_id(subscriber)
231 if user is None:
232 bad_request(response, b'No such user')
233 return
234 subscriber = user
235 else:
236 # This must be an email address. See if there's an existing
237 # address object associated with this email.
238 address = user_manager.get_address(subscriber)
239 if address is None:
240 # Create a new address, which of course will not be validated.
241 address = user_manager.create_address(
242 subscriber, display_name)
243 subscriber = address
244 # What role are we subscribing? Regular members go through the
245 # subscription policy workflow while owners, moderators, and
246 # nonmembers go through the legacy API for now.
247 role = arguments.pop('role', MemberRole.member)
248 if role is MemberRole.member:
249 # Get the pre_ flags for the subscription workflow.
250 pre_verified = arguments.pop('pre_verified', False)
251 pre_confirmed = arguments.pop('pre_confirmed', False)
252 pre_approved = arguments.pop('pre_approved', False)
253 # Now we can run the registration process until either the
254 # subscriber is subscribed, or the workflow is paused for
255 # verification, confirmation, or approval.
256 registrar = IRegistrar(mlist)
257 try:
258 token, token_owner, member = registrar.register(
259 subscriber,
260 pre_verified=pre_verified,
261 pre_confirmed=pre_confirmed,
262 pre_approved=pre_approved)
263 except AlreadySubscribedError:
264 conflict(response, b'Member already subscribed')
265 return
266 except MissingPreferredAddressError:
267 bad_request(response, b'User has no preferred address')
268 return
269 except MembershipIsBannedError:
270 bad_request(response, b'Membership is banned')
271 return
272 except SubscriptionPendingError:
273 conflict(response, b'Subscription request already pending')
274 return
275 if token is None:
276 assert token_owner is TokenOwner.no_one, token_owner
277 # The subscription completed. Let's get the resulting member
278 # and return the location to the new member. Member ids are
279 # UUIDs and need to be converted to URLs because JSON doesn't
280 # directly support UUIDs.
281 member_id = self.api.from_uuid(member.member_id)
282 location = self.api.path_to('members/{}'.format(member_id))
283 created(response, location)
284 return
285 # The member could not be directly subscribed because there are
286 # some out-of-band steps that need to be completed. E.g. the user
287 # must confirm their subscription or the moderator must approve
288 # it. In this case, an HTTP 202 Accepted is exactly the code that
289 # we should use, and we'll return both the confirmation token and
290 # the "token owner" so the client knows who should confirm it.
291 assert token is not None, token
292 assert token_owner is not TokenOwner.no_one, token_owner
293 assert member is None, member
294 content = dict(token=token, token_owner=token_owner.name)
295 accepted(response, etag(content))
296 return
297 # 2015-04-15 BAW: We're subscribing some role other than a regular
298 # member. Use the legacy API for this for now.
299 assert role in (MemberRole.owner,
300 MemberRole.moderator,
301 MemberRole.nonmember)
302 # 2015-04-15 BAW: We're limited to using an email address with this
303 # legacy API, so if the subscriber is a user, the user must have a
304 # preferred address, which we'll use, even though it will subscribe
305 # the explicit address. It is an error if the user does not have a
306 # preferred address.
308 # If the subscriber is an address object, just use that.
309 if IUser.providedBy(subscriber):
310 if subscriber.preferred_address is None:
311 bad_request(response, b'User without preferred address')
312 return
313 email = subscriber.preferred_address.email
314 else:
315 assert IAddress.providedBy(subscriber)
316 email = subscriber.email
317 delivery_mode = arguments.pop('delivery_mode', DeliveryMode.regular)
318 record = RequestRecord(email, display_name, delivery_mode)
319 try:
320 member = add_member(mlist, record, role)
321 except MembershipIsBannedError:
322 bad_request(response, b'Membership is banned')
323 return
324 except AlreadySubscribedError:
325 bad_request(response,
326 '{} is already an {} of {}'.format(
327 email, role.name, mlist.fqdn_listname))
328 return
329 # The subscription completed. Let's get the resulting member
330 # and return the location to the new member. Member ids are
331 # UUIDs and need to be converted to URLs because JSON doesn't
332 # directly support UUIDs.
333 member_id = self.api.from_uuid(member.member_id)
334 location = self.api.path_to('members/{}'.format(member_id))
335 created(response, location)
337 def on_get(self, request, response):
338 """/members"""
339 resource = self._make_collection(request)
340 okay(response, etag(resource))
343 class _FoundMembers(MemberCollection):
344 """The found members collection."""
346 def __init__(self, members, api):
347 super().__init__()
348 self._members = members
349 self.api = api
351 def _get_collection(self, request):
352 """See `CollectionMixin`."""
353 return self._members
356 @public
357 class FindMembers(_MemberBase):
358 """/members/find"""
360 def on_get(self, request, response):
361 return self._find(request, response)
363 def on_post(self, request, response):
364 return self._find(request, response)
366 def _find(self, request, response):
367 """Find a member"""
368 service = getUtility(ISubscriptionService)
369 validator = Validator(
370 list_id=str,
371 subscriber=str,
372 role=enum_validator(MemberRole),
373 # Allow pagination.
374 page=int,
375 count=int,
376 _optional=('list_id', 'subscriber', 'role', 'page', 'count'))
377 try:
378 data = validator(request)
379 except ValueError as error:
380 bad_request(response, str(error))
381 else:
382 # Remove any optional pagination query elements; they will be
383 # handled later.
384 data.pop('page', None)
385 data.pop('count', None)
386 members = service.find_members(**data)
387 resource = _FoundMembers(members, self.api)
388 okay(response, etag(resource._make_collection(request)))