← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~edwin-grubbs/launchpad/bug-615654-queue-addmember-emails into lp:launchpad

 

Edwin Grubbs has proposed merging lp:~edwin-grubbs/launchpad/bug-615654-queue-addmember-emails into lp:launchpad with lp:~edwin-grubbs/launchpad/bug-615654-registry-jobqueue-schema as a prerequisite.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  #615654 +editproposedmembers times out when working with many candidates
  https://bugs.launchpad.net/bugs/615654


Summary
-------

This branch adds the MembershipNotificationJob class to send emails
after the team membership status has changed, e.g. proposed, approved,
admin, expired.

A follow-up branch will be needed in order to add a cronjob to actually
process the job queue. This branch can't land until then.

Implementation details
----------------------

lib/lp/registry/model/teammembership.py

    lib/lp/registry/configure.zcml
    lib/lp/registry/enum.py
    lib/lp/registry/interfaces/persontransferjob.py
    lib/lp/registry/model/persontransferjob.py

Tests:
    lib/lp/registry/tests/test_persontransferjob.py
    lib/lp/testing/mail_helpers.py
    lib/lp/registry/doc/teammembership-email-notification.txt

Tests
-----

./bin/test -vv -t 'teammembership-email-notification.txt|test_persontransferjob'
-- 
https://code.launchpad.net/~edwin-grubbs/launchpad/bug-615654-queue-addmember-emails/+merge/35862
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~edwin-grubbs/launchpad/bug-615654-queue-addmember-emails into lp:launchpad.
=== modified file 'lib/lp/registry/configure.zcml'
--- lib/lp/registry/configure.zcml	2010-09-14 10:51:25 +0000
+++ lib/lp/registry/configure.zcml	2010-09-17 18:21:19 +0000
@@ -84,6 +84,23 @@
                 __call__"/>
     </class>
 
+    <!-- IPersonTransferJob -->
+    <securedutility
+        component="lp.registry.model.persontransferjob.MembershipNotificationJob"
+        provides="lp.registry.interfaces.persontransferjob.IMembershipNotificationJobSource">
+        <allow
+            interface="lp.registry.interfaces.persontransferjob.IMembershipNotificationJobSource"/>
+    </securedutility>
+
+    <class class="lp.registry.model.persontransferjob.PersonTransferJob">
+        <allow interface="lp.registry.interfaces.persontransferjob.IPersonTransferJob"/>
+    </class>
+
+    <class class="lp.registry.model.persontransferjob.MembershipNotificationJob">
+        <allow
+            interface="lp.registry.interfaces.persontransferjob.IMembershipNotificationJob"/>
+    </class>
+
     <!-- Location -->
 
     <class

