summaryrefslogtreecommitdiff
path: root/src/mailman/database
diff options
context:
space:
mode:
Diffstat (limited to 'src/mailman/database')
-rw-r--r--src/mailman/database/__init__.py153
-rw-r--r--src/mailman/database/address.py96
-rw-r--r--src/mailman/database/language.py40
-rw-r--r--src/mailman/database/listmanager.py82
-rw-r--r--src/mailman/database/mailinglist.py272
-rw-r--r--src/mailman/database/mailman.sql208
-rw-r--r--src/mailman/database/member.py105
-rw-r--r--src/mailman/database/message.py53
-rw-r--r--src/mailman/database/messagestore.py137
-rw-r--r--src/mailman/database/model.py56
-rw-r--r--src/mailman/database/pending.py177
-rw-r--r--src/mailman/database/preferences.py50
-rw-r--r--src/mailman/database/requests.py138
-rw-r--r--src/mailman/database/roster.py270
-rw-r--r--src/mailman/database/transaction.py53
-rw-r--r--src/mailman/database/types.py64
-rw-r--r--src/mailman/database/user.py94
-rw-r--r--src/mailman/database/usermanager.py103
-rw-r--r--src/mailman/database/version.py40
19 files changed, 2191 insertions, 0 deletions
diff --git a/src/mailman/database/__init__.py b/src/mailman/database/__init__.py
new file mode 100644
index 000000000..8b7f584c2
--- /dev/null
+++ b/src/mailman/database/__init__.py
@@ -0,0 +1,153 @@
+# Copyright (C) 2006-2009 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/>.
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'StockDatabase',
+ ]
+
+import os
+import logging
+
+from locknix.lockfile import Lock
+from lazr.config import as_boolean
+from pkg_resources import resource_string
+from storm.locals import create_database, Store
+from urlparse import urlparse
+from zope.interface import implements
+
+import mailman.version
+
+from mailman.config import config
+from mailman.database.listmanager import ListManager
+from mailman.database.messagestore import MessageStore
+from mailman.database.pending import Pendings
+from mailman.database.requests import Requests
+from mailman.database.usermanager import UserManager
+from mailman.database.version import Version
+from mailman.interfaces.database import IDatabase, SchemaVersionMismatchError
+from mailman.utilities.string import expand
+
+log = logging.getLogger('mailman.config')
+
+
+
+class StockDatabase:
+ """The standard database, using Storm on top of SQLite."""
+
+ implements(IDatabase)
+
+ def __init__(self):
+ self.list_manager = None
+ self.user_manager = None
+ self.message_store = None
+ self.pendings = None
+ self.requests = None
+ self._store = None
+
+ def initialize(self, debug=None):
+ """See `IDatabase`."""
+ # Serialize this so we don't get multiple processes trying to create
+ # the database at the same time.
+ with Lock(os.path.join(config.LOCK_DIR, 'dbcreate.lck')):
+ self._create(debug)
+ self.list_manager = ListManager()
+ self.user_manager = UserManager()
+ self.message_store = MessageStore()
+ self.pendings = Pendings()
+ self.requests = Requests()
+
+ def begin(self):
+ """See `IDatabase`."""
+ # Storm takes care of this for us.
+ pass
+
+ def commit(self):
+ """See `IDatabase`."""
+ self.store.commit()
+
+ def abort(self):
+ """See `IDatabase`."""
+ self.store.rollback()
+
+ def _create(self, debug):
+ # Calculate the engine url.
+ url = expand(config.database.url, config.paths)
+ log.debug('Database url: %s', url)
+ # XXX By design of SQLite, database file creation does not honor
+ # umask. See their ticket #1193:
+ # http://www.sqlite.org/cvstrac/tktview?tn=1193,31
+ #
+ # This sucks for us because the mailman.db file /must/ be group
+ # writable, however even though we guarantee our umask is 002 here, it
+ # still gets created without the necessary g+w permission, due to
+ # SQLite's policy. This should only affect SQLite engines because its
+ # the only one that creates a little file on the local file system.
+ # This kludges around their bug by "touch"ing the database file before
+ # SQLite has any chance to create it, thus honoring the umask and
+ # ensuring the right permissions. We only try to do this for SQLite
+ # engines, and yes, we could have chmod'd the file after the fact, but
+ # half dozen and all...
+ touch(url)
+ database = create_database(url)
+ store = Store(database)
+ 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')
+ for statement in sql.split(';'):
+ store.execute(statement + ';')
+ # Validate schema version.
+ v = store.find(Version, component=u'schema').one()
+ if not v:
+ # Database has not yet been initialized
+ v = Version(component='schema',
+ version=mailman.version.DATABASE_SCHEMA_VERSION)
+ store.add(v)
+ elif v.version <> mailman.version.DATABASE_SCHEMA_VERSION:
+ # XXX Update schema
+ raise SchemaVersionMismatchError(v.version)
+ self.store = store
+ store.commit()
+
+ def _reset(self):
+ """See `IDatabase`."""
+ from mailman.database.model import ModelMeta
+ self.store.rollback()
+ ModelMeta._reset(self.store)
+
+
+
+def touch(url):
+ parts = urlparse(url)
+ if parts.scheme <> 'sqlite':
+ return
+ path = os.path.normpath(parts.path)
+ fd = os.open(path, os.O_WRONLY | os.O_NONBLOCK | os.O_CREAT, 0666)
+ # Ignore errors
+ if fd > 0:
+ os.close(fd)
diff --git a/src/mailman/database/address.py b/src/mailman/database/address.py
new file mode 100644
index 000000000..528d3af51
--- /dev/null
+++ b/src/mailman/database/address.py
@@ -0,0 +1,96 @@
+# Copyright (C) 2006-2009 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/>.
+
+"""Model for addresses."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'Address',
+ ]
+
+
+from email.utils import formataddr
+from storm.locals import *
+from zope.interface import implements
+
+from mailman.config import config
+from mailman.database.member import Member
+from mailman.database.model import Model
+from mailman.database.preferences import Preferences
+from mailman.interfaces.member import AlreadySubscribedError
+from mailman.interfaces.address import IAddress
+
+
+
+class Address(Model):
+ implements(IAddress)
+
+ id = Int(primary=True)
+ address = Unicode()
+ _original = Unicode()
+ real_name = Unicode()
+ verified_on = DateTime()
+ registered_on = DateTime()
+
+ user_id = Int()
+ user = Reference(user_id, 'User.id')
+ preferences_id = Int()
+ preferences = Reference(preferences_id, 'Preferences.id')
+
+ def __init__(self, address, real_name):
+ super(Address, self).__init__()
+ lower_case = address.lower()
+ self.address = lower_case
+ self.real_name = real_name
+ self._original = (None if lower_case == address else address)
+
+ def __str__(self):
+ addr = (self.address if self._original is None else self._original)
+ return formataddr((self.real_name, addr))
+
+ def __repr__(self):
+ verified = ('verified' if self.verified_on else 'not verified')
+ address_str = str(self)
+ if self._original is None:
+ return '<Address: {0} [{1}] at {2:#x}>'.format(
+ address_str, verified, id(self))
+ else:
+ return '<Address: {0} [{1}] key: {2} at {3:#x}>'.format(
+ address_str, verified, self.address, id(self))
+
+ def subscribe(self, mailing_list, role):
+ # This member has no preferences by default.
+ member = config.db.store.find(
+ Member,
+ Member.role == role,
+ Member.mailing_list == mailing_list.fqdn_listname,
+ Member.address == self).one()
+ if member:
+ raise AlreadySubscribedError(
+ mailing_list.fqdn_listname, self.address, role)
+ member = Member(role=role,
+ mailing_list=mailing_list.fqdn_listname,
+ address=self)
+ member.preferences = Preferences()
+ config.db.store.add(member)
+ return member
+
+ @property
+ def original_address(self):
+ return (self.address if self._original is None else self._original)
diff --git a/src/mailman/database/language.py b/src/mailman/database/language.py
new file mode 100644
index 000000000..8adc5c4a5
--- /dev/null
+++ b/src/mailman/database/language.py
@@ -0,0 +1,40 @@
+# Copyright (C) 2006-2009 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/>.
+
+"""Model for languages."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'Language',
+ ]
+
+
+from storm.locals import *
+from zope.interface import implements
+
+from mailman.database import Model
+from mailman.interfaces import ILanguage
+
+
+
+class Language(Model):
+ implements(ILanguage)
+
+ id = Int(primary=True)
+ code = Unicode()
diff --git a/src/mailman/database/listmanager.py b/src/mailman/database/listmanager.py
new file mode 100644
index 000000000..790a2509a
--- /dev/null
+++ b/src/mailman/database/listmanager.py
@@ -0,0 +1,82 @@
+# Copyright (C) 2007-2009 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/>.
+
+"""A mailing list manager."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'ListManager',
+ ]
+
+
+import datetime
+
+from zope.interface import implements
+
+from mailman.config import config
+from mailman.database.mailinglist import MailingList
+from mailman.interfaces.listmanager import IListManager, ListAlreadyExistsError
+
+
+
+class ListManager(object):
+ """An implementation of the `IListManager` interface."""
+
+ implements(IListManager)
+
+ def create(self, fqdn_listname):
+ """See `IListManager`."""
+ listname, hostname = fqdn_listname.split('@', 1)
+ mlist = config.db.store.find(
+ MailingList,
+ MailingList.list_name == listname,
+ MailingList.host_name == hostname).one()
+ if mlist:
+ raise ListAlreadyExistsError(fqdn_listname)
+ mlist = MailingList(fqdn_listname)
+ mlist.created_at = datetime.datetime.now()
+ config.db.store.add(mlist)
+ return mlist
+
+ def get(self, fqdn_listname):
+ """See `IListManager`."""
+ listname, hostname = fqdn_listname.split('@', 1)
+ mlist = config.db.store.find(MailingList,
+ list_name=listname,
+ host_name=hostname).one()
+ if mlist is not None:
+ # XXX Fixme
+ mlist._restore()
+ return mlist
+
+ def delete(self, mlist):
+ """See `IListManager`."""
+ config.db.store.remove(mlist)
+
+ @property
+ def mailing_lists(self):
+ """See `IListManager`."""
+ for fqdn_listname in self.names:
+ yield self.get(fqdn_listname)
+
+ @property
+ def names(self):
+ """See `IListManager`."""
+ for mlist in config.db.store.find(MailingList):
+ yield '{0}@{1}'.format(mlist.list_name, mlist.host_name)
diff --git a/src/mailman/database/mailinglist.py b/src/mailman/database/mailinglist.py
new file mode 100644
index 000000000..8803a5fa4
--- /dev/null
+++ b/src/mailman/database/mailinglist.py
@@ -0,0 +1,272 @@
+# Copyright (C) 2006-2009 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/>.
+
+"""Model for mailing lists."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'MailingList',
+ ]
+
+
+import os
+import string
+
+from storm.locals import *
+from urlparse import urljoin
+from zope.interface import implements
+
+from mailman.config import config
+from mailman.database import roster
+from mailman.database.model import Model
+from mailman.database.types import Enum
+from mailman.interfaces.mailinglist import IMailingList, Personalization
+from mailman.utilities.filesystem import makedirs
+from mailman.utilities.string import expand
+
+
+SPACE = ' '
+UNDERSCORE = '_'
+
+
+
+class MailingList(Model):
+ implements(IMailingList)
+
+ id = Int(primary=True)
+
+ # List identity
+ list_name = Unicode()
+ host_name = Unicode()
+ # Attributes not directly modifiable via the web u/i
+ created_at = DateTime()
+ admin_member_chunksize = Int()
+ hold_and_cmd_autoresponses = Pickle()
+ # 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.
+ next_request_id = Int()
+ next_digest_number = Int()
+ admin_responses = Pickle()
+ postings_responses = Pickle()
+ request_responses = Pickle()
+ digest_last_sent_at = Float()
+ one_last_digest = Pickle()
+ volume = Int()
+ last_post_time = DateTime()
+ # 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.
+ accept_these_nonmembers = Pickle()
+ acceptable_aliases = Pickle()
+ admin_immed_notify = Bool()
+ admin_notify_mchanges = Bool()
+ administrivia = Bool()
+ advertised = Bool()
+ anonymous_list = Bool()
+ archive = Bool()
+ archive_private = Bool()
+ archive_volume_frequency = Int()
+ autorespond_admin = Bool()
+ autorespond_postings = Bool()
+ autorespond_requests = Int()
+ autoresponse_admin_text = Unicode()
+ autoresponse_graceperiod = TimeDelta()
+ autoresponse_postings_text = Unicode()
+ autoresponse_request_text = Unicode()
+ ban_list = Pickle()
+ bounce_info_stale_after = TimeDelta()
+ bounce_matching_headers = Unicode()
+ bounce_notify_owner_on_disable = Bool()
+ bounce_notify_owner_on_removal = Bool()
+ bounce_processing = Bool()
+ bounce_score_threshold = Int()
+ bounce_unrecognized_goes_to_list_owner = Bool()
+ bounce_you_are_disabled_warnings = Int()
+ bounce_you_are_disabled_warnings_interval = TimeDelta()
+ collapse_alternatives = Bool()
+ convert_html_to_plaintext = Bool()
+ default_member_moderation = Bool()
+ description = Unicode()
+ digest_footer = Unicode()
+ digest_header = Unicode()
+ digest_is_default = Bool()
+ digest_send_periodic = Bool()
+ digest_size_threshold = Int()
+ digest_volume_frequency = Int()
+ digestable = Bool()
+ discard_these_nonmembers = Pickle()
+ emergency = Bool()
+ encode_ascii_prefixes = Bool()
+ filter_action = Int()
+ filter_content = Bool()
+ filter_filename_extensions = Pickle()
+ filter_mime_types = Pickle()
+ first_strip_reply_to = Bool()
+ forward_auto_discards = Bool()
+ gateway_to_mail = Bool()
+ gateway_to_news = Bool()
+ generic_nonmember_action = Int()
+ goodbye_msg = Unicode()
+ header_matches = Pickle()
+ hold_these_nonmembers = Pickle()
+ include_list_post_header = Bool()
+ include_rfc2369_headers = Bool()
+ info = Unicode()
+ linked_newsgroup = Unicode()
+ max_days_to_hold = Int()
+ max_message_size = Int()
+ max_num_recipients = Int()
+ member_moderation_action = Enum()
+ member_moderation_notice = Unicode()
+ mime_is_default_digest = Bool()
+ moderator_password = Unicode()
+ msg_footer = Unicode()
+ msg_header = Unicode()
+ new_member_options = Int()
+ news_moderation = Enum()
+ news_prefix_subject_too = Bool()
+ nntp_host = Unicode()
+ nondigestable = Bool()
+ nonmember_rejection_notice = Unicode()
+ obscure_addresses = Bool()
+ pass_filename_extensions = Pickle()
+ pass_mime_types = Pickle()
+ personalize = Enum()
+ pipeline = Unicode()
+ post_id = Int()
+ preferred_language = Unicode()
+ private_roster = Bool()
+ real_name = Unicode()
+ reject_these_nonmembers = Pickle()
+ reply_goes_to_list = Enum()
+ reply_to_address = Unicode()
+ require_explicit_destination = Bool()
+ respond_to_post_requests = Bool()
+ scrub_nondigest = Bool()
+ send_goodbye_msg = Bool()
+ send_reminders = Bool()
+ send_welcome_msg = Bool()
+ start_chain = Unicode()
+ subject_prefix = Unicode()
+ subscribe_auto_approval = Pickle()
+ subscribe_policy = Int()
+ topics = Pickle()
+ topics_bodylines_limit = Int()
+ topics_enabled = Bool()
+ unsubscribe_policy = Int()
+ welcome_msg = Unicode()
+
+ def __init__(self, fqdn_listname):
+ super(MailingList, self).__init__()
+ listname, hostname = fqdn_listname.split('@', 1)
+ self.list_name = listname
+ self.host_name = hostname
+ # For the pending database
+ self.next_request_id = 1
+ self._restore()
+ # 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.
+ self.hold_and_cmd_autoresponses = {}
+ self.personalization = Personalization.none
+ self.real_name = string.capwords(
+ SPACE.join(listname.split(UNDERSCORE)))
+ makedirs(self.data_path)
+
+ # XXX FIXME
+ def _restore(self):
+ self.owners = roster.OwnerRoster(self)
+ self.moderators = roster.ModeratorRoster(self)
+ self.administrators = roster.AdministratorRoster(self)
+ self.members = roster.MemberRoster(self)
+ self.regular_members = roster.RegularMemberRoster(self)
+ self.digest_members = roster.DigestMemberRoster(self)
+ self.subscribers = roster.Subscribers(self)
+
+ @property
+ def fqdn_listname(self):
+ """See `IMailingList`."""
+ return '{0}@{1}'.format(self.list_name, self.host_name)
+
+ @property
+ def web_host(self):
+ """See `IMailingList`."""
+ return config.domains[self.host_name]
+
+ def script_url(self, target, context=None):
+ """See `IMailingList`."""
+ # Find the domain for this mailing list.
+ domain = config.domains[self.host_name]
+ # XXX Handle the case for when context is not None; those would be
+ # relative URLs.
+ return urljoin(domain.base_url, target + '/' + self.fqdn_listname)
+
+ @property
+ def data_path(self):
+ """See `IMailingList`."""
+ return os.path.join(config.LIST_DATA_DIR, self.fqdn_listname)
+
+ # IMailingListAddresses
+
+ @property
+ def posting_address(self):
+ return self.fqdn_listname
+
+ @property
+ def no_reply_address(self):
+ return '{0}@{1}'.format(config.mailman.noreply_address, self.host_name)
+
+ @property
+ def owner_address(self):
+ return '{0}-owner@{1}'.format(self.list_name, self.host_name)
+
+ @property
+ def request_address(self):
+ return '{0}-request@{1}'.format(self.list_name, self.host_name)
+
+ @property
+ def bounces_address(self):
+ return '{0}-bounces@{1}'.format(self.list_name, self.host_name)
+
+ @property
+ def join_address(self):
+ return '{0}-join@{1}'.format(self.list_name, self.host_name)
+
+ @property
+ def leave_address(self):
+ return '{0}-leave@{1}'.format(self.list_name, self.host_name)
+
+ @property
+ def subscribe_address(self):
+ return '{0}-subscribe@{1}'.format(self.list_name, self.host_name)
+
+ @property
+ def unsubscribe_address(self):
+ return '{0}-unsubscribe@{1}'.format(self.list_name, self.host_name)
+
+ def confirm_address(self, cookie):
+ local_part = expand(config.mta.verp_confirm_format, dict(
+ address = '{0}-confirm'.format(self.list_name),
+ cookie = cookie))
+ return '{0}@{1}'.format(local_part, self.host_name)
+
+ def __repr__(self):
+ return '<mailing list "{0}" at {1:#x}>'.format(
+ self.fqdn_listname, id(self))
diff --git a/src/mailman/database/mailman.sql b/src/mailman/database/mailman.sql
new file mode 100644
index 000000000..b098ed13b
--- /dev/null
+++ b/src/mailman/database/mailman.sql
@@ -0,0 +1,208 @@
+CREATE TABLE _request (
+ id INTEGER NOT NULL,
+ "key" TEXT,
+ request_type TEXT,
+ data_hash TEXT,
+ mailing_list_id INTEGER,
+ PRIMARY KEY (id),
+ CONSTRAINT _request_mailing_list_id_fk FOREIGN KEY(mailing_list_id) REFERENCES mailinglist (id)
+);
+CREATE TABLE address (
+ id INTEGER NOT NULL,
+ address TEXT,
+ _original TEXT,
+ real_name TEXT,
+ verified_on TIMESTAMP,
+ registered_on TIMESTAMP,
+ user_id INTEGER,
+ preferences_id INTEGER,
+ PRIMARY KEY (id),
+ CONSTRAINT address_user_id_fk FOREIGN KEY(user_id) REFERENCES user (id),
+ CONSTRAINT address_preferences_id_fk FOREIGN KEY(preferences_id) REFERENCES preferences (id)
+);
+CREATE TABLE language (
+ id INTEGER NOT NULL,
+ code TEXT,
+ PRIMARY KEY (id)
+);
+CREATE TABLE mailinglist (
+ id INTEGER NOT NULL,
+ list_name TEXT,
+ host_name TEXT,
+ created_at TIMESTAMP,
+ admin_member_chunksize INTEGER,
+ hold_and_cmd_autoresponses BLOB,
+ next_request_id INTEGER,
+ next_digest_number INTEGER,
+ admin_responses BLOB,
+ postings_responses BLOB,
+ request_responses BLOB,
+ digest_last_sent_at NUMERIC(10, 2),
+ one_last_digest BLOB,
+ volume INTEGER,
+ last_post_time TIMESTAMP,
+ accept_these_nonmembers BLOB,
+ acceptable_aliases BLOB,
+ admin_immed_notify BOOLEAN,
+ admin_notify_mchanges BOOLEAN,
+ administrivia BOOLEAN,
+ advertised BOOLEAN,
+ anonymous_list BOOLEAN,
+ archive BOOLEAN,
+ archive_private BOOLEAN,
+ archive_volume_frequency INTEGER,
+ autorespond_admin BOOLEAN,
+ autorespond_postings BOOLEAN,
+ autorespond_requests INTEGER,
+ autoresponse_admin_text TEXT,
+ autoresponse_graceperiod TEXT,
+ autoresponse_postings_text TEXT,
+ autoresponse_request_text TEXT,
+ ban_list BLOB,
+ bounce_info_stale_after TEXT,
+ bounce_matching_headers TEXT,
+ bounce_notify_owner_on_disable BOOLEAN,
+ bounce_notify_owner_on_removal BOOLEAN,
+ bounce_processing BOOLEAN,
+ bounce_score_threshold INTEGER,
+ bounce_unrecognized_goes_to_list_owner BOOLEAN,
+ bounce_you_are_disabled_warnings INTEGER,
+ bounce_you_are_disabled_warnings_interval TEXT,
+ collapse_alternatives BOOLEAN,
+ convert_html_to_plaintext BOOLEAN,
+ default_member_moderation BOOLEAN,
+ description TEXT,
+ digest_footer TEXT,
+ digest_header TEXT,
+ digest_is_default BOOLEAN,
+ digest_send_periodic BOOLEAN,
+ digest_size_threshold INTEGER,
+ digest_volume_frequency INTEGER,
+ digestable BOOLEAN,
+ discard_these_nonmembers BLOB,
+ emergency BOOLEAN,
+ encode_ascii_prefixes BOOLEAN,
+ filter_action INTEGER,
+ filter_content BOOLEAN,
+ filter_filename_extensions BLOB,
+ filter_mime_types BLOB,
+ 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 BLOB,
+ hold_these_nonmembers BLOB,
+ include_list_post_header BOOLEAN,
+ include_rfc2369_headers BOOLEAN,
+ info TEXT,
+ linked_newsgroup TEXT,
+ max_days_to_hold INTEGER,
+ max_message_size INTEGER,
+ max_num_recipients INTEGER,
+ member_moderation_action BOOLEAN,
+ member_moderation_notice TEXT,
+ mime_is_default_digest BOOLEAN,
+ moderator_password TEXT,
+ msg_footer TEXT,
+ msg_header TEXT,
+ new_member_options INTEGER,
+ news_moderation TEXT,
+ news_prefix_subject_too BOOLEAN,
+ nntp_host TEXT,
+ nondigestable BOOLEAN,
+ nonmember_rejection_notice TEXT,
+ obscure_addresses BOOLEAN,
+ pass_filename_extensions BLOB,
+ pass_mime_types BLOB,
+ personalize TEXT,
+ pipeline TEXT,
+ post_id INTEGER,
+ preferred_language TEXT,
+ private_roster BOOLEAN,
+ real_name TEXT,
+ reject_these_nonmembers BLOB,
+ reply_goes_to_list TEXT,
+ 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 BLOB,
+ subscribe_policy INTEGER,
+ topics BLOB,
+ topics_bodylines_limit INTEGER,
+ topics_enabled BOOLEAN,
+ unsubscribe_policy INTEGER,
+ welcome_msg TEXT,
+ PRIMARY KEY (id)
+);
+CREATE TABLE member (
+ id INTEGER NOT NULL,
+ role TEXT,
+ mailing_list TEXT,
+ is_moderated BOOLEAN,
+ address_id INTEGER,
+ preferences_id INTEGER,
+ PRIMARY KEY (id),
+ CONSTRAINT member_address_id_fk FOREIGN KEY(address_id) REFERENCES address (id),
+ CONSTRAINT member_preferences_id_fk FOREIGN KEY(preferences_id) REFERENCES preferences (id)
+);
+CREATE TABLE message (
+ id INTEGER NOT NULL,
+ message_id_hash TEXT,
+ path TEXT,
+ message_id TEXT,
+ PRIMARY KEY (id)
+);
+CREATE TABLE pended (
+ id INTEGER NOT NULL,
+ token TEXT,
+ expiration_date TIMESTAMP,
+ PRIMARY KEY (id)
+);
+CREATE TABLE pendedkeyvalue (
+ id INTEGER NOT NULL,
+ "key" TEXT,
+ value TEXT,
+ pended_id INTEGER,
+ PRIMARY KEY (id),
+ CONSTRAINT pendedkeyvalue_pended_id_fk FOREIGN KEY(pended_id) REFERENCES pended (id)
+);
+CREATE TABLE preferences (
+ id INTEGER NOT NULL,
+ acknowledge_posts BOOLEAN,
+ hide_address BOOLEAN,
+ preferred_language TEXT,
+ receive_list_copy BOOLEAN,
+ receive_own_postings BOOLEAN,
+ delivery_mode TEXT,
+ delivery_status TEXT,
+ PRIMARY KEY (id)
+);
+CREATE TABLE user (
+ id INTEGER NOT NULL,
+ real_name TEXT,
+ password TEXT,
+ preferences_id INTEGER,
+ PRIMARY KEY (id),
+ CONSTRAINT user_preferences_id_fk FOREIGN KEY(preferences_id) REFERENCES preferences (id)
+);
+CREATE TABLE version (
+ id INTEGER 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_member_address_id ON member (address_id);
+CREATE INDEX ix_member_preferences_id ON member (preferences_id);
+CREATE INDEX ix_pendedkeyvalue_pended_id ON pendedkeyvalue (pended_id);
+CREATE INDEX ix_user_preferences_id ON user (preferences_id);
diff --git a/src/mailman/database/member.py b/src/mailman/database/member.py
new file mode 100644
index 000000000..22bf042f6
--- /dev/null
+++ b/src/mailman/database/member.py
@@ -0,0 +1,105 @@
+# Copyright (C) 2007-2009 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/>.
+
+"""Model for members."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'Member',
+ ]
+
+from storm.locals import *
+from zope.interface import implements
+
+from mailman.config import config
+from mailman.constants import SystemDefaultPreferences
+from mailman.database.model import Model
+from mailman.database.types import Enum
+from mailman.interfaces.member import IMember
+
+
+
+class Member(Model):
+ implements(IMember)
+
+ id = Int(primary=True)
+ role = Enum()
+ mailing_list = Unicode()
+ is_moderated = Bool()
+
+ address_id = Int()
+ address = Reference(address_id, 'Address.id')
+ preferences_id = Int()
+ preferences = Reference(preferences_id, 'Preferences.id')
+
+ def __init__(self, role, mailing_list, address):
+ self.role = role
+ self.mailing_list = mailing_list
+ self.address = address
+ self.is_moderated = False
+
+ def __repr__(self):
+ return '<Member: {0} on {1} as {2}>'.format(
+ self.address, self.mailing_list, self.role)
+
+ def _lookup(self, preference):
+ pref = getattr(self.preferences, preference)
+ if pref is not None:
+ return pref
+ pref = getattr(self.address.preferences, preference)
+ if pref is not None:
+ return pref
+ if self.address.user:
+ pref = getattr(self.address.user.preferences, preference)
+ if pref is not None:
+ return pref
+ return getattr(SystemDefaultPreferences, preference)
+
+ @property
+ def acknowledge_posts(self):
+ return self._lookup('acknowledge_posts')
+
+ @property
+ def preferred_language(self):
+ return self._lookup('preferred_language')
+
+ @property
+ def receive_list_copy(self):
+ return self._lookup('receive_list_copy')
+
+ @property
+ def receive_own_postings(self):
+ return self._lookup('receive_own_postings')
+
+ @property
+ def delivery_mode(self):
+ return self._lookup('delivery_mode')
+
+ @property
+ def delivery_status(self):
+ return self._lookup('delivery_status')
+
+ @property
+ def options_url(self):
+ # XXX Um, this is definitely wrong
+ return 'http://example.com/' + self.address.address
+
+ def unsubscribe(self):
+ config.db.store.remove(self.preferences)
+ config.db.store.remove(self)
diff --git a/src/mailman/database/message.py b/src/mailman/database/message.py
new file mode 100644
index 000000000..e77e11429
--- /dev/null
+++ b/src/mailman/database/message.py
@@ -0,0 +1,53 @@
+# Copyright (C) 2007-2009 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/>.
+
+"""Model for messages."""
+
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'Message',
+ ]
+
+from storm.locals import *
+from zope.interface import implements
+
+from mailman.config import config
+from mailman.database.model import Model
+from mailman.interfaces.messages import IMessage
+
+
+
+class Message(Model):
+ """A message in the message store."""
+
+ implements(IMessage)
+
+ id = Int(primary=True, default=AutoReload)
+ message_id = Unicode()
+ message_id_hash = RawStr()
+ path = RawStr()
+ # This is a Messge-ID field representation, not a database row id.
+
+ def __init__(self, message_id, message_id_hash, path):
+ super(Message, self).__init__()
+ self.message_id = message_id
+ self.message_id_hash = message_id_hash
+ self.path = path
+ config.db.store.add(self)
diff --git a/src/mailman/database/messagestore.py b/src/mailman/database/messagestore.py
new file mode 100644
index 000000000..a129f47ec
--- /dev/null
+++ b/src/mailman/database/messagestore.py
@@ -0,0 +1,137 @@
+# Copyright (C) 2007-2009 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/>.
+
+"""Model for message stores."""
+
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'MessageStore',
+ ]
+
+import os
+import errno
+import base64
+import hashlib
+import cPickle as pickle
+
+from zope.interface import implements
+
+from mailman.config import config
+from mailman.database.message import Message
+from mailman.interfaces.messages import IMessageStore
+from mailman.utilities.filesystem import makedirs
+
+
+# It could be very bad if you have already stored files and you change this
+# value. We'd need a script to reshuffle and resplit.
+MAX_SPLITS = 2
+EMPTYSTRING = ''
+
+
+
+class MessageStore:
+ implements(IMessageStore)
+
+ def add(self, message):
+ # Ensure that the message has the requisite headers.
+ message_ids = message.get_all('message-id', [])
+ if len(message_ids) <> 1:
+ raise ValueError('Exactly one Message-ID header required')
+ # Calculate and insert the X-Message-ID-Hash.
+ message_id = message_ids[0]
+ # Complain if the Message-ID already exists in the storage.
+ existing = config.db.store.find(Message,
+ Message.message_id == message_id).one()
+ if existing is not None:
+ raise ValueError(
+ 'Message ID already exists in message store: {0}'.format(
+ message_id))
+ shaobj = hashlib.sha1(message_id)
+ hash32 = base64.b32encode(shaobj.digest())
+ del message['X-Message-ID-Hash']
+ message['X-Message-ID-Hash'] = hash32
+ # Calculate the path on disk where we're going to store this message
+ # object, in pickled format.
+ parts = []
+ split = list(hash32)
+ while split and len(parts) < MAX_SPLITS:
+ parts.append(split.pop(0) + split.pop(0))
+ parts.append(hash32)
+ relpath = os.path.join(*parts)
+ # Store the message in the database. This relies on the database
+ # providing a unique serial number, but to get this information, we
+ # have to use a straight insert instead of relying on Elixir to create
+ # the object.
+ row = Message(message_id=message_id,
+ message_id_hash=hash32,
+ path=relpath)
+ # Now calculate the full file system path.
+ path = os.path.join(config.MESSAGES_DIR, relpath)
+ # Write the file to the path, but catch the appropriate exception in
+ # case the parent directories don't yet exist. In that case, create
+ # them and try again.
+ while True:
+ try:
+ with open(path, 'w') as fp:
+ # -1 says to use the highest protocol available.
+ pickle.dump(message, fp, -1)
+ break
+ except IOError as error:
+ if error.errno <> errno.ENOENT:
+ raise
+ makedirs(os.path.dirname(path))
+ return hash32
+
+ def _get_message(self, row):
+ path = os.path.join(config.MESSAGES_DIR, row.path)
+ with open(path) as fp:
+ return pickle.load(fp)
+
+ def get_message_by_id(self, message_id):
+ row = config.db.store.find(Message, message_id=message_id).one()
+ if row is None:
+ return None
+ return self._get_message(row)
+
+ def get_message_by_hash(self, message_id_hash):
+ # It's possible the hash came from a message header, in which case it
+ # will be a Unicode. However when coming from source code, it may be
+ # an 8-string. Coerce to the latter if necessary; it must be
+ # US-ASCII.
+ if isinstance(message_id_hash, unicode):
+ message_id_hash = message_id_hash.encode('ascii')
+ row = config.db.store.find(Message,
+ message_id_hash=message_id_hash).one()
+ if row is None:
+ return None
+ return self._get_message(row)
+
+ @property
+ def messages(self):
+ for row in config.db.store.find(Message):
+ yield self._get_message(row)
+
+ def delete_message(self, message_id):
+ row = config.db.store.find(Message, message_id=message_id).one()
+ if row is None:
+ raise LookupError(message_id)
+ path = os.path.join(config.MESSAGES_DIR, row.path)
+ os.remove(path)
+ config.db.store.remove(row)
diff --git a/src/mailman/database/model.py b/src/mailman/database/model.py
new file mode 100644
index 000000000..85fa033c7
--- /dev/null
+++ b/src/mailman/database/model.py
@@ -0,0 +1,56 @@
+# Copyright (C) 2006-2009 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/>.
+
+"""Base class for all database classes."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'Model',
+ ]
+
+from storm.properties import PropertyPublisherMeta
+
+
+
+class ModelMeta(PropertyPublisherMeta):
+ """Do more magic on table classes."""
+
+ _class_registry = set()
+
+ def __init__(self, name, bases, dict):
+ # Before we let the base class do it's thing, force an __storm_table__
+ # property to enforce our table naming convention.
+ self.__storm_table__ = name.lower()
+ super(ModelMeta, self).__init__(name, bases, dict)
+ # Register the model class so that it can be more easily cleared.
+ # This is required by the test framework.
+ if name == 'Model':
+ return
+ ModelMeta._class_registry.add(self)
+
+ @staticmethod
+ def _reset(store):
+ for model_class in ModelMeta._class_registry:
+ store.find(model_class).remove()
+
+
+
+class Model:
+ """Like Storm's `Storm` subclass, but with a bit extra."""
+ __metaclass__ = ModelMeta
diff --git a/src/mailman/database/pending.py b/src/mailman/database/pending.py
new file mode 100644
index 000000000..f4c2057e0
--- /dev/null
+++ b/src/mailman/database/pending.py
@@ -0,0 +1,177 @@
+# Copyright (C) 2007-2009 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/>.
+
+"""Implementations of the IPendable and IPending interfaces."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'Pended',
+ 'Pendings',
+ ]
+
+import sys
+import time
+import random
+import hashlib
+import datetime
+
+from lazr.config import as_timedelta
+from storm.locals import *
+from zope.interface import implements
+from zope.interface.verify import verifyObject
+
+from mailman.config import config
+from mailman.database.model import Model
+from mailman.interfaces.pending import (
+ IPendable, IPended, IPendedKeyValue, IPendings)
+
+
+
+class PendedKeyValue(Model):
+ """A pended key/value pair, tied to a token."""
+
+ implements(IPendedKeyValue)
+
+ def __init__(self, key, value):
+ self.key = key
+ self.value = value
+
+ id = Int(primary=True)
+ key = Unicode()
+ value = Unicode()
+ pended_id = Int()
+
+
+class Pended(Model):
+ """A pended event, tied to a token."""
+
+ implements(IPended)
+
+ def __init__(self, token, expiration_date):
+ super(Pended, self).__init__()
+ self.token = token
+ self.expiration_date = expiration_date
+
+ id = Int(primary=True)
+ token = RawStr()
+ expiration_date = DateTime()
+ key_values = ReferenceSet(id, PendedKeyValue.pended_id)
+
+
+
+class UnpendedPendable(dict):
+ implements(IPendable)
+
+
+
+class Pendings:
+ """Implementation of the IPending interface."""
+
+ implements(IPendings)
+
+ def add(self, pendable, lifetime=None):
+ verifyObject(IPendable, pendable)
+ # Calculate the token and the lifetime.
+ if lifetime is None:
+ lifetime = as_timedelta(config.mailman.pending_request_life)
+ # Calculate a unique token. Algorithm vetted by the Timbot. time()
+ # has high resolution on Linux, clock() on Windows. random gives us
+ # about 45 bits in Python 2.2, 53 bits on Python 2.3. The time and
+ # clock values basically help obscure the random number generator, as
+ # does the hash calculation. The integral parts of the time values
+ # are discarded because they're the most predictable bits.
+ for attempts in range(3):
+ now = time.time()
+ x = random.random() + now % 1.0 + time.clock() % 1.0
+ # Use sha1 because it produces shorter strings.
+ token = hashlib.sha1(repr(x)).hexdigest()
+ # In practice, we'll never get a duplicate, but we'll be anal
+ # about checking anyway.
+ if config.db.store.find(Pended, token=token).count() == 0:
+ break
+ else:
+ raise AssertionError('Could not find a valid pendings token')
+ # Create the record, and then the individual key/value pairs.
+ pending = Pended(
+ token=token,
+ expiration_date=datetime.datetime.now() + lifetime)
+ for key, value in pendable.items():
+ if isinstance(key, str):
+ key = unicode(key, 'utf-8')
+ if isinstance(value, str):
+ value = unicode(value, 'utf-8')
+ elif type(value) is int:
+ value = '__builtin__.int\1%s' % value
+ elif type(value) is float:
+ value = '__builtin__.float\1%s' % value
+ elif type(value) is bool:
+ value = '__builtin__.bool\1%s' % value
+ elif type(value) is list:
+ # We expect this to be a list of strings.
+ value = ('mailman.database.pending.unpack_list\1' +
+ '\2'.join(value))
+ keyval = PendedKeyValue(key=key, value=value)
+ pending.key_values.add(keyval)
+ config.db.store.add(pending)
+ return token
+
+ def confirm(self, token, expunge=True):
+ store = config.db.store
+ pendings = store.find(Pended, token=token)
+ if pendings.count() == 0:
+ return None
+ assert pendings.count() == 1, (
+ 'Unexpected token count: {0}'.format(pendings.count()))
+ pending = pendings[0]
+ pendable = UnpendedPendable()
+ # Find all PendedKeyValue entries that are associated with the pending
+ # object's ID. Watch out for type conversions.
+ for keyvalue in store.find(PendedKeyValue,
+ PendedKeyValue.pended_id == pending.id):
+ if keyvalue.value is not None and '\1' in keyvalue.value:
+ typename, value = keyvalue.value.split('\1', 1)
+ package, classname = typename.rsplit('.', 1)
+ __import__(package)
+ module = sys.modules[package]
+ pendable[keyvalue.key] = getattr(module, classname)(value)
+ else:
+ pendable[keyvalue.key] = keyvalue.value
+ if expunge:
+ store.remove(keyvalue)
+ if expunge:
+ store.remove(pending)
+ return pendable
+
+ def evict(self):
+ store = config.db.store
+ now = datetime.datetime.now()
+ for pending in store.find(Pended):
+ if pending.expiration_date < now:
+ # Find all PendedKeyValue entries that are associated with the
+ # pending object's ID.
+ q = store.find(PendedKeyValue,
+ PendedKeyValue.pended_id == pending.id)
+ for keyvalue in q:
+ store.remove(keyvalue)
+ store.remove(pending)
+
+
+
+def unpack_list(value):
+ return value.split('\2')
diff --git a/src/mailman/database/preferences.py b/src/mailman/database/preferences.py
new file mode 100644
index 000000000..f3ee55673
--- /dev/null
+++ b/src/mailman/database/preferences.py
@@ -0,0 +1,50 @@
+# Copyright (C) 2006-2009 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/>.
+
+"""Model for preferences."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'Preferences',
+ ]
+
+
+from storm.locals import *
+from zope.interface import implements
+
+from mailman.database.model import Model
+from mailman.database.types import Enum
+from mailman.interfaces.preferences import IPreferences
+
+
+
+class Preferences(Model):
+ implements(IPreferences)
+
+ id = Int(primary=True)
+ acknowledge_posts = Bool()
+ hide_address = Bool()
+ preferred_language = Unicode()
+ receive_list_copy = Bool()
+ receive_own_postings = Bool()
+ delivery_mode = Enum()
+ delivery_status = Enum()
+
+ def __repr__(self):
+ return '<Preferences object at {0:#x}>'.format(id(self))
diff --git a/src/mailman/database/requests.py b/src/mailman/database/requests.py
new file mode 100644
index 000000000..249feb6b6
--- /dev/null
+++ b/src/mailman/database/requests.py
@@ -0,0 +1,138 @@
+# Copyright (C) 2007-2009 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/>.
+
+"""Implementations of the IRequests and IListRequests interfaces."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'Requests',
+ ]
+
+
+from datetime import timedelta
+from storm.locals import *
+from zope.interface import implements
+
+from mailman.config import config
+from mailman.database.model import Model
+from mailman.database.types import Enum
+from mailman.interfaces.pending import IPendable
+from mailman.interfaces.requests import IListRequests, IRequests, RequestType
+
+
+
+class DataPendable(dict):
+ implements(IPendable)
+
+
+
+class ListRequests:
+ implements(IListRequests)
+
+ def __init__(self, mailing_list):
+ self.mailing_list = mailing_list
+
+ @property
+ def count(self):
+ return config.db.store.find(
+ _Request, mailing_list=self.mailing_list).count()
+
+ def count_of(self, request_type):
+ return config.db.store.find(
+ _Request,
+ mailing_list=self.mailing_list, request_type=request_type).count()
+
+ @property
+ def held_requests(self):
+ results = config.db.store.find(
+ _Request, mailing_list=self.mailing_list)
+ for request in results:
+ yield request
+
+ def of_type(self, request_type):
+ results = config.db.store.find(
+ _Request,
+ mailing_list=self.mailing_list, request_type=request_type)
+ for request in results:
+ yield request
+
+ def hold_request(self, request_type, key, data=None):
+ if request_type not in RequestType:
+ raise TypeError(request_type)
+ if data is None:
+ data_hash = None
+ else:
+ # We're abusing the pending database as a way of storing arbitrary
+ # key/value pairs, where both are strings. This isn't ideal but
+ # it lets us get auxiliary data almost for free. We may need to
+ # lock this down more later.
+ pendable = DataPendable()
+ pendable.update(data)
+ token = config.db.pendings.add(pendable, timedelta(days=5000))
+ data_hash = token
+ request = _Request(key, request_type, self.mailing_list, data_hash)
+ config.db.store.add(request)
+ return request.id
+
+ def get_request(self, request_id):
+ result = config.db.store.get(_Request, request_id)
+ if result is None:
+ return None
+ if result.data_hash is None:
+ return result.key, result.data_hash
+ pendable = config.db.pendings.confirm(result.data_hash, expunge=False)
+ data = dict()
+ data.update(pendable)
+ return result.key, data
+
+ def delete_request(self, request_id):
+ request = config.db.store.get(_Request, request_id)
+ if request is None:
+ raise KeyError(request_id)
+ # Throw away the pended data.
+ config.db.pendings.confirm(request.data_hash)
+ config.db.store.remove(request)
+
+
+
+class Requests:
+ implements(IRequests)
+
+ def get_list_requests(self, mailing_list):
+ return ListRequests(mailing_list)
+
+
+
+class _Request(Model):
+ """Table for mailing list hold requests."""
+
+ id = Int(primary=True, default=AutoReload)
+ key = Unicode()
+ request_type = Enum()
+ data_hash = RawStr()
+
+ mailing_list_id = Int()
+ mailing_list = Reference(mailing_list_id, 'MailingList.id')
+
+ def __init__(self, key, request_type, mailing_list, data_hash):
+ super(_Request, self).__init__()
+ self.key = key
+ self.request_type = request_type
+ self.mailing_list = mailing_list
+ self.data_hash = data_hash
diff --git a/src/mailman/database/roster.py b/src/mailman/database/roster.py
new file mode 100644
index 000000000..fc0a24c7d
--- /dev/null
+++ b/src/mailman/database/roster.py
@@ -0,0 +1,270 @@
+# Copyright (C) 2007-2009 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/>.
+
+"""An implementation of an IRoster.
+
+These are hard-coded rosters which know how to filter a set of members to find
+the ones that fit a particular role. These are used as the member, owner,
+moderator, and administrator roster filters.
+"""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'AdministratorRoster',
+ 'DigestMemberRoster',
+ 'MemberRoster',
+ 'Memberships',
+ 'ModeratorRoster',
+ 'OwnerRoster',
+ 'RegularMemberRoster',
+ 'Subscribers',
+ ]
+
+
+from storm.locals import *
+from zope.interface import implements
+
+from mailman.config import config
+from mailman.database.address import Address
+from mailman.database.member import Member
+from mailman.interfaces.member import DeliveryMode, MemberRole
+from mailman.interfaces.roster import IRoster
+
+
+
+class AbstractRoster:
+ """An abstract IRoster class.
+
+ This class takes the simple approach of implemented the 'users' and
+ 'addresses' properties in terms of the 'members' property. This may not
+ be the most efficient way, but it works.
+
+ This requires that subclasses implement the 'members' property.
+ """
+ implements(IRoster)
+
+ role = None
+
+ def __init__(self, mlist):
+ self._mlist = mlist
+
+ @property
+ def members(self):
+ for member in config.db.store.find(
+ Member,
+ mailing_list=self._mlist.fqdn_listname,
+ role=self.role):
+ yield member
+
+ @property
+ def users(self):
+ # Members are linked to addresses, which in turn are linked to users.
+ # So while the 'members' attribute does most of the work, we have to
+ # keep a set of unique users. It's possible for the same user to be
+ # subscribed to a mailing list multiple times with different
+ # addresses.
+ users = set(member.address.user for member in self.members)
+ for user in users:
+ yield user
+
+ @property
+ def addresses(self):
+ # Every Member is linked to exactly one address so the 'members'
+ # attribute does most of the work.
+ for member in self.members:
+ yield member.address
+
+ def get_member(self, address):
+ results = config.db.store.find(
+ Member,
+ Member.mailing_list == self._mlist.fqdn_listname,
+ Member.role == self.role,
+ Address.address == address,
+ Member.address_id == Address.id)
+ if results.count() == 0:
+ return None
+ elif results.count() == 1:
+ return results[0]
+ else:
+ raise AssertionError(
+ 'Too many matching member results: {0}'.format(
+ results.count()))
+
+
+
+class MemberRoster(AbstractRoster):
+ """Return all the members of a list."""
+
+ name = 'member'
+ role = MemberRole.member
+
+
+
+class OwnerRoster(AbstractRoster):
+ """Return all the owners of a list."""
+
+ name = 'owner'
+ role = MemberRole.owner
+
+
+
+class ModeratorRoster(AbstractRoster):
+ """Return all the owners of a list."""
+
+ name = 'moderator'
+ role = MemberRole.moderator
+
+
+
+class AdministratorRoster(AbstractRoster):
+ """Return all the administrators of a list."""
+
+ name = 'administrator'
+
+ @property
+ def members(self):
+ # Administrators are defined as the union of the owners and the
+ # moderators.
+ members = config.db.store.find(
+ Member,
+ Member.mailing_list == self._mlist.fqdn_listname,
+ Or(Member.role == MemberRole.owner,
+ Member.role == MemberRole.moderator))
+ for member in members:
+ yield member
+
+ def get_member(self, address):
+ results = config.db.store.find(
+ Member,
+ Member.mailing_list == self._mlist.fqdn_listname,
+ Or(Member.role == MemberRole.moderator,
+ Member.role == MemberRole.owner),
+ Address.address == address,
+ Member.address_id == Address.id)
+ if results.count() == 0:
+ return None
+ elif results.count() == 1:
+ return results[0]
+ else:
+ raise AssertionError(
+ 'Too many matching member results: {0}'.format(results))
+
+
+
+class RegularMemberRoster(AbstractRoster):
+ """Return all the regular delivery members of a list."""
+
+ name = 'regular_members'
+
+ @property
+ def members(self):
+ # Query for all the Members which have a role of MemberRole.member and
+ # are subscribed to this mailing list. Then return only those members
+ # that have a regular delivery mode.
+ for member in config.db.store.find(
+ Member,
+ mailing_list=self._mlist.fqdn_listname,
+ role=MemberRole.member):
+ if member.delivery_mode == DeliveryMode.regular:
+ yield member
+
+
+
+_digest_modes = (
+ DeliveryMode.mime_digests,
+ DeliveryMode.plaintext_digests,
+ DeliveryMode.summary_digests,
+ )
+
+
+
+class DigestMemberRoster(AbstractRoster):
+ """Return all the regular delivery members of a list."""
+
+ name = 'digest_members'
+
+ @property
+ def members(self):
+ # Query for all the Members which have a role of MemberRole.member and
+ # are subscribed to this mailing list. Then return only those members
+ # that have one of the digest delivery modes.
+ for member in config.db.store.find(
+ Member,
+ mailing_list=self._mlist.fqdn_listname,
+ role=MemberRole.member):
+ if member.delivery_mode in _digest_modes:
+ yield member
+
+
+
+class Subscribers(AbstractRoster):
+ """Return all subscribed members regardless of their role."""
+
+ name = 'subscribers'
+
+ @property
+ def members(self):
+ for member in config.db.store.find(
+ Member,
+ mailing_list=self._mlist.fqdn_listname):
+ yield member
+
+
+
+class Memberships:
+ """A roster of a single user's memberships."""
+
+ implements(IRoster)
+
+ name = 'memberships'
+
+ def __init__(self, user):
+ self._user = user
+
+ @property
+ def members(self):
+ results = config.db.store.find(
+ Member,
+ Address.user_id == self._user.id,
+ Member.address_id == Address.id)
+ for member in results:
+ yield member
+
+ @property
+ def users(self):
+ yield self._user
+
+ @property
+ def addresses(self):
+ for address in self._user.addresses:
+ yield address
+
+ def get_member(self, address):
+ results = config.db.store.find(
+ Member,
+ Member.address_id == Address.id,
+ Address.user_id == self._user.id)
+ if results.count() == 0:
+ return None
+ elif results.count() == 1:
+ return results[0]
+ else:
+ raise AssertionError(
+ 'Too many matching member results: {0}'.format(
+ results.count()))
diff --git a/src/mailman/database/transaction.py b/src/mailman/database/transaction.py
new file mode 100644
index 000000000..d42562389
--- /dev/null
+++ b/src/mailman/database/transaction.py
@@ -0,0 +1,53 @@
+# Copyright (C) 2006-2009 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/>.
+
+"""Transactional support."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'txn',
+ ]
+
+
+from mailman.config import config
+
+
+
+class txn(object):
+ """Decorator for transactional support.
+
+ When the function this decorator wraps exits cleanly, the current
+ transaction is committed. When it exits uncleanly (i.e. because of an
+ exception, the transaction is aborted.
+
+ Either way, the current transaction is completed.
+ """
+ def __init__(self, function):
+ self._function = function
+
+ def __get__(self, obj, type=None):
+ def wrapper(*args, **kws):
+ try:
+ rtn = self._function(obj, *args, **kws)
+ config.db.commit()
+ return rtn
+ except:
+ config.db.abort()
+ raise
+ return wrapper
diff --git a/src/mailman/database/types.py b/src/mailman/database/types.py
new file mode 100644
index 000000000..2f901fe49
--- /dev/null
+++ b/src/mailman/database/types.py
@@ -0,0 +1,64 @@
+# Copyright (C) 2007-2009 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/>.
+
+"""Storm type conversions."""
+
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'Enum',
+ ]
+
+
+import sys
+
+from storm.properties import SimpleProperty
+from storm.variables import Variable
+
+
+
+class _EnumVariable(Variable):
+ """Storm variable."""
+
+ def parse_set(self, value, from_db):
+ if value is None:
+ return None
+ if not from_db:
+ return value
+ path, intvalue = value.rsplit(':', 1)
+ modulename, classname = path.rsplit('.', 1)
+ __import__(modulename)
+ cls = getattr(sys.modules[modulename], classname)
+ return cls[int(intvalue)]
+
+ def parse_get(self, value, to_db):
+ if value is None:
+ return None
+ if not to_db:
+ return value
+ return '{0}.{1}:{2}'.format(
+ value.enumclass.__module__,
+ value.enumclass.__name__,
+ int(value))
+
+
+class Enum(SimpleProperty):
+ """Custom munepy.Enum type for Storm."""
+
+ variable_class = _EnumVariable
diff --git a/src/mailman/database/user.py b/src/mailman/database/user.py
new file mode 100644
index 000000000..23701686b
--- /dev/null
+++ b/src/mailman/database/user.py
@@ -0,0 +1,94 @@
+# Copyright (C) 2007-2009 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/>.
+
+"""Model for users."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'User',
+ ]
+
+from storm.locals import *
+from zope.interface import implements
+
+from mailman.config import config
+from mailman.database.model import Model
+from mailman.database.address import Address
+from mailman.database.preferences import Preferences
+from mailman.database.roster import Memberships
+from mailman.interfaces.address import (
+ AddressAlreadyLinkedError, AddressNotLinkedError)
+from mailman.interfaces.user import IUser
+
+
+
+class User(Model):
+ """Mailman users."""
+
+ implements(IUser)
+
+ id = Int(primary=True)
+ real_name = Unicode()
+ password = Unicode()
+
+ addresses = ReferenceSet(id, 'Address.user_id')
+ preferences_id = Int()
+ preferences = Reference(preferences_id, 'Preferences.id')
+
+ def __repr__(self):
+ return '<User "{0}" at {1:#x}>'.format(self.real_name, id(self))
+
+ def link(self, address):
+ """See `IUser`."""
+ if address.user is not None:
+ raise AddressAlreadyLinkedError(address)
+ address.user = self
+
+ def unlink(self, address):
+ """See `IUser`."""
+ if address.user is None:
+ raise AddressNotLinkedError(address)
+ address.user = None
+
+ def controls(self, address):
+ """See `IUser`."""
+ found = config.db.store.find(Address, address=address)
+ if found.count() == 0:
+ return False
+ assert found.count() == 1, 'Unexpected count'
+ return found[0].user is self
+
+ def register(self, address, real_name=None):
+ """See `IUser`."""
+ # First, see if the address already exists
+ addrobj = config.db.store.find(Address, address=address).one()
+ if addrobj is None:
+ if real_name is None:
+ real_name = ''
+ addrobj = Address(address=address, real_name=real_name)
+ addrobj.preferences = Preferences()
+ # Link the address to the user if it is not already linked.
+ if addrobj.user is not None:
+ raise AddressAlreadyLinkedError(addrobj)
+ addrobj.user = self
+ return addrobj
+
+ @property
+ def memberships(self):
+ return Memberships(self)
diff --git a/src/mailman/database/usermanager.py b/src/mailman/database/usermanager.py
new file mode 100644
index 000000000..3b0c8b534
--- /dev/null
+++ b/src/mailman/database/usermanager.py
@@ -0,0 +1,103 @@
+# Copyright (C) 2007-2009 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/>.
+
+"""A user manager."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'UserManager',
+ ]
+
+from zope.interface import implements
+
+from mailman.config import config
+from mailman.database.address import Address
+from mailman.database.preferences import Preferences
+from mailman.database.user import User
+from mailman.interfaces.address import ExistingAddressError
+from mailman.interfaces.usermanager import IUserManager
+
+
+
+class UserManager(object):
+ implements(IUserManager)
+
+ def create_user(self, address=None, real_name=None):
+ user = User()
+ user.real_name = ('' if real_name is None else real_name)
+ if address:
+ addrobj = Address(address, user.real_name)
+ addrobj.preferences = Preferences()
+ user.link(addrobj)
+ user.preferences = Preferences()
+ config.db.store.add(user)
+ return user
+
+ def delete_user(self, user):
+ config.db.store.remove(user)
+
+ @property
+ def users(self):
+ for user in config.db.store.find(User):
+ yield user
+
+ def get_user(self, address):
+ addresses = config.db.store.find(Address, address=address.lower())
+ if addresses.count() == 0:
+ return None
+ elif addresses.count() == 1:
+ return addresses[0].user
+ else:
+ raise AssertionError('Unexpected query count')
+
+ def create_address(self, address, real_name=None):
+ addresses = config.db.store.find(Address, address=address.lower())
+ if addresses.count() == 1:
+ found = addresses[0]
+ raise ExistingAddressError(found.original_address)
+ assert addresses.count() == 0, 'Unexpected results'
+ if real_name is None:
+ real_name = ''
+ # It's okay not to lower case the 'address' argument because the
+ # constructor will do the right thing.
+ address = Address(address, real_name)
+ address.preferences = Preferences()
+ config.db.store.add(address)
+ return address
+
+ def delete_address(self, address):
+ # If there's a user controlling this address, it has to first be
+ # unlinked before the address can be deleted.
+ if address.user:
+ address.user.unlink(address)
+ config.db.store.remove(address)
+
+ def get_address(self, address):
+ addresses = config.db.store.find(Address, address=address.lower())
+ if addresses.count() == 0:
+ return None
+ elif addresses.count() == 1:
+ return addresses[0]
+ else:
+ raise AssertionError('Unexpected query count')
+
+ @property
+ def addresses(self):
+ for address in config.db.store.find(Address):
+ yield address
diff --git a/src/mailman/database/version.py b/src/mailman/database/version.py
new file mode 100644
index 000000000..d15065395
--- /dev/null
+++ b/src/mailman/database/version.py
@@ -0,0 +1,40 @@
+# Copyright (C) 2007-2009 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/>.
+
+"""Model class for version numbers."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'Version',
+ ]
+
+from storm.locals import *
+from mailman.database.model import Model
+
+
+
+class Version(Model):
+ id = Int(primary=True)
+ component = Unicode()
+ version = Int()
+
+ def __init__(self, component, version):
+ super(Version, self).__init__()
+ self.component = component
+ self.version = version