diff options
| -rw-r--r-- | src/mailman/app/digests.py | 2 | ||||
| -rw-r--r-- | src/mailman/commands/tests/test_digests.py | 2 | ||||
| -rw-r--r-- | src/mailman/docs/NEWS.rst | 10 | ||||
| -rw-r--r-- | src/mailman/rest/docs/lists.rst | 109 | ||||
| -rw-r--r-- | src/mailman/rest/lists.py | 47 | ||||
| -rw-r--r-- | src/mailman/rest/tests/test_lists.py | 77 |
6 files changed, 238 insertions, 9 deletions
diff --git a/src/mailman/app/digests.py b/src/mailman/app/digests.py index 2190fdf98..6ef37a391 100644 --- a/src/mailman/app/digests.py +++ b/src/mailman/app/digests.py @@ -73,7 +73,7 @@ def bump_digest_number_and_volume(mlist): -def maybe_send_digest_now(mlist, force=False): +def maybe_send_digest_now(mlist, *, force=False): """Send this mailing list's digest now. If there are any messages in this mailing list's digest, the diff --git a/src/mailman/commands/tests/test_digests.py b/src/mailman/commands/tests/test_digests.py index 607949f6e..2106f5089 100644 --- a/src/mailman/commands/tests/test_digests.py +++ b/src/mailman/commands/tests/test_digests.py @@ -377,7 +377,7 @@ Subject: message 3 items = get_queue_messages('virgin') self.assertEqual(len(items), 0) - def test_bump_after_send(self): + def test_bump_before_send(self): self._mlist.digest_volume_frequency = DigestFrequency.monthly self._mlist.volume = 7 self._mlist.next_digest_number = 4 diff --git a/src/mailman/docs/NEWS.rst b/src/mailman/docs/NEWS.rst index 3e8870589..e8ac58c68 100644 --- a/src/mailman/docs/NEWS.rst +++ b/src/mailman/docs/NEWS.rst @@ -123,6 +123,12 @@ REST * Expose ``digest_send_periodic``, ``digest_volume_frequency``, and ``digests_enabled`` (renamed from ``digestable``) to the REST API. (Closes: #159) + * Expose the "bump digest" and "send digest" functionality though the REST + API via the ``<api>/lists/<list-id>/digest`` end-point. GETting this + resource returns the ``next_digest_number`` and ``volume`` as the same + values accessible through the list's configuraiton resource. POSTing to + the resource with either ``send=True``, ``bump=True``, or both invokes the + given action. Other ----- @@ -132,9 +138,9 @@ Other ``list_url`` or permalink. Given by Aurélien Bompard. * Large performance improvement in ``SubscriptionService.find_members()``. Given by Aurélien Bompard. - * Rework the digest machinery, and add a new `send-digests` subcommand, which + * Rework the digest machinery, and add a new ``digests`` subcommand, which can be used from the command line or cron to immediately send out any - partially collected digests. + partially collected digests, or bump the digest and volume numbers. * The mailing list "data directory" has been renamed. Instead of using the fqdn listname, the subdirectory inside ``[paths]list_data_dir`` now uses the List-ID. diff --git a/src/mailman/rest/docs/lists.rst b/src/mailman/rest/docs/lists.rst index 1006a8f0c..49f2d454e 100644 --- a/src/mailman/rest/docs/lists.rst +++ b/src/mailman/rest/docs/lists.rst @@ -288,3 +288,112 @@ You can change the state of a subset of the list archivers. mail-archive: False mhonarc: False prototype: False + + +List digests +============ + +A list collects messages and prepares a digest which can be periodically sent +to all members who elect to receive digests. Digests are usually sent +whenever their size has reached a threshold, but you can force a digest to be +sent immediately via the REST API. + +Let's create a mailing list that has a digest recipient. + + >>> from mailman.interfaces.member import DeliveryMode + >>> from mailman.testing.helpers import subscribe + >>> emu = create_list('emu@example.com') + >>> emu.send_welcome_message = False + >>> anne = subscribe(emu, 'Anne') + >>> anne.preferences.delivery_mode = DeliveryMode.plaintext_digests + +The mailing list has a fairly high size threshold so that sending a single +message through the list won't trigger an automatic digest. The threshold is +the maximum digest size in kibibytes (1024 bytes). + + >>> emu.digest_size_threshold = 100 + >>> transaction.commit() + +We send a message through the mailing list to start collecting for a digest. + + >>> from mailman.runners.digest import DigestRunner + >>> from mailman.testing.helpers import make_testable_runner + >>> msg = message_from_string("""\ + ... From: anne@example.com + ... To: emu@example.com + ... Subject: Message #1 + ... + ... """) + >>> config.handlers['to-digest'].process(emu, msg, {}) + >>> runner = make_testable_runner(DigestRunner, 'digest') + >>> runner.run() + +No digest was sent because it didn't reach the size threshold. + + >>> from mailman.testing.helpers import get_queue_messages + >>> len(get_queue_messages('virgin')) + 0 + +By POSTing to the list's digest end-point with the ``send`` parameter set, we +can force the digest to be sent. + + >>> dump_json('http://localhost:9001/3.0/lists/emu.example.com/digest', { + ... 'send': True, + ... }) + content-length: 0 + date: ... + +Once the runner does its thing, the digest message will be sent. + + >>> runner.run() + >>> items = get_queue_messages('virgin') + >>> len(items) + 1 + >>> print(items[0].msg) + From: emu-request@example.com + Subject: Emu Digest, Vol 1, Issue 1 + To: emu@example.com + ... + From: anne@example.com + Subject: Message #1 + To: emu@example.com + ... + End of Emu Digest, Vol 1, Issue 1 + ********************************* + <BLANKLINE> + +Digests also have a volume number and digest number which can be bumped, also +by POSTing to the REST API. Bumping the digest for this list will increment +the digest volume and reset the digest number to 1. We have to fake that the +last digest was sent a couple of days ago. + + >>> from datetime import timedelta + >>> from mailman.interfaces.digests import DigestFrequency + >>> emu.digest_volume_frequency = DigestFrequency.daily + >>> emu.digest_last_sent_at -= timedelta(days=2) + >>> transaction.commit() + +Before bumping, we can get the next digest volume and number. Doing a GET on +the digest resource is just a shorthand for getting some interesting +information about the digest. Note that ``volume`` and ``next_digest_number`` +can also be retrieved from the list's configuration resource. + + >>> dump_json('http://localhost:9001/3.0/lists/emu.example.com/digest') + http_etag: ... + next_digest_number: 2 + volume: 1 + +Let's bump the digest. + + >>> dump_json('http://localhost:9001/3.0/lists/emu.example.com/digest', { + ... 'bump': True, + ... }) + content-length: 0 + date: ... + +And now the next digest to be sent will have a new volume number. + + >>> dump_json('http://localhost:9001/3.0/lists/emu.example.com/digest') + http_etag: ... + next_digest_number: 1 + volume: 2 diff --git a/src/mailman/rest/lists.py b/src/mailman/rest/lists.py index 467e71508..e73cadf08 100644 --- a/src/mailman/rest/lists.py +++ b/src/mailman/rest/lists.py @@ -28,6 +28,8 @@ __all__ = [ from lazr.config import as_boolean +from mailman.app.digests import ( + bump_digest_number_and_volume, maybe_send_digest_now) from mailman.app.lifecycle import create_list, remove_list from mailman.config import config from mailman.interfaces.domain import BadDomainSpecificationError @@ -39,8 +41,8 @@ from mailman.interfaces.styles import IStyleManager from mailman.interfaces.subscriptions import ISubscriptionService from mailman.rest.listconf import ListConfiguration from mailman.rest.helpers import ( - CollectionMixin, GetterSetter, NotFound, bad_request, child, created, - etag, no_content, not_found, okay) + CollectionMixin, GetterSetter, NotFound, accepted, bad_request, child, + created, etag, no_content, not_found, okay) from mailman.rest.members import AMember, MemberCollection from mailman.rest.post_moderation import HeldMessages from mailman.rest.sub_moderation import SubscriptionRequests @@ -192,6 +194,12 @@ class AList(_ListBase): return NotFound(), [] return ListArchivers(self._mlist) + @child() + def digest(self, request, segments): + if self._mlist is None: + return NotFound(), [] + return ListDigest(self._mlist) + class AllLists(_ListBase): @@ -310,6 +318,41 @@ class ListArchivers: +class ListDigest: + """Simple resource representing actions on a list's digest.""" + + def __init__(self, mlist): + self._mlist = mlist + + def on_get(self, request, response): + resource = dict( + next_digest_number=self._mlist.next_digest_number, + volume=self._mlist.volume, + ) + okay(response, etag(resource)) + + def on_post(self, request, response): + try: + validator = Validator( + send=as_boolean, + bump=as_boolean, + _optional=('send', 'bump')) + values = validator(request) + except ValueError as error: + bad_request(response, str(error)) + return + if len(values) == 0: + # There's nothing to do, but that's okay. + okay(response) + return + if values.get('bump', False): + bump_digest_number_and_volume(self._mlist) + if values.get('send', False): + maybe_send_digest_now(self._mlist, force=True) + accepted(response) + + + class Styles: """Simple resource representing all list styles.""" diff --git a/src/mailman/rest/tests/test_lists.py b/src/mailman/rest/tests/test_lists.py index 546e431d3..4782bc011 100644 --- a/src/mailman/rest/tests/test_lists.py +++ b/src/mailman/rest/tests/test_lists.py @@ -19,6 +19,7 @@ __all__ = [ 'TestListArchivers', + 'TestListDigests', 'TestListPagination', 'TestLists', 'TestListsMissing', @@ -27,15 +28,22 @@ __all__ = [ import unittest +from datetime import timedelta from mailman.app.lifecycle import create_list from mailman.config import config from mailman.database.transaction import transaction +from mailman.interfaces.digests import DigestFrequency from mailman.interfaces.listmanager import IListManager from mailman.interfaces.mailinglist import IAcceptableAliasSet +from mailman.interfaces.member import DeliveryMode from mailman.interfaces.usermanager import IUserManager from mailman.model.mailinglist import AcceptableAlias -from mailman.testing.helpers import call_api +from mailman.runners.digest import DigestRunner +from mailman.testing.helpers import ( + call_api, get_queue_messages, make_testable_runner, + specialized_message_from_string as mfs) from mailman.testing.layers import RESTLayer +from mailman.utilities.datetime import now as right_now from urllib.error import HTTPError from zope.component import getUtility @@ -318,7 +326,7 @@ class TestListPagination(unittest.TestCase): def test_zeroth_page(self): # Page numbers start at one. with self.assertRaises(HTTPError) as cm: - resource, response = call_api( + call_api( 'http://localhost:9001/3.0/domains/example.com/lists' '?count=1&page=0') self.assertEqual(cm.exception.code, 400) @@ -326,7 +334,7 @@ class TestListPagination(unittest.TestCase): def test_negative_page(self): # Negative pages are not allowed. with self.assertRaises(HTTPError) as cm: - resource, response = call_api( + call_api( 'http://localhost:9001/3.0/domains/example.com/lists' '?count=1&page=-1') self.assertEqual(cm.exception.code, 400) @@ -340,3 +348,66 @@ class TestListPagination(unittest.TestCase): self.assertEqual(resource['total_size'], 6) self.assertEqual(resource['start'], 6) self.assertNotIn('entries', resource) + + + +class TestListDigests(unittest.TestCase): + """Test /lists/<list-id>/digest""" + + layer = RESTLayer + + def setUp(self): + with transaction(): + self._mlist = create_list('ant@example.com') + self._mlist.send_welcome_message = False + anne = getUtility(IUserManager).create_address('anne@example.com') + self._mlist.subscribe(anne) + anne.preferences.delivery_mode = DeliveryMode.plaintext_digests + + def test_post_nothing_to_do(self): + resource, response = call_api( + 'http://localhost:9001/3.0/lists/ant.example.com/digest', {}) + self.assertEqual(response.status, 200) + + def test_post_something_to_do(self): + resource, response = call_api( + 'http://localhost:9001/3.0/lists/ant.example.com/digest', dict( + bump=True)) + self.assertEqual(response.status, 202) + + def test_post_bad_request(self): + with self.assertRaises(HTTPError) as cm: + call_api( + 'http://localhost:9001/3.0/lists/ant.example.com/digest', dict( + bogus=True)) + self.assertEqual(cm.exception.code, 400) + self.assertEqual(cm.exception.reason, b'Unexpected parameters: bogus') + + def test_bump_before_send(self): + with transaction(): + self._mlist.digest_volume_frequency = DigestFrequency.monthly + self._mlist.volume = 7 + self._mlist.next_digest_number = 4 + self._mlist.digest_last_sent_at = right_now() + timedelta( + days=-32) + msg = mfs("""\ +To: ant@example.com +From: anne@example.com +Subject: message 1 + +""") + config.handlers['to-digest'].process(self._mlist, msg, {}) + resource, response = call_api( + 'http://localhost:9001/3.0/lists/ant.example.com/digest', dict( + send=True, + bump=True)) + self.assertEqual(response.status, 202) + make_testable_runner(DigestRunner, 'digest').run() + # The volume is 8 and the digest number is 2 because a digest was sent + # after the volume/number was bumped. + self.assertEqual(self._mlist.volume, 8) + self.assertEqual(self._mlist.next_digest_number, 2) + self.assertEqual(self._mlist.digest_last_sent_at, right_now()) + items = get_queue_messages('virgin') + self.assertEqual(len(items), 1) + self.assertEqual(items[0].msg['subject'], 'Ant Digest, Vol 8, Issue 1') |
