Add per list member roster visibility option
[mailman.git] / src / mailman / rest / members.py
blob3da9d09f144633446c021b5d5dc35ff217148f87
1 # Copyright (C) 2010-2019 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 lazr.config import as_boolean
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.subscriptions import (
29 ISubscriptionManager, ISubscriptionService, RequestRecord,
30 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 public import public
40 from uuid import UUID
41 from zope.component import getUtility
44 class _MemberBase(CollectionMixin):
45 """Shared base class for member representations."""
47 def _resource_as_dict(self, member):
48 """See `CollectionMixin`."""
49 enum, dot, role = str(member.role).partition('.')
50 # The member will always have a member id and an address id. It will
51 # only have a user id if the address is linked to a user.
52 # E.g. nonmembers we've only seen via postings to lists they are not
53 # subscribed to will not have a user id. The user_id and the
54 # member_id are UUIDs. In API 3.0 we use the integer equivalent of
55 # the UID in the URL, but in API 3.1 we use the hex equivalent. See
56 # issue #121 for details.
57 member_id = self.api.from_uuid(member.member_id)
58 response = dict(
59 address=self.api.path_to(
60 'addresses/{}'.format(member.address.email)),
61 delivery_mode=member.delivery_mode,
62 email=member.address.email,
63 list_id=member.list_id,
64 member_id=member_id,
65 role=role,
66 self_link=self.api.path_to('members/{}'.format(member_id)),
68 # Add the moderation action if overriding the list's default.
69 if member.moderation_action is not None:
70 response['moderation_action'] = member.moderation_action
71 # Add display_name if it is present
72 if member.display_name is not None:
73 response['display_name'] = member.display_name
74 # Add the user link if there is one.
75 user = member.user
76 if user is not None:
77 user_id = self.api.from_uuid(user.user_id)
78 response['user'] = self.api.path_to('users/{}'.format(user_id))
79 return response
81 def _get_collection(self, request):
82 """See `CollectionMixin`."""
83 return list(getUtility(ISubscriptionService))
86 @public
87 class MemberCollection(_MemberBase):
88 """Abstract class for supporting submemberships.
90 This is used for example to return a resource representing all the
91 memberships of a mailing list, or all memberships for a specific email
92 address.
93 """
94 def _get_collection(self, request):
95 """See `CollectionMixin`."""
96 raise NotImplementedError
98 def on_get(self, request, response):
99 """roster/[members|owners|moderators]"""
100 resource = self._make_collection(request)
101 okay(response, etag(resource))
104 @public
105 class AMember(_MemberBase):
106 """A member."""
108 def __init__(self, member_id):
109 # The member_id is either the member's UUID or the string
110 # representation of the member's UUID.
111 service = getUtility(ISubscriptionService)
112 self._member_id = member_id
113 self._member = (None if member_id is None
114 else service.get_member(member_id))
116 def on_get(self, request, response):
117 """Return a single member end-point."""
118 if self._member is None:
119 not_found(response)
120 else:
121 okay(response, self._resource_as_json(self._member))
123 @child()
124 def preferences(self, context, segments):
125 """/members/<id>/preferences"""
126 if len(segments) != 0:
127 return NotFound(), []
128 if self._member is None:
129 return NotFound(), []
130 member_id = self.api.from_uuid(self._member_id)
131 child = Preferences(
132 self._member.preferences, 'members/{}'.format(member_id))
133 return child, []
135 @child()
136 def all(self, context, segments):
137 """/members/<id>/all/preferences"""
138 if len(segments) == 0:
139 return NotFound(), []
140 if self._member is None:
141 return NotFound(), []
142 member_id = self.api.from_uuid(self._member_id)
143 child = ReadOnlyPreferences(
144 self._member, 'members/{}/all'.format(member_id))
145 return child, []
147 def on_delete(self, request, response):
148 """Delete the member (i.e. unsubscribe)."""
149 # Leaving a list is a bit different than deleting a moderator or
150 # owner. Handle the former case first. For now too, we will not send
151 # an admin or user notification.
152 if self._member is None:
153 not_found(response)
154 return
155 mlist = getUtility(IListManager).get_by_list_id(self._member.list_id)
156 if self._member.role is MemberRole.member:
157 delete_member(mlist, self._member.address.email, False, False)
158 else:
159 self._member.unsubscribe()
160 no_content(response)
162 def on_patch(self, request, response):
163 """Patch the membership.
165 This is how subscription changes are done.
167 if self._member is None:
168 not_found(response)
169 return
170 try:
171 values = Validator(
172 address=str,
173 delivery_mode=enum_validator(DeliveryMode),
174 moderation_action=enum_validator(Action, allow_blank=True),
175 _optional=('address', 'delivery_mode', 'moderation_action'),
176 )(request)
177 except ValueError as error:
178 bad_request(response, str(error))
179 return
180 if 'address' in values:
181 email = values['address']
182 address = getUtility(IUserManager).get_address(email)
183 if address is None:
184 bad_request(response, b'Address not registered')
185 return
186 try:
187 self._member.address = address
188 except (MembershipError, UnverifiedAddressError) as error:
189 bad_request(response, str(error))
190 return
191 if 'delivery_mode' in values:
192 self._member.preferences.delivery_mode = values['delivery_mode']
193 if 'moderation_action' in values:
194 self._member.moderation_action = values['moderation_action']
195 no_content(response)
198 @public
199 class AllMembers(_MemberBase):
200 """The members."""
202 def on_post(self, request, response):
203 """Create a new member."""
204 try:
205 validator = Validator(
206 list_id=str,
207 subscriber=subscriber_validator(self.api),
208 display_name=str,
209 delivery_mode=enum_validator(DeliveryMode),
210 role=enum_validator(MemberRole),
211 pre_verified=as_boolean,
212 pre_confirmed=as_boolean,
213 pre_approved=as_boolean,
214 _optional=('delivery_mode', 'display_name', 'role',
215 'pre_verified', 'pre_confirmed', 'pre_approved'))
216 arguments = validator(request)
217 except ValueError as error:
218 bad_request(response, str(error))
219 return
220 # Dig the mailing list out of the arguments.
221 list_id = arguments.pop('list_id')
222 mlist = getUtility(IListManager).get_by_list_id(list_id)
223 if mlist is None:
224 bad_request(response, b'No such list')
225 return
226 # Figure out what kind of subscriber is being registered. Either it's
227 # a user via their preferred email address or it's an explicit address.
228 # If it's a UUID, then it must be associated with an existing user.
229 subscriber = arguments.pop('subscriber')
230 user_manager = getUtility(IUserManager)
231 # We use the display name if there is one.
232 display_name = arguments.pop('display_name', '')
233 if isinstance(subscriber, UUID):
234 user = user_manager.get_user_by_id(subscriber)
235 if user is None:
236 bad_request(response, b'No such user')
237 return
238 subscriber = user
239 else:
240 # This must be an email address. See if there's an existing
241 # address object associated with this email.
242 address = user_manager.get_address(subscriber)
243 if address is None:
244 # Create a new address, which of course will not be validated.
245 address = user_manager.create_address(
246 subscriber, display_name)
247 subscriber = address
248 # What role are we subscribing? Regular members go through the
249 # subscription policy workflow while owners, moderators, and
250 # nonmembers go through the legacy API for now.
251 role = arguments.pop('role', MemberRole.member)
252 if role is MemberRole.member:
253 # Get the pre_ flags for the subscription workflow.
254 pre_verified = arguments.pop('pre_verified', False)
255 pre_confirmed = arguments.pop('pre_confirmed', False)
256 pre_approved = arguments.pop('pre_approved', False)
257 # Now we can run the registration process until either the
258 # subscriber is subscribed, or the workflow is paused for
259 # verification, confirmation, or approval.
260 registrar = ISubscriptionManager(mlist)
261 try:
262 token, token_owner, member = registrar.register(
263 subscriber,
264 pre_verified=pre_verified,
265 pre_confirmed=pre_confirmed,
266 pre_approved=pre_approved)
267 except AlreadySubscribedError:
268 conflict(response, b'Member already subscribed')
269 return
270 except MissingPreferredAddressError:
271 bad_request(response, b'User has no preferred address')
272 return
273 except MembershipIsBannedError:
274 bad_request(response, b'Membership is banned')
275 return
276 except SubscriptionPendingError:
277 conflict(response, b'Subscription request already pending')
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 except AlreadySubscribedError:
329 bad_request(response,
330 '{} is already an {} of {}'.format(
331 email, role.name, mlist.fqdn_listname))
332 return
333 # The subscription completed. Let's get the resulting member
334 # and return the location to the new member. Member ids are
335 # UUIDs and need to be converted to URLs because JSON doesn't
336 # directly support UUIDs.
337 member_id = self.api.from_uuid(member.member_id)
338 location = self.api.path_to('members/{}'.format(member_id))
339 created(response, location)
341 def on_get(self, request, response):
342 """/members"""
343 resource = self._make_collection(request)
344 okay(response, etag(resource))
347 class _FoundMembers(MemberCollection):
348 """The found members collection."""
350 def __init__(self, members, api):
351 super().__init__()
352 self._members = members
353 self.api = api
355 def _get_collection(self, request):
356 """See `CollectionMixin`."""
357 return self._members
360 @public
361 class FindMembers(_MemberBase):
362 """/members/find"""
364 def on_get(self, request, response):
365 return self._find(request, response)
367 def on_post(self, request, response):
368 return self._find(request, response)
370 def _find(self, request, response):
371 """Find a member"""
372 service = getUtility(ISubscriptionService)
373 validator = Validator(
374 list_id=str,
375 subscriber=str,
376 role=enum_validator(MemberRole),
377 # Allow pagination.
378 page=int,
379 count=int,
380 _optional=('list_id', 'subscriber', 'role', 'page', 'count'))
381 try:
382 data = validator(request)
383 except ValueError as error:
384 bad_request(response, str(error))
385 else:
386 # Remove any optional pagination query elements; they will be
387 # handled later.
388 data.pop('page', None)
389 data.pop('count', None)
390 members = service.find_members(**data)
391 resource = _FoundMembers(members, self.api)
392 okay(response, etag(resource._make_collection(request)))