=== modified file 'lib/lp/registry/doc/teammembership-email-notification.txt'
--- lib/lp/registry/doc/teammembership-email-notification.txt	2010-03-26 00:45:36 +0000
+++ lib/lp/registry/doc/teammembership-email-notification.txt	2010-09-17 18:21:19 +0000
@@ -24,7 +24,7 @@
 
     >>> from lp.services.mail import stub
     >>> from lp.testing.mail_helpers import (
-    ...     pop_notifications, print_distinct_emails)
+    ...     pop_notifications, print_distinct_emails, run_mail_jobs)
 
     >>> from zope.component import getUtility
     >>> from canonical.launchpad.interfaces import (
@@ -49,7 +49,7 @@
     >>> membership.status.title
     'Proposed'
 
-    >>> transaction.commit()
+    >>> run_mail_jobs()
     >>> len(stub.test_emails)
     5
     >>> print_distinct_emails(include_reply_to=True)
@@ -87,6 +87,11 @@
     # that team.
     >>> login('mark@xxxxxxxxxxx')
     >>> setStatus(membership, TeamMembershipStatus.DECLINED, reviewer=mark)
+
+addMember() has queued up a job to send out the emails. We'll run the
+job now.
+
+    >>> run_mail_jobs()
     >>> len(stub.test_emails)
     6
 
@@ -125,6 +130,7 @@
 
     >>> setStatus(daf_membership, TeamMembershipStatus.APPROVED,
     ...           reviewer=mark, comment='This is a nice guy; I like him')
+    >>> run_mail_jobs()
     >>> stub.test_emails.sort(by_to_addrs)
     >>> len(stub.test_emails)
     6
@@ -158,6 +164,7 @@
 
     >>> setStatus(daf_membership, TeamMembershipStatus.DEACTIVATED,
     ...           reviewer=mark)
+    >>> run_mail_jobs()
     >>> stub.test_emails.sort(by_to_addrs)
     >>> len(stub.test_emails)
     6
@@ -187,7 +194,7 @@
 
     >>> admins = personset.getByName('admins')
     >>> admins.join(ubuntu_team, requester=mark)
-    >>> transaction.commit()
+    >>> run_mail_jobs()
     >>> len(stub.test_emails)
     5
     >>> print_distinct_emails(include_reply_to=True)
@@ -228,7 +235,10 @@
     >>> cprov = personset.getByName('cprov')
     >>> marilize = personset.getByName('marilize')
     >>> ignored = ubuntu_team.addMember(marilize, reviewer=cprov)
-    >>> transaction.commit()
+    >>> run_mail_jobs()
+
+Now, the emails have been sent.
+
     >>> len(stub.test_emails)
     6
     >>> print_distinct_emails()
@@ -277,7 +287,7 @@
     >>> mirror_admins.getTeamAdminsEmailAddresses()
     ['mark@xxxxxxxxxxx']
     >>> ignored = ubuntu_team.addMember(mirror_admins, reviewer=cprov)
-    >>> transaction.commit()
+    >>> run_mail_jobs()
     >>> len(stub.test_emails)
     1
     >>> print_distinct_emails()
@@ -304,7 +314,7 @@
     >>> comment = "Of course I want to be part of ubuntu!"
     >>> mirror_admins.acceptInvitationToBeMemberOf(ubuntu_team, comment)
     >>> flush_database_updates()
-    >>> transaction.commit()
+    >>> run_mail_jobs()
 
     >>> len(stub.test_emails)
     6
@@ -337,7 +347,7 @@
     >>> comment = "Landscape has nothing to do with ubuntu, unfortunately."
     >>> landscape.declineInvitationToBeMemberOf(ubuntu_team, comment)
     >>> flush_database_updates()
-    >>> transaction.commit()
+    >>> run_mail_jobs()
 
     >>> len(stub.test_emails)
     7
@@ -363,7 +373,7 @@
     >>> ignored = ubuntu_team.addMember(
     ...     launchpad, reviewer=cprov, force_team_add=True)
     >>> flush_database_updates()
-    >>> transaction.commit()
+    >>> run_mail_jobs()
     >>> len(stub.test_emails)
     5
     >>> print_distinct_emails()
@@ -610,7 +620,7 @@
     >>> membershipset.handleMembershipsExpiringToday(
     ...     reviewer=getUtility(ILaunchpadCelebrities).janitor)
     >>> flush_database_updates()
-    >>> transaction.commit()
+    >>> run_mail_jobs()
 
     >>> len(stub.test_emails)
     8
@@ -690,7 +700,7 @@
 
     >>> login_person(karl)
     >>> karl.renewTeamMembership(mirror_admins)
-    >>> transaction.commit()
+    >>> run_mail_jobs()
     >>> len(stub.test_emails)
     1
 
@@ -713,7 +723,7 @@
 approved to admin, but he won't get a notification of that.
 
     >>> team = personset.newTeam(mark, 'testteam', 'Test')
-    >>> transaction.commit()
+    >>> run_mail_jobs()
     >>> len(stub.test_emails)
     0
 
@@ -730,6 +740,7 @@
     >>> login('mark@xxxxxxxxxxx')
     >>> setStatus(
     ...     cprov_membership, TeamMembershipStatus.ADMIN, reviewer=mark)
+    >>> run_mail_jobs()
     >>> len(stub.test_emails)
     6
     >>> print_distinct_emails()
@@ -760,6 +771,7 @@
     >>> jdub_membership = membershipset.getByPersonAndTeam(jdub, ubuntu_team)
     >>> setStatus(jdub_membership, TeamMembershipStatus.APPROVED,
     ...           reviewer=jdub)
+    >>> run_mail_jobs()
     >>> len(stub.test_emails)
     5
     >>> print_distinct_emails()
@@ -784,6 +796,7 @@
     ...     mirror_admins, ubuntu_team)
     >>> setStatus(mirror_admins_membership, TeamMembershipStatus.DEACTIVATED,
     ...           reviewer=mark, silent=False)
