summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/mailman/interfaces/preferences.py6
-rw-r--r--src/mailman/interfaces/user.py8
-rw-r--r--src/mailman/model/preferences.py14
-rw-r--r--src/mailman/model/tests/test_preferences.py89
-rw-r--r--src/mailman/model/tests/test_user.py114
-rw-r--r--src/mailman/model/user.py31
6 files changed, 262 insertions, 0 deletions
diff --git a/src/mailman/interfaces/preferences.py b/src/mailman/interfaces/preferences.py
index 4b5b490cb..84f85820a 100644
--- a/src/mailman/interfaces/preferences.py
+++ b/src/mailman/interfaces/preferences.py
@@ -68,3 +68,9 @@ class IPreferences(Interface):
which means that no preference is specified.
XXX I'm not sure this is the right place to put this.""")
+
+ def absorb(preferences):
+ """Merge these preferences, and then delete them.
+
+ Only this instance's unset preferences (None/NULL) will be imported.
+ """
diff --git a/src/mailman/interfaces/user.py b/src/mailman/interfaces/user.py
index f1932fd7f..bdeccce3f 100644
--- a/src/mailman/interfaces/user.py
+++ b/src/mailman/interfaces/user.py
@@ -109,3 +109,11 @@ class IUser(Interface):
preferences = Attribute(
"""This user's preferences.""")
+
+ def absorb(user):
+ """Merge this user's attributes and memberships, and then delete it.
+
+ In case of conflict, the current user's properties are preserved.
+ If an IAddress is given, the merge will be performed on the addresses'
+ linked user.
+ """
diff --git a/src/mailman/model/preferences.py b/src/mailman/model/preferences.py
index 53bfe6975..22d22ed94 100644
--- a/src/mailman/model/preferences.py
+++ b/src/mailman/model/preferences.py
@@ -20,6 +20,7 @@
from mailman import public
from mailman.database.model import Model
from mailman.database.types import Enum
+from mailman.database.transaction import dbconnection
from mailman.interfaces.languages import ILanguageManager
from mailman.interfaces.member import DeliveryMode, DeliveryStatus
from mailman.interfaces.preferences import IPreferences
@@ -43,6 +44,8 @@ class Preferences(Model):
receive_own_postings = Column(Boolean)
delivery_mode = Column(Enum(DeliveryMode))
delivery_status = Column(Enum(DeliveryStatus))
+ # When adding new columns, also add them to
+ # mailman.model.tests.test_preferences.TestPreferences.test_absorb_all_attributes()
def __repr__(self):
return '<Preferences object at {:#x}>'.format(id(self))
@@ -62,3 +65,14 @@ class Preferences(Model):
self._preferred_language = language.code
except AttributeError:
self._preferred_language = language
+
+ @dbconnection
+ def absorb(self, store, preferences):
+ """See `IPreferences`."""
+ column_names = [ c.name for c in self.__table__.columns
+ if not c.primary_key ]
+ for cname in column_names:
+ if (getattr(self, cname) is None and
+ getattr(preferences, cname) is not None):
+ setattr(self, cname, getattr(preferences, cname))
+ store.delete(preferences)
diff --git a/src/mailman/model/tests/test_preferences.py b/src/mailman/model/tests/test_preferences.py
new file mode 100644
index 000000000..7b9d25356
--- /dev/null
+++ b/src/mailman/model/tests/test_preferences.py
@@ -0,0 +1,89 @@
+# Copyright (C) 2015 by the Free Software Foundation, Inc.
+#
+# This file is part of GNU Mailman.
+#
+# GNU Mailman is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option)
+# any later version.
+#
+# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
+
+"""Test preferences."""
+
+__all__ = [
+ 'TestPreferences',
+ ]
+
+
+import unittest
+
+from mailman.app.lifecycle import create_list
+from mailman.database.transaction import transaction
+from mailman.interfaces.usermanager import IUserManager
+from mailman.interfaces.languages import ILanguageManager
+from mailman.interfaces.member import DeliveryMode, DeliveryStatus
+from mailman.model.preferences import Preferences
+from mailman.testing.layers import ConfigLayer
+from zope.component import getUtility
+
+
+
+class TestPreferences(unittest.TestCase):
+ """Test preferences."""
+
+ layer = ConfigLayer
+
+ def setUp(self):
+ self._manager = getUtility(IUserManager)
+ with transaction():
+ # This has to happen in a transaction so that both the user and
+ # the preferences objects get valid ids.
+ self._mlist = create_list('test@example.com')
+ self._anne = self._manager.create_user(
+ 'anne@example.com', 'Anne Person')
+ self._bill = self._manager.create_user(
+ 'bill@example.com', 'Bill Person')
+
+ def test_absorb_all_attributes(self):
+ attributes = {
+ 'acknowledge_posts': True,
+ 'hide_address': True,
+ 'preferred_language':
+ getUtility(ILanguageManager)['fr'],
+ 'receive_list_copy': True,
+ 'receive_own_postings': True,
+ 'delivery_mode': DeliveryMode.mime_digests,
+ 'delivery_status': DeliveryStatus.by_user,
+ }
+ bill_prefs = self._bill.preferences
+ for name, value in attributes.items():
+ setattr(bill_prefs, name, value)
+ self._anne.preferences.absorb(self._bill.preferences)
+ for name, value in attributes.items():
+ self.assertEqual(getattr(self._anne.preferences, name), value)
+
+ def test_absorb_overwrite(self):
+ # Only overwrite the pref if it is unset in the absorber
+ anne_prefs = self._anne.preferences
+ bill_prefs = self._bill.preferences
+ self.assertEqual(self._anne.preferences.acknowledge_posts, None)
+ self.assertEqual(self._anne.preferences.hide_address, None)
+ self.assertEqual(self._anne.preferences.receive_list_copy, None)
+ anne_prefs.acknowledge_posts = False
+ bill_prefs.acknowledge_posts = True
+ anne_prefs.hide_address = True
+ bill_prefs.receive_list_copy = True
+ self._anne.preferences.absorb(self._bill.preferences)
+ # set for both anne and bill, don't overwrite
+ self.assertEqual(self._anne.preferences.acknowledge_posts, False)
+ # set only for anne
+ self.assertEqual(self._anne.preferences.hide_address, True)
+ # set only for bill, overwrite anne's default value
+ self.assertEqual(self._anne.preferences.receive_list_copy, True)
diff --git a/src/mailman/model/tests/test_user.py b/src/mailman/model/tests/test_user.py
index 7c8a3581e..7aaf78343 100644
--- a/src/mailman/model/tests/test_user.py
+++ b/src/mailman/model/tests/test_user.py
@@ -26,6 +26,7 @@ from mailman.interfaces.address import (
AddressAlreadyLinkedError, AddressNotLinkedError)
from mailman.interfaces.user import UnverifiedAddressError
from mailman.interfaces.usermanager import IUserManager
+from mailman.interfaces.member import MemberRole
from mailman.model.preferences import Preferences
from mailman.testing.helpers import set_preferred
from mailman.testing.layers import ConfigLayer
@@ -120,3 +121,116 @@ class TestUser(unittest.TestCase):
preferences = config.db.store.query(Preferences).filter_by(
id=user.preferences.id)
self.assertEqual(preferences.count(), 0)
+
+ def test_absorb_addresses(self):
+ anne_addr = self._anne.preferred_address
+ with transaction():
+ # This has to happen in a transaction so that both the user and
+ # the preferences objects get valid ids.
+ bill = self._manager.create_user(
+ 'bill@example.com', 'Bill Person')
+ bill_addr_2 = self._manager.create_address('bill2@example.com')
+ bill.link(bill_addr_2)
+ self._anne.absorb(bill)
+ self.assertIn('bill@example.com',
+ list(a.email for a in self._anne.addresses))
+ self.assertIn('bill2@example.com',
+ list(a.email for a in self._anne.addresses))
+ # the preferred address shouldn't change
+ self.assertEqual(self._anne.preferred_address, anne_addr)
+
+ def test_absorb_memberships(self):
+ mlist2 = create_list('test2@example.com')
+ mlist3 = create_list('test3@example.com')
+ with transaction():
+ # This has to happen in a transaction so that both the user and
+ # the preferences objects get valid ids.
+ bill = self._manager.create_user(
+ 'bill@example.com', 'Bill Person')
+ bill_address = list(bill.addresses)[0]
+ bill_address.verified_on = now()
+ bill.preferred_address = bill_address
+ # Subscribe both users to self._mlist
+ self._mlist.subscribe(self._anne, MemberRole.member)
+ self._mlist.subscribe(bill, MemberRole.moderator)
+ # Subscribe only bill to mlist2
+ mlist2.subscribe(bill, MemberRole.owner)
+ # Subscribe only bill's address to mlist3
+ mlist3.subscribe(bill.preferred_address, MemberRole.moderator)
+ self._anne.absorb(bill)
+ # check that anne is subscribed to all lists
+ self.assertEqual(self._anne.memberships.member_count, 3)
+ memberships = {}
+ for member in self._anne.memberships.members:
+ memberships[member.list_id] = member
+ self.assertEqual(
+ set(memberships.keys()),
+ set(['test.example.com', 'test2.example.com', 'test3.example.com']))
+ # The subscription to test@example.com already existed, it must not be
+ # overwritten.
+ self.assertEqual(
+ memberships['test.example.com'].role, MemberRole.member)
+ # Check that the subscription roles were imported
+ self.assertEqual(
+ memberships['test2.example.com'].role, MemberRole.owner)
+ self.assertEqual(
+ memberships['test3.example.com'].role, MemberRole.moderator)
+ # The user bill was subscribed, the subscription must thus be
+ # transferred to anne's primary address.
+ self.assertEqual(
+ memberships['test2.example.com'].address,
+ self._anne.preferred_address)
+ # The address was subscribed, it must not be changed
+ self.assertEqual(
+ memberships['test3.example.com'].address.email,
+ 'bill@example.com')
+
+ def test_absorb_preferences(self):
+ with transaction():
+ # This has to happen in a transaction so that both the user and
+ # the preferences objects get valid ids.
+ bill = self._manager.create_user(
+ 'bill@example.com', 'Bill Person')
+ bill.preferences.acknowledge_posts = True
+ self.assertIsNone(self._anne.preferences.acknowledge_posts)
+ self._anne.absorb(bill)
+ self.assertEqual(self._anne.preferences.acknowledge_posts, True)
+
+ def test_absorb_properties(self):
+ props = {
+ 'password': 'dummy',
+ 'is_server_owner': True
+ }
+ with transaction():
+ # This has to happen in a transaction so that both the user and
+ # the preferences objects get valid ids.
+ bill = self._manager.create_user(
+ 'bill@example.com', 'Bill Person')
+ for prop, value in props.items():
+ setattr(bill, prop, value)
+ self._anne.absorb(bill)
+ for prop, value in props.items():
+ self.assertEqual(getattr(self._anne, prop), value)
+ # This was not empty so it must not be overwritten
+ self.assertEqual(self._anne.display_name, 'Anne Person')
+
+ def test_absorb_delete_user(self):
+ # Make sure the user was deleted
+ with transaction():
+ # This has to happen in a transaction so that both the user and
+ # the preferences objects get valid ids.
+ bill = self._manager.create_user(
+ 'bill@example.com', 'Bill Person')
+ bill_user_id = bill.user_id
+ self._anne.absorb(bill)
+ self.assertIsNone(self._manager.get_user_by_id(bill_user_id))
+
+ def test_absorb_self(self):
+ # Absorbing oneself should be a no-op (it must not delete the user)
+ self._mlist.subscribe(self._anne)
+ self._anne.absorb(self._anne)
+ new_anne = self._manager.get_user_by_id(self._anne.user_id)
+ self.assertIsNotNone(new_anne)
+ self.assertEqual(
+ [a.email for a in new_anne.addresses], ['anne@example.com'])
+ self.assertEqual(new_anne.memberships.member_count, 1)
diff --git a/src/mailman/model/user.py b/src/mailman/model/user.py
index 767600bdc..3d02b2777 100644
--- a/src/mailman/model/user.py
+++ b/src/mailman/model/user.py
@@ -26,10 +26,12 @@ from mailman.interfaces.address import (
from mailman.interfaces.user import (
IUser, PasswordChangeEvent, UnverifiedAddressError)
from mailman.model.address import Address
+from mailman.model.member import Member
from mailman.model.preferences import Preferences
from mailman.model.roster import Memberships
from mailman.utilities.datetime import factory as date_factory
from mailman.utilities.uid import UIDFactory
+from sqlalchemy import not_
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, Unicode
from sqlalchemy.orm import backref, relationship
from zope.event import notify
@@ -174,6 +176,35 @@ class User(Model):
def memberships(self):
return Memberships(self)
+ @dbconnection
+ def absorb(self, store, user):
+ """See `IUser`."""
+ assert user is not None
+ if user.id == self.id:
+ return # Protect against absorbing oneself.
+ # Relink addresses.
+ for address in list(user.addresses):
+ # convert to list because we'll mutate the result
+ address.user = self
+ # Merge memberships.
+ other_members = store.query(Member).filter(
+ Member.user_id == user.id)
+ # (only import memberships of lists I'm not subscribed to yet)
+ subscribed_lists = [ m.list_id for m in self.memberships.members ]
+ if subscribed_lists:
+ other_members = other_members.filter(
+ not_(Member.list_id.in_(subscribed_lists)))
+ for member in other_members:
+ member.user_id = self.id
+ # Merge the user preferences
+ self.preferences.absorb(user.preferences)
+ # Merge display_name, password and is_server_owner attributes.
+ for prop in ('display_name', 'password', 'is_server_owner'):
+ if getattr(user, prop) and not getattr(self, prop):
+ setattr(self, prop, getattr(user, prop))
+ # Delete the other user.
+ store.delete(user)
+
@public
class DomainOwner(Model):