Barry Warsaw pushed to branch master at mailman / Mailman
Commits: ad53d761 by Aurélien Bompard at 2016-01-13T14:20:16-05:00 Expose the ban list on the REST API - - - - - fe57b60b by Aurélien Bompard at 2016-01-13T14:20:16-05:00 Add the self_link for bans and factor some code - - - - - 95446742 by Barry Warsaw at 2016-01-13T16:53:20-05:00 Clean up the branch for landing. * Add NEWS * Update copyright years. * Adjust for new IAPI.path_to() interface. * Style. * Remove some unreachable code. * Boost coverage. * Change some Bad Requests into Not Founds. * Do not include `list_id` in resource JSON for global bans. * Rephrase some doctests. - - - - - 8 changed files: - src/mailman/docs/NEWS.rst - src/mailman/interfaces/bans.py - src/mailman/model/bans.py - + src/mailman/rest/bans.py - src/mailman/rest/docs/membership.rst - src/mailman/rest/lists.py - src/mailman/rest/root.py - + src/mailman/rest/tests/test_bans.py Changes: ===================================== src/mailman/docs/NEWS.rst ===================================== --- a/src/mailman/docs/NEWS.rst +++ b/src/mailman/docs/NEWS.rst @@ -131,6 +131,8 @@ REST values accessible through the list's configuraiton resource. POSTing to the resource with either ``send=True``, ``bump=True``, or both invokes the given action. + * Global and list-centric bans can now be managed through the REST API. + Given by Aurélien Bompard. Other ----- ===================================== src/mailman/interfaces/bans.py ===================================== --- a/src/mailman/interfaces/bans.py +++ b/src/mailman/interfaces/bans.py @@ -97,3 +97,10 @@ class IBanManager(Interface): or not. :rtype: bool """ + + def __iter__(): + """Iterate over all banned addresses. + + :return: The list of all banned addresses. + :rtype: list of `str` + """ ===================================== src/mailman/model/bans.py ===================================== --- a/src/mailman/model/bans.py +++ b/src/mailman/model/bans.py @@ -111,3 +111,8 @@ class BanManager: re.match(ban.email, email, re.IGNORECASE) is not None): return True return False + + @dbconnection + def __iter__(self, store): + """See `IBanManager`.""" + yield from store.query(Ban).filter_by(list_id=self._list_id) ===================================== src/mailman/rest/bans.py ===================================== --- /dev/null +++ b/src/mailman/rest/bans.py @@ -0,0 +1,115 @@ +# Copyright (C) 2016 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""REST for banned emails.""" + +__all__ = [ + 'BannedEmail', + 'BannedEmails', + ] + + +from mailman.interfaces.bans import IBanManager +from mailman.rest.helpers import ( + CollectionMixin, bad_request, child, created, etag, no_content, not_found, + okay) +from mailman.rest.validator import Validator + + +class _BannedBase: + """Common base class.""" + + def __init__(self, mlist): + self._mlist = mlist + self.ban_manager = IBanManager(self._mlist) + + def _location(self, email): + if self._mlist is None: + base_location = '' + else: + base_location = 'lists/{}/'.format(self._mlist.list_id) + return self.api.path_to('{}bans/{}'.format(base_location, email)) + + +class BannedEmail(_BannedBase): + """A banned email.""" + + def __init__(self, mlist, email): + super().__init__(mlist) + self._email = email + + def on_get(self, request, response): + """Get a banned email.""" + if self.ban_manager.is_banned(self._email): + resource = dict( + email=self._email, + self_link=self._location(self._email), + ) + if self._mlist is not None: + resource['list_id'] = self._mlist.list_id + okay(response, etag(resource)) + else: + not_found(response, 'Email is not banned: {}'.format(self._email)) + + def on_delete(self, request, response): + """Remove an email from the ban list.""" + if self.ban_manager.is_banned(self._email): + self.ban_manager.unban(self._email) + no_content(response) + else: + not_found(response, 'Email is not banned: {}'.format(self._email)) + + +class BannedEmails(_BannedBase, CollectionMixin): + """The list of all banned emails.""" + + def _resource_as_dict(self, ban): + """See `CollectionMixin`.""" + resource = dict( + email=ban.email, + self_link=self._location(ban.email), + ) + if ban.list_id is not None: + resource['list_id'] = ban.list_id + return resource + + def _get_collection(self, request): + """See `CollectionMixin`.""" + return list(self.ban_manager) + + def on_get(self, request, response): + """/bans""" + resource = self._make_collection(request) + okay(response, etag(resource)) + + def on_post(self, request, response): + """Ban some email from subscribing.""" + validator = Validator(email=str) + try: + email = validator(request)['email'] + except ValueError as error: + bad_request(response, str(error)) + return + if self.ban_manager.is_banned(email): + bad_request(response, b'Address is already banned') + else: + self.ban_manager.ban(email) + created(response, self._location(email)) + + @child(r'^(?P<email>[^/]+)') + def email(self, request, segments, **kw): + return BannedEmail(self._mlist, kw['email']) ===================================== src/mailman/rest/docs/membership.rst ===================================== --- a/src/mailman/rest/docs/membership.rst +++ b/src/mailman/rest/docs/membership.rst @@ -965,3 +965,97 @@ The moderation action for a member can be changed by PATCH'ing the ... moderation_action: hold ... + + +Handling the list of banned addresses +===================================== + +To ban an address from subscribing you can POST to the ``/bans`` child +of any list using the REST API. + + >>> dump_json('http://localhost:9001/3.0/lists/ant.example.com/bans', + ... {'email': 'ban...@example.com'}) + content-length: 0 + ... + location: .../3.0/lists/ant.example.com/bans/ban...@example.com + ... + status: 201 + +This address is now banned, and you can get the list of banned addresses by +issuing a GET request on the ``/bans`` child. + + >>> dump_json('http://localhost:9001/3.0/lists/ant.example.com/bans') + entry 0: + email: ban...@example.com + http_etag: "..." + list_id: ant.example.com + self_link: .../3.0/lists/ant.example.com/bans/ban...@example.com + ... + +You can always GET a single banned address. + + >>> dump_json('http://localhost:9001/3.0/lists/ant.example.com' + ... '/bans/ban...@example.com') + email: ban...@example.com + http_etag: "..." + list_id: ant.example.com + self_link: .../3.0/lists/ant.example.com/bans/ban...@example.com + +Unbanning addresses is also possible by issuing a DELETE request. + + >>> dump_json('http://localhost:9001/3.0/lists/ant.example.com' + ... '/bans/ban...@example.com', + ... method='DELETE') + content-length: 0 + ... + status: 204 + +After unbanning, the address is not shown in the ban list anymore. + + >>> dump_json('http://localhost:9001/3.0/lists/ant.example.com/bans') + http_etag: "..." + start: 0 + total_size: 0 + +Global bans prevent an address from subscribing to any mailing list, and they +can be added via the top-level ``bans`` resource. + + >>> dump_json('http://localhost:9001/3.0/bans', + ... {'email': 'ban...@example.com'}) + content-length: 0 + ... + location: http://localhost:9001/3.0/bans/ban...@example.com + ... + status: 201 + +Note that entries in the global bans do not have a ``list_id`` field. +:: + + >>> dump_json('http://localhost:9001/3.0/bans') + entry 0: + email: ban...@example.com + http_etag: "..." + self_link: http://localhost:9001/3.0/bans/ban...@example.com + ... + + >>> dump_json('http://localhost:9001/3.0/bans/ban...@example.com') + email: ban...@example.com + http_etag: "..." + self_link: http://localhost:9001/3.0/bans/ban...@example.com + +As with list-centric bans, you can delete a global ban. + + >>> dump_json('http://localhost:9001/3.0/bans/ban...@example.com', + ... method='DELETE') + content-length: 0 + ... + status: 204 + + >>> dump_json('http://localhost:9001/3.0/bans/ban...@example.com') + Traceback (most recent call last): + ... + urllib.error.HTTPError: HTTP Error 404: ... + >>> dump_json('http://localhost:9001/3.0/bans') + http_etag: "..." + start: 0 + total_size: 0 ===================================== src/mailman/rest/lists.py ===================================== --- a/src/mailman/rest/lists.py +++ b/src/mailman/rest/lists.py @@ -39,6 +39,7 @@ from mailman.interfaces.mailinglist import IListArchiverSet from mailman.interfaces.member import MemberRole from mailman.interfaces.styles import IStyleManager from mailman.interfaces.subscriptions import ISubscriptionService +from mailman.rest.bans import BannedEmails from mailman.rest.listconf import ListConfiguration from mailman.rest.helpers import ( CollectionMixin, GetterSetter, NotFound, accepted, bad_request, child, @@ -198,6 +199,13 @@ class AList(_ListBase): return NotFound(), [] return ListDigest(self._mlist) + @child() + def bans(self, request, segments): + """Return a collection of mailing list's banned addresses.""" + if self._mlist is None: + return NotFound(), [] + return BannedEmails(self._mlist) + class AllLists(_ListBase): ===================================== src/mailman/rest/root.py ===================================== --- a/src/mailman/rest/root.py +++ b/src/mailman/rest/root.py @@ -32,6 +32,7 @@ from mailman.core.system import system from mailman.interfaces.listmanager import IListManager from mailman.model.uid import UID from mailman.rest.addresses import AllAddresses, AnAddress +from mailman.rest.bans import BannedEmail, BannedEmails from mailman.rest.domains import ADomain, AllDomains from mailman.rest.helpers import ( BadRequest, NotFound, child, etag, no_content, not_found, okay) @@ -290,6 +291,17 @@ class TopLevel: return BadRequest(), [] @child() + def bans(self, request, segments): + """/<api>/bans + /<api>/bans/<email> + """ + if len(segments) == 0: + return BannedEmails(None) + else: + email = segments.pop(0) + return BannedEmail(None, email), segments + + @child() def reserved(self, request, segments): """/<api>/reserved/[...]""" return Reserved(segments), [] ===================================== src/mailman/rest/tests/test_bans.py ===================================== --- /dev/null +++ b/src/mailman/rest/tests/test_bans.py @@ -0,0 +1,88 @@ +# Copyright (C) 2016 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Test address bans.""" + +__all__ = [ + 'TestBans', + ] + + +import unittest + +from mailman.app.lifecycle import create_list +from mailman.database.transaction import transaction +from mailman.interfaces.bans import IBanManager +from mailman.testing.layers import RESTLayer +from mailman.testing.helpers import call_api +from urllib.error import HTTPError + + +class TestBans(unittest.TestCase): + layer = RESTLayer + + def setUp(self): + with transaction(): + self._mlist = create_list('a...@example.com') + + def test_get_missing_banned_address(self): + with self.assertRaises(HTTPError) as cm: + call_api('http://localhost:9001/3.0/lists/ant.example.com' + '/bans/notban...@example.com') + self.assertEqual(cm.exception.code, 404) + self.assertEqual(cm.exception.reason, + b'Email is not banned: notban...@example.com') + + def test_delete_missing_banned_address(self): + with self.assertRaises(HTTPError) as cm: + call_api('http://localhost:9001/3.0/lists/ant.example.com' + '/bans/notban...@example.com', + method='DELETE') + self.assertEqual(cm.exception.code, 404) + self.assertEqual(cm.exception.reason, + b'Email is not banned: notban...@example.com') + + def test_not_found_after_unbanning(self): + manager = IBanManager(self._mlist) + with transaction(): + manager.ban('ban...@example.com') + url = ('http://localhost:9001/3.0/lists/ant.example.com' + '/bans/ban...@example.com') + response, content = call_api(url) + self.assertEqual(response['email'], 'ban...@example.com') + response, content = call_api(url, method='DELETE') + self.assertEqual(content.status, 204) + with self.assertRaises(HTTPError) as cm: + call_api(url) + self.assertEqual(cm.exception.code, 404) + self.assertEqual(cm.exception.reason, + b'Email is not banned: ban...@example.com') + + def test_not_found_after_unbanning_global(self): + manager = IBanManager(None) + with transaction(): + manager.ban('ban...@example.com') + url = ('http://localhost:9001/3.0/bans/ban...@example.com') + response, content = call_api(url) + self.assertEqual(response['email'], 'ban...@example.com') + response, content = call_api(url, method='DELETE') + self.assertEqual(content.status, 204) + with self.assertRaises(HTTPError) as cm: + call_api(url) + self.assertEqual(cm.exception.code, 404) + self.assertEqual(cm.exception.reason, + b'Email is not banned: ban...@example.com') View it on GitLab: https://gitlab.com/mailman/mailman/compare/187dad97bf278b0ca9d080774072e8fb235154cc...95446742669349777ee4101237a76395f1dfaa87
_______________________________________________ Mailman-checkins mailing list Mailman-checkins@python.org Unsubscribe: https://mail.python.org/mailman/options/mailman-checkins/archive%40jab.org