[#5384] Make spam-checking provider pluggable and add Mollom impl.
diff --git a/Allura/allura/lib/app_globals.py b/Allura/allura/lib/app_globals.py
index 09f5bd9..862a03c 100644
--- a/Allura/allura/lib/app_globals.py
+++ b/Allura/allura/lib/app_globals.py
@@ -161,6 +161,7 @@
             registration=_cache_eps('allura.project_registration'),
             theme=_cache_eps('allura.theme'),
             user_prefs=_cache_eps('allura.user_prefs'),
+            spam=_cache_eps('allura.spam'),
             )
 
         # Zarkov logger
@@ -168,21 +169,10 @@
 
     @LazyProperty
     def spam_checker(self):
-        """Return an Akismet spam checker if config defines an Akismet API key.
-        Otherwise, return a no-op spam checker.
-
-        Eventually we may support checkers for other services like Mollom and
-        Defensio.
+        """Return a SpamFilter implementation.
         """
-        akismet_key = config.get('spam.akismet_key')
-        if akismet_key:
-            from allura.lib.spam import akismetservice
-            checker = akismetservice.Akismet(akismet_key, config.get('base_url'))
-            checker.verify_key()
-        else:
-            from allura.lib import spam
-            checker = spam.FakeSpamChecker()
-        return checker
+        from allura.lib import spam
+        return spam.SpamFilter.get(config, self.entry_points['spam'])
 
     @LazyProperty
     def director(self):
diff --git a/Allura/allura/lib/spam/__init__.py b/Allura/allura/lib/spam/__init__.py
index 954cda1..5c5841d 100644
--- a/Allura/allura/lib/spam/__init__.py
+++ b/Allura/allura/lib/spam/__init__.py
@@ -1,4 +1,25 @@
-class FakeSpamChecker(object):
-    """No-op spam checker"""
-    def check(self, *args, **kw):
+import logging
+
+
+log = logging.getLogger(__name__)
+
+
+class SpamFilter(object):
+    """Defines the spam checker interface and provides a default no-op impl."""
+    def __init__(self, config):
+        pass
+
+    def check(self, text, artifact=None, user=None, content_type='comment', **kw):
+        """Return True if ``text`` is spam, else False."""
+        log.info("No spam checking enabled")
         return False
+
+    @classmethod
+    def get(cls, config, entry_points):
+        """Return an instance of the SpamFilter impl specified in ``config``.
+        """
+        method = config.get('spam.method')
+        if not method:
+            return cls(config)
+        result = entry_points[method]
+        return result(config)
diff --git a/Allura/allura/lib/spam/akismetservice.py b/Allura/allura/lib/spam/akismetfilter.py
similarity index 61%
rename from Allura/allura/lib/spam/akismetservice.py
rename to Allura/allura/lib/spam/akismetfilter.py
index af5ece7..6af659b 100644
--- a/Allura/allura/lib/spam/akismetservice.py
+++ b/Allura/allura/lib/spam/akismetfilter.py
@@ -4,12 +4,32 @@
 from pylons import tmpl_context as c
 
 from allura.lib import helpers as h
+from allura.lib.spam import SpamFilter
 
 import akismet
 
+
 log = logging.getLogger(__name__)
 
-class Akismet(akismet.Akismet):
+
+class AkismetSpamFilter(SpamFilter):
+    """Spam checking implementation via Akismet service.
+
+    To enable Akismet spam filtering in your Allura instance, first
+    enable the entry point in setup.py::
+
+        [allura.spam]
+        akismet = allura.lib.spam.akismetfilter:AkismetSpamFilter
+
+    Then include the following parameters in your .ini file::
+
+        spam.method = akismet
+        spam.key = <your Akismet key here>
+    """
+    def __init__(self, config):
+        self.service = akismet.Akismet(config.get('spam.key'), config.get('base_url'))
+        self.service.verify_key()
+
     def check(self, text, artifact=None, user=None, content_type='comment', **kw):
         log_msg = text
         kw['comment_content'] = text
@@ -28,6 +48,6 @@
         # kw will be urlencoded, need to utf8-encode
         for k, v in kw.items():
             kw[k] = h.really_unicode(v).encode('utf8')
-        res = self.comment_check(text, data=kw, build_data=False)
+        res = self.service.comment_check(text, data=kw, build_data=False)
         log.info("spam=%s (akismet): %s" % (str(res), log_msg))
         return res
diff --git a/Allura/allura/lib/spam/mollomfilter.py b/Allura/allura/lib/spam/mollomfilter.py
new file mode 100644
index 0000000..c8356bb
--- /dev/null
+++ b/Allura/allura/lib/spam/mollomfilter.py
@@ -0,0 +1,61 @@
+import logging
+
+from pylons import request
+from pylons import tmpl_context as c
+
+from allura.lib import helpers as h
+from allura.lib.spam import SpamFilter
+
+import Mollom
+
+
+log = logging.getLogger(__name__)
+
+
+class MollomSpamFilter(SpamFilter):
+    """Spam checking implementation via Mollom service.
+
+    To enable Mollom spam filtering in your Allura instance, first
+    enable the entry point in setup.py::
+
+        [allura.spam]
+        mollom = allura.lib.spam.mollomfilter:MollomSpamFilter
+
+    Then include the following parameters in your .ini file::
+
+        spam.method = mollom
+        spam.public_key = <your Mollom public key here>
+        spam.private_key = <your Mollom private key here>
+    """
+    def __init__(self, config):
+        self.service = Mollom.MollomAPI(
+            publicKey=config.get('spam.public_key'),
+            privateKey=config.get('spam.private_key'))
+        if not self.service.verifyKey():
+            raise Mollom.MollomFault('Your MOLLOM credentials are invalid.')
+
+    def check(self, text, artifact=None, user=None, content_type='comment', **kw):
+        """Basic content spam check via Mollom. For more options
+        see http://mollom.com/api#api-content
+        """
+        log_msg = text
+        kw['postBody'] = text
+        if artifact:
+            # Should be able to send url, but can't right now due to a bug in
+            # the PyMollom lib
+            # kw['url'] = artifact.url()
+            log_msg = artifact.url()
+        user = user or c.user
+        if user:
+            kw['authorName'] = user.display_name or user.username
+            kw['authorMail'] = user.email_addresses[0] if user.email_addresses else ''
+        user_ip = request.headers.get('X_FORWARDED_FOR', request.remote_addr)
+        kw['authorIP'] = user_ip.split(',')[0].strip()
+        # kw will be urlencoded, need to utf8-encode
+        for k, v in kw.items():
+            kw[k] = h.really_unicode(v).encode('utf8')
+        cc = self.service.checkContent(**kw)
+        res = cc['spam'] == 2
+        log.info("spam=%s (mollom): %s" % (str(res), log_msg))
+        return res
+
diff --git a/Allura/allura/tests/unit/test_spam.py b/Allura/allura/tests/unit/spam/test_akismet.py
similarity index 66%
rename from Allura/allura/tests/unit/test_spam.py
rename to Allura/allura/tests/unit/spam/test_akismet.py
index e364783..aeb4afe 100644
--- a/Allura/allura/tests/unit/test_spam.py
+++ b/Allura/allura/tests/unit/spam/test_akismet.py
@@ -5,20 +5,21 @@
 import urllib
 
 try:
-    from allura.lib.spam.akismetservice import Akismet
+    from allura.lib.spam.akismetfilter import AkismetSpamFilter
 except ImportError:
-    Akismet = None
+    AkismetSpamFilter = None
 
 
-@unittest.skipIf(Akismet is None, "Can't import Akismet")
+@unittest.skipIf(AkismetSpamFilter is None, "Can't import AkismetSpamFilter")
 class TestAkismet(unittest.TestCase):
-    def setUp(self):
-        self.akismet = Akismet()
+    @mock.patch('allura.lib.spam.akismetfilter.akismet')
+    def setUp(self, akismet_lib):
+        self.akismet = AkismetSpamFilter({})
         def side_effect(*args, **kw):
             # side effect to test that data being sent to
             # akismet can be successfully urlencoded
             urllib.urlencode(kw.get('data', {}))
-        self.akismet.comment_check = mock.Mock(side_effect=side_effect)
+        self.akismet.service.comment_check = mock.Mock(side_effect=side_effect)
         self.fake_artifact = mock.Mock(**{'url.return_value': 'artifact url'})
         self.fake_user = mock.Mock(display_name=u'Søme User',
                 email_addresses=['user@domain'])
@@ -35,38 +36,38 @@
             user_agent='some browser',
             referrer='some url')
 
-    @mock.patch('allura.lib.spam.akismetservice.c')
-    @mock.patch('allura.lib.spam.akismetservice.request')
+    @mock.patch('allura.lib.spam.akismetfilter.c')
+    @mock.patch('allura.lib.spam.akismetfilter.request')
     def test_check(self, request, c):
         request.headers = self.fake_headers
         c.user = None
         self.akismet.check(self.content)
-        self.akismet.comment_check.assert_called_once_with(self.content,
+        self.akismet.service.comment_check.assert_called_once_with(self.content,
                 data=self.expected_data, build_data=False)
 
-    @mock.patch('allura.lib.spam.akismetservice.c')
-    @mock.patch('allura.lib.spam.akismetservice.request')
+    @mock.patch('allura.lib.spam.akismetfilter.c')
+    @mock.patch('allura.lib.spam.akismetfilter.request')
     def test_check_with_explicit_content_type(self, request, c):
         request.headers = self.fake_headers
         c.user = None
         self.akismet.check(self.content, content_type='some content type')
         self.expected_data['comment_type'] = 'some content type'
-        self.akismet.comment_check.assert_called_once_with(self.content,
+        self.akismet.service.comment_check.assert_called_once_with(self.content,
                 data=self.expected_data, build_data=False)
 
-    @mock.patch('allura.lib.spam.akismetservice.c')
-    @mock.patch('allura.lib.spam.akismetservice.request')
+    @mock.patch('allura.lib.spam.akismetfilter.c')
+    @mock.patch('allura.lib.spam.akismetfilter.request')
     def test_check_with_artifact(self, request, c):
         request.headers = self.fake_headers
         c.user = None
         self.akismet.check(self.content, artifact=self.fake_artifact)
         expected_data = self.expected_data
         expected_data['permalink'] = 'artifact url'
-        self.akismet.comment_check.assert_called_once_with(self.content,
+        self.akismet.service.comment_check.assert_called_once_with(self.content,
                 data=expected_data, build_data=False)
 
-    @mock.patch('allura.lib.spam.akismetservice.c')
-    @mock.patch('allura.lib.spam.akismetservice.request')
+    @mock.patch('allura.lib.spam.akismetfilter.c')
+    @mock.patch('allura.lib.spam.akismetfilter.request')
     def test_check_with_user(self, request, c):
         request.headers = self.fake_headers
         c.user = None
@@ -74,11 +75,11 @@
         expected_data = self.expected_data
         expected_data.update(comment_author=u'Søme User'.encode('utf8'),
                 comment_author_email='user@domain')
-        self.akismet.comment_check.assert_called_once_with(self.content,
+        self.akismet.service.comment_check.assert_called_once_with(self.content,
                 data=expected_data, build_data=False)
 
-    @mock.patch('allura.lib.spam.akismetservice.c')
-    @mock.patch('allura.lib.spam.akismetservice.request')
+    @mock.patch('allura.lib.spam.akismetfilter.c')
+    @mock.patch('allura.lib.spam.akismetfilter.request')
     def test_check_with_implicit_user(self, request, c):
         request.headers = self.fake_headers
         c.user = self.fake_user
@@ -86,11 +87,11 @@
         expected_data = self.expected_data
         expected_data.update(comment_author=u'Søme User'.encode('utf8'),
                 comment_author_email='user@domain')
-        self.akismet.comment_check.assert_called_once_with(self.content,
+        self.akismet.service.comment_check.assert_called_once_with(self.content,
                 data=expected_data, build_data=False)
 
-    @mock.patch('allura.lib.spam.akismetservice.c')
-    @mock.patch('allura.lib.spam.akismetservice.request')
+    @mock.patch('allura.lib.spam.akismetfilter.c')
+    @mock.patch('allura.lib.spam.akismetfilter.request')
     def test_check_with_fallback_ip(self, request, c):
         self.expected_data['user_ip'] = 'fallback ip'
         self.fake_headers.pop('X_FORWARDED_FOR')
@@ -98,5 +99,5 @@
         request.remote_addr = self.fake_headers['REMOTE_ADDR']
         c.user = None
         self.akismet.check(self.content)
-        self.akismet.comment_check.assert_called_once_with(self.content,
+        self.akismet.service.comment_check.assert_called_once_with(self.content,
                 data=self.expected_data, build_data=False)
diff --git a/Allura/allura/tests/unit/spam/test_mollom.py b/Allura/allura/tests/unit/spam/test_mollom.py
new file mode 100644
index 0000000..e2a0390
--- /dev/null
+++ b/Allura/allura/tests/unit/spam/test_mollom.py
@@ -0,0 +1,77 @@
+# -*- coding: utf-8 -*-
+
+import mock
+import unittest
+import urllib
+
+try:
+    from allura.lib.spam.mollomfilter import MollomSpamFilter
+except ImportError:
+    MollomSpamFilter = None
+
+
+@unittest.skipIf(MollomSpamFilter is None, "Can't import MollomSpamFilter")
+class TestMollom(unittest.TestCase):
+    @mock.patch('allura.lib.spam.mollomfilter.Mollom')
+    def setUp(self, mollom_lib):
+        self.mollom = MollomSpamFilter({})
+        def side_effect(*args, **kw):
+            # side effect to test that data being sent to
+            # mollom can be successfully urlencoded
+            urllib.urlencode(kw.get('data', {}))
+            return dict(spam=2)
+        self.mollom.service.checkContent = mock.Mock(side_effect=side_effect,
+                return_value=dict(spam=2))
+        self.fake_artifact = mock.Mock(**{'url.return_value': 'artifact url'})
+        self.fake_user = mock.Mock(display_name=u'Søme User',
+                email_addresses=['user@domain'])
+        self.fake_headers = dict(
+            REMOTE_ADDR='fallback ip',
+            X_FORWARDED_FOR='some ip',
+            USER_AGENT='some browser',
+            REFERER='some url')
+        self.content = u'spåm text'
+        self.expected_data = dict(
+            postBody=self.content.encode('utf8'),
+            authorIP='some ip')
+
+    @mock.patch('allura.lib.spam.mollomfilter.c')
+    @mock.patch('allura.lib.spam.mollomfilter.request')
+    def test_check(self, request, c):
+        request.headers = self.fake_headers
+        c.user = None
+        self.mollom.check(self.content)
+        self.mollom.service.checkContent.assert_called_once_with(**self.expected_data)
+
+    @mock.patch('allura.lib.spam.mollomfilter.c')
+    @mock.patch('allura.lib.spam.mollomfilter.request')
+    def test_check_with_user(self, request, c):
+        request.headers = self.fake_headers
+        c.user = None
+        self.mollom.check(self.content, user=self.fake_user)
+        expected_data = self.expected_data
+        expected_data.update(authorName=u'Søme User'.encode('utf8'),
+                authorMail='user@domain')
+        self.mollom.service.checkContent.assert_called_once_with(**self.expected_data)
+
+    @mock.patch('allura.lib.spam.mollomfilter.c')
+    @mock.patch('allura.lib.spam.mollomfilter.request')
+    def test_check_with_implicit_user(self, request, c):
+        request.headers = self.fake_headers
+        c.user = self.fake_user
+        self.mollom.check(self.content)
+        expected_data = self.expected_data
+        expected_data.update(authorName=u'Søme User'.encode('utf8'),
+                authorMail='user@domain')
+        self.mollom.service.checkContent.assert_called_once_with(**self.expected_data)
+
+    @mock.patch('allura.lib.spam.mollomfilter.c')
+    @mock.patch('allura.lib.spam.mollomfilter.request')
+    def test_check_with_fallback_ip(self, request, c):
+        self.expected_data['authorIP'] = 'fallback ip'
+        self.fake_headers.pop('X_FORWARDED_FOR')
+        request.headers = self.fake_headers
+        request.remote_addr = self.fake_headers['REMOTE_ADDR']
+        c.user = None
+        self.mollom.check(self.content)
+        self.mollom.service.checkContent.assert_called_once_with(**self.expected_data)
diff --git a/Allura/allura/tests/unit/spam/test_spam_filter.py b/Allura/allura/tests/unit/spam/test_spam_filter.py
new file mode 100644
index 0000000..990cb70
--- /dev/null
+++ b/Allura/allura/tests/unit/spam/test_spam_filter.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+
+import mock
+import unittest
+import urllib
+
+from allura.lib.spam import SpamFilter
+
+
+class TestSpamFilter(unittest.TestCase):
+    def test_check(self):
+        # default no-op impl always returns False
+        self.assertFalse(SpamFilter({}).check('foo'))
+
+    def test_get_default(self):
+        config = {}
+        entry_points = None
+        checker = SpamFilter.get(config, entry_points)
+        self.assertTrue(isinstance(checker, SpamFilter))
+
+    def test_get_method(self):
+        config = {'spam.method': 'mock'}
+        entry_points = {'mock': mock.Mock}
+        checker = SpamFilter.get(config, entry_points)
+        self.assertTrue(isinstance(checker, mock.Mock))
+
diff --git a/Allura/setup.py b/Allura/setup.py
index cdc6a06..c1a10d6 100644
--- a/Allura/setup.py
+++ b/Allura/setup.py
@@ -104,6 +104,10 @@
     [allura.theme]
     allura = allura.lib.plugin:ThemeProvider
 
+    [allura.spam]
+    #akismet = allura.lib.spam.akismetfilter:AkismetSpamFilter
+    #mollom = allura.lib.spam.mollomfilter:MollomSpamFilter
+
     [paste.paster_command]
     taskd = allura.command.taskd:TaskdCommand
     taskd_cleanup = allura.command.taskd_cleanup:TaskdCleanupCommand