summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorBarry Warsaw2011-10-23 22:10:36 -0400
committerBarry Warsaw2011-10-23 22:10:36 -0400
commit27ee61e8c69db8152678912c07f9de3e7dad84dc (patch)
tree54983c34580b73dc8697504583b81ead26e5f4fe /src
parent63b338e18c6cf07a3c46a8e9db436c9c10654330 (diff)
parentb4020ac6233b8c01966530ca81116c066546109b (diff)
downloadmailman-27ee61e8c69db8152678912c07f9de3e7dad84dc.tar.gz
mailman-27ee61e8c69db8152678912c07f9de3e7dad84dc.tar.zst
mailman-27ee61e8c69db8152678912c07f9de3e7dad84dc.zip
Diffstat (limited to 'src')
-rw-r--r--src/mailman/commands/docs/info.rst4
-rw-r--r--src/mailman/config/schema.cfg2
-rw-r--r--src/mailman/database/base.py (renamed from src/mailman/database/stock.py)73
-rw-r--r--src/mailman/database/model.py11
-rw-r--r--src/mailman/database/postgresql.py67
-rw-r--r--src/mailman/database/sql/__init__.py0
-rw-r--r--src/mailman/database/sql/postgres.sql346
-rw-r--r--src/mailman/database/sql/sqlite.sql (renamed from src/mailman/database/mailman.sql)2
-rw-r--r--src/mailman/database/sqlite.py46
-rw-r--r--src/mailman/docs/DATABASE.rst71
-rw-r--r--src/mailman/docs/NEWS.rst4
-rw-r--r--src/mailman/model/docs/users.rst5
-rw-r--r--src/mailman/rest/addresses.py10
-rw-r--r--src/mailman/testing/testing.cfg5
14 files changed, 622 insertions, 24 deletions
diff --git a/src/mailman/commands/docs/info.rst b/src/mailman/commands/docs/info.rst
index 83b3fe179..34883711e 100644
--- a/src/mailman/commands/docs/info.rst
+++ b/src/mailman/commands/docs/info.rst
@@ -19,7 +19,7 @@ script ``mailman info``. By default, the info is printed to standard output.
Python ...
...
config file: .../test.cfg
- db url: sqlite:.../mailman.db
+ db url: ...
REST root url: http://localhost:9001/3.0/
REST credentials: restadmin:restpass
@@ -36,7 +36,7 @@ By passing in the ``-o/--output`` option, you can print the info to a file.
Python ...
...
config file: .../test.cfg
- db url: sqlite:.../mailman.db
+ db url: ...
REST root url: http://localhost:9001/3.0/
REST credentials: restadmin:restpass
diff --git a/src/mailman/config/schema.cfg b/src/mailman/config/schema.cfg
index 40c4756c2..8f0c863fc 100644
--- a/src/mailman/config/schema.cfg
+++ b/src/mailman/config/schema.cfg
@@ -191,7 +191,7 @@ sleep_time: 1s
[database]
# The class implementing the IDatabase.
-class: mailman.database.stock.StockDatabase
+class: mailman.database.sqlite.SQLiteDatabase
# Use this to set the Storm database engine URL. You generally have one
# primary database connection for all of Mailman. List data and most rosters
diff --git a/src/mailman/database/stock.py b/src/mailman/database/base.py
index e69fe9c7c..1e71341e0 100644
--- a/src/mailman/database/stock.py
+++ b/src/mailman/database/base.py
@@ -19,15 +19,15 @@ from __future__ import absolute_import, unicode_literals
__metaclass__ = type
__all__ = [
- 'StockDatabase',
+ 'StormBaseDatabase',
]
+
import os
import logging
from flufl.lock import Lock
from lazr.config import as_boolean
-from pkg_resources import resource_string
from storm.cache import GenerationalCache
from storm.locals import create_database, Store
from urlparse import urlparse
@@ -42,10 +42,15 @@ from mailman.utilities.string import expand
log = logging.getLogger('mailman.config')
+NL = '\n'
+
-class StockDatabase:
- """The standard database, using Storm on top of SQLite."""
+class StormBaseDatabase:
+ """The database base class for use with the Storm ORM.
+
+ Use this as a base class for your DB-specific derived classes.
+ """
implements(IDatabase)
@@ -73,6 +78,43 @@ class StockDatabase:
"""See `IDatabase`."""
self.store.rollback()
+ def _database_exists(self):
+ """Return True if the database exists and is initialized.
+
+ Return False when Mailman needs to create and initialize the
+ underlying database schema.
+
+ Base classes *must* override this.
+ """
+ raise NotImplementedError
+
+ def _get_schema(self):
+ """Return the database schema as a string.
+
+ This will be loaded into the database when it is first created.
+
+ Base classes *must* override this.
+ """
+ raise NotImplementedError
+
+ def _pre_reset(self, store):
+ """Clean up method for testing.
+
+ This method is called during the test suite just before all the model
+ tables are removed. Override this to perform any database-specific
+ pre-removal cleanup.
+ """
+ pass
+
+ def _post_reset(self, store):
+ """Clean up method for testing.
+
+ This method is called during the test suite just after all the model
+ tables have been removed. Override this to perform any
+ database-specific post-removal cleanup.
+ """
+ pass
+
def _create(self, debug):
# Calculate the engine url.
url = expand(config.database.url, config.paths)
@@ -97,18 +139,19 @@ class StockDatabase:
store = Store(database, GenerationalCache())
database.DEBUG = (as_boolean(config.database.debug)
if debug is None else debug)
- # Check the sqlite master database to see if the version file exists.
- # If so, then we assume the database schema is correctly initialized.
- # Storm does not currently have schema creation. This is not an ideal
- # way to handle creating the database, but it's cheap and easy for
- # now.
- table_names = [item[0] for item in
- store.execute('select tbl_name from sqlite_master;')]
- if 'version' not in table_names:
- # Initialize the database.
- sql = resource_string('mailman.database', 'mailman.sql')
+ # Check the master / schema database to see if the version table
+ # exists. If so, then we assume the database schema is correctly
+ # initialized. Storm does not currently provide schema creation.
+ if not self._database_exists(store):
+ # Initialize the database. Start by getting the schema and
+ # discarding all blank and comment lines.
+ lines = self._get_schema().splitlines()
+ lines = (line for line in lines
+ if line.strip() != '' and line.strip()[:2] != '--')
+ sql = NL.join(lines)
for statement in sql.split(';'):
- store.execute(statement + ';')
+ if statement.strip() != '':
+ store.execute(statement + ';')
# Validate schema version.
v = store.find(Version, component='schema').one()
if not v:
diff --git a/src/mailman/database/model.py b/src/mailman/database/model.py
index 3e5dcad57..eec88936f 100644
--- a/src/mailman/database/model.py
+++ b/src/mailman/database/model.py
@@ -24,6 +24,9 @@ __all__ = [
'Model',
]
+
+from operator import attrgetter
+
from storm.properties import PropertyPublisherMeta
@@ -46,8 +49,14 @@ class ModelMeta(PropertyPublisherMeta):
@staticmethod
def _reset(store):
- for model_class in ModelMeta._class_registry:
+ from mailman.config import config
+ config.db._pre_reset(store)
+ # Make sure this is deterministic, by sorting on the storm table name.
+ classes = sorted(ModelMeta._class_registry,
+ key=attrgetter('__storm_table__'))
+ for model_class in classes:
store.find(model_class).remove()
+ config.db._post_reset(store)
diff --git a/src/mailman/database/postgresql.py b/src/mailman/database/postgresql.py
new file mode 100644
index 000000000..4e40558a4
--- /dev/null
+++ b/src/mailman/database/postgresql.py
@@ -0,0 +1,67 @@
+# Copyright (C) 2011 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/>.
+
+"""PostgreSQL database support."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'PostgreSQLDatabase',
+ ]
+
+
+from operator import attrgetter
+from pkg_resources import resource_string
+
+from mailman.database.base import StormBaseDatabase
+
+
+
+class PostgreSQLDatabase(StormBaseDatabase):
+ """Database class for PostgreSQL."""
+
+ def _database_exists(self, store):
+ """See `BaseDatabase`."""
+ table_query = ('SELECT table_name FROM information_schema.tables '
+ "WHERE table_schema = 'public'")
+ table_names = set(item[0] for item in
+ store.execute(table_query))
+ return 'version' in table_names
+
+ def _get_schema(self):
+ """See `BaseDatabase`."""
+ return resource_string('mailman.database.sql', 'postgres.sql')
+
+ def _post_reset(self, store):
+ """PostgreSQL-specific test suite cleanup.
+
+ Reset the <tablename>_id_seq.last_value so that primary key ids
+ restart from zero for new tests.
+ """
+ from mailman.database.model import ModelMeta
+ classes = sorted(ModelMeta._class_registry,
+ key=attrgetter('__storm_table__'))
+ # Recipe adapted from
+ # http://stackoverflow.com/questions/544791/
+ # django-postgresql-how-to-reset-primary-key
+ for model_class in classes:
+ store.execute("""\
+ SELECT setval('"{0}_id_seq"', coalesce(max("id"), 1),
+ max("id") IS NOT null)
+ FROM "{0}";
+ """.format(model_class.__storm_table__))
diff --git a/src/mailman/database/sql/__init__.py b/src/mailman/database/sql/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/mailman/database/sql/__init__.py
diff --git a/src/mailman/database/sql/postgres.sql b/src/mailman/database/sql/postgres.sql
new file mode 100644
index 000000000..2ca94217f
--- /dev/null
+++ b/src/mailman/database/sql/postgres.sql
@@ -0,0 +1,346 @@
+CREATE TABLE mailinglist (
+ id SERIAL NOT NULL,
+ -- List identity
+ list_name TEXT,
+ mail_host TEXT,
+ list_id TEXT,
+ include_list_post_header BOOLEAN,
+ include_rfc2369_headers BOOLEAN,
+ -- Attributes not directly modifiable via the web u/i
+ created_at TIMESTAMP,
+ admin_member_chunksize INTEGER,
+ next_request_id INTEGER,
+ next_digest_number INTEGER,
+ digest_last_sent_at TIMESTAMP,
+ volume INTEGER,
+ last_post_at TIMESTAMP,
+ accept_these_nonmembers BYTEA,
+ acceptable_aliases_id INTEGER,
+ admin_immed_notify BOOLEAN,
+ admin_notify_mchanges BOOLEAN,
+ administrivia BOOLEAN,
+ advertised BOOLEAN,
+ anonymous_list BOOLEAN,
+ archive BOOLEAN,
+ archive_private BOOLEAN,
+ archive_volume_frequency INTEGER,
+ -- Automatic responses.
+ 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,
+ -- Bounces.
+ 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,
+ -- Content filtering.
+ filter_content BOOLEAN,
+ collapse_alternatives BOOLEAN,
+ convert_html_to_plaintext BOOLEAN,
+ default_member_action INTEGER,
+ default_nonmember_action INTEGER,
+ description TEXT,
+ digest_footer TEXT,
+ digest_header TEXT,
+ digest_is_default BOOLEAN,
+ digest_send_periodic BOOLEAN,
+ digest_size_threshold REAL,
+ digest_volume_frequency INTEGER,
+ digestable BOOLEAN,
+ discard_these_nonmembers BYTEA,
+ emergency BOOLEAN,
+ encode_ascii_prefixes BOOLEAN,
+ first_strip_reply_to BOOLEAN,
+ forward_auto_discards BOOLEAN,
+ gateway_to_mail BOOLEAN,
+ gateway_to_news BOOLEAN,
+ generic_nonmember_action INTEGER,
+ goodbye_msg TEXT,
+ header_matches BYTEA,
+ hold_these_nonmembers BYTEA,
+ 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,
+ msg_footer TEXT,
+ msg_header TEXT,
+ new_member_options INTEGER,
+ news_moderation INTEGER,
+ news_prefix_subject_too BOOLEAN,
+ nntp_host TEXT,
+ nondigestable BOOLEAN,
+ nonmember_rejection_notice TEXT,
+ obscure_addresses BOOLEAN,
+ personalize INTEGER,
+ pipeline TEXT,
+ post_id INTEGER,
+ preferred_language TEXT,
+ private_roster BOOLEAN,
+ real_name TEXT,
+ reject_these_nonmembers BYTEA,
+ reply_goes_to_list INTEGER,
+ reply_to_address TEXT,
+ require_explicit_destination BOOLEAN,
+ respond_to_post_requests BOOLEAN,
+ scrub_nondigest BOOLEAN,
+ send_goodbye_msg BOOLEAN,
+ send_reminders BOOLEAN,
+ send_welcome_msg BOOLEAN,
+ start_chain TEXT,
+ subject_prefix TEXT,
+ subscribe_auto_approval BYTEA,
+ subscribe_policy INTEGER,
+ topics BYTEA,
+ topics_bodylines_limit INTEGER,
+ topics_enabled BOOLEAN,
+ unsubscribe_policy INTEGER,
+ welcome_msg TEXT,
+ moderation_callback TEXT,
+ PRIMARY KEY (id)
+ );
+
+CREATE TABLE _request (
+ id SERIAL NOT NULL,
+ "key" TEXT,
+ request_type INTEGER,
+ data_hash BYTEA,
+ mailing_list_id INTEGER,
+ PRIMARY KEY (id)
+ -- XXX: config.db_reset() triggers IntegrityError
+ -- ,
+ -- CONSTRAINT _request_mailing_list_id_fk
+ -- FOREIGN KEY (mailing_list_id) REFERENCES mailinglist (id)
+ );
+
+CREATE TABLE acceptablealias (
+ id SERIAL NOT NULL,
+ "alias" TEXT NOT NULL,
+ mailing_list_id INTEGER NOT NULL,
+ PRIMARY KEY (id)
+ -- XXX: config.db_reset() triggers IntegrityError
+ -- ,
+ -- CONSTRAINT acceptablealias_mailing_list_id_fk
+ -- FOREIGN KEY (mailing_list_id) REFERENCES mailinglist (id)
+ );
+CREATE INDEX ix_acceptablealias_mailing_list_id
+ ON acceptablealias (mailing_list_id);
+CREATE INDEX ix_acceptablealias_alias ON acceptablealias ("alias");
+
+CREATE TABLE preferences (
+ id SERIAL NOT NULL,
+ acknowledge_posts BOOLEAN,
+ hide_address BOOLEAN,
+ preferred_language TEXT,
+ receive_list_copy BOOLEAN,
+ receive_own_postings BOOLEAN,
+ delivery_mode INTEGER,
+ delivery_status INTEGER,
+ PRIMARY KEY (id)
+ );
+
+CREATE TABLE address (
+ id SERIAL NOT NULL,
+ email TEXT,
+ _original TEXT,
+ real_name TEXT,
+ verified_on TIMESTAMP,
+ registered_on TIMESTAMP,
+ user_id INTEGER,
+ preferences_id INTEGER,
+ PRIMARY KEY (id)
+ -- XXX: config.db_reset() triggers IntegrityError
+ -- ,
+ -- CONSTRAINT address_preferences_id_fk
+ -- FOREIGN KEY (preferences_id) REFERENCES preferences (id)
+ );
+
+CREATE TABLE "user" (
+ id SERIAL NOT NULL,
+ real_name TEXT,
+ password BYTEA,
+ _user_id UUID,
+ _created_on TIMESTAMP,
+ _preferred_address_id INTEGER,
+ preferences_id INTEGER,
+ PRIMARY KEY (id)
+ -- XXX: config.db_reset() triggers IntegrityError
+ -- ,
+ -- CONSTRAINT user_preferences_id_fk
+ -- FOREIGN KEY (preferences_id) REFERENCES preferences (id),
+ -- XXX: config.db_reset() triggers IntegrityError
+ -- CONSTRAINT _preferred_address_id_fk
+ -- FOREIGN KEY (_preferred_address_id) REFERENCES address (id)
+ );
+CREATE INDEX ix_user_user_id ON "user" (_user_id);
+
+-- since user and address have circular foreign key refs, the
+-- constraint on the address table has to be added after
+-- the user table is created
+--
+-- XXX: users.rst triggers an IntegrityError
+-- ALTER TABLE address ADD
+-- CONSTRAINT address_user_id_fk
+-- FOREIGN KEY (user_id) REFERENCES "user" (id);
+
+CREATE TABLE autoresponserecord (
+ id SERIAL NOT NULL,
+ address_id INTEGER,
+ mailing_list_id INTEGER,
+ response_type INTEGER,
+ date_sent TIMESTAMP,
+ PRIMARY KEY (id)
+ -- XXX: config.db_reset() triggers IntegrityError
+ -- ,
+ -- CONSTRAINT autoresponserecord_address_id_fk
+ -- FOREIGN KEY (address_id) REFERENCES address (id)
+ -- XXX: config.db_reset() triggers IntegrityError
+ -- ,
+ -- CONSTRAINT autoresponserecord_mailing_list_id
+ -- FOREIGN KEY (mailing_list_id) REFERENCES mailinglist (id)
+ );
+CREATE INDEX ix_autoresponserecord_address_id
+ ON autoresponserecord (address_id);
+CREATE INDEX ix_autoresponserecord_mailing_list_id
+ ON autoresponserecord (mailing_list_id);
+
+CREATE TABLE bounceevent (
+ id SERIAL NOT NULL,
+ list_name TEXT,
+ email TEXT,
+ "timestamp" TIMESTAMP,
+ message_id TEXT,
+ context INTEGER,
+ processed BOOLEAN,
+ PRIMARY KEY (id)
+ );
+
+CREATE TABLE contentfilter (
+ id SERIAL NOT NULL,
+ mailing_list_id INTEGER,
+ filter_pattern TEXT,
+ filter_type INTEGER,
+ PRIMARY KEY (id),
+ CONSTRAINT contentfilter_mailing_list_id
+ FOREIGN KEY (mailing_list_id) REFERENCES mailinglist (id)
+ );
+CREATE INDEX ix_contentfilter_mailing_list_id
+ ON contentfilter (mailing_list_id);
+
+CREATE TABLE domain (
+ id SERIAL NOT NULL,
+ mail_host TEXT,
+ base_url TEXT,
+ description TEXT,
+ contact_address TEXT,
+ PRIMARY KEY (id)
+ );
+
+CREATE TABLE language (
+ id SERIAL NOT NULL,
+ code TEXT,
+ PRIMARY KEY (id)
+ );
+
+CREATE TABLE member (
+ id SERIAL NOT NULL,
+ _member_id UUID,
+ role INTEGER,
+ mailing_list TEXT,
+ moderation_action INTEGER,
+ address_id INTEGER,
+ preferences_id INTEGER,
+ user_id INTEGER,
+ PRIMARY KEY (id)
+ -- XXX: config.db_reset() triggers IntegrityError
+ -- ,
+ -- CONSTRAINT member_address_id_fk
+ -- FOREIGN KEY (address_id) REFERENCES address (id),
+ -- XXX: config.db_reset() triggers IntegrityError
+ -- CONSTRAINT member_preferences_id_fk
+ -- FOREIGN KEY (preferences_id) REFERENCES preferences (id),
+ -- CONSTRAINT member_user_id_fk
+ -- FOREIGN KEY (user_id) REFERENCES "user" (id)
+ );
+CREATE INDEX ix_member__member_id ON member (_member_id);
+CREATE INDEX ix_member_address_id ON member (address_id);
+CREATE INDEX ix_member_preferences_id ON member (preferences_id);
+
+CREATE TABLE message (
+ id SERIAL NOT NULL,
+ message_id_hash BYTEA,
+ path BYTEA,
+ message_id TEXT,
+ PRIMARY KEY (id)
+ );
+
+CREATE TABLE onelastdigest (
+ id SERIAL NOT NULL,
+ mailing_list_id INTEGER,
+ address_id INTEGER,
+ delivery_mode INTEGER,
+ PRIMARY KEY (id),
+ CONSTRAINT onelastdigest_mailing_list_id_fk
+ FOREIGN KEY (mailing_list_id) REFERENCES mailinglist(id),
+ CONSTRAINT onelastdigest_address_id_fk
+ FOREIGN KEY (address_id) REFERENCES address(id)
+ );
+
+CREATE TABLE pended (
+ id SERIAL NOT NULL,
+ token BYTEA,
+ expiration_date TIMESTAMP,
+ PRIMARY KEY (id)
+ );
+
+CREATE TABLE pendedkeyvalue (
+ id SERIAL NOT NULL,
+ "key" TEXT,
+ value TEXT,
+ pended_id INTEGER,
+ PRIMARY KEY (id)
+ -- ,
+ -- XXX: config.db_reset() triggers IntegrityError
+ -- CONSTRAINT pendedkeyvalue_pended_id_fk
+ -- FOREIGN KEY (pended_id) REFERENCES pended (id)
+ );
+
+CREATE TABLE version (
+ id SERIAL NOT NULL,
+ component TEXT,
+ version INTEGER,
+ PRIMARY KEY (id)
+ );
+
+CREATE INDEX ix__request_mailing_list_id ON _request (mailing_list_id);
+CREATE INDEX ix_address_preferences_id ON address (preferences_id);
+CREATE INDEX ix_address_user_id ON address (user_id);
+CREATE INDEX ix_pendedkeyvalue_pended_id ON pendedkeyvalue (pended_id);
+CREATE INDEX ix_user_preferences_id ON "user" (preferences_id);
+
+CREATE TABLE ban (
+ id SERIAL NOT NULL,
+ email TEXT,
+ mailing_list TEXT,
+ PRIMARY KEY (id)
+ );
+
+CREATE TABLE uid (
+ -- Keep track of all assigned unique ids to prevent re-use.
+ id SERIAL NOT NULL,
+ uid UUID,
+ PRIMARY KEY (id)
+ );
+CREATE INDEX ix_uid_uid ON uid (uid);
diff --git a/src/mailman/database/mailman.sql b/src/mailman/database/sql/sqlite.sql
index f203f764f..7368f2159 100644
--- a/src/mailman/database/mailman.sql
+++ b/src/mailman/database/sql/sqlite.sql
@@ -149,7 +149,7 @@ CREATE TABLE mailinglist (
digest_header TEXT,
digest_is_default BOOLEAN,
digest_send_periodic BOOLEAN,
- digest_size_threshold INTEGER,
+ digest_size_threshold FLOAT,
digest_volume_frequency INTEGER,
digestable BOOLEAN,
discard_these_nonmembers BLOB,
diff --git a/src/mailman/database/sqlite.py b/src/mailman/database/sqlite.py
new file mode 100644
index 000000000..30c4959b7
--- /dev/null
+++ b/src/mailman/database/sqlite.py
@@ -0,0 +1,46 @@
+# Copyright (C) 2011 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/>.
+
+"""SQLite database support."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'SQLiteDatabase',
+ ]
+
+
+from pkg_resources import resource_string
+
+from mailman.database.base import StormBaseDatabase
+
+
+
+class SQLiteDatabase(StormBaseDatabase):
+ """Database class for SQLite."""
+
+ def _database_exists(self, store):
+ """See `BaseDatabase`."""
+ table_query = 'select tbl_name from sqlite_master;'
+ table_names = set(item[0] for item in
+ store.execute(table_query))
+ return 'version' in table_names
+
+ def _get_schema(self):
+ """See `BaseDatabase`."""
+ return resource_string('mailman.database.sql', 'sqlite.sql')
diff --git a/src/mailman/docs/DATABASE.rst b/src/mailman/docs/DATABASE.rst
new file mode 100644
index 000000000..f5fe39849
--- /dev/null
+++ b/src/mailman/docs/DATABASE.rst
@@ -0,0 +1,71 @@
+========================
+Setting up your database
+========================
+
+Mailman uses the Storm_ ORM to provide persistence of data in a relational
+database. By default, Mailman uses Python's built-in SQLite3_ database,
+however, Storm is compatible with PostgreSQL_ and MySQL, among possibly
+others.
+
+Currently, Mailman is known to work with either the default SQLite3 database,
+or PostgreSQL. (Volunteers to port it to MySQL are welcome!). If you want to
+use SQLite3, you generally don't need to change anything, but if you want
+Mailman to use PostgreSQL, you'll need to set that up first, and then change a
+configuration variable in your `/etc/mailman.cfg` file.
+
+Two configuration variables control which database Mailman uses. The first
+names the class implementing the database interface. The second names the
+Storm URL for connecting to the database. Both variables live in the
+`[database]` section of the configuration file.
+
+
+SQLite3
+=======
+
+As mentioned, if you want to use SQLite3 in the default configuration, you
+generally don't need to change anything. However, if you want to change where
+the SQLite3 database is stored, you can change the `url` variable in the
+`[database]` section. By default, the database is stored in the *data
+directory* in the `mailman.db` file. Here's how you'd force Mailman to store
+its database in `/var/lib/mailman/sqlite.db` file::
+
+ [database]
+ url: sqlite:////var/lib/mailman/sqlite.db
+
+
+PostgreSQL
+==========
+
+First, you need to configure PostgreSQL itself. This `Ubuntu article`_ may
+help. Let's say you create the `mailman` database in PostgreSQL via::
+
+ $ sudo -u postgres createdb -O myuser mailman
+
+You would then need to set both the `class` and `url` variables in
+`mailman.cfg` like so::
+
+ [database]
+ class: mailman.database.postgresql.PostgreSQLDatabase
+ url: postgres://myuser:mypassword@mypghost/mailman
+
+That should be it.
+
+Note that if you want to run the full test suite against PostgreSQL, you
+should make these changes to the `mailman/testing/test.cfg` file (yes,
+eventually we'll make this easier), start up PostgreSQL and run `bin/test` as
+normal.
+
+If you have any problems, you may need to delete the database and re-create
+it::
+
+ $ sudo -u postgres dropdb mailman
+ $ sudo -u postgres createdb -O myuser mailman
+
+My thanks to Stephen A. Goss for his contribution of PostgreSQL support.
+
+
+.. _Storm: http://storm.canonical.com
+.. _SQLite3: http://docs.python.org/library/sqlite3.html
+.. _PostgreSQL: http://www.postgresql.org/
+.. _MySQL: http://dev.mysql.com/
+.. _`Ubuntu article`: https://help.ubuntu.com/community/PostgreSQL
diff --git a/src/mailman/docs/NEWS.rst b/src/mailman/docs/NEWS.rst
index accb934bb..b8c1261cd 100644
--- a/src/mailman/docs/NEWS.rst
+++ b/src/mailman/docs/NEWS.rst
@@ -12,6 +12,10 @@ Here is a history of user visible changes to Mailman.
=================================
(20XX-XX-XX)
+Features
+--------
+ * PostgreSQL support contributed by Stephen A. Goss. (LP: #860159)
+
3.0 alpha 8 -- "Where's My Thing?"
==================================
diff --git a/src/mailman/model/docs/users.rst b/src/mailman/model/docs/users.rst
index 6d0c9a5b0..bd1f36b35 100644
--- a/src/mailman/model/docs/users.rst
+++ b/src/mailman/model/docs/users.rst
@@ -198,7 +198,7 @@ it controlled by the user.
>>> user_2.controls(aperson.email)
True
- >>> zperson = list(user_1.addresses)[0]
+ >>> zperson = user_manager.get_address('zperson@example.com')
>>> zperson.verified_on = now()
>>> user_2.controls(zperson.email)
False
@@ -220,7 +220,8 @@ A user can disavow their preferred address.
The preferred address always shows up in the set of addresses controlled by
this user.
- >>> for address in user_2.addresses:
+ >>> from operator import attrgetter
+ >>> for address in sorted(user_2.addresses, key=attrgetter('email')):
... print address.email
anne@example.com
aperson@example.com
diff --git a/src/mailman/rest/addresses.py b/src/mailman/rest/addresses.py
index 5d479d9cb..d0b0fa1c9 100644
--- a/src/mailman/rest/addresses.py
+++ b/src/mailman/rest/addresses.py
@@ -137,6 +137,11 @@ class UserAddresses(_AddressBase):
+def membership_key(member):
+ # Sort first by mailing list, then by address, then by role.
+ return member.mailing_list, member.address.email, int(member.role)
+
+
class AddressMemberships(MemberCollection):
"""All the memberships of a particular email address."""
@@ -157,5 +162,6 @@ class AddressMemberships(MemberCollection):
user = getUtility(IUserManager).get_user(self._address.email)
if user is None:
return []
- return [member for member in user.memberships.members
- if member.address == self._address]
+ return sorted((member for member in user.memberships.members
+ if member.address == self._address),
+ key=membership_key)
diff --git a/src/mailman/testing/testing.cfg b/src/mailman/testing/testing.cfg
index 8ed784621..b102e6f41 100644
--- a/src/mailman/testing/testing.cfg
+++ b/src/mailman/testing/testing.cfg
@@ -17,6 +17,11 @@
# A testing configuration.
+# For testing against PostgreSQL.
+#[database]
+#class: mailman.database.postgresql.PostgreSQLDatabase
+#url: postgres://barry:barry@localhost/mailman
+
[mta]
smtp_port: 9025
lmtp_port: 9024