Add per list member roster visibility option
[mailman.git] / src / mailman / rest / lists.py
blob424916dda5061ef0ecbdb3a4a3b7732fe0e7c34b
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 mailing lists."""
20 from lazr.config import as_boolean
21 from mailman.app.digests import (
22 bump_digest_number_and_volume, maybe_send_digest_now)
23 from mailman.app.lifecycle import (
24 InvalidListNameError, create_list, remove_list)
25 from mailman.config import config
26 from mailman.interfaces.address import InvalidEmailAddressError
27 from mailman.interfaces.domain import BadDomainSpecificationError
28 from mailman.interfaces.listmanager import (
29 IListManager, ListAlreadyExistsError)
30 from mailman.interfaces.mailinglist import IListArchiverSet
31 from mailman.interfaces.member import MemberRole
32 from mailman.interfaces.styles import IStyleManager
33 from mailman.interfaces.subscriptions import ISubscriptionService
34 from mailman.rest.bans import BannedEmails
35 from mailman.rest.header_matches import HeaderMatches
36 from mailman.rest.helpers import (
37 BadRequest, CollectionMixin, GetterSetter, NotFound, accepted,
38 bad_request, child, created, etag, no_content, not_found, okay)
39 from mailman.rest.listconf import ListConfiguration
40 from mailman.rest.members import AMember, MemberCollection
41 from mailman.rest.post_moderation import HeldMessages
42 from mailman.rest.sub_moderation import SubscriptionRequests
43 from mailman.rest.uris import AListURI, AllListURIs
44 from mailman.rest.validator import (
45 Validator, enum_validator, list_of_strings_validator, subscriber_validator)
46 from public import public
47 from zope.component import getUtility
50 def member_matcher(segments):
51 """A matcher of member URLs inside mailing lists.
53 e.g. /<role>/aperson@example.org
54 """
55 if len(segments) != 2:
56 return None
57 try:
58 role = MemberRole[segments[0]]
59 except KeyError:
60 # Not a valid role.
61 return None
62 return (), dict(role=role, email=segments[1]), ()
65 def roster_matcher(segments):
66 """A matcher of all members URLs inside mailing lists.
68 e.g. /roster/<role>
69 """
70 if len(segments) != 2 or segments[0] != 'roster':
71 return None
72 try:
73 return (), dict(role=MemberRole[segments[1]]), ()
74 except KeyError:
75 # Not a valid role.
76 return None
79 def config_matcher(segments):
80 """A matcher for a mailing list's configuration resource.
82 e.g. /config
83 e.g. /config/description
84 """
85 if len(segments) < 1 or segments[0] != 'config':
86 return None
87 if len(segments) == 1:
88 return (), {}, ()
89 if len(segments) == 2:
90 return (), dict(attribute=segments[1]), ()
91 # More segments are not allowed.
92 return None
95 class _ListBase(CollectionMixin):
96 """Shared base class for mailing list representations."""
98 def _resource_as_dict(self, mlist):
99 """See `CollectionMixin`."""
100 return dict(
101 display_name=mlist.display_name,
102 fqdn_listname=mlist.fqdn_listname,
103 list_id=mlist.list_id,
104 list_name=mlist.list_name,
105 mail_host=mlist.mail_host,
106 member_count=mlist.members.member_count,
107 volume=mlist.volume,
108 description=mlist.description,
109 self_link=self.api.path_to('lists/{}'.format(mlist.list_id)),
112 def _get_collection(self, request):
113 """See `CollectionMixin`."""
114 return self._filter_lists(request)
116 def _filter_lists(self, request, **kw):
117 """Filter a collection using query parameters."""
118 advertised = request.get_param_as_bool('advertised')
119 if advertised:
120 kw['advertised'] = True
121 return getUtility(IListManager).find(**kw)
124 class _ListOfLists(_ListBase):
125 """An abstract class to return a sub-set of Lists.
127 This is used for filtering Lists based on some parameters.
129 def __init__(self, lists, api):
130 super().__init__()
131 self._lists = lists
132 self.api = api
134 def _get_collection(self, request):
135 return self._lists
138 @public
139 class FindLists(_ListBase):
140 """The mailing lists that a user is a member of."""
142 def on_get(self, request, response):
143 return self._find(request, response)
145 def on_post(self, request, response):
146 return self._find(request, response)
148 def _find(self, request, response):
149 validator = Validator(
150 subscriber=subscriber_validator(self.api),
151 role=enum_validator(MemberRole),
152 # Allow pagination.
153 page=int,
154 count=int,
155 _optional=('role', 'page', 'count'))
156 try:
157 data = validator(request)
158 except ValueError as error:
159 bad_request(response, str(error))
160 return
161 else:
162 # Remove any optional pagination query elements.
163 data.pop('page', None)
164 data.pop('count', None)
165 service = getUtility(ISubscriptionService)
166 # Get all membership records for given subscriber.
167 memberships = service.find_members(**data)
168 # Get all the lists from from the membership records.
169 lists = [getUtility(IListManager).get_by_list_id(member.list_id)
170 for member in memberships]
171 # If there are no matching lists, return a 404.
172 if not len(lists):
173 return not_found(response)
174 resource = _ListOfLists(lists, self.api)
175 okay(response, etag(resource._make_collection(request)))
178 @public
179 class AList(_ListBase):
180 """A mailing list."""
182 def __init__(self, list_identifier):
183 # list-id is preferred, but for backward compatibility, fqdn_listname
184 # is also accepted. If the string contains '@', treat it as the
185 # latter.
186 manager = getUtility(IListManager)
187 if '@' in list_identifier:
188 self._mlist = manager.get(list_identifier)
189 else:
190 self._mlist = manager.get_by_list_id(list_identifier)
192 def on_get(self, request, response):
193 """Return a single mailing list end-point."""
194 if self._mlist is None:
195 not_found(response)
196 else:
197 okay(response, self._resource_as_json(self._mlist))
199 def on_delete(self, request, response):
200 """Delete the named mailing list."""
201 if self._mlist is None:
202 not_found(response)
203 else:
204 remove_list(self._mlist)
205 no_content(response)
207 @child(member_matcher)
208 def member(self, context, segments, role, email):
209 """Return a single member representation."""
210 if self._mlist is None:
211 return NotFound(), []
212 member = getUtility(ISubscriptionService).find_member(
213 email, self._mlist.list_id, role)
214 if member is None:
215 return NotFound(), []
216 return AMember(member.member_id)
218 @child(roster_matcher)
219 def roster(self, context, segments, role):
220 """Return the collection of all a mailing list's members."""
221 if self._mlist is None:
222 return NotFound(), []
223 return MembersOfList(self._mlist, role)
225 @child(config_matcher)
226 def config(self, context, segments, attribute=None):
227 """Return a mailing list configuration object."""
228 if self._mlist is None:
229 return NotFound(), []
230 return ListConfiguration(self._mlist, attribute)
232 @child()
233 def held(self, context, segments):
234 """Return a list of held messages for the mailing list."""
235 if self._mlist is None:
236 return NotFound(), []
237 return HeldMessages(self._mlist)
239 @child()
240 def requests(self, context, segments):
241 """Return a list of subscription/unsubscription requests."""
242 if self._mlist is None:
243 return NotFound(), []
244 return SubscriptionRequests(self._mlist)
246 @child()
247 def archivers(self, context, segments):
248 """Return a representation of mailing list archivers."""
249 if self._mlist is None:
250 return NotFound(), []
251 return ListArchivers(self._mlist)
253 @child()
254 def digest(self, context, segments):
255 if self._mlist is None:
256 return NotFound(), []
257 return ListDigest(self._mlist)
259 @child()
260 def bans(self, context, segments):
261 """Return a collection of mailing list's banned addresses."""
262 if self._mlist is None:
263 return NotFound(), []
264 return BannedEmails(self._mlist)
266 @child(r'^header-matches')
267 def header_matches(self, context, segments):
268 """Return a collection of mailing list's header matches."""
269 if self._mlist is None:
270 return NotFound(), []
271 return HeaderMatches(self._mlist)
273 @child()
274 def uris(self, context, segments):
275 """Return the template URIs of the mailing list.
277 These are only available after API 3.0.
279 if self._mlist is None or self.api.version_info < (3, 1):
280 return NotFound(), []
281 if len(segments) == 0:
282 return AllListURIs(self._mlist)
283 if len(segments) > 1:
284 return BadRequest(), []
285 template = segments[0]
286 if template not in AllListURIs.URIs:
287 return NotFound(), []
288 return AListURI(self._mlist, template), []
291 @public
292 class AllLists(_ListBase):
293 """The mailing lists."""
295 def on_post(self, request, response):
296 """Create a new mailing list."""
297 try:
298 validator = Validator(fqdn_listname=str,
299 style_name=str,
300 _optional=('style_name',))
301 mlist = create_list(**validator(request))
302 except ListAlreadyExistsError:
303 bad_request(response, b'Mailing list exists')
304 except BadDomainSpecificationError as error:
305 reason = 'Domain does not exist: {}'.format(error.domain)
306 bad_request(response, reason.encode('utf-8'))
307 except InvalidListNameError as error:
308 reason = 'Invalid list name: {}'.format(error.listname)
309 bad_request(response, reason.encode('utf-8'))
310 except InvalidEmailAddressError as error:
311 reason = 'Invalid list posting address: {}'.format(error.email)
312 bad_request(response, reason.encode('utf-8'))
313 else:
314 location = self.api.path_to('lists/{0}'.format(mlist.list_id))
315 created(response, location)
317 def on_get(self, request, response):
318 """/lists"""
319 resource = self._make_collection(request)
320 okay(response, etag(resource))
323 @public
324 class MembersOfList(MemberCollection):
325 """The members of a mailing list."""
327 def __init__(self, mailing_list, role):
328 super().__init__()
329 self._mlist = mailing_list
330 self._role = role
332 def _get_collection(self, request):
333 """See `CollectionMixin`."""
334 # Overrides _MemberBase._get_collection() because we only want to
335 # return the members from the contexted roster.
336 return getUtility(ISubscriptionService).find_members(
337 list_id=self._mlist.list_id,
338 role=self._role)
340 def on_delete(self, request, response):
341 """Delete the members of the named mailing list."""
342 status = {}
343 try:
344 validator = Validator(emails=list_of_strings_validator)
345 arguments = validator(request)
346 except ValueError as error:
347 bad_request(response, str(error))
348 return
349 emails = arguments.pop('emails')
350 success, fail = getUtility(ISubscriptionService).unsubscribe_members(
351 self._mlist.list_id, emails)
352 # There should be no email in both sets.
353 assert success.isdisjoint(fail), (success, fail)
354 status.update({email: True for email in success})
355 status.update({email: False for email in fail})
356 okay(response, etag(status))
359 @public
360 class ListsForDomain(_ListBase):
361 """The mailing lists for a particular domain."""
363 def __init__(self, domain):
364 self._domain = domain
366 def on_get(self, request, response):
367 """/domains/<domain>/lists"""
368 resource = self._make_collection(request)
369 okay(response, etag(resource))
371 def _get_collection(self, request):
372 """See `CollectionMixin`."""
373 return self._filter_lists(request, mail_host=self._domain.mail_host)
376 @public
377 class ArchiverGetterSetter(GetterSetter):
378 """Resource for updating archiver statuses."""
380 def __init__(self, mlist):
381 super().__init__()
382 self._archiver_set = IListArchiverSet(mlist)
384 def put(self, mlist, attribute, value):
385 # attribute will contain the (bytes) name of the archiver that is
386 # getting a new status. value will be the representation of the new
387 # boolean status.
388 archiver = self._archiver_set.get(attribute)
389 assert archiver is not None, attribute
390 archiver.is_enabled = as_boolean(value)
393 @public
394 class ListArchivers:
395 """The archivers for a list, with their enabled flags."""
397 def __init__(self, mlist):
398 self._mlist = mlist
400 def on_get(self, request, response):
401 """Get all the archiver statuses."""
402 archiver_set = IListArchiverSet(self._mlist)
403 resource = {archiver.name: archiver.is_enabled
404 for archiver in archiver_set.archivers
405 if archiver.system_archiver.is_enabled}
406 okay(response, etag(resource))
408 def patch_put(self, request, response, is_optional):
409 archiver_set = IListArchiverSet(self._mlist)
410 kws = {archiver.name: ArchiverGetterSetter(self._mlist)
411 for archiver in archiver_set.archivers
412 if archiver.system_archiver.is_enabled}
413 if is_optional:
414 # For a PATCH, all attributes are optional.
415 kws['_optional'] = kws.keys()
416 try:
417 Validator(**kws).update(self._mlist, request)
418 except ValueError as error:
419 bad_request(response, str(error))
420 else:
421 no_content(response)
423 def on_put(self, request, response):
424 """Update all the archiver statuses."""
425 self.patch_put(request, response, is_optional=False)
427 def on_patch(self, request, response):
428 """Patch some archiver statueses."""
429 self.patch_put(request, response, is_optional=True)
432 @public
433 class ListDigest:
434 """Simple resource representing actions on a list's digest."""
436 def __init__(self, mlist):
437 self._mlist = mlist
439 def on_get(self, request, response):
440 resource = dict(
441 next_digest_number=self._mlist.next_digest_number,
442 volume=self._mlist.volume,
444 okay(response, etag(resource))
446 def on_post(self, request, response):
447 try:
448 validator = Validator(
449 send=as_boolean,
450 bump=as_boolean,
451 periodic=as_boolean,
452 _optional=('send', 'bump', 'periodic'))
453 values = validator(request)
454 except ValueError as error:
455 bad_request(response, str(error))
456 return
457 if values.get('send', False) and values.get('periodic', False):
458 # Send and periodic and mutually exclusive options.
459 bad_request(
460 response, 'send and periodic options are mutually exclusive')
461 return
462 if len(values) == 0:
463 # There's nothing to do, but that's okay.
464 okay(response)
465 return
466 if values.get('bump', False):
467 bump_digest_number_and_volume(self._mlist)
468 if values.get('send', False):
469 maybe_send_digest_now(self._mlist, force=True)
470 if values.get('periodic', False) and self._mlist.digest_send_periodic:
471 maybe_send_digest_now(self._mlist, force=True)
472 accepted(response)
475 @public
476 class Styles:
477 """Simple resource representing all list styles."""
479 def __init__(self):
480 manager = getUtility(IStyleManager)
481 styles = [dict(name=style.name, description=style.description)
482 for style in manager.styles]
483 style_names = sorted(style.name for style in manager.styles)
484 self._resource = dict(
485 # TODO (maxking): style_name is meant for backwards compatibility
486 # and should be removed in 3.3 release.
487 style_names=style_names,
488 styles=styles,
489 default=config.styles.default)
491 def on_get(self, request, response):
492 okay(response, etag(self._resource))