Merge lp:~jskrzeszewska/mailman/mailman into lp:mailman

Proposed by Joanna Skrzeszewska
Status: Needs review
Proposed branch: lp:~jskrzeszewska/mailman/mailman
Merge into: lp:mailman
Diff against target: 553 lines (+513/-0)
5 files modified
src/mailman/archiving/rssarchive.py (+225/-0)
src/mailman/archiving/tests/test_rssarchive.py (+203/-0)
src/mailman/config/mailman.cfg (+4/-0)
src/mailman/rest/root.py (+9/-0)
src/mailman/rest/rss.py (+72/-0)
To merge this branch: bzr merge lp:~jskrzeszewska/mailman/mailman
Reviewer Review Type Date Requested Status
Nicki Hutchens (community) Approve
Nicki Hutchens Pending
Barry Warsaw Pending
Review via email: mp+178414@code.launchpad.net

Description of the change

Added archiver for rss feed and REST API for rss settings.

To post a comment you must log in.
lp:~jskrzeszewska/mailman/mailman updated
7218. By Joanna Skrzeszewska

Changes in rssarchive.py

7219. By Joanna Skrzeszewska

Tests for rssarchive

Revision history for this message
Nicki Hutchens (nhutch01) :
review: Approve

Unmerged revisions

7219. By Joanna Skrzeszewska

Tests for rssarchive

7218. By Joanna Skrzeszewska

Changes in rssarchive.py

7217. By Joanna Skrzeszewska

Adding REST API for rss archiver.

7216. By Joanna Skrzeszewska

