diff options
| author | Barry Warsaw | 2012-12-30 14:39:10 -0500 |
|---|---|---|
| committer | Barry Warsaw | 2012-12-30 14:39:10 -0500 |
| commit | a0244a524117c90cbf22f0007b96933c4fb2aa4b (patch) | |
| tree | 21fc100ba690971aa1310fb08c82fedc5f38084c | |
| parent | 2450a9c9642d06af1a60df70acb742e67959d77e (diff) | |
| parent | 5ec8a131c602f9b00d6b25d914ffc923cd1aa964 (diff) | |
| download | mailman-a0244a524117c90cbf22f0007b96933c4fb2aa4b.tar.gz mailman-a0244a524117c90cbf22f0007b96933c4fb2aa4b.tar.zst mailman-a0244a524117c90cbf22f0007b96933c4fb2aa4b.zip | |
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 |
