diff options
Diffstat (limited to 'src/mailman/database')
| -rw-r--r-- | src/mailman/database/__init__.py | 153 | ||||
| -rw-r--r-- | src/mailman/database/address.py | 96 | ||||
| -rw-r--r-- | src/mailman/database/language.py | 40 | ||||
| -rw-r--r-- | src/mailman/database/listmanager.py | 82 | ||||
| -rw-r--r-- | src/mailman/database/mailinglist.py | 272 | ||||
| -rw-r--r-- | src/mailman/database/mailman.sql | 208 | ||||
| -rw-r--r-- | src/mailman/database/member.py | 105 | ||||
| -rw-r--r-- | src/mailman/database/message.py | 53 | ||||
| -rw-r--r-- | src/mailman/database/messagestore.py | 137 | ||||
| -rw-r--r-- | src/mailman/database/model.py | 56 | ||||
| -rw-r--r-- | src/mailman/database/pending.py | 177 | ||||
| -rw-r--r-- | src/mailman/database/preferences.py | 50 | ||||
| -rw-r--r-- | src/mailman/database/requests.py | 138 | ||||
| -rw-r--r-- | src/mailman/database/roster.py | 270 | ||||
| -rw-r--r-- | src/mailman/database/transaction.py | 53 | ||||
| -rw-r--r-- | src/mailman/database/types.py | 64 | ||||
| -rw-r--r-- | src/mailman/database/user.py | 94 | ||||
| -rw-r--r-- | src/mailman/database/usermanager.py | 103 | ||||
| -rw-r--r-- | src/mailman/database/version.py | 40 |
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 |