+    >>> run_mail_jobs()
     >>> len(stub.test_emails)
     6
 
@@ -811,6 +824,7 @@
     Approved
     >>> setStatus(dumper_hwdb_membership,
     ...     TeamMembershipStatus.DEACTIVATED, reviewer=mark, silent=True)
+    >>> run_mail_jobs()
     >>> len(stub.test_emails)
     0
     >>> print dumper_hwdb_membership.status.title

=== modified file 'lib/lp/registry/enum.py'
--- lib/lp/registry/enum.py	2010-09-10 10:08:58 +0000
+++ lib/lp/registry/enum.py	2010-09-17 18:21:19 +0000
@@ -5,6 +5,7 @@
 
 __metaclass__ = type
 __all__ = [
+    'PersonTransferJobType',
     'BugNotificationLevel',
     'DistroSeriesDifferenceStatus',
     'DistroSeriesDifferenceType',
@@ -82,6 +83,7 @@
         This difference has been resolved and versions are now equal.
         """)
 
+
 class DistroSeriesDifferenceType(DBEnumeratedType):
     """Distribution series difference type."""
 
@@ -104,3 +106,13 @@
 
         This package is present in both series with different versions.
         """)
+
+
+class PersonTransferJobType(DBEnumeratedType):
+    """Values that IPersonTransferJob.job_type can take."""
+
+    MEMBERSHIP_NOTIFICATION = DBItem(0, """
+        Add-member notification
+
+        Notify affected users of new team membership.
+        """)

=== added file 'lib/lp/registry/interfaces/persontransferjob.py'
--- lib/lp/registry/interfaces/persontransferjob.py	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/interfaces/persontransferjob.py	2010-09-17 18:21:19 +0000
@@ -0,0 +1,73 @@
+# Copyright 2010 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Interfaces for the Jobs system to change memberships or merge persons."""
+
+__metaclass__ = type
+__all__ = [
+    'IMembershipNotificationJob',
+    'IMembershipNotificationJobSource',
+    'IPersonTransferJob',
+    'IPersonTransferJobSource',
+    ]
+
+from zope.interface import (
+    Attribute,
+    Interface,
+    )
+from zope.schema import (
+    Int,
+    Object,
+    )
+
+from canonical.launchpad import _
+from lp.services.fields import PublicPersonChoice
+from lp.services.job.interfaces.job import (
+    IJob,
+    IJobSource,
+    IRunnableJob,
+    )
+
+
+class IPersonTransferJob(IRunnableJob):
+    """A Job related to team membership or a person merge."""
+
+    id = Int(
+        title=_('DB ID'), required=True, readonly=True,
+        description=_("The tracking number for this job."))
+
+    job = Object(
+        title=_('The common Job attributes'),
+        schema=IJob,
+        required=True)
+
+    minor_person = PublicPersonChoice(
+        title=_('The person being added to the major person/team'),
+        vocabulary='ValidPersonOrTeam',
+        required=True)
+
+    major_person = PublicPersonChoice(
+        title=_('The person or team receiving the minor person'),
+        vocabulary='ValidPersonOrTeam',
+        required=True)
+
+    metadata = Attribute('A dict of data about the job.')
+
+
+class IPersonTransferJobSource(IJobSource):
+    """An interface for acquiring IPersonTransferJobs."""
+
+    def create(minor_person, major_person, metadata):
+        """Create a new IPersonTransferJob."""
+
+
+class IMembershipNotificationJob(IPersonTransferJob):
+    """A Job to notify new members of a team of that change."""
+
+
+class IMembershipNotificationJobSource(IJobSource):
+    """An interface for acquiring IMembershipNotificationJobs."""
+
+    def create(member, team, reviewer, old_status, new_status,
+               last_change_comment=None):
+        """Create a new IMembershipNotificationJob."""

