diff options
| -rw-r--r-- | Mailman/Defaults.py.in | 21 | ||||
| -rw-r--r-- | Mailman/MailList.py | 22 | ||||
| -rw-r--r-- | Mailman/MemberAdaptor.py | 57 | ||||
| -rw-r--r-- | Mailman/OldStyleMemberships.py | 15 | ||||
| -rw-r--r-- | Mailman/SAMemberships.py | 330 | ||||
| -rw-r--r-- | Mailman/configuration.py | 2 | ||||
| -rw-r--r-- | Mailman/testing/base.py | 9 | ||||
| -rw-r--r-- | Mailman/testing/emailbase.py | 1 | ||||
| -rw-r--r-- | misc/mailman.cfg.sample | 33 |
9 files changed, 440 insertions, 50 deletions
diff --git a/Mailman/Defaults.py.in b/Mailman/Defaults.py.in index e843bc249..0c5a6f648 100644 --- a/Mailman/Defaults.py.in +++ b/Mailman/Defaults.py.in @@ -97,6 +97,27 @@ HTML_TO_PLAIN_TEXT_COMMAND = '/usr/bin/lynx -dump %(filename)s' ##### +# Database options +##### + +# Specify the name of the membership adaptor class that implements the +# MemberAdaptor interface you want to use. The first is traditional +# pickle-based adapter that was standard for Mailman 2.1 and earlier. +MEMBER_ADAPTOR_CLASS = 'Mailman.OldStyleMemberships.OldStyleMemberships' + +# This is the SQLAlchemy-based adaptor which lets you store all membership +# data in any of several supported RDBMs. +#MEMBER_ADAPTOR_CLASS = 'Mailman.SAMemberships.SAMemberships' + +# If you choose the SAMemberships adaptor, set this variable to specify the +# connection url to the backend database engine. Specify the placeholder +# $listdir for the directory that list data is stored in, and $listname for +# the name of the mailing list. +SQLALCHEMY_ENGINE_URL = 'sqlite:///$listdir/members.db' + + + +##### # Virtual domains ##### diff --git a/Mailman/MailList.py b/Mailman/MailList.py index 953ac02d7..c834ab9c6 100644 --- a/Mailman/MailList.py +++ b/Mailman/MailList.py @@ -68,10 +68,10 @@ from Mailman import Gui from Mailman import i18n from Mailman import MemberAdaptor from Mailman import Message -from Mailman.OldStyleMemberships import OldStyleMemberships _ = i18n._ +DOT = '.' EMPTYSTRING = '' OR = '|' @@ -89,7 +89,7 @@ class MailList(HTMLFormatter, Deliverer, ListAdmin, # # A MailList object's basic Python object model support # - def __init__(self, name=None, lock=1): + def __init__(self, name=None, lock=True): # No timeout by default. If you want to timeout, open the list # unlocked, then lock explicitly. # @@ -99,8 +99,13 @@ class MailList(HTMLFormatter, Deliverer, ListAdmin, baseclass.__init__(self) # Initialize volatile attributes self.InitTempVars(name) - # Default membership adaptor class - self._memberadaptor = OldStyleMemberships(self) + # Attach a membership adaptor instance. + parts = config.MEMBER_ADAPTOR_CLASS.split(DOT) + adaptor_class = parts.pop() + adaptor_module = DOT.join(parts) + __import__(adaptor_module) + mod = sys.modules[adaptor_module] + self._memberadaptor = getattr(mod, adaptor_class)(self) # This extension mechanism allows list-specific overrides of any # method (well, except __init__(), InitTempVars(), and InitVars() # I think). Note that fullpath() will return None when we're creating @@ -157,6 +162,7 @@ class MailList(HTMLFormatter, Deliverer, ListAdmin, # def Lock(self, timeout=0): self.__lock.lock(timeout) + self._memberadaptor.lock() # Must reload our database for consistency. Watch out for lists that # don't exist. try: @@ -166,7 +172,8 @@ class MailList(HTMLFormatter, Deliverer, ListAdmin, raise def Unlock(self): - self.__lock.unlock(unconditionally=1) + self.__lock.unlock(unconditionally=True) + self._memberadaptor.unlock() def Locked(self): return self.__lock.locked() @@ -280,9 +287,10 @@ class MailList(HTMLFormatter, Deliverer, ListAdmin, lifetime=config.LIST_LOCK_LIFETIME) # XXX FIXME Sometimes name is fully qualified, sometimes it's not. if name and '@' in name: - self._internal_name, email_host = name.split('@', 1) + self._internal_name, self.host_name = name.split('@', 1) else: self._internal_name = name + self.host_name = config.DEFAULT_EMAIL_HOST if name: self._full_path = os.path.join(config.LIST_DATA_DIR, name) else: @@ -571,6 +579,7 @@ class MailList(HTMLFormatter, Deliverer, ListAdmin, # the lock (which is a serious problem!). TBD: do we need to be more # defensive? self.__lock.refresh() + self._memberadaptor.save() # copy all public attributes to serializable dictionary dict = {} for key, value in self.__dict__.items(): @@ -639,6 +648,7 @@ class MailList(HTMLFormatter, Deliverer, ListAdmin, fqdn_listname = self.fqdn_listname if not Utils.list_exists(fqdn_listname): raise Errors.MMUnknownListError + self._memberadaptor.load() # We first try to load config.pck, which contains the up-to-date # version of the database. If that fails, perhaps because it's # corrupted or missing, we'll try to load the backup file diff --git a/Mailman/MemberAdaptor.py b/Mailman/MemberAdaptor.py index bcaf1ace0..2b5d248c5 100644 --- a/Mailman/MemberAdaptor.py +++ b/Mailman/MemberAdaptor.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2003 by the Free Software Foundation, Inc. +# Copyright (C) 2001-2006 by the Free Software Foundation, Inc. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License @@ -12,7 +12,8 @@ # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. """This is an interface to list-specific membership information. @@ -56,6 +57,47 @@ BYBOUNCE = 4 # disabled by bounces class MemberAdaptor: + def __init__(self, mlist): + """Create the adaptor, attached to the given mailing list.""" + raise NotImplementedError + + # + # Transaction interface + # + def load(self): + """Called when the mailing list data is loaded. + + The adaptor should refresh/clear all in-memory objects. The reason is + that other processes may have changed the database since the last time + the adaptor has been accessed. + """ + + def lock(self): + """Called when the mailing list is locked. + + This should correspond to a database 'begin'. + """ + raise NotImplementedError + + def save(self): + """Called when a locked mailing list is saved. + + This should correspond to a database 'commit' except that the adaptor + should record that the database has been saved, and this flag should + be checked in any subsequent unlock() calls. + """ + raise NotImplementedError + + def unlock(self): + """Called when a locked mailing list is unlocked. + + Generally, this can be no-op'd if save() was previously called, but if + not, then a database 'rollback' should be issued. There is no + explicit transaction rollback operation in the MailList API, but + processes will unlock without saving to mean the same thing. + """ + raise NotImplementedError + # # The readable interface # @@ -181,6 +223,9 @@ class MemberAdaptor: def getDeliveryStatusChangeTime(self, member): """Return the time of the last disabled delivery status change. + Time is returned in float seconds since the epoch. XXX this should be + a Python datetime. + If the current delivery status is ENABLED, the status change time will be zero. If member is not a member of the list, raise NotAMemberError. @@ -254,7 +299,7 @@ class MemberAdaptor: """ raise NotImplementedError - def changeMemberAddress(self, memberkey, newaddress, nodelete=0): + def changeMemberAddress(self, memberkey, newaddress, nodelete=False): """Change the address for the member KEY. memberkey will be a KEY, not an LCE. newaddress should be the @@ -273,8 +318,6 @@ class MemberAdaptor: """Set the password for member LCE/KEY. If member does not refer to a valid member, raise NotAMemberError. - Also raise BadPasswordError if the password is illegal (e.g. too - short or easily guessed via a dictionary attack). """ raise NotImplementedError @@ -282,8 +325,6 @@ class MemberAdaptor: """Set the language for the member LCE/KEY. If member does not refer to a valid member, raise NotAMemberError. - Also raise BadLanguageError if the language is invalid (e.g. the list - is not configured to support the given language). """ raise NotImplementedError @@ -294,8 +335,6 @@ class MemberAdaptor: Default.py, and value is a boolean. If member does not refer to a valid member, raise NotAMemberError. - Also raise BadOptionError if the flag does not refer to a valid - option. """ raise NotImplementedError diff --git a/Mailman/OldStyleMemberships.py b/Mailman/OldStyleMemberships.py index 04f3d1a7c..8961c2825 100644 --- a/Mailman/OldStyleMemberships.py +++ b/Mailman/OldStyleMemberships.py @@ -47,6 +47,17 @@ class OldStyleMemberships(MemberAdaptor.MemberAdaptor): self.__mlist = mlist # + # Transaction interface + # + + # These are all no-op'd because the data is all attached to and managed by + # the MailList object. + def load(self): pass + def lock(self): pass + def save(self): pass + def unlock(self): pass + + # # Read interface # def getMembers(self): @@ -271,6 +282,10 @@ class OldStyleMemberships(MemberAdaptor.MemberAdaptor): # toggling the Digests flag, then we need to move their entry from # mlist.members to mlist.digest_members or vice versa. Blarg. Do # this before the flag setting below in case it fails. + # + # XXX Adaptors should not be doing these semantic integrity checks, + # but for backward compatibility I'm not changing this. New adaptors + # should not mimic this behavior. if flag == mm_cfg.Digests: if value: # Be sure the list supports digest delivery diff --git a/Mailman/SAMemberships.py b/Mailman/SAMemberships.py new file mode 100644 index 000000000..dec76fe58 --- /dev/null +++ b/Mailman/SAMemberships.py @@ -0,0 +1,330 @@ +# Copyright (C) 2006 by the Free Software Foundation, Inc. +# +# This program 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 2 +# of the License, or (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""An experimental SQLAlchemy-based membership adaptor.""" + +# XXX THIS FILE DOES NOT YET WORK! + +import os +import re +import time + +from sqlalchemy import * +from string import Template + +from Mailman import Defaults +from Mailman import Errors +from Mailman import MemberAdaptor +from Mailman import Utils +from Mailman.configuration import config + +NUL = '\0' + + + +# Python classes representing the data in the SQLAlchemy database. These will +# be associated with tables via object mappers. + +class Member(object): + def __init__(self, mlist, + address, realname=None, password=None, + digests_p=False, language=None): + self.lckey = address.lower() + self.address = address + self.realname = realname + self.password = password or Utils.MakeRandomPassword() + self.language = language or mlist.preferred_language + self.digests_p = digests_p + self.options = mlist.new_member_options + self.topics = '' + self.status = MemberAdaptor.ENABLED + # XXX This should really be a datetime + self.disable_time = 0 + + + +_table = None +_metadata = None +_mapper = None + +def create_table(): + global _table, _metadata, _mapper + + if _table: + return + _metadata = MetaData('table metadata') + _table = Table( + 'members', _metadata, + Column('member_id', Integer, primary_key=True), + Column('lckey', Unicode(), index=True, nullable=False), + Column('address', Unicode(), index=True, nullable=False), + Column('realname', Unicode()), + Column('password', Unicode()), + Column('language', String(2)), + Column('digest', Boolean), + Column('options', Integer), + Column('status', Integer), + Column('disable_time', Float), + ) + _mapper = mapper(Member, _table) + + + +class SAMemberships(MemberAdaptor.MemberAdaptor): + def __init__(self, mlist): + self._mlist = mlist + self._metadata = None + self._session = None + self._txn = None + + def _connect(self): + create_table() + # We cannot connect in the __init__() because our adaptor requires the + # fqdn_listname to exist. In MailList.Create() that won't be the case. + # + # Calculate the engine url, expanding placeholder variables. + engine_url = Template(config.SQLALCHEMY_ENGINE_URL).substitute( + {'listname' : self._mlist.fqdn_listname, + 'listdir' : os.path.join(config.LIST_DATA_DIR, + self._mlist.fqdn_listname), + }) + print 'engine_url:', engine_url + self._engine = create_engine(engine_url) + self._session = create_session(bind_to=self._engine) + self._session.bind_table(_table, self._engine) + self._session.bind_mapper(_mapper, self._engine) + # XXX There must be a better way to figure out whether the tables need + # to be created or not. + try: + _table.create() + except exceptions.SQLError: + pass + + # + # The transaction interface + # + + def load(self): + if self._session is None: + self._connect() + assert self._txn is None + self._session.clear() + self._txn = self._session.create_transaction() + + def lock(self): + pass + + def save(self): + # When a MailList is first Create()'d, the load() callback doesn't get + # called, so there will be no transaction. + if self._txn: + self._txn.commit() + self._txn = None + + def unlock(self): + if self._txn is not None: + # The MailList has not been saved, but it is being unlocked, so + # throw away all pending changes. + self._txn.rollback() + self._txn = None + + # + # The readable interface + # + + def getMembers(self): + return [m.lckey for m in self._session.query(Member).select_by()] + + def getRegularMemberKeys(self): + query = self._session.query(Member) + return [m.lckey for m in query.select(Member.c.digests_p == False)] + + def getDigestMemberKeys(self): + query = self._session.query(Member) + return [m.lckey for m in query.select(Member.c.digests_p == True)] + + def _get_member(self, member): + members = self._session.query(Member).select_by(lckey=member.lower()) + if not members: + return None + assert len(members) == 1 + return members[0] + + def _get_member_strict(self, member): + member_obj = self._get_member(member) + if not member_obj: + raise Errors.NotAMemberError(member) + return member_obj + + def isMember(self, member): + return bool(self._get_member(member)) + + def getMemberKey(self, member): + self._get_member_strict(member) + return member.lower() + + def getMemberCPAddress(self, member): + return self._get_member(member).address + + def getMemberCPAddresses(self, members): + query = self._session.query(Member) + return [user.address for user in query.select( + in_(Member.c.lckey, [m.lower() for m in members]))] + + def getMemberPassword(self, member): + return self._get_member_strict(member).password + + def authenticateMember(self, member, response): + return self._get_member_strict(member).password == response + + def getMemberLanguage(self, member): + member = self._get_member(member) + if member and member.language in self._mlist.GetAvailableLanguages(): + return member.language + return self._mlist.preferred_language + + def getMemberOption(self, member, flag): + return bool(self._get_member_strict(member).options & flag) + + def getMemberName(self, member): + return self._get_member_strict(member).realname + + def getMemberTopics(self, member): + topics = self._get_member_strict(member).topics + if not topics: + return [] + return topics.split(NUL) + + def getDeliveryStatus(self, member): + return self._get_member_strict(member).status + + def getDeliveryStatusChangeTime(self, member): + member = self._get_member_strict(member) + if member.status == MemberAdaptor.ENABLED: + return 0 + return member.disable_time + + def getDeliveryStatusMembers(self, status=(MemberAdaptor.UNKNOWN, + MemberAdaptor.BYUSER, + MemberAdaptor.BYADMIN, + MemberAdaptor.BYBOUNCE)): + query = self._session.query(Member) + return [user.lckey for user in query.select( + in_(Member.c.status, status))] + + def getBouncingMembers(self): + "XXX" + + def getBounceInfo(self): + "XXX" + + # + # The writable interface + # + + def addNewMember(self, member, **kws): + assert self._mlist.Locked() + if self.isMember(member): + raise Errors.MMAlreadyAMember(member) + try: + new_member = Member(self._mlist, member, **kws) + self._session.save(new_member) + self._session.flush() + except TypeError: + # Transform exception to API specification + raise ValueError + + def removeMember(self, memberkey): + assert self._mlist.Locked() + member = self._get_member_strict(memberkey) + self._session.delete(member) + + def changeMemberAddress(self, memberkey, newaddress, nodelete=False): + assert self._mlist.Locked() + member = self._get_member_strict(memberkey) + # First, add the new member from the previous data + self.addNewMember(newaddress, member.realname, member.password, + member.digests_p, member.language) + new_member = self._get_member(newaddress) + assert new_member + new_member.options = member.options + new_member.topics = member.topics + new_member.status = MemberAdaptor.ENABLED + new_member.disable_time = 0 + if not nodelete: + self._session.delete(member) + + def setMemberPassword(self, member, password): + assert self._mlist.Locked() + self._get_member_strict(member).password = password + + def setMemberLanguage(self, member, language): + assert self._mlist.Locked() + self._get_member_strict(member).language = language + + def setMemberOption(self, member, flag, value): + assert self._mlist.Locked() + member = self._get_member_strict(member) + # XXX the OldStyleMemberships adaptor will raise CantDigestError, + # MustDigestError, AlreadyReceivingDigests, and + # AlreadyReceivingRegularDeliveries in certain cases depending on the + # configuration of the mailing list and the member's delivery status. + # These semantics are not defined in the API so to keep things simple, + # I am not reproducing them here. Ideally, adaptors should not be + # doing semantic integrity checks, but I'm also not going to change + # the OldStyleMemberships adaptor. + # + # We still need to handle digests differently, because they aren't + # really represented as a unique flag in the options bitfield. + if flag == Defaults.Digests: + member.digests_p = bool(value) + else: + if value: + member.options |= flag + else: + member.options &= ~flag + + def setMemberName(self, member, realname): + assert self._mlist.Locked() + self._get_member_strict(member).realname = realname + + def setMemberTopics(self, member, topics): + assert self._mlist.Locked() + # For simplicity, we represent a user's topics of interest as a + # null-joined string, which will be split properly by the accessor. + if not topics: + topics = None + else: + topics = NUL.join(topics) + self._get_member_strict(member).topics = topics + + def setDeliveryStatus(self, member, status): + assert status in (MemberAdaptor.ENABLED, MemberAdaptor.UNKNOWN, + MemberAdaptor.BYUSER, MemberAdaptor.BYADMIN, + MemberAdaptor.BYBOUNCE) + assert self._mlist.Locked() + member = self._get_member_strict(member) + if status == MemberAdaptor.ENABLED: + # XXX zap bounce info + disable_time = 0 + else: + disable_time = time.time() + member.disable_time = disable_time + member.status = status + + def setBounceInfo(self, member, info): + "XXX" diff --git a/Mailman/configuration.py b/Mailman/configuration.py index 1f1c52f19..6ee94b911 100644 --- a/Mailman/configuration.py +++ b/Mailman/configuration.py @@ -58,7 +58,7 @@ class Configuration(object): raise # The file didn't exist, so try mm_cfg.py from Mailman import mm_cfg - ns = mm_cfg.__dict__.copy() + ns.update(mm_cfg.__dict__) # Pull out the defaults PREFIX = ns['PREFIX'] VAR_PREFIX = ns['VAR_PREFIX'] diff --git a/Mailman/testing/base.py b/Mailman/testing/base.py index 589ab0abb..a65c62bdd 100644 --- a/Mailman/testing/base.py +++ b/Mailman/testing/base.py @@ -37,6 +37,9 @@ PERMISSIONS = stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH class TestBase(unittest.TestCase): def _configure(self, fp): +## print >> fp, \ +## "MEMBER_ADAPTOR_CLASS = 'Mailman.SAMemberships.SAMemberships'" +## config.MEMBER_ADAPTOR_CLASS = 'Mailman.SAMemberships.SAMemberships' print >> fp, 'add_domain("example.com", "www.example.com")' # Only add this domain once to the current process if 'example.com' not in config.domains: @@ -66,6 +69,11 @@ class TestBase(unittest.TestCase): mlist = MailList.MailList() mlist.Create('_xtest@example.com', 'owner@example.com', 'xxxxx') mlist.Save() + # We need to reload the mailing list to ensure that the member + # adaptors are all sync'd up. This isn't strictly necessary with the + # OldStyleMemberships adaptor, but it may be required for other + # adaptors + mlist.Load() # This leaves the list in a locked state self._mlist = mlist @@ -83,3 +91,4 @@ class TestBase(unittest.TestCase): os.unlink(dir) elif os.path.isdir(dir): shutil.rmtree(dir) + os.unlink(self._config) diff --git a/Mailman/testing/emailbase.py b/Mailman/testing/emailbase.py index 48cb163e2..53b915abb 100644 --- a/Mailman/testing/emailbase.py +++ b/Mailman/testing/emailbase.py @@ -86,7 +86,6 @@ class EmailBase(TestBase): else: raise TestBase.tearDown(self) - os.remove(self._config) def _readmsg(self): global MSGTEXT diff --git a/misc/mailman.cfg.sample b/misc/mailman.cfg.sample index 4cc4ec280..dcf38e313 100644 --- a/misc/mailman.cfg.sample +++ b/misc/mailman.cfg.sample @@ -31,36 +31,3 @@ To use this, copy this file to $VAR_PREFIX/etc/mailman.cfg Mailman's installation procedure will never overwrite mailman.cfg. """ -# -*- python -*- - -# Copyright (C) 2006 by the Free Software Foundation, Inc. -# -# This program 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 2 -# of the License, or (at your option) any later version. -# -# This program 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 this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, -# USA. - -"""This module contains your site-specific settings. - -Use this to override the default settings in Mailman/Defaults.py. You only -need to include those settings that you want to change, and unlike the old -mm_cfg.py file, you do /not/ need to import Defaults. Its variables will -automatically be available in this module's namespace. - -You should consult Defaults.py though for a complete listing of configuration -variables that you can change. - -To use this, copy this file to $VAR_PREFIX/etc/mailman.cfg - -Mailman's installation procedure will never overwrite mailman.cfg. -""" |
