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