=== added file 'lib/lp/registry/model/persontransferjob.py'
--- lib/lp/registry/model/persontransferjob.py	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/model/persontransferjob.py	2010-09-17 18:21:19 +0000
@@ -0,0 +1,322 @@
+# Copyright 2010 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Job classes related to PersonTransferJob are in here."""
+
+__metaclass__ = type
+__all__ = [
+    'MembershipNotificationJob',
+    'PersonTransferJob',
+    ]
+
+from lazr.delegates import delegates
+import simplejson
+from sqlobject import SQLObjectNotFound
+from storm.base import Storm
+from storm.expr import And
+from storm.locals import (
+    Int,
+    Reference,
+    Unicode,
+    )
+from zope.component import getUtility
+from zope.interface import (
+    classProvides,
+    implements,
+    )
+
+
+from canonical.config import config
+from canonical.database.enumcol import EnumCol
+from canonical.launchpad.helpers import (
+    get_contact_email_addresses,
+    get_email_template,
+    )
+from canonical.launchpad.interfaces.lpstorm import (
+    IMasterStore,
+    IStore,
+    )
+from canonical.launchpad.mail import (
+    format_address,
+    simple_sendmail,
+    )
+from canonical.launchpad.mailnotification import MailWrapper
+from canonical.launchpad.webapp import canonical_url
+
+
+from lp.registry.enum import PersonTransferJobType
+from lp.registry.interfaces.person import (
+    IPerson,
+    IPersonSet,
+    ITeam,
+    )
+from lp.registry.interfaces.persontransferjob import (
+    IPersonTransferJob,
+    IPersonTransferJobSource,
+    IMembershipNotificationJob,
+    IMembershipNotificationJobSource,
+    )
+from lp.registry.interfaces.teammembership import TeamMembershipStatus
+from lp.registry.model.person import Person
+from lp.services.job.model.job import Job
+from lp.services.job.runner import BaseRunnableJob
+
+
+class PersonTransferJob(Storm):
+    """Base class for team membership and person merge jobs."""
+
+    implements(IPersonTransferJob)
+
+    __storm_table__ = 'PersonTransferJob'
+
+    id = Int(primary=True)
+
+    job_id = Int(name='job')
+    job = Reference(job_id, Job.id)
+
+    major_person_id = Int(name='major_person')
+    major_person = Reference(major_person_id, Person.id)
+
+    minor_person_id = Int(name='minor_person')
+    minor_person = Reference(minor_person_id, Person.id)
+
+    job_type = EnumCol(enum=PersonTransferJobType, notNull=True)
+
+    _json_data = Unicode('json_data')
+
+    @property
+    def metadata(self):
+        return simplejson.loads(self._json_data)
+
+    def __init__(self, minor_person, major_person, job_type, metadata):
+        """Constructor.
+
+        :param minor_person: The person or team being added to or removed
+                             from the major_person.
+        :param major_person: The person or team that is receiving or losing
+                             the minor person.
+        :param job_type: The specific membership action being performed.
+        :param metadata: The type-specific variables, as a JSON-compatible
+            dict.
+        """
+        super(PersonTransferJob, self).__init__()
+        self.job = Job()
+        self.job_type = job_type
+        self.major_person = major_person
+        self.minor_person = minor_person
+
+        json_data = simplejson.dumps(metadata)
+        # XXX AaronBentley 2009-01-29 bug=322819: This should be a bytestring,
+        # but the DB representation is unicode.
+        self._json_data = json_data.decode('utf-8')
+
+    @classmethod
+    def get(cls, key):
+        """Return the instance of this class whose key is supplied."""
+        store = IMasterStore(PersonTransferJob)
+        instance = store.get(cls, key)
+        if instance is None:
+            raise SQLObjectNotFound(
+                'No occurrence of %s has key %s' % (cls.__name__, key))
+        return instance
+
+
+class PersonTransferJobDerived(BaseRunnableJob):
+    """Intermediate class for deriving from PersonTransferJob."""
+    delegates(IPersonTransferJob)
+    classProvides(IPersonTransferJobSource)
+
+    def __init__(self, job):
+        self.context = job
+
+    def __repr__(self):
+        return (
+            '<%(job_type)s branch job (%(id)s) for %(minor_person)s '
+            'as part of %(major_person)s. status=%(status)s>' % {
+                'job_type': self.context.job_type.name,
+                'id': self.context.id,
+                'minor_person': self.minor_person.name,
+                'major_person': self.major_person.name,
+                'status': self.job.status,
+                })
+
+    @classmethod
+    def create(cls, minor_person, major_person, metadata):
+        """See `IPersonTransferJob`."""
+        assert IPerson.providedBy(minor_person)
+        assert IPerson.providedBy(major_person)
+        job = PersonTransferJob(
+            minor_person=minor_person,
+            major_person=major_person,
+            job_type=cls.class_job_type,
+            metadata=metadata)
+        return cls(job)
+
+    @classmethod
+    def iterReady(cls):
+        """Iterate through all ready PersonTransferJobs."""
+        store = IMasterStore(PersonTransferJob)
+        jobs = store.find(
+            PersonTransferJob,
+            And(PersonTransferJob.job_type == cls.class_job_type,
+                PersonTransferJob.job_id.is_in(Job.ready_jobs)))
+        return (cls(job) for job in jobs)
+
+    def getOopsVars(self):
+        """See `IRunnableJob`."""
+        vars = BaseRunnableJob.getOopsVars(self)
+        vars.extend([
+            ('major_person_name', self.context.major_person.name),
+            ('minor_person_name', self.context.minor_person.name),
+            ])
+        return vars
+
+
+class MembershipNotificationJob(PersonTransferJobDerived):
+    """A Job that sends notifications about team membership changes."""
+
+    implements(IMembershipNotificationJob)
+    classProvides(IMembershipNotificationJobSource)
+
+    class_job_type = PersonTransferJobType.MEMBERSHIP_NOTIFICATION
+
+    @classmethod
+    def create(cls, member, team, reviewer, old_status, new_status,
+               last_change_comment=None):
+        assert ITeam.providedBy(team)
+        assert IPerson.providedBy(reviewer)
+        assert old_status in TeamMembershipStatus
+        assert new_status in TeamMembershipStatus
+        metadata = {
+            'reviewer': reviewer.id,
+            'old_status': old_status.name,
+            'new_status': new_status.name,
+            'last_change_comment': last_change_comment,
+            }
+        return super(MembershipNotificationJob, cls).create(
+            minor_person=member, major_person=team, metadata=metadata)
+
+    @property
+    def member(self):
+        return self.minor_person
+
+    @property
+    def team(self):
+        return self.major_person
+
+    @property
+    def reviewer(self):
+        return getUtility(IPersonSet).get(self.metadata['reviewer'])
+
+    @property
+    def old_status(self):
+        return TeamMembershipStatus.items[self.metadata['old_status']]
+
+    @property
+    def new_status(self):
+        return TeamMembershipStatus.items[self.metadata['new_status']]
+
+    @property
+    def last_change_comment(self):
+        return self.metadata['last_change_comment']
+
+    def run(self):
+        """See `IBranchScanJob`."""
+        from canonical.launchpad.scripts import log
+        from_addr = format_address(
+            self.team.displayname, config.canonical.noreply_from_address)
+        admins_emails = self.team.getTeamAdminsEmailAddresses()
+        # person might be a self.team, so we can't rely on its preferredemail.
+        self.member_email = get_contact_email_addresses(self.member)
+        # Make sure we don't send the same notification twice to anybody.
+        for email in self.member_email:
+            if email in admins_emails:
+                admins_emails.remove(email)
+
+        if self.reviewer != self.member:
+            self.reviewer_name = self.reviewer.unique_displayname
+        else:
+            # The user himself changed his self.membership.
+            self.reviewer_name = 'the user himself'
+
+        if self.last_change_comment:
+            comment = ("\n%s said:\n %s\n" % (
+                self.reviewer.displayname, self.last_change_comment.strip()))
+        else:
+            comment = ""
+
+        replacements = {
+            'member_name': self.member.unique_displayname,
+            'recipient_name': self.member.displayname,
+            'team_name': self.team.unique_displayname,
+            'team_url': canonical_url(self.team),
+            'old_status': self.old_status.title,
+            'new_status': self.new_status.title,
+            'reviewer_name': self.reviewer_name,
+            'comment': comment}
+
+        template_name = 'membership-statuschange'
+        subject = (
+            'Membership change: %(member)s in %(team)s'
+            % {
+                'member': self.member.name,
+                'team': self.team.name,
+              })
+        if self.new_status == TeamMembershipStatus.EXPIRED:
+            template_name = 'membership-expired'
+            subject = '%s expired from team' % self.member.name
+        elif (self.new_status == TeamMembershipStatus.APPROVED and
+            self.old_status != TeamMembershipStatus.ADMIN):
+            if self.old_status == TeamMembershipStatus.INVITED:
+                subject = ('Invitation to %s accepted by %s'
+                        % (self.member.name, self.reviewer.name))
+                template_name = 'membership-invitation-accepted'
+            elif self.old_status == TeamMembershipStatus.PROPOSED:
+                subject = '%s approved by %s' % (
+                    self.member.name, self.reviewer.name)
+            else:
+                subject = '%s added by %s' % (
+                    self.member.name, self.reviewer.name)
+        elif self.new_status == TeamMembershipStatus.INVITATION_DECLINED:
+            subject = ('Invitation to %s declined by %s'
+                    % (self.member.name, self.reviewer.name))
+            template_name = 'membership-invitation-declined'
+        elif self.new_status == TeamMembershipStatus.DEACTIVATED:
+            subject = '%s deactivated by %s' % (
+                self.member.name, self.reviewer.name)
+        elif self.new_status == TeamMembershipStatus.ADMIN:
+            subject = '%s made admin by %s' % (
+                self.member.name, self.reviewer.name)
+        elif self.new_status == TeamMembershipStatus.DECLINED:
+            subject = '%s declined by %s' % (
+                self.member.name, self.reviewer.name)
+        else:
+            # Use the default template and subject.
+            pass
+
+        if admins_emails:
+            admins_template = get_email_template(
+                "%s-bulk.txt" % template_name)
+            for address in admins_emails:
+                recipient = getUtility(IPersonSet).getByEmail(address)
+                replacements['recipient_name'] = recipient.displayname
+                msg = MailWrapper().format(
+                    admins_template % replacements, force_wrap=True)
+                simple_sendmail(from_addr, address, subject, msg)
+
+        # The self.member can be a self.self.team without any
+        # self.members, and in this case we won't have a single email
+        # address to send this notification to.
+        if self.member_email and self.reviewer != self.member:
+            if self.member.isTeam():
+                template = '%s-bulk.txt' % template_name
+            else:
+                template = '%s-personal.txt' % template_name
+            self.member_template = get_email_template(template)
+            for address in self.member_email:
+                recipient = getUtility(IPersonSet).getByEmail(address)
+                replacements['recipient_name'] = recipient.displayname
+                msg = MailWrapper().format(
+                    self.member_template % replacements, force_wrap=True)
+                simple_sendmail(from_addr, address, subject, msg)
+        log.debug('MembershipNotificationJob sent email')