Adding rss archiver.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== added file 'src/mailman/archiving/rssarchive.py'
--- src/mailman/archiving/rssarchive.py 1970-01-01 00:00:00 +0000
+++ src/mailman/archiving/rssarchive.py 2013-09-01 16:37:50 +0000
@@ -0,0 +1,225 @@
1# Copyright (C) 2008-2013 by the Free Software Foundation, Inc.
2#
3# This file is part of GNU Mailman.
4#
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.
9#
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.
14#
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/>.
17
18from __future__ import absolute_import, print_function, unicode_literals
19
20__metaclass__ = type
21__all__ = [
22 'RssArchiver',
23 ]
24
25import os
26import errno
27import logging
28import datetime
29import PyRSS2Gen
30import sqlite3
31
32from email.utils import parsedate
33from datetime import datetime
34from operator import itemgetter
35from datetime import timedelta
36from mailbox import Maildir
37from urlparse import urljoin
38
39from flufl.lock import Lock, TimeOutError
40from zope.interface import implementer
41from mailman.interfaces.listmanager import IListManager
42from zope.component import getUtility
43
44from mailman.config import config
45from mailman.interfaces.archiver import IArchiver
46
47log = logging.getLogger('mailman.error')
48
49@implementer(IArchiver)
50class RssArchiver:
51 name = 'rssarchive'
52 table_name = 'admin_features'
53
54 @staticmethod
55 def set_length_limit(mlist, length_limit):
56 rss_db_dir = os.path.join(config.ARCHIVE_DIR, 'rss.db')
57 is_enabled = RssArchiver.is_feed_enabled(mlist)
58 conn = sqlite3.connect(rss_db_dir)
59 c = conn.cursor()
60 sql = ('insert or replace into ' + RssArchiver.table_name +
61 ' values (\'' + mlist.fqdn_listname + '\', ' + str(is_enabled) +
62 ', ' + str(length_limit) + ')')
63 c.execute(sql)
64 conn.commit()
65 c.close()
66 conn.close()
67
68 @staticmethod
69 def set_feed_enabled(mlist, is_enabled):
70 rss_db_dir = os.path.join(config.ARCHIVE_DIR, 'rss.db')
71 conn = sqlite3.connect(rss_db_dir)
72 c = conn.cursor()
73 length_limit = RssArchiver.get_length_limit(mlist)
74 sql = ('insert or replace into ' + RssArchiver.table_name +
75 ' values (\'' + mlist.fqdn_listname + '\', ' + str(is_enabled) +
76 ', ' +str(length_limit) + ')')
77 c.execute(sql)
78 conn.commit()
79 c.close()
80 conn.close()
81 if is_enabled:
82 RssArchiver.generate_rss_feed(mlist)
83
84 @staticmethod
85 def get_length_limit(mlist):
86 rss_db_dir = os.path.join(config.ARCHIVE_DIR, 'rss.db')
87 RssArchiver.init_database()
88 conn = sqlite3.connect(rss_db_dir)
89 c = conn.cursor()
90 sql = ('select entries_no from ' + RssArchiver.table_name +
91 ' where list_name = \'' + mlist.fqdn_listname + '\'')
92 is_enabled = c.execute(sql).fetchone()
93 if is_enabled == None:
94 is_enabled = [100]
95 conn.commit()
96 c.close()
97 conn.close()
98 return is_enabled[0]
99
100 @staticmethod
101 def is_feed_enabled(mlist):
102 rss_db_dir = os.path.join(config.ARCHIVE_DIR, 'rss.db')
103 RssArchiver.init_database()
104 conn = sqlite3.connect(rss_db_dir)
105 c = conn.cursor()
106 sql = ('select is_switched_on from ' + RssArchiver.table_name +
107 ' where list_name = \'' + mlist.fqdn_listname + '\'')
108 is_enabled = c.execute(sql).fetchone()
109 if is_enabled == None:
110 is_enabled = [1]
111 conn.commit()
112 c.close()
113 conn.close()
114 return is_enabled[0]
115
116 @staticmethod
117 def init_database():
118 rss_db_dir = os.path.join(config.ARCHIVE_DIR, 'rss.db')
119 conn = sqlite3.connect(rss_db_dir)
120 c = conn.cursor()
121 sql = ('create table if not exists ' + RssArchiver.table_name +
122 ' (list_name text unique, is_switched_on integer, entries_no integer)')
123 c.execute(sql)
124 conn.commit()
125 lists = []
126 list_manager = getUtility(IListManager)
127 for fqdn_name in sorted(list_manager.names):
128 mlist = list_manager.get(fqdn_name)
129 lists.append(mlist)
130 for list in lists:
131 log.error(list.fqdn_listname)
132 sql = ('insert or ignore into ' + RssArchiver.table_name +
133 ' values (\'' + list.fqdn_listname + '\', 1, 30)')
134 c.execute(sql)
135 conn.commit()
136 c.close()
137 conn.close()
138
139 @staticmethod
140 def format_message(msg, msg_as_string):
141 string_msg = ''
142 string_msg += 'Date: ' + msg['date'] + '\n'
143 string_msg += 'From: ' + msg['from'] + '\n'
144 string_msg += 'To: ' + msg['to'] + '\n\n' + msg_as_string
145 return string_msg.replace('\n', '<br>').replace(' ', '&nbsp')
146
147 @staticmethod
148 def extract_content(msg):
149 if msg.is_multipart():
150 for part in msg.walk():
151 if part.get_content_type() == 'text/plain':
152 msg_as_string = part.get_payload + msg_as_string
153 else:
154 msg_as_string = msg.get_payload()
155 return RssArchiver.format_message(msg, msg_as_string)
156
157 @staticmethod
158 def generate_rss_feed(mlist):
159 archive_dir = os.path.join(config.ARCHIVE_DIR, 'rssarchive')
160 RssArchiver.create_if_none(archive_dir)
161 list_dir = os.path.join(archive_dir, mlist.fqdn_listname)
162 mailbox = Maildir(list_dir, create=True, factory=None)
163 messages = mailbox.values()
164 for mes in messages:
165 mes['parsedate'] = parsedate(mes['date'])
166 messages.sort(key=itemgetter('parsedate'), reverse=True)
167 length_limit = RssArchiver.get_length_limit(mlist)
168 messages = messages[0: length_limit]
169 rss = PyRSS2Gen.RSS2(
170 title = mlist.fqdn_listname,
171 link = "",
172 description = "The latest messages from: " + mlist.fqdn_listname,
173 lastBuildDate = datetime.now(),
174 items = [
175 PyRSS2Gen.RSSItem(
176 title = mes['subject'],
177 description = RssArchiver.extract_content(mes),
178 pubDate = mes['date'])
179 for mes in messages
180 ])
181 dirPath = os.path.abspath("feeds")
182 if not os.path.exists(dirPath):
183 os.makedirs(dirPath)
184 fileName = mlist.fqdn_listname + '.xml'
185 filePath = os.path.abspath(os.path.join(dirPath, fileName))
186 with open(filePath, "w") as f:
187 rss.write_xml(f)
188
189 @staticmethod
190 def list_url(mlist):
191 return None
192
193 @staticmethod
194 def permalink(mlist, msg):
195 return None
196
197 @staticmethod
198 def create_if_none(archive_dir):
199 try:
200 os.makedirs(archive_dir, 0775)
201 except OSError as error:
202 if error.errno != errno.EEXIST:
203 raise
204
205 @staticmethod
206 def archive_message(mlist, message):
207 archive_dir = os.path.join(config.ARCHIVE_DIR, 'rssarchive')
208 RssArchiver.create_if_none(archive_dir)
209 list_dir = os.path.join(archive_dir, mlist.fqdn_listname)
210 mailbox = Maildir(list_dir, create=True, factory=None)
211 lock_file = os.path.join(
212 config.LOCK_DIR, '{0}-maildir.lock'.format(mlist.fqdn_listname))
213 lock = Lock(lock_file)
214 try:
215 lock.lock(timeout=timedelta(seconds=1))
216 mailbox.add(message)
217 if RssArchiver.is_feed_enabled(mlist):
218 RssArchiver.generate_rss_feed(mlist);
219 except TimeOutError:
220 log.error('Unable to acquire rss archiver lock for {0}, '
221 'discarding: {1}'.format(
222 mlist.fqdn_listname,
223 message.get('message-id', 'n/a')))
224 finally:
225 lock.unlock(unconditionally=True)
0226
=== added file 'src/mailman/archiving/tests/test_rssarchive.py'
--- src/mailman/archiving/tests/test_rssarchive.py 1970-01-01 00:00:00 +0000
+++ src/mailman/archiving/tests/test_rssarchive.py 2013-09-01 16:37:50 +0000
@@ -0,0 +1,203 @@
1# Copyright (C) 2012-2013 by the Free Software Foundation, Inc.
2#
3# This file is part of GNU Mailman.
4#
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.
9#
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.
14#
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/>.
17
18"""Test the rssarchiver."""
19
20from __future__ import absolute_import, print_function, unicode_literals
21
22__metaclass__ = type
23__all__ = [
24 'TestRssArchiver',
25 ]
26
27
28import os
29import shutil
30import tempfile
31import unittest
32import threading
33
34from xml.dom.minidom import parse, parseString
35from email import message_from_file
36from flufl.lock import Lock
37
38from mailman.app.lifecycle import create_list
39from mailman.archiving.rssarchive import RssArchiver
40from mailman.config import config
41from mailman.database.transaction import transaction
42from mailman.testing.helpers import LogFileMark
43from mailman.testing.helpers import (
44 specialized_message_from_string as mfs)
45from mailman.testing.layers import ConfigLayer
46from mailman.utilities.email import add_message_hash
47
48
49class TestRssArchiver(unittest.TestCase):
50 """Test the rss archiver."""
51
52 layer = ConfigLayer
53
54 def setUp(self):
55 # Create a fake mailing list and message object
56 self._msg = mfs("""\
57To: test@example.com
58From: anne@example.com
59Date: 16-05-2012
60Subject: Testing the test list
61Message-ID: <ant>
62X-Message-ID-Hash: MS6QLWERIJLGCRF44J7USBFDELMNT2BW
63
64Tests are better than no tests
65but the water deserves to be swum.
66""")
67 with transaction():
68 self._mlist = create_list('test@example.com')
69 # Set up a temporary directory for the prototype archiver so that it's
70 # easier to clean up.
71 self._tempdir = tempfile.mkdtemp()
72 config.push('rssarchive', """
73 [paths.testing]
74 archive_dir: {0}
75 """.format(self._tempdir))
76 # Capture the structure of a maildir.
77 self._expected_dir_structure = set(
78 (os.path.join(config.ARCHIVE_DIR, path) for path in (
79 'rssarchive',
80 os.path.join('rssarchive', self._mlist.fqdn_listname),
81 os.path.join('rssarchive', self._mlist.fqdn_listname, 'cur'),
82 os.path.join('rssarchive', self._mlist.fqdn_listname, 'new'),
83 os.path.join('rssarchive', self._mlist.fqdn_listname, 'tmp'),
84 )))
85 self._expected_dir_structure.add(config.ARCHIVE_DIR)
86
87 def tearDown(self):
88 shutil.rmtree(self._tempdir)
89 config.pop('rssarchive')
90
91 def _find(self, path):
92 all_filenames = set()
93 for dirpath, dirnames, filenames in os.walk(path):
94 if not isinstance(dirpath, unicode):
95 dirpath = unicode(dirpath)
96 all_filenames.add(dirpath)
97 for filename in filenames:
98 new_filename = filename
99 if not isinstance(filename, unicode):
100 new_filename = unicode(filename)
101 all_filenames.add(os.path.join(dirpath, new_filename))
102 return all_filenames
103
104 def test_rssarchiver_xml_file(self):
105 RssArchiver.archive_message(self._mlist, self._msg)
106 xml_path = os.path.join('/root/mailman/parts/test/feeds/', (self._mlist.fqdn_listname + '.xml'))
107
108 datasource = open(xml_path)
109 dom = parse(datasource)
110 rssNode = dom.childNodes[0]
111 channelNode = rssNode.childNodes[0]
112
113 self.assertEqual(len(channelNode.childNodes), 7)
114
115 itemNode = channelNode.childNodes[6]
116 titleNode = itemNode.childNodes[0]
117 self.assertEqual(titleNode.nodeName, 'title')
118 self.assertEqual(titleNode.firstChild.nodeValue, 'Testing the test list')
119
120 def test_set_length_limit(self):
121 RssArchiver.set_length_limit(self._mlist, 2)
122 self.assertEqual(RssArchiver.get_length_limit(self._mlist), 2)
123
124 def test_set_feed_enabled(self):
125 RssArchiver.set_feed_enabled(self._mlist, 1)
126 self.assertEqual(RssArchiver.is_feed_enabled(self._mlist), 1)
127 RssArchiver.set_feed_enabled(self._mlist, 0)
128 self.assertEqual(RssArchiver.is_feed_enabled(self._mlist), 0)
129
130 def test_archive_maildir_created(self):
131 # Archiving a message to the rssarchiver should create the
132 # expected directory structure.
133 RssArchiver.archive_message(self._mlist, self._msg)
134 all_filenames = self._find(config.ARCHIVE_DIR)
135 # Check that the directory structure has been created and we have one
136 # more file (the archived message) than expected directories.
137 archived_messages = [x for x in (all_filenames - self._expected_dir_structure) if 'rss.db' not in x]
138 self.assertEqual(len(archived_messages), 1)
139 self.assertTrue(
140 archived_messages.pop().startswith(
141 os.path.join(config.ARCHIVE_DIR, 'rssarchive',
142 self._mlist.fqdn_listname, 'new')))
143
144 def test_archive_maildir_existence_does_not_raise(self):
145 # Archiving a second message does not cause an EEXIST to be raised
146 # when a second message is archived.
147 new_dir = None
148 RssArchiver.archive_message(self._mlist, self._msg)
149 for directory in ('cur', 'new', 'tmp'):
150 path = os.path.join(config.ARCHIVE_DIR, 'rssarchive',
151 self._mlist.fqdn_listname, directory)
152 if directory == 'new':
153 new_dir = path
154 self.assertTrue(os.path.isdir(path))
155 # There should be one message in the 'new' directory.
156 self.assertEqual(len(os.listdir(new_dir)), 1)
157 # Archive a second message. If an exception occurs, let it fail the
158 # test. Afterward, two messages should be in the 'new' directory.
159 del self._msg['message-id']
160 del self._msg['x-message-id-hash']
161 self._msg['Message-ID'] = '<bee>'
162 add_message_hash(self._msg)
163 RssArchiver.archive_message(self._mlist, self._msg)
164 self.assertEqual(len(os.listdir(new_dir)), 2)
165
166 def test_archive_lock_used(self):
167 # Test that locking the maildir when adding works as a failure here
168 # could mean we lose mail.
169 lock_file = os.path.join(
170 config.LOCK_DIR, '{0}-maildir.lock'.format(
171 self._mlist.fqdn_listname))
172 with Lock(lock_file):
173 # Acquire the archiver lock, then make sure the archiver logs the
174 # fact that it could not acquire the lock.
175 archive_thread = threading.Thread(
176 target=RssArchiver.archive_message,
177 args=(self._mlist, self._msg))
178 mark = LogFileMark('mailman.error')
179 archive_thread.run()
180 # Test that the archiver output the correct error.
181 line = mark.readline()
182 # XXX 2012-03-15 BAW: we really should remove timestamp prefixes
183 # from the loggers when under test.
184 self.assertTrue(line.endswith(
185 'Unable to acquire rss archiver lock for {0}, '
186 'discarding: {1}\n'.format(
187 self._mlist.fqdn_listname,
188 self._msg.get('message-id'))))
189 # Check that the message didn't get archived.
190 created_files = self._find(config.ARCHIVE_DIR)
191 self.assertEqual(self._expected_dir_structure, created_files)
192
193 def test_rssarchiver_good_path(self):
194 # Verify the good path; the message gets archived.
195 RssArchiver.archive_message(self._mlist, self._msg)
196 new_path = os.path.join(
197 config.ARCHIVE_DIR, 'rssarchive', self._mlist.fqdn_listname, 'new')
198 archived_messages = list(os.listdir(new_path))
199 self.assertEqual(len(archived_messages), 1)
200 # Check that the email has been added.
201 with open(os.path.join(new_path, archived_messages[0])) as fp:
202 archived_message = message_from_file(fp)
203 self.assertEqual(self._msg.as_string(), archived_message.as_string())
0204
=== modified file 'src/mailman/config/mailman.cfg'
--- src/mailman/config/mailman.cfg 2013-01-16 23:54:32 +0000
+++ src/mailman/config/mailman.cfg 2013-09-01 16:37:50 +0000
@@ -91,3 +91,7 @@
91class: mailman.runners.digest.DigestRunner91class: mailman.runners.digest.DigestRunner
9292
93[style.default]93[style.default]
94
95[archiver.rssarchive]
96class: mailman.archiving.rssarchive.RssArchiver
97enable: yes
9498
=== modified file 'src/mailman/rest/root.py'
--- src/mailman/rest/root.py 2013-01-01 14:05:42 +0000
+++ src/mailman/rest/root.py 2013-09-01 16:37:50 +0000
@@ -42,6 +42,7 @@
42from mailman.rest.preferences import ReadOnlyPreferences42from mailman.rest.preferences import ReadOnlyPreferences
43from mailman.rest.templates import TemplateFinder43from mailman.rest.templates import TemplateFinder
44from mailman.rest.users import AUser, AllUsers44from mailman.rest.users import AUser, AllUsers
45from mailman.rest.rss import RssFeed
4546
4647
4748
4849
@@ -136,6 +137,14 @@
136 return AList(list_identifier), segments137 return AList(list_identifier), segments
137138
138 @resource.child()139 @resource.child()
140 def feeds(self, request, segments):
141 """/<api>/feeds/<list>/
142 """
143 #return http.ok([], etag({u'segments': segments[0]}))
144 list_identifier = segments.pop(0)
145 return RssFeed(list_identifier), segments
146
147 @resource.child()
139 def members(self, request, segments):148 def members(self, request, segments):
140 """/<api>/members"""149 """/<api>/members"""
141 if len(segments) == 0:150 if len(segments) == 0:
142151
=== added file 'src/mailman/rest/rss.py'
--- src/mailman/rest/rss.py 1970-01-01 00:00:00 +0000
+++ src/mailman/rest/rss.py 2013-09-01 16:37:50 +0000
@@ -0,0 +1,72 @@
1# Copyright (C) 2010-2013 by the Free Software Foundation, Inc.
2#
3# This file is part of GNU Mailman.
4#
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.
9#
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.
14#
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/>.
17
18"""REST for members."""
19
20from __future__ import absolute_import, unicode_literals
21
22__metaclass__ = type
23__all__ = [
24 'RssAdmin',
25 ]
26
27
28from uuid import UUID
29from operator import attrgetter
30from restish import http, resource
31from zope.component import getUtility
32
33from mailman.app.membership import delete_member
34from mailman.interfaces.address import InvalidEmailAddressError
35from mailman.interfaces.listmanager import IListManager, NoSuchListError
36from mailman.interfaces.member import (
37 AlreadySubscribedError, DeliveryMode, MemberRole, MembershipError,
38 NotAMemberError)
39from mailman.interfaces.subscriptions import ISubscriptionService
40from mailman.interfaces.user import UnverifiedAddressError
41from mailman.interfaces.usermanager import IUserManager
42from mailman.rest.helpers import (
43 CollectionMixin, PATCH, etag, no_content, paginate, path_to)
44from mailman.rest.preferences import Preferences, ReadOnlyPreferences
45from mailman.rest.validator import (
46 Validator, enum_validator, subscriber_validator)
47from mailman.archiving.rssarchive import RssArchiver
48
49class RssFeed(resource.Resource):
50 def __init__(self, list_identifier):
51 # list-id is preferred, but for backward compatibility, fqdn_listname
52 # is also accepted. If the string contains '@', treat it as the
53 # latter.
54 manager = getUtility(IListManager)
55 if '@' in list_identifier:
56 self._mlist = manager.get(list_identifier)
57 else:
58 self._mlist = manager.get_by_list_id(list_identifier)
59
60 @resource.GET()
61 def getData(self, request):
62 is_feed_enabled = RssArchiver.is_feed_enabled(self._mlist)
63 size = RssArchiver.get_length_limit(self._mlist)
64 return http.ok([], etag({u'enabled': is_feed_enabled, u'sizeLimit': size}))
65
66 @resource.POST()
67 def setData(self, request):
68 length_limit = request.POST.get('size_limit')
69 is_enabled = request.POST.get('is_enabled')
70 RssArchiver.set_length_limit(self._mlist, length_limit)
71 RssArchiver.set_feed_enabled(self._mlist, is_enabled)
72 return no_content()