summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/mailman/app/commands.py2
-rw-r--r--src/mailman/app/docs/lifecycle.rst118
-rw-r--r--src/mailman/app/finder.py78
-rw-r--r--src/mailman/app/lifecycle.py12
-rw-r--r--src/mailman/app/tests/test_lifecycle.py50
-rw-r--r--src/mailman/app/tests/test_subscriptions.py4
-rw-r--r--src/mailman/bin/mailman.py2
-rw-r--r--src/mailman/config/schema.cfg14
-rw-r--r--src/mailman/core/chains.py2
-rw-r--r--src/mailman/core/pipelines.py3
-rw-r--r--src/mailman/core/rules.py2
-rw-r--r--src/mailman/database/schema/mm_20121015000000.py21
-rw-r--r--src/mailman/database/schema/sqlite_20120407000000_01.sql2
-rw-r--r--src/mailman/database/schema/sqlite_20121015000000_01.sql208
-rw-r--r--src/mailman/database/tests/test_migrations.py31
-rw-r--r--src/mailman/docs/NEWS.rst17
-rw-r--r--src/mailman/interfaces/styles.py48
-rw-r--r--src/mailman/model/mailinglist.py11
-rw-r--r--src/mailman/rest/docs/configuration.rst63
-rw-r--r--src/mailman/rest/docs/lists.rst139
-rw-r--r--src/mailman/rest/lists.py6
-rw-r--r--src/mailman/rest/root.py7
-rw-r--r--src/mailman/rest/tests/test_lists.py47
-rw-r--r--src/mailman/runners/tests/test_bounce.py6
-rw-r--r--src/mailman/styles/base.py270
-rw-r--r--src/mailman/styles/default.py218
-rw-r--r--src/mailman/styles/docs/styles.rst194
-rw-r--r--src/mailman/styles/manager.py37
-rw-r--r--src/mailman/testing/helpers.py1
-rw-r--r--src/mailman/tests/test_documentation.py10
-rw-r--r--src/mailman/utilities/modules.py53
31 files changed, 1007 insertions, 669 deletions
diff --git a/src/mailman/app/commands.py b/src/mailman/app/commands.py
index 70f0a8fa6..ad0b9d4a0 100644
--- a/src/mailman/app/commands.py
+++ b/src/mailman/app/commands.py
@@ -27,9 +27,9 @@ __all__ = [
from zope.interface.verify import verifyObject
-from mailman.app.finder import find_components
from mailman.config import config
from mailman.interfaces.command import IEmailCommand
+from mailman.utilities.modules import find_components
diff --git a/src/mailman/app/docs/lifecycle.rst b/src/mailman/app/docs/lifecycle.rst
index f6bb7ddae..ee7185373 100644
--- a/src/mailman/app/docs/lifecycle.rst
+++ b/src/mailman/app/docs/lifecycle.rst
@@ -12,72 +12,18 @@ which performs additional tasks such as:
* validating the list's posting address (which also serves as the list's
fully qualified name);
* ensuring that the list's domain is registered;
- * applying all matching styles to the new list;
+ * :ref:`applying a list style <list-creation-styles>` to the new list;
* creating and assigning list owners;
* notifying watchers of list creation;
* creating ancillary artifacts (such as the list's on-disk directory)
-Posting address validation
-==========================
-
-If you try to use the higher-level interface to create a mailing list with a
-bogus posting address, you get an exception.
-
- >>> create_list('not a valid address')
- Traceback (most recent call last):
- ...
- InvalidEmailAddressError: not a valid address
-
-If the posting address is valid, but the domain has not been registered with
-Mailman yet, you get an exception.
-
- >>> create_list('test@example.org')
- Traceback (most recent call last):
- ...
- BadDomainSpecificationError: example.org
-
-
-Creating a list applies its styles
-==================================
-
-Start by registering a test style.
-::
-
- >>> from zope.interface import implementer
- >>> from mailman.interfaces.styles import IStyle
- >>> @implementer(IStyle)
- ... class TestStyle(object):
- ... name = 'test'
- ... priority = 10
- ... def apply(self, mailing_list):
- ... # Just does something very simple.
- ... mailing_list.msg_footer = 'test footer'
- ... def match(self, mailing_list, styles):
- ... # Applies to any test list
- ... if 'test' in mailing_list.fqdn_listname:
- ... styles.append(self)
-
- >>> from zope.component import getUtility
- >>> from mailman.interfaces.styles import IStyleManager
- >>> getUtility(IStyleManager).register(TestStyle())
-
-Using the higher level interface for creating a list, applies all matching
-list styles.
-
- >>> mlist_1 = create_list('test_1@example.com')
- >>> print mlist_1.fqdn_listname
- test_1@example.com
- >>> print mlist_1.msg_footer
- test footer
-
-
Creating a list with owners
===========================
You can also specify a list of owner email addresses. If these addresses are
not yet known, they will be registered, and new users will be linked to them.
-However the addresses are not verified.
+::
>>> owners = [
... 'aperson@example.com',
@@ -85,12 +31,9 @@ However the addresses are not verified.
... 'cperson@example.com',
... 'dperson@example.com',
... ]
- >>> mlist_2 = create_list('test_2@example.com', owners)
- >>> print mlist_2.fqdn_listname
- test_2@example.com
- >>> print mlist_2.msg_footer
- test footer
- >>> dump_list(address.email for address in mlist_2.owners.addresses)
+
+ >>> ant = create_list('ant@example.com', owners)
+ >>> dump_list(address.email for address in ant.owners.addresses)
aperson@example.com
bperson@example.com
cperson@example.com
@@ -99,37 +42,33 @@ However the addresses are not verified.
None of the owner addresses are verified.
>>> any(address.verified_on is not None
- ... for address in mlist_2.owners.addresses)
+ ... for address in ant.owners.addresses)
False
However, all addresses are linked to users.
- >>> # The owners have no names yet
- >>> len(list(mlist_2.owners.users))
- 4
-
-If you create a mailing list with owner addresses that are already known to
-the system, they won't be created again.
-::
-
>>> from mailman.interfaces.usermanager import IUserManager
+ >>> from zope.component import getUtility
>>> user_manager = getUtility(IUserManager)
+ >>> for address in owners:
+ ... user = user_manager.get_user(address)
+ ... print int(user.user_id.int), list(user.addresses)[0]
+ 1 aperson@example.com
+ 2 bperson@example.com
+ 3 cperson@example.com
+ 4 dperson@example.com
- >>> user_a = user_manager.get_user('aperson@example.com')
- >>> user_b = user_manager.get_user('bperson@example.com')
- >>> user_c = user_manager.get_user('cperson@example.com')
- >>> user_d = user_manager.get_user('dperson@example.com')
- >>> user_a.display_name = 'Anne Person'
- >>> user_b.display_name = 'Bart Person'
- >>> user_c.display_name = 'Caty Person'
- >>> user_d.display_name = 'Dirk Person'
+If you create a mailing list with owner addresses that are already known to
+the system, they won't be created again.
- >>> mlist_3 = create_list('test_3@example.com', owners)
- >>> dump_list(user.display_name for user in mlist_3.owners.users)
- Anne Person
- Bart Person
- Caty Person
- Dirk Person
+ >>> bee = create_list('bee@example.com', owners)
+ >>> from operator import attrgetter
+ >>> for user in sorted(bee.owners.users, key=attrgetter('user_id')):
+ ... print int(user.user_id.int), list(user.addresses)[0]
+ 1 aperson@example.com
+ 2 bperson@example.com
+ 3 cperson@example.com
+ 4 dperson@example.com
Deleting a list
@@ -140,17 +79,16 @@ artifacts.
::
>>> from mailman.app.lifecycle import remove_list
- >>> remove_list(mlist_2)
+ >>> remove_list(bee)
>>> from mailman.interfaces.listmanager import IListManager
- >>> from zope.component import getUtility
- >>> print getUtility(IListManager).get('test_2@example.com')
+ >>> print getUtility(IListManager).get('bee@example.com')
None
We should now be able to completely recreate the mailing list.
- >>> mlist_2a = create_list('test_2@example.com', owners)
- >>> dump_list(address.email for address in mlist_2a.owners.addresses)
+ >>> buzz = create_list('bee@example.com', owners)
+ >>> dump_list(address.email for address in bee.owners.addresses)
aperson@example.com
bperson@example.com
cperson@example.com
diff --git a/src/mailman/app/finder.py b/src/mailman/app/finder.py
deleted file mode 100644
index 679da483a..000000000
--- a/src/mailman/app/finder.py
+++ /dev/null
@@ -1,78 +0,0 @@
-# Copyright (C) 2009-2012 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/>.
-
-"""Find various kinds of object in package space."""
-
-from __future__ import absolute_import, unicode_literals
-
-__metaclass__ = type
-__all__ = [
- 'find_components',
- 'scan_module',
- ]
-
-
-import os
-import sys
-
-from pkg_resources import resource_listdir
-
-
-
-def scan_module(module, interface):
- """Return all the items in a module that conform to an interface.
-
- :param module: A module object. The module's `__all__` will be scanned.
- :type module: module
- :param interface: The interface that returned objects must conform to.
- :type interface: `Interface`
- :return: The sequence of matching components.
- :rtype: objects implementing `interface`
- """
- missing = object()
- for name in module.__all__:
- component = getattr(module, name, missing)
- assert component is not missing, (
- '%s has bad __all__: %s' % (module, name))
- if interface.implementedBy(component):
- yield component
-
-
-def find_components(package, interface):
- """Find components which conform to a given interface.
-
- Search all the modules in a given package, returning an iterator over all
- objects found that conform to the given interface.
-
- :param package: The package path to search.
- :type package: string
- :param interface: The interface that returned objects must conform to.
- :type interface: `Interface`
- :return: The sequence of matching components.
- :rtype: objects implementing `interface`
- """
- for filename in resource_listdir(package, ''):
- basename, extension = os.path.splitext(filename)
- if extension != '.py':
- continue
- module_name = '{0}.{1}'.format(package, basename)
- __import__(module_name, fromlist='*')
- module = sys.modules[module_name]
- if not hasattr(module, '__all__'):
- continue
- for component in scan_module(module, interface):
- yield component
diff --git a/src/mailman/app/lifecycle.py b/src/mailman/app/lifecycle.py
index 326498478..9dfa5a9a2 100644
--- a/src/mailman/app/lifecycle.py
+++ b/src/mailman/app/lifecycle.py
@@ -47,7 +47,7 @@ log = logging.getLogger('mailman.error')
-def create_list(fqdn_listname, owners=None):
+def create_list(fqdn_listname, owners=None, style_name=None):
"""Create the named list and apply styles.
The mailing may not exist yet, but the domain specified in `fqdn_listname`
@@ -57,6 +57,9 @@ def create_list(fqdn_listname, owners=None):
:type fqdn_listname: string
:param owners: The mailing list owners.
:type owners: list of string email addresses
+ :param style_name: The name of the style to apply to the newly created
+ list. If not given, the default is taken from the configuration file.
+ :type style_name: string
:return: The new mailing list.
:rtype: `IMailingList`
:raises BadDomainSpecificationError: when the hostname part of
@@ -66,13 +69,16 @@ def create_list(fqdn_listname, owners=None):
"""
if owners is None:
owners = []
- # This raises I
+ # This raises InvalidEmailAddressError if the address is not a valid
+ # posting address. Let these percolate up.
getUtility(IEmailValidator).validate(fqdn_listname)
listname, domain = fqdn_listname.split('@', 1)
if domain not in getUtility(IDomainManager):
raise BadDomainSpecificationError(domain)
mlist = getUtility(IListManager).create(fqdn_listname)
- for style in getUtility(IStyleManager).lookup(mlist):
+ style = getUtility(IStyleManager).get(
+ config.styles.default if style_name is None else style_name)
+ if style is not None:
style.apply(mlist)
# Coordinate with the MTA, as defined in the configuration file.
call_name(config.mta.incoming).create(mlist)
diff --git a/src/mailman/app/tests/test_lifecycle.py b/src/mailman/app/tests/test_lifecycle.py
new file mode 100644
index 000000000..f0bcdad3f
--- /dev/null
+++ b/src/mailman/app/tests/test_lifecycle.py
@@ -0,0 +1,50 @@
+# Copyright (C) 2012 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 the high level list lifecycle API."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'TestLifecycle',
+ ]
+
+
+import unittest
+
+from mailman.interfaces.address import InvalidEmailAddressError
+from mailman.interfaces.domain import BadDomainSpecificationError
+from mailman.app.lifecycle import create_list
+from mailman.testing.layers import ConfigLayer
+
+
+
+class TestLifecycle(unittest.TestCase):
+ """Test the high level list lifecycle API."""
+
+ layer = ConfigLayer
+
+ def test_posting_address_validation(self):
+ # Creating a mailing list with a bogus address raises an exception.
+ self.assertRaises(InvalidEmailAddressError,
+ create_list, 'bogus address')
+
+ def test_unregistered_domain(self):
+ # Creating a list with an unregistered domain raises an exception.
+ self.assertRaises(BadDomainSpecificationError,
+ create_list, 'test@nodomain.example.org')
diff --git a/src/mailman/app/tests/test_subscriptions.py b/src/mailman/app/tests/test_subscriptions.py
index c4c8f2795..8771390c1 100644
--- a/src/mailman/app/tests/test_subscriptions.py
+++ b/src/mailman/app/tests/test_subscriptions.py
@@ -34,7 +34,6 @@ from mailman.app.lifecycle import create_list
from mailman.interfaces.address import InvalidEmailAddressError
from mailman.interfaces.subscriptions import (
MissingUserError, ISubscriptionService)
-from mailman.testing.helpers import reset_the_world
from mailman.testing.layers import ConfigLayer
@@ -46,9 +45,6 @@ class TestJoin(unittest.TestCase):
self._mlist = create_list('test@example.com')
self._service = getUtility(ISubscriptionService)
- def tearDown(self):
- reset_the_world()
-
def test_join_user_with_bogus_id(self):
# When `subscriber` is a missing user id, an exception is raised.
with self.assertRaises(MissingUserError) as cm:
diff --git a/src/mailman/bin/mailman.py b/src/mailman/bin/mailman.py
index 6b15c9838..b3150148c 100644
--- a/src/mailman/bin/mailman.py
+++ b/src/mailman/bin/mailman.py
@@ -30,10 +30,10 @@ import argparse
from zope.interface.verify import verifyObject
-from mailman.app.finder import find_components
from mailman.core.i18n import _
from mailman.core.initialize import initialize
from mailman.interfaces.command import ICLISubCommand
+from mailman.utilities.modules import find_components
from mailman.version import MAILMAN_VERSION_FULL
diff --git a/src/mailman/config/schema.cfg b/src/mailman/config/schema.cfg
index 37322418a..b75b26a71 100644
--- a/src/mailman/config/schema.cfg
+++ b/src/mailman/config/schema.cfg
@@ -577,12 +577,16 @@ configuration: python:mailman.config.mail_archive
class: mailman.archiving.prototype.Prototype
-[style.master]
-# The style's priority, with 0 being the lowest priority.
-priority: 0
+[styles]
+# Python import paths inside which components are searched for which implement
+# the IStyle interface. Use one path per line.
+paths:
+ mailman.styles
-# The class implementing the IStyle interface, which applies the style.
-class: mailman.styles.default.DefaultStyle
+# The default style to apply if nothing else was requested. The value is the
+# name of an existing style. If no such style exists, no style will be
+# applied.
+default: legacy-default
[digests]
diff --git a/src/mailman/core/chains.py b/src/mailman/core/chains.py
index 61807a6c0..a02cfa972 100644
--- a/src/mailman/core/chains.py
+++ b/src/mailman/core/chains.py
@@ -28,10 +28,10 @@ __all__ = [
from zope.interface.verify import verifyObject
-from mailman.app.finder import find_components
from mailman.chains.base import Chain, TerminalChainBase
from mailman.config import config
from mailman.interfaces.chain import LinkAction, IChain
+from mailman.utilities.modules import find_components
diff --git a/src/mailman/core/pipelines.py b/src/mailman/core/pipelines.py
index 972417c2c..fe007b9e2 100644
--- a/src/mailman/core/pipelines.py
+++ b/src/mailman/core/pipelines.py
@@ -36,12 +36,13 @@ from zope.interface import implementer
from zope.interface.verify import verifyObject
from mailman.app.bounces import bounce_message
-from mailman.app.finder import find_components
from mailman.config import config
from mailman.core import errors
from mailman.core.i18n import _
from mailman.interfaces.handler import IHandler
from mailman.interfaces.pipeline import IPipeline
+from mailman.utilities.modules import find_components
+
dlog = logging.getLogger('mailman.debug')
vlog = logging.getLogger('mailman.vette')
diff --git a/src/mailman/core/rules.py b/src/mailman/core/rules.py
index 6aac0a64b..8a7ce2b4a 100644
--- a/src/mailman/core/rules.py
+++ b/src/mailman/core/rules.py
@@ -27,9 +27,9 @@ __all__ = [
from zope.interface.verify import verifyObject
-from mailman.app.finder import find_components
from mailman.config import config
from mailman.interfaces.rules import IRule
+from mailman.utilities.modules import find_components
diff --git a/src/mailman/database/schema/mm_20121015000000.py b/src/mailman/database/schema/mm_20121015000000.py
index 51e0602e7..dc688d33b 100644
--- a/src/mailman/database/schema/mm_20121015000000.py
+++ b/src/mailman/database/schema/mm_20121015000000.py
@@ -17,7 +17,12 @@
"""3.0b2 -> 3.0b3 schema migrations.
-* bans.mailing_list -> bans.list_id
+Renamed:
+ * bans.mailing_list -> bans.list_id
+
+Removed:
+ * mailinglist.new_member_options
+ * mailinglist.send_remindersn
"""
from __future__ import absolute_import, print_function, unicode_literals
@@ -65,9 +70,12 @@ def upgrade_sqlite(database, store, version, module_path):
UPDATE ban_backup SET list_id = '{0}'
WHERE id = {1};
""".format(_make_listid(mailing_list), id))
- # Pivot the backup table to the real thing.
+ # Pivot the bans backup table to the real thing.
store.execute('DROP TABLE ban;')
store.execute('ALTER TABLE ban_backup RENAME TO ban;')
+ # Pivot the mailinglist backup table to the real thing.
+ store.execute('DROP TABLE mailinglist;')
+ store.execute('ALTER TABLE ml_backup RENAME TO mailinglist;')
@@ -84,5 +92,14 @@ def upgrade_postgres(database, store, version, module_path):
WHERE id = {1};
""".format(_make_listid(mailing_list), id))
store.execute('ALTER TABLE ban DROP COLUMN mailing_list;')
+ store.execute('ALTER TABLE mailinglist DROP COLUMN new_member_options;')
+ store.execute('ALTER TABLE mailinglist DROP COLUMN send_reminders;')
+ store.execute('ALTER TABLE mailinglist DROP COLUMN subscribe_policy;')
+ store.execute('ALTER TABLE mailinglist DROP COLUMN unsubscribe_policy;')
+ store.execute(
+ 'ALTER TABLE mailinglist DROP COLUMN subscribe_auto_approval;')
+ store.execute('ALTER TABLE mailinglist DROP COLUMN private_roster;')
+ store.execute(
+ 'ALTER TABLE mailinglist DROP COLUMN admin_member_chunksize;')
# Record the migration in the version table.
database.load_schema(store, version, None, module_path)
diff --git a/src/mailman/database/schema/sqlite_20120407000000_01.sql b/src/mailman/database/schema/sqlite_20120407000000_01.sql
index 53bab70dd..5eacc4047 100644
--- a/src/mailman/database/schema/sqlite_20120407000000_01.sql
+++ b/src/mailman/database/schema/sqlite_20120407000000_01.sql
@@ -34,7 +34,7 @@
-- LP: #1024509
-CREATE TABLE ml_backup(
+CREATE TABLE ml_backup (
id INTEGER NOT NULL,
-- List identity
list_name TEXT,
diff --git a/src/mailman/database/schema/sqlite_20121015000000_01.sql b/src/mailman/database/schema/sqlite_20121015000000_01.sql
index c0df75111..3e2410c3b 100644
--- a/src/mailman/database/schema/sqlite_20121015000000_01.sql
+++ b/src/mailman/database/schema/sqlite_20121015000000_01.sql
@@ -20,3 +20,211 @@ INSERT INTO ban_backup SELECT
FROM ban;
ALTER TABLE ban_backup ADD COLUMN list_id TEXT;
+
+-- REMOVALS from the mailinglist table.
+-- REM new_member_options
+-- REM send_reminders
+-- REM subscribe_policy
+-- REM unsubscribe_policy
+-- REM subscribe_auto_approval
+-- REM private_roster
+-- REM admin_member_chunksize
+
+CREATE TABLE ml_backup (
+ id INTEGER NOT NULL,
+ list_name TEXT,
+ mail_host TEXT,
+ allow_list_posts BOOLEAN,
+ include_rfc2369_headers BOOLEAN,
+ created_at TIMESTAMP,
+ next_request_id INTEGER,
+ next_digest_number INTEGER,
+ digest_last_sent_at TIMESTAMP,
+ volume INTEGER,
+ last_post_at TIMESTAMP,
+ accept_these_nonmembers BLOB,
+ acceptable_aliases_id INTEGER,
+ admin_immed_notify BOOLEAN,
+ admin_notify_mchanges BOOLEAN,
+ administrivia BOOLEAN,
+ advertised BOOLEAN,
+ anonymous_list BOOLEAN,
+ autorespond_owner INTEGER,
+ autoresponse_owner_text TEXT,
+ autorespond_postings INTEGER,
+ autoresponse_postings_text TEXT,
+ autorespond_requests INTEGER,
+ autoresponse_request_text TEXT,
+ autoresponse_grace_period TEXT,
+ forward_unrecognized_bounces_to INTEGER,
+ process_bounces BOOLEAN,
+ bounce_info_stale_after TEXT,
+ bounce_matching_headers TEXT,
+ bounce_notify_owner_on_disable BOOLEAN,
+ bounce_notify_owner_on_removal BOOLEAN,
+ bounce_score_threshold INTEGER,
+ bounce_you_are_disabled_warnings INTEGER,
+ bounce_you_are_disabled_warnings_interval TEXT,
+ filter_action INTEGER,
+ filter_content BOOLEAN,
+ collapse_alternatives BOOLEAN,
+ convert_html_to_plaintext BOOLEAN,
+ default_member_action INTEGER,
+ default_nonmember_action INTEGER,
+ description TEXT,
+ digest_footer_uri TEXT,
+ digest_header_uri TEXT,
+ digest_is_default BOOLEAN,
+ digest_send_periodic BOOLEAN,
+ digest_size_threshold FLOAT,
+ digest_volume_frequency INTEGER,
+ digestable BOOLEAN,
+ discard_these_nonmembers BLOB,
+ emergency BOOLEAN,
+ encode_ascii_prefixes BOOLEAN,
+ first_strip_reply_to BOOLEAN,
+ footer_uri TEXT,
+ forward_auto_discards BOOLEAN,
+ gateway_to_mail BOOLEAN,
+ gateway_to_news BOOLEAN,
+ goodbye_message_uri TEXT,
+ header_matches BLOB,
+ header_uri TEXT,
+ hold_these_nonmembers BLOB,
+ info TEXT,
+ linked_newsgroup TEXT,
+ max_days_to_hold INTEGER,
+ max_message_size INTEGER,
+ max_num_recipients INTEGER,
+ member_moderation_notice TEXT,
+ mime_is_default_digest BOOLEAN,
+ moderator_password TEXT,
+ nondigestable BOOLEAN,
+ nonmember_rejection_notice TEXT,
+ obscure_addresses BOOLEAN,
+ owner_chain TEXT,
+ owner_pipeline TEXT,
+ personalize INTEGER,
+ post_id INTEGER,
+ posting_chain TEXT,
+ posting_pipeline TEXT,
+ preferred_language TEXT,
+ display_name TEXT,
+ reject_these_nonmembers BLOB,
+ reply_goes_to_list INTEGER,
+ reply_to_address TEXT,
+ require_explicit_destination BOOLEAN,
+ respond_to_post_requests BOOLEAN,
+ scrub_nondigest BOOLEAN,
+ send_goodbye_message BOOLEAN,
+ send_welcome_message BOOLEAN,
+ subject_prefix TEXT,
+ topics BLOB,
+ topics_bodylines_limit INTEGER,
+ topics_enabled BOOLEAN,
+ welcome_message_uri TEXT,
+ archive_policy INTEGER,
+ list_id TEXT,
+ nntp_prefix_subject_too INTEGER,
+ newsgroup_moderation INTEGER,
+ PRIMARY KEY (id)
+ );
+
+INSERT INTO ml_backup SELECT
+ id,
+ list_name,
+ mail_host,
+ allow_list_posts,
+ include_rfc2369_headers,
+ created_at,
+ next_request_id,
+ next_digest_number,
+ digest_last_sent_at,
+ volume,
+ last_post_at,
+ accept_these_nonmembers,
+ acceptable_aliases_id,
+ admin_immed_notify,
+ admin_notify_mchanges,
+ administrivia,
+ advertised,
+ anonymous_list,
+ autorespond_owner,
+ autoresponse_owner_text,
+ autorespond_postings,
+ autoresponse_postings_text,
+ autorespond_requests,
+ autoresponse_request_text,
+ autoresponse_grace_period,
+ forward_unrecognized_bounces_to,
+ process_bounces,
+ bounce_info_stale_after,
+ bounce_matching_headers,
+ bounce_notify_owner_on_disable,
+ bounce_notify_owner_on_removal,
+ bounce_score_threshold,
+ bounce_you_are_disabled_warnings,
+ bounce_you_are_disabled_warnings_interval,
+ filter_action,
+ filter_content,
+ collapse_alternatives,
+ convert_html_to_plaintext,
+ default_member_action,
+ default_nonmember_action,
+ description,
+ digest_footer_uri,
+ digest_header_uri,
+ digest_is_default,
+ digest_send_periodic,
+ digest_size_threshold,
+ digest_volume_frequency,
+ digestable,
+ discard_these_nonmembers,
+ emergency,
+ encode_ascii_prefixes,
+ first_strip_reply_to,
+ footer_uri,
+ forward_auto_discards,
+ gateway_to_mail,
+ gateway_to_news,
+ goodbye_message_uri,
+ header_matches,
+ header_uri,
+ hold_these_nonmembers,
+ info,
+ linked_newsgroup,
+ max_days_to_hold,
+ max_message_size,
+ max_num_recipients,
+ member_moderation_notice,
+ mime_is_default_digest,
+ moderator_password,
+ nondigestable,
+ nonmember_rejection_notice,
+ obscure_addresses,
+ owner_chain,
+ owner_pipeline,
+ personalize,
+ post_id,
+ posting_chain,
+ posting_pipeline,
+ preferred_language,
+ display_name,
+ reject_these_nonmembers,
+ reply_goes_to_list,
+ reply_to_address,
+ require_explicit_destination,
+ respond_to_post_requests,
+ scrub_nondigest,
+ send_goodbye_message,
+ send_welcome_message,
+ subject_prefix,
+ topics,
+ topics_bodylines_limit,
+ topics_enabled,
+ welcome_message_uri,
+ archive_policy,
+ list_id,
+ nntp_prefix_subject_too,
+ newsgroup_moderation
+ FROM mailinglist;
diff --git a/src/mailman/database/tests/test_migrations.py b/src/mailman/database/tests/test_migrations.py
index 4401b030f..4b784d8b3 100644
--- a/src/mailman/database/tests/test_migrations.py
+++ b/src/mailman/database/tests/test_migrations.py
@@ -24,6 +24,8 @@ __all__ = [
'TestMigration20120407MigratedData',
'TestMigration20120407Schema',
'TestMigration20120407UnchangedData',
+ 'TestMigration20121015MigratedData',
+ 'TestMigration20121015Schema',
]
@@ -151,8 +153,10 @@ class TestMigration20120407UnchangedData(MigrationTestBase):
'mailman.database.tests.data',
'migration_{0}_1.sql'.format(self._database.TAG))
self._database.load_sql(self._database.store, sample_data)
- # Update to the current migration we're testing.
- self._database.load_migrations('20120407000000')
+ # XXX 2012-12-28: We have to load the last migration defined in the
+ # system, otherwise the ORM model will not match the SQL table
+ # definitions and we'll get OperationalErrors from SQLite.
+ self._database.load_migrations('20121015000000')
def test_migration_domains(self):
# Test that the domains table, which isn't touched, doesn't change.
@@ -216,8 +220,10 @@ class TestMigration20120407MigratedData(MigrationTestBase):
self._database.load_sql(self._database.store, sample_data)
def _upgrade(self):
- # Update to the current migration we're testing.
- self._database.load_migrations('20120407000000')
+ # XXX 2012-12-28: We have to load the last migration defined in the
+ # system, otherwise the ORM model will not match the SQL table
+ # definitions and we'll get OperationalErrors from SQLite.
+ self._database.load_migrations('20121015000000')
def test_migration_archive_policy_never_0(self):
# Test that the new archive_policy value is updated correctly. In the
@@ -369,6 +375,14 @@ class TestMigration20121015Schema(MigrationTestBase):
['20121014999999'],
('list_id',),
('mailing_list',))
+ self._missing_present('mailinglist',
+ ['20121014999999'],
+ (),
+ ('new_member_options', 'send_reminders',
+ 'subscribe_policy', 'unsubscribe_policy',
+ 'subscribe_auto_approval', 'private_roster',
+ 'admin_member_chunksize'),
+ )
def test_post_upgrade_column_migrations(self):
self._missing_present('ban',
@@ -376,8 +390,17 @@ class TestMigration20121015Schema(MigrationTestBase):
'20121015000000'],
('mailing_list',),
('list_id',))
+ self._missing_present('mailinglist',
+ ['20121014999999',
+ '20121015000000'],
+ ('new_member_options', 'send_reminders',
+ 'subscribe_policy', 'unsubscribe_policy',
+ 'subscribe_auto_approval', 'private_roster',
+ 'admin_member_chunksize'),
+ ())
+
class TestMigration20121015MigratedData(MigrationTestBase):
"""Test non-migrated data."""
diff --git a/src/mailman/docs/NEWS.rst b/src/mailman/docs/NEWS.rst
index aad8f37ff..8ddfd0e9f 100644
--- a/src/mailman/docs/NEWS.rst
+++ b/src/mailman/docs/NEWS.rst
@@ -14,7 +14,7 @@ Here is a history of user visible changes to Mailman.
Compatibility
-------------
- * Python 2.7 is not required. Python 2.6 is no longer officially supported.
+ * Python 2.7 is now required. Python 2.6 is no longer officially supported.
The code base is now also `python2.7 -3` clean, although there are still
some warnings in 3rd party dependencies. (LP: #1073506)
@@ -33,6 +33,9 @@ REST
- `_mod_sender` -> `sender`
- `_mod_message_id` -> `message_id`
+ * List styles are supported through the REST API. Get the list of available
+ styles (by name) via `.../lists/styles`. Create a list in a specific style
+ by using POST data `style_name=<style>`. (LP: #975692)
* Allow the getting/setting of IMailingList.subject_prefix via the REST API
(given by Terri Oda). (LP: #1062893)
* Expose a REST API for membership change (subscriptions and unsubscriptions)
@@ -65,11 +68,23 @@ Configuration
separate file, named by the `[mta]configuration` variable.
* In the new `postfix.cfg` file, `postfix_map_cmd` is renamed to
`postmap_command`.
+ * The default list style is renamed to `legacy-default` and a new
+ `legacy-announce` style is added. This is similar to the `legacy-default`
+ except set up for announce-only lists.
Database
--------
* The `ban` table now uses list-ids to cross-reference the mailing list,
since these cannot change even if the mailing list is moved or renamed.
+ * The following columns were unused and have been removed:
+
+ - `mailinglist.new_member_options`
+ - `mailinglist.send_reminders`
+ - `mailinglist.subscribe_policy`
+ - `mailinglist.unsubscribe_policy`
+ - `mailinglist.subscribe_auto_approval`
+ - `mailinglist.private_roster`
+ - `mailinglist.admin_member_chunksize`
Interfaces
----------
diff --git a/src/mailman/interfaces/styles.py b/src/mailman/interfaces/styles.py
index 8dfd52145..4a7b69dcb 100644
--- a/src/mailman/interfaces/styles.py
+++ b/src/mailman/interfaces/styles.py
@@ -43,9 +43,6 @@ class IStyle(Interface):
name = Attribute(
"""The name of this style. Must be unique.""")
- priority = Attribute(
- """The priority of this style, as an integer.""")
-
def apply(mailing_list):
"""Apply the style to the mailing list.
@@ -53,50 +50,28 @@ class IStyle(Interface):
:param mailing_list: the mailing list to apply the style to.
"""
- def match(mailing_list, styles):
- """Give this style a chance to match the mailing list.
-
- If the style's internal matching rules match the `mailing_list`, then
- the style may append itself to the `styles` list. This list will be
- ordered when returned from `IStyleManager.lookup()`.
-
- :type mailing_list: `IMailingList`.
- :param mailing_list: the mailing list object.
- :param styles: ordered list of `IStyles` matched so far.
- """
-
-
class IStyleManager(Interface):
- """A manager of styles and style chains."""
+ """A manager of styles."""
def get(name):
"""Return the named style or None.
- :type name: Unicode
+ :type name: string
:param name: A style name.
:return: the named `IStyle` or None if the style doesn't exist.
"""
- def lookup(mailing_list):
- """Return a list of styles for the given mailing list.
-
- Use various registered rules to find an `IStyle` for the given mailing
- list. The returned styles are ordered by their priority.
+ styles = Attribute(
+ 'An iterator over all the styles known by this manager.')
- Style matches can be registered and reordered by plugins.
+ def populate():
+ """Populate the styles from the configuration files.
- :type mailing_list: `IMailingList`.
- :param mailing_list: The mailing list object to find a style for.
- :return: ordered list of `IStyles`. Zero is the lowest priority.
+ This clears the current set of styles and resets them from those
+ defined in the configuration files.
"""
- styles = Attribute(
- """An iterator over all the styles known by this manager.
-
- Styles are ordered by their priority, which may be changed.
- """)
-
def register(style):
"""Register a style with this manager.
@@ -111,10 +86,3 @@ class IStyleManager(Interface):
:param style: an IStyle.
:raises KeyError: If the style's name is not currently registered.
"""
-
- def populate():
- """Populate the styles from the configuration files.
-
- This clears the current set of styles and resets them from those
- defined in the configuration files.
- """
diff --git a/src/mailman/model/mailinglist.py b/src/mailman/model/mailinglist.py
index 324e1a1c8..1ffef716c 100644
--- a/src/mailman/model/mailinglist.py
+++ b/src/mailman/model/mailinglist.py
@@ -26,7 +26,6 @@ __all__ = [
import os
-import string
from storm.locals import (
And, Bool, DateTime, Float, Int, Pickle, RawStr, Reference, Store,
@@ -89,7 +88,6 @@ class MailingList(Model):
anonymous_list = Bool()
# Attributes not directly modifiable via the web u/i
created_at = DateTime()
- admin_member_chunksize = Int()
# Attributes which are directly modifiable via the web u/i. The more
# complicated attributes are currently stored as pickles, though that
# will change as the schema and implementation is developed.
@@ -163,7 +161,6 @@ class MailingList(Model):
member_moderation_notice = Unicode()
mime_is_default_digest = Bool()
moderator_password = RawStr()
- new_member_options = Int()
newsgroup_moderation = Enum(NewsgroupModeration)
nntp_prefix_subject_too = Bool()
nondigestable = Bool()
@@ -176,7 +173,6 @@ class MailingList(Model):
posting_chain = Unicode()
posting_pipeline = Unicode()
_preferred_language = Unicode(name='preferred_language')
- private_roster = Bool()
display_name = Unicode()
reject_these_nonmembers = Pickle()
reply_goes_to_list = Enum(ReplyToMunging)
@@ -185,15 +181,11 @@ class MailingList(Model):
respond_to_post_requests = Bool()
scrub_nondigest = Bool()
send_goodbye_message = Bool()
- send_reminders = Bool()
send_welcome_message = Bool()
subject_prefix = Unicode()
- subscribe_auto_approval = Pickle()
- subscribe_policy = Int()
topics = Pickle()
topics_bodylines_limit = Int()
topics_enabled = Bool()
- unsubscribe_policy = Int()
welcome_message_uri = Unicode()
def __init__(self, fqdn_listname):
@@ -210,9 +202,6 @@ class MailingList(Model):
# that's not the case when the constructor is called. So, set up the
# rosters explicitly.
self.__storm_loaded__()
- self.personalize = Personalization.none
- self.display_name = string.capwords(
- SPACE.join(listname.split(UNDERSCORE)))
makedirs(self.data_path)
def __storm_loaded__(self):
diff --git a/src/mailman/rest/docs/configuration.rst b/src/mailman/rest/docs/configuration.rst
index 01b6e0700..27560a026 100644
--- a/src/mailman/rest/docs/configuration.rst
+++ b/src/mailman/rest/docs/configuration.rst
@@ -4,7 +4,7 @@ Mailing list configuration
Mailing lists can be configured via the REST API.
- >>> mlist = create_list('test-one@example.com')
+ >>> mlist = create_list('ant@example.com')
>>> transaction.commit()
@@ -13,8 +13,7 @@ Reading a configuration
All readable attributes for a list are available on a sub-resource.
- >>> dump_json('http://localhost:9001/3.0/lists/'
- ... 'test-one@example.com/config')
+ >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/config')
acceptable_aliases: []
admin_immed_notify: True
admin_notify_mchanges: False
@@ -30,7 +29,7 @@ All readable attributes for a list are available on a sub-resource.
autoresponse_owner_text:
autoresponse_postings_text:
autoresponse_request_text:
- bounces_address: test-one-bounces@example.com
+ bounces_address: ant-bounces@example.com
collapse_alternatives: True
convert_html_to_plaintext: False
created_at: 20...T...
@@ -39,27 +38,27 @@ All readable attributes for a list are available on a sub-resource.
description:
digest_last_sent_at: None
digest_size_threshold: 30.0
- display_name: Test-one
+ display_name: Ant
filter_content: False
- fqdn_listname: test-one@example.com
+ fqdn_listname: ant@example.com
http_etag: "..."
include_rfc2369_headers: True
- join_address: test-one-join@example.com
+ join_address: ant-join@example.com
last_post_at: None
- leave_address: test-one-leave@example.com
- list_name: test-one
+ leave_address: ant-leave@example.com
+ list_name: ant
mail_host: example.com
next_digest_number: 1
no_reply_address: noreply@example.com
- owner_address: test-one-owner@example.com
+ owner_address: ant-owner@example.com
post_id: 1
- posting_address: test-one@example.com
+ posting_address: ant@example.com
posting_pipeline: default-posting-pipeline
reply_goes_to_list: no_munging
- request_address: test-one-request@example.com
+ request_address: ant-request@example.com
scheme: http
send_welcome_message: True
- subject_prefix: [Test-one]
+ subject_prefix: [Ant]
volume: 1
web_host: lists.example.com
welcome_message_uri: mailman:///welcome.txt
@@ -74,7 +73,7 @@ all the writable attributes in one request.
>>> from mailman.interfaces.action import Action
>>> dump_json('http://localhost:9001/3.0/lists/'
- ... 'test-one@example.com/config',
+ ... 'ant@example.com/config',
... dict(
... acceptable_aliases=['one@example.com', 'two@example.com'],
... admin_immed_notify=False,
@@ -101,7 +100,7 @@ all the writable attributes in one request.
... collapse_alternatives=False,
... reply_goes_to_list='point_to_list',
... send_welcome_message=False,
- ... subject_prefix='[test-one]',
+ ... subject_prefix='[ant]',
... welcome_message_uri='mailman:///welcome.txt',
... default_member_action='hold',
... default_nonmember_action='discard',
@@ -115,8 +114,8 @@ all the writable attributes in one request.
These values are changed permanently.
>>> dump_json('http://localhost:9001/3.0/lists/'
- ... 'test-one@example.com/config')
- acceptable_aliases: [u'one@example.com', u'two@example.com']
+ ... 'ant@example.com/config')
+ acceptable_aliases: ['one@example.com', 'two@example.com']
admin_immed_notify: False
admin_notify_mchanges: True
administrivia: False
@@ -149,7 +148,7 @@ These values are changed permanently.
reply_goes_to_list: point_to_list
...
send_welcome_message: False
- subject_prefix: [test-one]
+ subject_prefix: [ant]
...
welcome_message_uri: mailman:///welcome.txt
@@ -157,7 +156,7 @@ If you use ``PUT`` to change a list's configuration, all writable attributes
must be included. It is an error to leave one or more out...
>>> dump_json('http://localhost:9001/3.0/lists/'
- ... 'test-one@example.com/config',
+ ... 'ant@example.com/config',
... dict(
... #acceptable_aliases=['one', 'two'],
... admin_immed_notify=False,
@@ -184,7 +183,7 @@ must be included. It is an error to leave one or more out...
... collapse_alternatives=False,
... reply_goes_to_list='point_to_list',
... send_welcome_message=True,
- ... subject_prefix='[test-one]',
+ ... subject_prefix='[ant]',
... welcome_message_uri='welcome message',
... default_member_action='accept',
... default_nonmember_action='accept',
@@ -197,7 +196,7 @@ must be included. It is an error to leave one or more out...
...or to add an unknown one.
>>> dump_json('http://localhost:9001/3.0/lists/'
- ... 'test-one@example.com/config',
+ ... 'ant@example.com/config',
... dict(
... a_mailing_list_attribute=False,
... acceptable_aliases=['one', 'two'],
@@ -220,7 +219,7 @@ must be included. It is an error to leave one or more out...
... allow_list_posts=False,
... digest_size_threshold=10.5,
... posting_pipeline='virgin',
- ... subject_prefix='[test-one]',
+ ... subject_prefix='[ant]',
... filter_content=True,
... convert_html_to_plaintext=True,
... collapse_alternatives=False,
@@ -233,7 +232,7 @@ must be included. It is an error to leave one or more out...
It is also an error to spell an attribute value incorrectly...
>>> dump_json('http://localhost:9001/3.0/lists/'
- ... 'test-one@example.com/config',
+ ... 'ant@example.com/config',
... dict(
... admin_immed_notify='Nope',
... acceptable_aliases=['one', 'two'],
@@ -254,7 +253,7 @@ It is also an error to spell an attribute value incorrectly...
... allow_list_posts=False,
... digest_size_threshold=10.5,
... posting_pipeline='virgin',
- ... subject_prefix='[test-one]',
+ ... subject_prefix='[ant]',
... filter_content=True,
... convert_html_to_plaintext=True,
... collapse_alternatives=False,
@@ -267,7 +266,7 @@ It is also an error to spell an attribute value incorrectly...
...or to name a pipeline that doesn't exist...
>>> dump_json('http://localhost:9001/3.0/lists/'
- ... 'test-one@example.com/config',
+ ... 'ant@example.com/config',
... dict(
... acceptable_aliases=['one', 'two'],
... admin_immed_notify=False,
@@ -288,7 +287,7 @@ It is also an error to spell an attribute value incorrectly...
... allow_list_posts=False,
... digest_size_threshold=10.5,
... posting_pipeline='dummy',
- ... subject_prefix='[test-one]',
+ ... subject_prefix='[ant]',
... filter_content=True,
... convert_html_to_plaintext=True,
... collapse_alternatives=False,
@@ -301,7 +300,7 @@ It is also an error to spell an attribute value incorrectly...
...or to name an invalid auto-response enumeration value.
>>> dump_json('http://localhost:9001/3.0/lists/'
- ... 'test-one@example.com/config',
+ ... 'ant@example.com/config',
... dict(
... acceptable_aliases=['one', 'two'],
... admin_immed_notify=False,
@@ -321,7 +320,7 @@ It is also an error to spell an attribute value incorrectly...
... allow_list_posts=False,
... digest_size_threshold=10.5,
... posting_pipeline='virgin',
- ... subject_prefix='[test-one]',
+ ... subject_prefix='[ant]',
... filter_content=True,
... convert_html_to_plaintext=True,
... collapse_alternatives=False,
@@ -338,7 +337,7 @@ Changing a partial configuration
Using ``PATCH``, you can change just one attribute.
>>> dump_json('http://localhost:9001/3.0/lists/'
- ... 'test-one@example.com/config',
+ ... 'ant@example.com/config',
... dict(display_name='My List'),
... 'PATCH')
content-length: 0
@@ -372,7 +371,7 @@ emails. By default, a mailing list has no acceptable aliases.
>>> IAcceptableAliasSet(mlist).clear()
>>> transaction.commit()
>>> dump_json('http://localhost:9001/3.0/lists/'
- ... 'test-one@example.com/config/acceptable_aliases')
+ ... 'ant@example.com/config/acceptable_aliases')
acceptable_aliases: []
http_etag: "..."
@@ -380,7 +379,7 @@ We can add a few by ``PUT``-ing them on the sub-resource. The keys in the
dictionary are ignored.
>>> dump_json('http://localhost:9001/3.0/lists/'
- ... 'test-one@example.com/config/acceptable_aliases',
+ ... 'ant@example.com/config/acceptable_aliases',
... dict(acceptable_aliases=['foo@example.com',
... 'bar@example.net']),
... 'PUT')
@@ -393,7 +392,7 @@ Aliases are returned as a list on the ``aliases`` key.
>>> response = call_http(
... 'http://localhost:9001/3.0/lists/'
- ... 'test-one@example.com/config/acceptable_aliases')
+ ... 'ant@example.com/config/acceptable_aliases')
>>> for alias in response['acceptable_aliases']:
... print alias
bar@example.net
diff --git a/src/mailman/rest/docs/lists.rst b/src/mailman/rest/docs/lists.rst
index 0c4bbc419..5c65a7951 100644
--- a/src/mailman/rest/docs/lists.rst
+++ b/src/mailman/rest/docs/lists.rst
@@ -14,48 +14,41 @@ yet though.
Create a mailing list in a domain and it's accessible via the API.
::
- >>> create_list('test-one@example.com')
- <mailing list "test-one@example.com" at ...>
+ >>> mlist = create_list('ant@example.com')
>>> transaction.commit()
>>> dump_json('http://localhost:9001/3.0/lists')
entry 0:
- display_name: Test-one
- fqdn_listname: test-one@example.com
+ display_name: Ant
+ fqdn_listname: ant@example.com
http_etag: "..."
- list_id: test-one.example.com
- list_name: test-one
+ list_id: ant.example.com
+ list_name: ant
mail_host: example.com
member_count: 0
- self_link: http://localhost:9001/3.0/lists/test-one.example.com
+ self_link: http://localhost:9001/3.0/lists/ant.example.com
volume: 1
http_etag: "..."
start: 0
total_size: 1
You can also query for lists from a particular domain.
-::
>>> dump_json('http://localhost:9001/3.0/domains/example.com/lists')
entry 0:
- display_name: Test-one
- fqdn_listname: test-one@example.com
+ display_name: Ant
+ fqdn_listname: ant@example.com
http_etag: "..."
- list_id: test-one.example.com
- list_name: test-one
+ list_id: ant.example.com
+ list_name: ant
mail_host: example.com
member_count: 0
- self_link: http://localhost:9001/3.0/lists/test-one.example.com
+ self_link: http://localhost:9001/3.0/lists/ant.example.com
volume: 1
http_etag: "..."
start: 0
total_size: 1
- >>> dump_json('http://localhost:9001/3.0/domains/no.example.org/lists')
- Traceback (most recent call last):
- ...
- HTTPError: HTTP Error 404: 404 Not Found
-
Creating lists via the API
==========================
@@ -64,11 +57,11 @@ New mailing lists can also be created through the API, by posting to the
``lists`` URL.
>>> dump_json('http://localhost:9001/3.0/lists', {
- ... 'fqdn_listname': 'test-two@example.com',
+ ... 'fqdn_listname': 'bee@example.com',
... })
content-length: 0
date: ...
- location: http://localhost:9001/3.0/lists/test-two.example.com
+ location: http://localhost:9001/3.0/lists/bee.example.com
...
The mailing list exists in the database.
@@ -78,24 +71,29 @@ The mailing list exists in the database.
>>> from zope.component import getUtility
>>> list_manager = getUtility(IListManager)
- >>> list_manager.get('test-two@example.com')
- <mailing list "test-two@example.com" at ...>
+ >>> bee = list_manager.get('bee@example.com')
+ >>> bee
+ <mailing list "bee@example.com" at ...>
+
+The mailing list was created using the default style, which allows list posts.
- # The above starts a Storm transaction, which will lock the database
- # unless we abort it.
+ >>> bee.allow_list_posts
+ True
+
+.. Abort the Storm transaction.
>>> transaction.abort()
-It is also available via the location given in the response.
+It is also available in the REST API via the location given in the response.
- >>> dump_json('http://localhost:9001/3.0/lists/test-two.example.com')
- display_name: Test-two
- fqdn_listname: test-two@example.com
+ >>> dump_json('http://localhost:9001/3.0/lists/bee.example.com')
+ display_name: Bee
+ fqdn_listname: bee@example.com
http_etag: "..."
- list_id: test-two.example.com
- list_name: test-two
+ list_id: bee.example.com
+ list_name: bee
mail_host: example.com
member_count: 0
- self_link: http://localhost:9001/3.0/lists/test-two.example.com
+ self_link: http://localhost:9001/3.0/lists/bee.example.com
volume: 1
Normally, you access the list via its RFC 2369 list-id as shown above, but for
@@ -103,35 +101,52 @@ backward compatibility purposes, you can also access it via the list's posting
address, if that has never been changed (since the list-id is immutable, but
the posting address is not).
- >>> dump_json('http://localhost:9001/3.0/lists/test-two@example.com')
- display_name: Test-two
- fqdn_listname: test-two@example.com
+ >>> dump_json('http://localhost:9001/3.0/lists/bee@example.com')
+ display_name: Bee
+ fqdn_listname: bee@example.com
http_etag: "..."
- list_id: test-two.example.com
- list_name: test-two
+ list_id: bee.example.com
+ list_name: bee
mail_host: example.com
member_count: 0
- self_link: http://localhost:9001/3.0/lists/test-two.example.com
+ self_link: http://localhost:9001/3.0/lists/bee.example.com
volume: 1
-However, you are not allowed to create a mailing list in a domain that does
-not exist.
- >>> dump_json('http://localhost:9001/3.0/lists', {
- ... 'fqdn_listname': 'test-three@example.org',
- ... })
- Traceback (most recent call last):
- ...
- HTTPError: HTTP Error 400: Domain does not exist example.org
+Apply a style at list creation time
+-----------------------------------
+
+:ref:`List styles <list-styles>` allow you to more easily create mailing lists
+of a particular type, e.g. discussion lists. We can see which styles are
+available, and which is the default style.
+
+ >>> dump_json('http://localhost:9001/3.0/lists/styles')
+ default: legacy-default
+ http_etag: "..."
+ style_names: ['legacy-announce', 'legacy-default']
-Nor can you create a mailing list that already exists.
+When creating a list, if we don't specify a style to apply, the default style
+is used. However, we can provide a style name in the POST data to choose a
+different style.
>>> dump_json('http://localhost:9001/3.0/lists', {
- ... 'fqdn_listname': 'test-one@example.com',
+ ... 'fqdn_listname': 'cat@example.com',
+ ... 'style_name': 'legacy-announce',
... })
- Traceback (most recent call last):
+ content-length: 0
+ date: ...
+ location: http://localhost:9001/3.0/lists/cat.example.com
...
- HTTPError: HTTP Error 400: Mailing list exists
+
+We can tell that the list was created using the `legacy-announce` style,
+because announce lists don't allow posting by the general public.
+
+ >>> cat = list_manager.get('cat@example.com')
+ >>> cat.allow_list_posts
+ False
+
+.. Abort the Storm transaction.
+ >>> transaction.abort()
Deleting lists via the API
@@ -141,7 +156,7 @@ Existing mailing lists can be deleted through the API, by doing an HTTP
``DELETE`` on the mailing list URL.
::
- >>> dump_json('http://localhost:9001/3.0/lists/test-two.example.com',
+ >>> dump_json('http://localhost:9001/3.0/lists/bee.example.com',
... method='DELETE')
content-length: 0
date: ...
@@ -150,32 +165,16 @@ Existing mailing lists can be deleted through the API, by doing an HTTP
The mailing list does not exist.
- >>> print list_manager.get('test-two@example.com')
+ >>> print list_manager.get('bee@example.com')
None
- # Unlock the database.
+.. Abort the Storm transaction.
>>> transaction.abort()
-You cannot delete a mailing list that does not exist or has already been
-deleted.
-::
-
- >>> dump_json('http://localhost:9001/3.0/lists/test-two.example.com',
- ... method='DELETE')
- Traceback (most recent call last):
- ...
- HTTPError: HTTP Error 404: 404 Not Found
-
- >>> dump_json('http://localhost:9001/3.0/lists/test-ten.example.com',
- ... method='DELETE')
- Traceback (most recent call last):
- ...
- HTTPError: HTTP Error 404: 404 Not Found
-
For backward compatibility purposes, you can delete a list via its posting
address as well.
- >>> dump_json('http://localhost:9001/3.0/lists/test-one@example.com',
+ >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com',
... method='DELETE')
content-length: 0
date: ...
@@ -184,5 +183,5 @@ address as well.
The mailing list does not exist.
- >>> print list_manager.get('test-one@example.com')
+ >>> print list_manager.get('ant@example.com')
None
diff --git a/src/mailman/rest/lists.py b/src/mailman/rest/lists.py
index 4a4b243b3..1fc31c6f0 100644
--- a/src/mailman/rest/lists.py
+++ b/src/mailman/rest/lists.py
@@ -197,12 +197,14 @@ class AllLists(_ListBase):
def create(self, request):
"""Create a new mailing list."""
try:
- validator = Validator(fqdn_listname=unicode)
+ validator = Validator(fqdn_listname=unicode,
+ style_name=unicode,
+ _optional=('style_name',))
mlist = create_list(**validator(request))
except ListAlreadyExistsError:
return http.bad_request([], b'Mailing list exists')
except BadDomainSpecificationError as error:
- return http.bad_request([], b'Domain does not exist {0}'.format(
+ return http.bad_request([], b'Domain does not exist: {0}'.format(
error.domain))
except ValueError as error:
return http.bad_request([], str(error))
diff --git a/src/mailman/rest/root.py b/src/mailman/rest/root.py
index ea1650e75..13ba68ea9 100644
--- a/src/mailman/rest/root.py
+++ b/src/mailman/rest/root.py
@@ -33,6 +33,7 @@ from mailman.config import config
from mailman.core.constants import system_preferences
from mailman.core.system import system
from mailman.interfaces.listmanager import IListManager
+from mailman.interfaces.styles import IStyleManager
from mailman.rest.addresses import AllAddresses, AnAddress
from mailman.rest.domains import ADomain, AllDomains
from mailman.rest.helpers import etag, path_to
@@ -122,6 +123,12 @@ class TopLevel(resource.Resource):
"""
if len(segments) == 0:
return AllLists()
+ elif len(segments) == 1 and segments[0] == 'styles':
+ manager = getUtility(IStyleManager)
+ style_names = sorted(style.name for style in manager.styles)
+ resource = dict(style_names=style_names,
+ default=config.styles.default)
+ return http.ok([], etag(resource))
else:
# list-id is preferred, but for backward compatibility,
# fqdn_listname is also accepted.
diff --git a/src/mailman/rest/tests/test_lists.py b/src/mailman/rest/tests/test_lists.py
index 9686ce6a8..913b40b6f 100644
--- a/src/mailman/rest/tests/test_lists.py
+++ b/src/mailman/rest/tests/test_lists.py
@@ -112,3 +112,50 @@ class TestLists(unittest.TestCase):
'http://localhost:9001/3.0/lists/test@example.com')
self.assertEqual(response.status, 200)
self.assertEqual(resource['member_count'], 2)
+
+ def test_query_for_lists_in_missing_domain(self):
+ # You cannot ask all the mailing lists in a non-existent domain.
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/domains/no.example.org/lists')
+ self.assertEqual(cm.exception.code, 404)
+
+ def test_cannot_create_list_in_missing_domain(self):
+ # You cannot create a mailing list in a domain that does not exist.
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/lists', {
+ 'fqdn_listname': 'ant@no-domain.example.org',
+ })
+ self.assertEqual(cm.exception.code, 400)
+ self.assertEqual(cm.exception.reason,
+ 'Domain does not exist: no-domain.example.org')
+
+ def test_cannot_create_duplicate_list(self):
+ # You cannot create a list that already exists.
+ call_api('http://localhost:9001/3.0/lists', {
+ 'fqdn_listname': 'ant@example.com',
+ })
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/lists', {
+ 'fqdn_listname': 'ant@example.com',
+ })
+ self.assertEqual(cm.exception.code, 400)
+ self.assertEqual(cm.exception.reason, 'Mailing list exists')
+
+ def test_cannot_delete_missing_list(self):
+ # You cannot delete a list that does not exist.
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/lists/bee.example.com',
+ method='DELETE')
+ self.assertEqual(cm.exception.code, 404)
+
+ def test_cannot_delete_already_deleted_list(self):
+ # You cannot delete a list twice.
+ call_api('http://localhost:9001/3.0/lists', {
+ 'fqdn_listname': 'ant@example.com',
+ })
+ call_api('http://localhost:9001/3.0/lists/ant.example.com',
+ method='DELETE')
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/lists/ant.example.com',
+ method='DELETE')
+ self.assertEqual(cm.exception.code, 404)
diff --git a/src/mailman/runners/tests/test_bounce.py b/src/mailman/runners/tests/test_bounce.py
index 5c21000bf..54bbdba4f 100644
--- a/src/mailman/runners/tests/test_bounce.py
+++ b/src/mailman/runners/tests/test_bounce.py
@@ -243,15 +243,11 @@ class TestStyle:
"""See `IStyle`."""
name = 'test'
- priority = 10
def apply(self, mailing_list):
"""See `IStyle`."""
mailing_list.preferred_language = 'en'
- def match(self, mailing_list, styles):
- styles.append(self)
-
class TestBounceRunnerBug876774(unittest.TestCase):
@@ -271,7 +267,7 @@ class TestBounceRunnerBug876774(unittest.TestCase):
self._style_manager = getUtility(IStyleManager)
self._style_manager.register(self._style)
# Now we can create the mailing list.
- self._mlist = create_list('test@example.com')
+ self._mlist = create_list('test@example.com', style_name='test')
self._bounceq = config.switchboards['bounces']
self._processor = getUtility(IBounceProcessor)
self._runner = make_testable_runner(BounceRunner, 'bounces')
diff --git a/src/mailman/styles/base.py b/src/mailman/styles/base.py
new file mode 100644
index 000000000..689fde1e1
--- /dev/null
+++ b/src/mailman/styles/base.py
@@ -0,0 +1,270 @@
+# Copyright (C) 2012 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/>.
+
+"""Building blocks for styles.
+
+Use these to compose higher level styles. Their apply() methods deliberately
+have no super() upcall. You need to explicitly call all base class apply()
+methods in your compositional derived class.
+"""
+
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'Announcement',
+ 'BasicOperation',
+ 'Bounces',
+ 'Discussion',
+ 'Identity',
+ 'Moderation',
+ 'Public',
+ ]
+
+
+from datetime import timedelta
+
+from mailman.core.i18n import _
+from mailman.interfaces.action import Action, FilterAction
+from mailman.interfaces.archiver import ArchivePolicy
+from mailman.interfaces.autorespond import ResponseAction
+from mailman.interfaces.bounce import UnrecognizedBounceDisposition
+from mailman.interfaces.digests import DigestFrequency
+from mailman.interfaces.mailinglist import Personalization, ReplyToMunging
+from mailman.interfaces.nntp import NewsgroupModeration
+
+
+
+class Identity:
+ """Set basic identify attributes."""
+
+ def apply(self, mailing_list):
+ # For cut-n-paste convenience.
+ mlist = mailing_list
+ mlist.display_name = mlist.list_name.capitalize()
+ mlist.include_rfc2369_headers = True
+ mlist.volume = 1
+ mlist.post_id = 1
+ mlist.description = ''
+ mlist.info = ''
+ mlist.preferred_language = 'en'
+ mlist.subject_prefix = _('[$mlist.display_name] ')
+ # Set this to Never if the list's preferred language uses us-ascii,
+ # otherwise set it to As Needed.
+ if mlist.preferred_language.charset == 'us-ascii':
+ mlist.encode_ascii_prefixes = 0
+ else:
+ mlist.encode_ascii_prefixes = 2
+
+
+
+class BasicOperation:
+ """Set basic operational attributes."""
+
+ def apply(self, mailing_list):
+ # For cut-n-paste convenience.
+ mlist = mailing_list
+ mlist.emergency = False
+ mlist.personalize = Personalization.none
+ mlist.default_member_action = Action.defer
+ mlist.default_nonmember_action = Action.hold
+ # Notify the administrator of pending requests and membership changes.
+ mlist.admin_immed_notify = True
+ mlist.admin_notify_mchanges = False
+ mlist.respond_to_post_requests = True
+ mlist.obscure_addresses = True
+ mlist.collapse_alternatives = True
+ mlist.convert_html_to_plaintext = False
+ mlist.filter_action = FilterAction.discard
+ mlist.filter_content = False
+ # Digests.
+ mlist.digestable = True
+ mlist.digest_is_default = False
+ mlist.mime_is_default_digest = False
+ mlist.digest_size_threshold = 30 # KB
+ mlist.digest_send_periodic = True
+ mlist.digest_header_uri = None
+ mlist.digest_footer_uri = (
+ 'mailman:///$listname/$language/footer-generic.txt')
+ mlist.digest_volume_frequency = DigestFrequency.monthly
+ mlist.next_digest_number = 1
+ mlist.nondigestable = True
+ # NNTP gateway
+ mlist.nntp_host = ''
+ mlist.linked_newsgroup = ''
+ mlist.gateway_to_news = False
+ mlist.gateway_to_mail = False
+ mlist.nntp_prefix_subject_too = True
+ # In patch #401270, this was called newsgroup_is_moderated, but the
+ # semantics weren't quite the same.
+ mlist.newsgroup_moderation = NewsgroupModeration.none
+ # Topics
+ #
+ # `topics' is a list of 4-tuples of the following form:
+ #
+ # (name, pattern, description, emptyflag)
+ #
+ # name is a required arbitrary string displayed to the user when they
+ # get to select their topics of interest
+ #
+ # pattern is a required verbose regular expression pattern which is
+ # used as IGNORECASE.
+ #
+ # description is an optional description of what this topic is
+ # supposed to match
+ #
+ # emptyflag is a boolean used internally in the admin interface to
+ # signal whether a topic entry is new or not (new ones which do not
+ # have a name or pattern are not saved when the submit button is
+ # pressed).
+ mlist.topics = []
+ mlist.topics_enabled = False
+ mlist.topics_bodylines_limit = 5
+ # This is a mapping between user "names" (i.e. addresses) and
+ # information about which topics that user is interested in. The
+ # values are a list of topic names that the user is interested in,
+ # which should match the topic names in mlist.topics above.
+ #
+ # If the user has not selected any topics of interest, then the rule
+ # is that they will get all messages, and they will not have an entry
+ # in this dictionary.
+ mlist.topics_userinterest = {}
+ # Other
+ mlist.header_uri = None
+ mlist.footer_uri = 'mailman:///$listname/$language/footer-generic.txt'
+ # scrub regular delivery
+ mlist.scrub_nondigest = False
+
+
+
+class Bounces:
+ """Basic bounce processing."""
+
+ def apply(self, mailing_list):
+ # For cut-n-paste convenience.
+ mlist = mailing_list
+ # Bounces
+ mlist.forward_unrecognized_bounces_to = (
+ UnrecognizedBounceDisposition.administrators)
+ mlist.process_bounces = True
+ mlist.bounce_score_threshold = 5.0
+ mlist.bounce_info_stale_after = timedelta(days=7)
+ mlist.bounce_you_are_disabled_warnings = 3
+ mlist.bounce_you_are_disabled_warnings_interval = timedelta(days=7)
+ mlist.bounce_notify_owner_on_disable = True
+ mlist.bounce_notify_owner_on_removal = True
+ # Autoresponder
+ mlist.autorespond_owner = ResponseAction.none
+ mlist.autoresponse_owner_text = ''
+ mlist.autorespond_postings = ResponseAction.none
+ mlist.autoresponse_postings_text = ''
+ mlist.autorespond_requests = ResponseAction.none
+ mlist.autoresponse_request_text = ''
+ mlist.autoresponse_grace_period = timedelta(days=90)
+ # This holds legacy member related information. It's keyed by the
+ # member address, and the value is an object containing the bounce
+ # score, the date of the last received bounce, and a count of the
+ # notifications left to send.
+ mlist.bounce_info = {}
+ # New style delivery status
+ mlist.delivery_status = {}
+ # The processing chain that messages posted to this mailing list get
+ # processed by.
+ mlist.posting_chain = 'default-posting-chain'
+ # The default pipeline to send accepted messages through to the
+ # mailing list's members.
+ mlist.posting_pipeline = 'default-posting-pipeline'
+ # The processing chain that messages posted to this mailing list's
+ # -owner address gets processed by.
+ mlist.owner_chain = 'default-owner-chain'
+ # The default pipeline to send -owner email through.
+ mlist.owner_pipeline = 'default-owner-pipeline'
+
+
+
+class Public:
+ """Settings for public mailing lists."""
+
+ def apply(self, mailing_list):
+ # For cut-n-paste convenience.
+ mlist = mailing_list
+ mlist.advertised = True
+ mlist.reply_goes_to_list = ReplyToMunging.no_munging
+ mlist.reply_to_address = ''
+ mlist.first_strip_reply_to = False
+ mlist.archive_policy = ArchivePolicy.public
+
+
+
+class Announcement:
+ """Settings for announce-only lists."""
+
+ def apply(self, mailing_list):
+ # For cut-n-paste convenience.
+ mlist = mailing_list
+ mlist.allow_list_posts = False
+ mlist.send_welcome_message = True
+ mlist.send_goodbye_message = True
+ mlist.anonymous_list = False
+ mlist.welcome_message_uri = 'mailman:///welcome.txt'
+ mlist.goodbye_message_uri = ''
+
+
+class Discussion:
+ """Settings for standard discussion lists."""
+
+ def apply(self, mailing_list):
+ # For cut-n-paste convenience.
+ mlist = mailing_list
+ mlist.allow_list_posts = True
+ mlist.send_welcome_message = True
+ mlist.send_goodbye_message = True
+ mlist.anonymous_list = False
+ mlist.welcome_message_uri = 'mailman:///welcome.txt'
+ mlist.goodbye_message_uri = ''
+
+
+
+class Moderation:
+ """Settings for basic moderation."""
+
+ def apply(self, mailing_list):
+ # For cut-n-paste convenience.
+ mlist = mailing_list
+ mlist.max_num_recipients = 10
+ mlist.max_message_size = 40 # KB
+ mlist.require_explicit_destination = True
+ mlist.bounce_matching_headers = """
+# Lines that *start* with a '#' are comments.
+to: friend@public.com
+message-id: relay.comanche.denmark.eu
+from: list@listme.com
+from: .*@uplinkpro.com
+"""
+ mlist.header_matches = []
+ mlist.administrivia = True
+ # Member moderation.
+ mlist.member_moderation_notice = ''
+ mlist.accept_these_nonmembers = []
+ mlist.hold_these_nonmembers = []
+ mlist.reject_these_nonmembers = []
+ mlist.discard_these_nonmembers = []
+ mlist.forward_auto_discards = True
+ mlist.nonmember_rejection_notice = ''
+ # automatic discarding
+ mlist.max_days_to_hold = 0
diff --git a/src/mailman/styles/default.py b/src/mailman/styles/default.py
index cb4da396d..7e668973f 100644
--- a/src/mailman/styles/default.py
+++ b/src/mailman/styles/default.py
@@ -21,205 +21,51 @@ from __future__ import absolute_import, print_function, unicode_literals
__metaclass__ = type
__all__ = [
- 'DefaultStyle',
+ 'LegacyDefaultStyle',
+ 'LegacyAnnounceOnly',
]
-# XXX Styles need to be reconciled with lazr.config.
-
-from datetime import timedelta
from zope.interface import implementer
-from mailman.core.i18n import _
-from mailman.interfaces.action import Action, FilterAction
-from mailman.interfaces.archiver import ArchivePolicy
-from mailman.interfaces.bounce import UnrecognizedBounceDisposition
-from mailman.interfaces.digests import DigestFrequency
-from mailman.interfaces.autorespond import ResponseAction
-from mailman.interfaces.mailinglist import Personalization, ReplyToMunging
-from mailman.interfaces.nntp import NewsgroupModeration
from mailman.interfaces.styles import IStyle
+from mailman.styles.base import (
+ Announcement, BasicOperation, Bounces, Discussion, Identity, Moderation,
+ Public)
@implementer(IStyle)
-class DefaultStyle:
- """The default (i.e. legacy) style."""
+class LegacyDefaultStyle(
+ Identity, BasicOperation, Bounces, Public, Discussion, Moderation):
+
+ """The legacy default style."""
- name = 'default'
- priority = 0 # the lowest priority style
+ name = 'legacy-default'
def apply(self, mailing_list):
"""See `IStyle`."""
- # For cut-n-paste convenience.
- mlist = mailing_list
- # List identity.
- mlist.display_name = mlist.list_name.capitalize()
- mlist.include_rfc2369_headers = True
- mlist.allow_list_posts = True
- # Most of these were ripped from the old MailList.InitVars() method.
- mlist.volume = 1
- mlist.post_id = 1
- mlist.new_member_options = 256
- mlist.respond_to_post_requests = True
- mlist.advertised = True
- mlist.max_num_recipients = 10
- mlist.max_message_size = 40 # KB
- mlist.reply_goes_to_list = ReplyToMunging.no_munging
- mlist.reply_to_address = ''
- mlist.first_strip_reply_to = False
- mlist.admin_immed_notify = True
- mlist.admin_notify_mchanges = False
- mlist.require_explicit_destination = True
- mlist.send_reminders = True
- mlist.send_welcome_message = True
- mlist.send_goodbye_message = True
- mlist.bounce_matching_headers = """
-# Lines that *start* with a '#' are comments.
-to: friend@public.com
-message-id: relay.comanche.denmark.eu
-from: list@listme.com
-from: .*@uplinkpro.com
-"""
- mlist.header_matches = []
- mlist.anonymous_list = False
- mlist.description = ''
- mlist.info = ''
- mlist.welcome_message_uri = 'mailman:///welcome.txt'
- mlist.goodbye_message_uri = ''
- mlist.subscribe_policy = 1
- mlist.subscribe_auto_approval = []
- mlist.unsubscribe_policy = 0
- mlist.private_roster = 1
- mlist.obscure_addresses = True
- mlist.admin_member_chunksize = 30
- mlist.administrivia = True
- mlist.preferred_language = 'en'
- mlist.collapse_alternatives = True
- mlist.convert_html_to_plaintext = False
- mlist.filter_action = FilterAction.discard
- mlist.filter_content = False
- # Digest related variables
- mlist.digestable = True
- mlist.digest_is_default = False
- mlist.mime_is_default_digest = False
- mlist.digest_size_threshold = 30 # KB
- mlist.digest_send_periodic = True
- mlist.digest_header_uri = None
- mlist.digest_footer_uri = (
- 'mailman:///$listname/$language/footer-generic.txt')
- mlist.digest_volume_frequency = DigestFrequency.monthly
- mlist.next_digest_number = 1
- mlist.nondigestable = True
- mlist.personalize = Personalization.none
- # New sender-centric moderation (privacy) options
- mlist.default_member_action = Action.defer
- mlist.default_nonmember_action = Action.hold
- # Archiver
- mlist.archive_policy = ArchivePolicy.public
- mlist.emergency = False
- mlist.member_moderation_notice = ''
- mlist.accept_these_nonmembers = []
- mlist.hold_these_nonmembers = []
- mlist.reject_these_nonmembers = []
- mlist.discard_these_nonmembers = []
- mlist.forward_auto_discards = True
- mlist.nonmember_rejection_notice = ''
- # Max autoresponses per day. A mapping between addresses and a
- # 2-tuple of the date of the last autoresponse and the number of
- # autoresponses sent on that date.
- mlist.subject_prefix = _('[$mlist.display_name] ')
- mlist.header_uri = None
- mlist.footer_uri = 'mailman:///$listname/$language/footer-generic.txt'
- # Set this to Never if the list's preferred language uses us-ascii,
- # otherwise set it to As Needed.
- if mlist.preferred_language.charset == 'us-ascii':
- mlist.encode_ascii_prefixes = 0
- else:
- mlist.encode_ascii_prefixes = 2
- # scrub regular delivery
- mlist.scrub_nondigest = False
- # automatic discarding
- mlist.max_days_to_hold = 0
- # Autoresponder
- mlist.autorespond_owner = ResponseAction.none
- mlist.autoresponse_owner_text = ''
- mlist.autorespond_postings = ResponseAction.none
- mlist.autoresponse_postings_text = ''
- mlist.autorespond_requests = ResponseAction.none
- mlist.autoresponse_request_text = ''
- mlist.autoresponse_grace_period = timedelta(days=90)
- # Bounces
- mlist.forward_unrecognized_bounces_to = (
- UnrecognizedBounceDisposition.administrators)
- mlist.process_bounces = True
- mlist.bounce_score_threshold = 5.0
- mlist.bounce_info_stale_after = timedelta(days=7)
- mlist.bounce_you_are_disabled_warnings = 3
- mlist.bounce_you_are_disabled_warnings_interval = timedelta(days=7)
- mlist.bounce_notify_owner_on_disable = True
- mlist.bounce_notify_owner_on_removal = True
- # This holds legacy member related information. It's keyed by the
- # member address, and the value is an object containing the bounce
- # score, the date of the last received bounce, and a count of the
- # notifications left to send.
- mlist.bounce_info = {}
- # New style delivery status
- mlist.delivery_status = {}
- # NNTP gateway
- mlist.nntp_host = ''
- mlist.linked_newsgroup = ''
- mlist.gateway_to_news = False
- mlist.gateway_to_mail = False
- mlist.nntp_prefix_subject_too = True
- # In patch #401270, this was called newsgroup_is_moderated, but the
- # semantics weren't quite the same.
- mlist.newsgroup_moderation = NewsgroupModeration.none
- # Topics
- #
- # `topics' is a list of 4-tuples of the following form:
- #
- # (name, pattern, description, emptyflag)
- #
- # name is a required arbitrary string displayed to the user when they
- # get to select their topics of interest
- #
- # pattern is a required verbose regular expression pattern which is
- # used as IGNORECASE.
- #
- # description is an optional description of what this topic is
- # supposed to match
- #
- # emptyflag is a boolean used internally in the admin interface to
- # signal whether a topic entry is new or not (new ones which do not
- # have a name or pattern are not saved when the submit button is
- # pressed).
- mlist.topics = []
- mlist.topics_enabled = False
- mlist.topics_bodylines_limit = 5
- # This is a mapping between user "names" (i.e. addresses) and
- # information about which topics that user is interested in. The
- # values are a list of topic names that the user is interested in,
- # which should match the topic names in mlist.topics above.
- #
- # If the user has not selected any topics of interest, then the rule
- # is that they will get all messages, and they will not have an entry
- # in this dictionary.
- mlist.topics_userinterest = {}
- # The processing chain that messages posted to this mailing list get
- # processed by.
- mlist.posting_chain = 'default-posting-chain'
- # The default pipeline to send accepted messages through to the
- # mailing list's members.
- mlist.posting_pipeline = 'default-posting-pipeline'
- # The processing chain that messages posted to this mailing list's
- # -owner address gets processed by.
- mlist.owner_chain = 'default-owner-chain'
- # The default pipeline to send -owner email through.
- mlist.owner_pipeline = 'default-owner-pipeline'
+ Identity.apply(self, mailing_list)
+ BasicOperation.apply(self, mailing_list)
+ Bounces.apply(self, mailing_list)
+ Public.apply(self, mailing_list)
+ Discussion.apply(self, mailing_list)
+ Moderation.apply(self, mailing_list)
+
+
+@implementer(IStyle)
+class LegacyAnnounceOnly(
+ Identity, BasicOperation, Bounces, Public, Announcement, Moderation):
+
+ """Similar to the legacy-default style, but for announce-only lists."""
- def match(self, mailing_list, styles):
+ name = 'legacy-announce'
+
+ def apply(self, mailing_list):
"""See `IStyle`."""
- # If no other styles have matched, then the default style matches.
- if len(styles) == 0:
- styles.append(self)
+ Identity.apply(self, mailing_list)
+ BasicOperation.apply(self, mailing_list)
+ Bounces.apply(self, mailing_list)
+ Public.apply(self, mailing_list)
+ Announcement.apply(self, mailing_list)
+ Moderation.apply(self, mailing_list)
diff --git a/src/mailman/styles/docs/styles.rst b/src/mailman/styles/docs/styles.rst
index 8f589f10b..00d95b08d 100644
--- a/src/mailman/styles/docs/styles.rst
+++ b/src/mailman/styles/docs/styles.rst
@@ -1,60 +1,42 @@
+.. _list-styles:
+
===========
List styles
===========
List styles are a way to name and apply a template of attribute settings to
-new mailing lists. Every style has a name, which must be unique within the
-context of a specific style manager. There is usually only one global style
-manager.
+new mailing lists. Every style has a name, which must be unique.
-Styles also have a priority, which allows you to specify the order in which
-multiple styles will be applied. A style has a `match` function which is used
-to determine whether the style should be applied to a particular mailing list
-or not. And finally, application of a style to a mailing list can really
-modify the mailing list any way it wants.
+Styles are generally only applied when a mailing list is created, although
+there is no reason why styles can't be applied to an existing mailing list.
+However, when a style changes, the mailing lists using that style are not
+automatically updated. Instead, think of styles as the initial set of
+defaults for just about any mailing list attribute. In fact, application of a
+style to a mailing list can really modify the mailing list in any way.
-Let's start with a vanilla mailing list and a default style manager.
-::
+To start with, there are a few legacy styles.
- >>> from mailman.interfaces.listmanager import IListManager
>>> from zope.component import getUtility
- >>> mlist = getUtility(IListManager).create('_xtest@example.com')
-
- >>> from mailman.styles.manager import StyleManager
- >>> style_manager = StyleManager()
- >>> style_manager.populate()
- >>> styles = sorted(style.name for style in style_manager.styles)
- >>> len(styles)
- 1
- >>> print styles[0]
- default
-
+ >>> from mailman.interfaces.styles import IStyleManager
+ >>> manager = getUtility(IStyleManager)
+ >>> for style in manager.styles:
+ ... print style.name
+ legacy-announce
+ legacy-default
-The default style
-=================
+When you create a mailing list through the low-level `IListManager` API, no
+style is applied.
-There is a default style which implements a legacy style roughly corresponding
-to discussion mailing lists. This style matches when no other styles match,
-and it has the lowest priority. The low priority means that it is matched
-last and if it matches, it is applied last.
-
- >>> default_style = style_manager.get('default')
- >>> print default_style.name
- default
- >>> default_style.priority
- 0
+ >>> from mailman.interfaces.listmanager import IListManager
+ >>> mlist = getUtility(IListManager).create('ant@example.com')
+ >>> print mlist.display_name
+ None
-Given a mailing list, you can ask the style manager to find all the styles
-that match the list. The registered styles will be sorted by decreasing
-priority and each style's ``match()`` method will be called in turn. The
-sorted list of matching styles will be returned -- but not applied -- by the
-style manager's ``lookup()`` method.
+The legacy default style sets the list's display name.
- >>> matched_styles = [style.name for style in style_manager.lookup(mlist)]
- >>> len(matched_styles)
- 1
- >>> print matched_styles[0]
- default
+ >>> manager.get('legacy-default').apply(mlist)
+ >>> print mlist.display_name
+ Ant
Registering styles
@@ -66,82 +48,84 @@ New styles must implement the ``IStyle`` interface.
>>> from mailman.interfaces.styles import IStyle
>>> @implementer(IStyle)
... class TestStyle:
- ... name = 'test'
- ... priority = 10
+ ... name = 'a-test-style'
... def apply(self, mailing_list):
... # Just does something very simple.
- ... mailing_list.style_thing = 'thing 1'
- ... def match(self, mailing_list, styles):
- ... # Applies to any test list
- ... if 'test' in mailing_list.fqdn_listname:
- ... styles.append(self)
+ ... mailing_list.display_name = 'TEST STYLE LIST'
You can register a new style with the style manager.
- >>> style_manager.register(TestStyle())
+ >>> manager.register(TestStyle())
-And now if you look up matching styles, you should find only the new test
-style. This is because the default style only gets applied when no other
-styles match the mailing list.
+All registered styles are returned in alphabetical order by style name.
- >>> matched_styles = sorted(
- ... style.name for style in style_manager.lookup(mlist))
- >>> len(matched_styles)
- 1
- >>> print matched_styles[0]
- test
- >>> for style in style_manager.lookup(mlist):
- ... style.apply(mlist)
- >>> print mlist.style_thing
- thing 1
+ >>> for style in manager.styles:
+ ... print style.name
+ a-test-style
+ legacy-announce
+ legacy-default
+You can also ask the style manager for the style, by name.
-Style priority
-==============
+ >>> test_style = manager.get('a-test-style')
+ >>> print test_style.name
+ a-test-style
-When multiple styles match a particular mailing list, they are applied in
-descending order of priority. In other words, a priority zero style would be
-applied last.
-::
- >>> class AnotherTestStyle(TestStyle):
- ... name = 'another'
- ... priority = 5
- ... # Use the base class's match() method.
- ... def apply(self, mailing_list):
- ... mailing_list.style_thing = 'thing 2'
+Unregistering styles
+====================
- >>> mlist.style_thing = 'thing 0'
- >>> print mlist.style_thing
- thing 0
- >>> style_manager.register(AnotherTestStyle())
- >>> for style in style_manager.lookup(mlist):
- ... style.apply(mlist)
- >>> print mlist.style_thing
- thing 2
+You can unregister a style, making it unavailable in the future.
-You can change the priority of a style, and if you reapply the styles, they
-will take effect in the new priority order.
+ >>> manager.unregister(test_style)
+ >>> for style in manager.styles:
+ ... print style.name
+ legacy-announce
+ legacy-default
- >>> style_1 = style_manager.get('test')
- >>> style_1.priority = 5
- >>> style_2 = style_manager.get('another')
- >>> style_2.priority = 10
- >>> for style in style_manager.lookup(mlist):
- ... style.apply(mlist)
- >>> print mlist.style_thing
- thing 1
+Asking for a missing style returns None.
+ >>> print manager.get('a-test-style')
+ None
-Unregistering styles
-====================
-You can unregister a style, making it unavailable in the future.
+.. _list-creation-styles:
+
+Apply styles at list creation
+=============================
+
+You can specify a style to apply when creating a list through the high-level
+API. Let's start by registering the test style.
+
+ >>> manager.register(test_style)
+
+Now, when we use the high level API, we can ask for the style to be applied.
+
+ >>> from mailman.app.lifecycle import create_list
+ >>> mlist = create_list('bee@example.com', style_name=test_style.name)
+
+The style has been applied.
+
+ >>> print mlist.display_name
+ TEST STYLE LIST
+
+If no style name is provided when creating the list, the system default style
+(taken from the configuration file) is applied.
+
+ >>> @implementer(IStyle)
+ ... class AnotherStyle:
+ ... name = 'another-style'
+ ... def apply(self, mailing_list):
+ ... # Just does something very simple.
+ ... mailing_list.display_name = 'ANOTHER STYLE LIST'
+ >>> another_style = AnotherStyle()
+
+We'll set up the system default to apply this newly registered style if no
+other style is explicitly given.
- >>> style_manager.unregister(style_2)
- >>> matched_styles = sorted(
- ... style.name for style in style_manager.lookup(mlist))
- >>> len(matched_styles)
- 1
- >>> print matched_styles[0]
- test
+ >>> from mailman.testing.helpers import configuration
+ >>> with configuration('styles', default=another_style.name):
+ ... manager.register(another_style)
+ ... mlist = create_list('cat@example.com')
+ >>> print mlist.display_name
+ ANOTHER STYLE LIST
diff --git a/src/mailman/styles/manager.py b/src/mailman/styles/manager.py
index c2729abfb..5962d308f 100644
--- a/src/mailman/styles/manager.py
+++ b/src/mailman/styles/manager.py
@@ -26,7 +26,6 @@ __all__ = [
]
-from operator import attrgetter
from zope.component import getUtility
from zope.interface import implementer
from zope.interface.verify import verifyObject
@@ -34,7 +33,7 @@ from zope.interface.verify import verifyObject
from mailman.interfaces.configuration import ConfigurationUpdatedEvent
from mailman.interfaces.styles import (
DuplicateStyleError, IStyle, IStyleManager)
-from mailman.utilities.modules import call_name
+from mailman.utilities.modules import find_components
@@ -50,35 +49,27 @@ class StyleManager:
self._styles.clear()
# Avoid circular imports.
from mailman.config import config
- # Install all the styles described by the configuration files.
- for section in config.style_configs:
- class_path = section['class']
- style = call_name(class_path)
- assert section.name.startswith('style'), (
- 'Bad style section name: %s' % section.name)
- style.name = section.name[6:]
- style.priority = int(section.priority)
- self.register(style)
+ # Calculate the Python import paths to search.
+ paths = filter(None, (path.strip()
+ for path in config.styles.paths.splitlines()))
+ for path in paths:
+ for style_class in find_components(path, IStyle):
+ style = style_class()
+ verifyObject(IStyle, style)
+ assert style.name not in self._styles, (
+ 'Duplicate style "{0}" found in {1}'.format(
+ style.name, style_class))
+ self._styles[style.name] = style
def get(self, name):
"""See `IStyleManager`."""
return self._styles.get(name)
- def lookup(self, mailing_list):
- """See `IStyleManager`."""
- matched_styles = []
- for style in self.styles:
- style.match(mailing_list, matched_styles)
- for style in matched_styles:
- yield style
-
@property
def styles(self):
"""See `IStyleManager`."""
- for style in sorted(self._styles.values(),
- key=attrgetter('priority'),
- reverse=True):
- yield style
+ for style_name in sorted(self._styles):
+ yield self._styles[style_name]
def register(self, style):
"""See `IStyleManager`."""
diff --git a/src/mailman/testing/helpers.py b/src/mailman/testing/helpers.py
index b233e7bd4..c3618f030 100644
--- a/src/mailman/testing/helpers.py
+++ b/src/mailman/testing/helpers.py
@@ -154,6 +154,7 @@ def digest_mbox(mlist):
+# Remember, Master is mailman.bin.master.Loop.
class TestableMaster(Master):
"""A testable master loop watcher."""
diff --git a/src/mailman/tests/test_documentation.py b/src/mailman/tests/test_documentation.py
index b769f07d6..c00c41b83 100644
--- a/src/mailman/tests/test_documentation.py
+++ b/src/mailman/tests/test_documentation.py
@@ -46,6 +46,7 @@ from mailman.testing.layers import SMTPLayer
DOT = '.'
+COMMASPACE = ', '
@@ -133,14 +134,19 @@ def dump_json(url, data=None, method=None, username=None, password=None):
if results is None:
return
for key in sorted(results):
+ value = results[key]
if key == 'entries':
- for i, entry in enumerate(results[key]):
+ for i, entry in enumerate(value):
# entry is a dictionary.
print 'entry %d:' % i
for entry_key in sorted(entry):
print ' {0}: {1}'.format(entry_key, entry[entry_key])
+ elif isinstance(value, list):
+ printable_value = COMMASPACE.join(
+ "'{0}'".format(s) for s in sorted(value))
+ print '{0}: [{1}]'.format(key, printable_value)
else:
- print '{0}: {1}'.format(key, results[key])
+ print '{0}: {1}'.format(key, value)
diff --git a/src/mailman/utilities/modules.py b/src/mailman/utilities/modules.py
index d8b788054..4ba549f99 100644
--- a/src/mailman/utilities/modules.py
+++ b/src/mailman/utilities/modules.py
@@ -22,12 +22,17 @@ from __future__ import absolute_import, unicode_literals
__metaclass__ = type
__all__ = [
'call_name',
+ 'find_components',
'find_name',
+ 'scan_module',
]
+import os
import sys
+from pkg_resources import resource_listdir
+
def find_name(dotted_name):
@@ -43,6 +48,7 @@ def find_name(dotted_name):
return getattr(sys.modules[package_path], object_name)
+
def call_name(dotted_name, *args, **kws):
"""Imports and calls the named object in package space.
@@ -57,3 +63,50 @@ def call_name(dotted_name, *args, **kws):
"""
named_callable = find_name(dotted_name)
return named_callable(*args, **kws)
+
+
+
+def scan_module(module, interface):
+ """Return all the items in a module that conform to an interface.
+
+ :param module: A module object. The module's `__all__` will be scanned.
+ :type module: module
+ :param interface: The interface that returned objects must conform to.
+ :type interface: `Interface`
+ :return: The sequence of matching components.
+ :rtype: objects implementing `interface`
+ """
+ missing = object()
+ for name in module.__all__:
+ component = getattr(module, name, missing)
+ assert component is not missing, (
+ '%s has bad __all__: %s' % (module, name))
+ if interface.implementedBy(component):
+ yield component
+
+
+
+def find_components(package, interface):
+ """Find components which conform to a given interface.
+
+ Search all the modules in a given package, returning an iterator over all
+ objects found that conform to the given interface.
+
+ :param package: The package path to search.
+ :type package: string
+ :param interface: The interface that returned objects must conform to.
+ :type interface: `Interface`
+ :return: The sequence of matching components.
+ :rtype: objects implementing `interface`
+ """
+ for filename in resource_listdir(package, ''):
+ basename, extension = os.path.splitext(filename)
+ if extension != '.py':
+ continue
+ module_name = '{0}.{1}'.format(package, basename)
+ __import__(module_name, fromlist='*')
+ module = sys.modules[module_name]
+ if not hasattr(module, '__all__'):
+ continue
+ for component in scan_module(module, interface):
+ yield component