=== modified file 'lib/lp/registry/model/teammembership.py'
--- lib/lp/registry/model/teammembership.py	2010-08-20 20:31:18 +0000
+++ lib/lp/registry/model/teammembership.py	2010-09-17 18:21:19 +0000
@@ -5,6 +5,7 @@
 
 __metaclass__ = type
 __all__ = [
+    'sendStatusChangeNotification',
     'TeamMembership',
     'TeamMembershipSet',
     'TeamParticipation',
@@ -51,6 +52,9 @@
     TeamMembershipRenewalPolicy,
     validate_public_person,
     )
+from lp.registry.interfaces.persontransferjob import (
+    IMembershipNotificationJobSource,
+    )
 from lp.registry.interfaces.teammembership import (
     CyclicalTeamMembershipError,
     DAYS_BEFORE_EXPIRATION_WARNING_IS_SENT,
@@ -400,96 +404,11 @@
         """Send a status change notification to all team admins and the
         member whose membership's status changed.
         """
-        team = self.team
-        member = self.person
         reviewer = self.last_changed_by
-        from_addr = format_address(
-            team.displayname, config.canonical.noreply_from_address)
         new_status = self.status
-        admins_emails = team.getTeamAdminsEmailAddresses()
-        # self.person might be a team, so we can't rely on its preferredemail.
-        member_email = get_contact_email_addresses(member)
-        # Make sure we don't send the same notification twice to anybody.
-        for email in member_email:
-            if email in admins_emails:
-                admins_emails.remove(email)
-
-        if reviewer != member:
-            reviewer_name = reviewer.unique_displayname
-        else:
-            # The user himself changed his membership.
-            reviewer_name = 'the user himself'
-
-        if self.last_change_comment:
-            comment = ("\n%s said:\n %s\n" % (
-                reviewer.displayname, self.last_change_comment.strip()))
-        else:
-            comment = ""
-
-        replacements = {
-            'member_name': member.unique_displayname,
-            'recipient_name': member.displayname,
-            'team_name': team.unique_displayname,
-            'team_url': canonical_url(team),
-            'old_status': old_status.title,
-            'new_status': new_status.title,
-            'reviewer_name': reviewer_name,
-            'comment': comment}
-
-        template_name = 'membership-statuschange'
-        subject = ('Membership change: %(member)s in %(team)s'
-                   % {'member': member.name, 'team': team.name})
-        if new_status == TeamMembershipStatus.EXPIRED:
-            template_name = 'membership-expired'
-            subject = '%s expired from team' % member.name
-        elif (new_status == TeamMembershipStatus.APPROVED and
-              old_status != TeamMembershipStatus.ADMIN):
-            if old_status == TeamMembershipStatus.INVITED:
-                subject = ('Invitation to %s accepted by %s'
-                           % (member.name, reviewer.name))
-                template_name = 'membership-invitation-accepted'
-            elif old_status == TeamMembershipStatus.PROPOSED:
-                subject = '%s approved by %s' % (member.name, reviewer.name)
-            else:
-                subject = '%s added by %s' % (member.name, reviewer.name)
-        elif new_status == TeamMembershipStatus.INVITATION_DECLINED:
-            subject = ('Invitation to %s declined by %s'
-                       % (member.name, reviewer.name))
-            template_name = 'membership-invitation-declined'
-        elif new_status == TeamMembershipStatus.DEACTIVATED:
-            subject = '%s deactivated by %s' % (member.name, reviewer.name)
-        elif new_status == TeamMembershipStatus.ADMIN:
-            subject = '%s made admin by %s' % (member.name, reviewer.name)
-        elif new_status == TeamMembershipStatus.DECLINED:
-            subject = '%s declined by %s' % (member.name, reviewer.name)
-        else:
-            # Use the default template and subject.
-            pass
-
-        if admins_emails:
-            admins_template = get_email_template(
-                "%s-bulk.txt" % template_name)
-            for address in admins_emails:
-                recipient = getUtility(IPersonSet).getByEmail(address)
-                replacements['recipient_name'] = recipient.displayname
-                msg = MailWrapper().format(
-                    admins_template % replacements, force_wrap=True)
-                simple_sendmail(from_addr, address, subject, msg)
-
-        # The member can be a team without any members, and in this case we
-        # won't have a single email address to send this notification to.
-        if member_email and reviewer != member:
-            if member.isTeam():
-                template = '%s-bulk.txt' % template_name
-            else:
-                template = '%s-personal.txt' % template_name
-            member_template = get_email_template(template)
-            for address in member_email:
-                recipient = getUtility(IPersonSet).getByEmail(address)
-                replacements['recipient_name'] = recipient.displayname
-                msg = MailWrapper().format(
-                    member_template % replacements, force_wrap=True)
-                simple_sendmail(from_addr, address, subject, msg)
+        getUtility(IMembershipNotificationJobSource).create(
+            self.person, self.team, reviewer, old_status, new_status,
+            self.last_change_comment)
 
 
 class TeamMembershipSet:

=== added file 'lib/lp/registry/tests/test_persontransferjob.py'
--- lib/lp/registry/tests/test_persontransferjob.py	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/tests/test_persontransferjob.py	2010-09-17 18:21:19 +0000
@@ -0,0 +1,62 @@
+# Copyright 2010 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for PersonTransferJobs."""
+
+__metaclass__ = type
+
+from canonical.testing import LaunchpadZopelessLayer
+from lp.registry.enum import PersonTransferJobType
+from lp.registry.model.persontransferjob import (
+    PersonTransferJob,
+    PersonTransferJobDerived,
+    )
+from lp.testing import TestCaseWithFactory
+
+
+class PersonTransferJobTestCase(TestCaseWithFactory):
+    """Test case for basic PersonTransferJob class."""
+
+    layer = LaunchpadZopelessLayer
+
+    def test_instantiate(self):
+        # PersonTransferJob.__init__() instantiates a
+        # PersonTransferJob instance.
+        person = self.factory.makePerson()
+        team = self.factory.makeTeam()
+
+        metadata = ('some', 'arbitrary', 'metadata')
+        person_transfer_job = PersonTransferJob(
+            person,
+            team,
+            PersonTransferJobType.MEMBERSHIP_NOTIFICATION,
+            metadata)
+
+        self.assertEqual(person, person_transfer_job.minor_person)
+        self.assertEqual(team, person_transfer_job.major_person)
+        self.assertEqual(
+            PersonTransferJobType.MEMBERSHIP_NOTIFICATION,
+            person_transfer_job.job_type)
+
+        # When we actually access the PersonTransferJob's metadata it gets
+        # unserialized from JSON, so the representation returned by
+        # bug_heat.metadata will be different from what we originally
+        # passed in.
+        metadata_expected = [u'some', u'arbitrary', u'metadata']
+        self.assertEqual(metadata_expected, person_transfer_job.metadata)
+
+
+class PersonTransferJobDerivedTestCase(TestCaseWithFactory):
+    """Test case for the PersonTransferJobDerived class."""
+
+    layer = LaunchpadZopelessLayer
+
+    def test_create_explodes(self):
+        # PersonTransferJobDerived.create() will blow up because it
+        # needs to be subclassed to work properly.
+        person = self.factory.makePerson()
+        team = self.factory.makeTeam()
+        metadata = {'foo': 'bar'}
+        self.assertRaises(
+            AttributeError,
+            PersonTransferJobDerived.create, person, team, metadata)

=== modified file 'lib/lp/testing/mail_helpers.py'
--- lib/lp/testing/mail_helpers.py	2010-08-20 20:31:18 +0000
+++ lib/lp/testing/mail_helpers.py	2010-09-17 18:21:19 +0000
@@ -10,7 +10,14 @@
 
 import transaction
 
+from zope.component import getUtility
+
+from lp.registry.interfaces.persontransferjob import (
+    IMembershipNotificationJobSource,
+    )
+from lp.services.job.runner import JobRunner
 from lp.services.mail import stub
+from lp.testing.logger import MockLogger
 
 
 def pop_notifications(sort_key=None, commit=True):
@@ -91,8 +98,23 @@
         print body
         print "-"*40
 
+
 def print_distinct_emails(include_reply_to=False, include_rationale=True):
     """A convenient shortcut for `print_emails`(group_similar=True)."""
     return print_emails(group_similar=True,
                         include_reply_to=include_reply_to,
                         include_rationale=include_rationale)
+
+
+def run_mail_jobs():
+    """Process job queues that send out emails.
+
+    New job subclasses need to be added to this function.
+    """
+    # Commit the transaction to make sure that the JobRunner can find
+    # the queued jobs.
+    transaction.commit()
+    job_source = getUtility(IMembershipNotificationJobSource)
+    logger = MockLogger()
+    runner = JobRunner.fromReady(job_source, logger)
+    runner.runAll()