Merge lp:~edwin-grubbs/launchpad/bug-615654-queue-addmember-emails into lp:launchpad/db-devel

Proposed by Edwin Grubbs
Status: Merged
Approved by: Edwin Grubbs
Approved revision: no longer in the source branch.
Merged at revision: 9873
Proposed branch: lp:~edwin-grubbs/launchpad/bug-615654-queue-addmember-emails
Merge into: lp:launchpad/db-devel
Prerequisite: lp:~edwin-grubbs/launchpad/bug-615654-registry-jobqueue-schema
Diff against target: 875 lines (+551/-99)
8 files modified
lib/lp/registry/configure.zcml (+17/-0)
lib/lp/registry/doc/teammembership-email-notification.txt (+25/-11)
lib/lp/registry/enum.py (+12/-0)
lib/lp/registry/interfaces/persontransferjob.py (+70/-0)
lib/lp/registry/model/persontransferjob.py (+334/-0)
lib/lp/registry/model/teammembership.py (+7/-88)
lib/lp/registry/tests/test_persontransferjob.py (+62/-0)
lib/lp/testing/mail_helpers.py (+24/-0)
To merge this branch: bzr merge lp:~edwin-grubbs/launchpad/bug-615654-queue-addmember-emails
Reviewer Review Type Date Requested Status
Brad Crittenden (community) code Approve
Review via email: mp+35862@code.launchpad.net

Description of the change

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'

To post a comment you must log in.
Revision history for this message
Brad Crittenden (bac) wrote :
Download full text (12.6 KiB)

Hi Edwin,

Very interesting branch. I've got some suggestions but overall it is great.

--Brad

> === 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:59:05 +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."""

s/ 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,
> + )
> +
> +

extra blank line

> +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
> +
> +

Hmmm, two of them. is there supposed to be two? did you do that
manually or did henning's tool do it? if the latter, then keep it.

> +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):
[...]
> + 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.

Indent to match.

> + """
> + 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.dump...

review: Approve (code)
Revision history for this message
Edwin Grubbs (edwin-grubbs) wrote :
Download full text (13.7 KiB)

On Fri, Sep 17, 2010 at 3:12 PM, Brad Crittenden <email address hidden> wrote:
> Review: Approve code
> Hi Edwin,
>
> Very interesting branch.  I've got some suggestions but overall it is great.
>
> --Brad

Thanks for the review.

>> === 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:59:05 +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."""
>
> s/ are in here//

Fixed. That's just odd. I'm blaming it on my rampant plagiarism in this branch.

>> +__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,
>> +    )
>> +
>> +
>
> extra blank line

Fixed with henning's tool.

>> +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
>> +
>> +
>
> Hmmm, two of them.  is there supposed to be two?  did you do that
> manually or did henning's tool do it?  if the latter, then keep it.

Fixed. I really need to update my vim plugin.

>> +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):
> [...]
>> +    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 b...

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== 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 22:26:48 +0000
@@ -84,6 +84,23 @@
84 __call__"/>84 __call__"/>
85 </class>85 </class>
8686
87 <!-- IPersonTransferJob -->
88 <securedutility
89 component="lp.registry.model.persontransferjob.MembershipNotificationJob"
90 provides="lp.registry.interfaces.persontransferjob.IMembershipNotificationJobSource">
91 <allow
92 interface="lp.registry.interfaces.persontransferjob.IMembershipNotificationJobSource"/>
93 </securedutility>
94
95 <class class="lp.registry.model.persontransferjob.PersonTransferJob">
96 <allow interface="lp.registry.interfaces.persontransferjob.IPersonTransferJob"/>
97 </class>
98
99 <class class="lp.registry.model.persontransferjob.MembershipNotificationJob">
100 <allow
101 interface="lp.registry.interfaces.persontransferjob.IMembershipNotificationJob"/>
102 </class>
103
87 <!-- Location -->104 <!-- Location -->
88105
89 <class106 <class
90107
=== 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 22:26:48 +0000
@@ -24,7 +24,7 @@
2424
25 >>> from lp.services.mail import stub25 >>> from lp.services.mail import stub
26 >>> from lp.testing.mail_helpers import (26 >>> from lp.testing.mail_helpers import (
27 ... pop_notifications, print_distinct_emails)27 ... pop_notifications, print_distinct_emails, run_mail_jobs)
2828
29 >>> from zope.component import getUtility29 >>> from zope.component import getUtility
30 >>> from canonical.launchpad.interfaces import (30 >>> from canonical.launchpad.interfaces import (
@@ -49,7 +49,7 @@
49 >>> membership.status.title49 >>> membership.status.title
50 'Proposed'50 'Proposed'
5151
52 >>> transaction.commit()52 >>> run_mail_jobs()
53 >>> len(stub.test_emails)53 >>> len(stub.test_emails)
54 554 5
55 >>> print_distinct_emails(include_reply_to=True)55 >>> print_distinct_emails(include_reply_to=True)
@@ -87,6 +87,11 @@
87 # that team.87 # that team.
88 >>> login('mark@example.com')88 >>> login('mark@example.com')
89 >>> setStatus(membership, TeamMembershipStatus.DECLINED, reviewer=mark)89 >>> setStatus(membership, TeamMembershipStatus.DECLINED, reviewer=mark)
90
91addMember() has queued up a job to send out the emails. We'll run the
92job now.
93
94 >>> run_mail_jobs()
90 >>> len(stub.test_emails)95 >>> len(stub.test_emails)
91 696 6
9297
@@ -125,6 +130,7 @@
125130
126 >>> setStatus(daf_membership, TeamMembershipStatus.APPROVED,131 >>> setStatus(daf_membership, TeamMembershipStatus.APPROVED,
127 ... reviewer=mark, comment='This is a nice guy; I like him')132 ... reviewer=mark, comment='This is a nice guy; I like him')
133 >>> run_mail_jobs()
128 >>> stub.test_emails.sort(by_to_addrs)134 >>> stub.test_emails.sort(by_to_addrs)
129 >>> len(stub.test_emails)135 >>> len(stub.test_emails)
130 6136 6
@@ -158,6 +164,7 @@
158164
159 >>> setStatus(daf_membership, TeamMembershipStatus.DEACTIVATED,165 >>> setStatus(daf_membership, TeamMembershipStatus.DEACTIVATED,
160 ... reviewer=mark)166 ... reviewer=mark)
167 >>> run_mail_jobs()
161 >>> stub.test_emails.sort(by_to_addrs)168 >>> stub.test_emails.sort(by_to_addrs)
162 >>> len(stub.test_emails)169 >>> len(stub.test_emails)
163 6170 6
@@ -187,7 +194,7 @@
187194
188 >>> admins = personset.getByName('admins')195 >>> admins = personset.getByName('admins')
189 >>> admins.join(ubuntu_team, requester=mark)196 >>> admins.join(ubuntu_team, requester=mark)
190 >>> transaction.commit()197 >>> run_mail_jobs()
191 >>> len(stub.test_emails)198 >>> len(stub.test_emails)
192 5199 5
193 >>> print_distinct_emails(include_reply_to=True)200 >>> print_distinct_emails(include_reply_to=True)
@@ -228,7 +235,10 @@
228 >>> cprov = personset.getByName('cprov')235 >>> cprov = personset.getByName('cprov')
229 >>> marilize = personset.getByName('marilize')236 >>> marilize = personset.getByName('marilize')
230 >>> ignored = ubuntu_team.addMember(marilize, reviewer=cprov)237 >>> ignored = ubuntu_team.addMember(marilize, reviewer=cprov)
231 >>> transaction.commit()238 >>> run_mail_jobs()
239
240Now, the emails have been sent.
241
232 >>> len(stub.test_emails)242 >>> len(stub.test_emails)
233 6243 6
234 >>> print_distinct_emails()244 >>> print_distinct_emails()
@@ -277,7 +287,7 @@
277 >>> mirror_admins.getTeamAdminsEmailAddresses()287 >>> mirror_admins.getTeamAdminsEmailAddresses()
278 ['mark@example.com']288 ['mark@example.com']
279 >>> ignored = ubuntu_team.addMember(mirror_admins, reviewer=cprov)289 >>> ignored = ubuntu_team.addMember(mirror_admins, reviewer=cprov)
280 >>> transaction.commit()290 >>> run_mail_jobs()
281 >>> len(stub.test_emails)291 >>> len(stub.test_emails)
282 1292 1
283 >>> print_distinct_emails()293 >>> print_distinct_emails()
@@ -304,7 +314,7 @@
304 >>> comment = "Of course I want to be part of ubuntu!"314 >>> comment = "Of course I want to be part of ubuntu!"
305 >>> mirror_admins.acceptInvitationToBeMemberOf(ubuntu_team, comment)315 >>> mirror_admins.acceptInvitationToBeMemberOf(ubuntu_team, comment)
306 >>> flush_database_updates()316 >>> flush_database_updates()
307 >>> transaction.commit()317 >>> run_mail_jobs()
308318
309 >>> len(stub.test_emails)319 >>> len(stub.test_emails)
310 6320 6
@@ -337,7 +347,7 @@
337 >>> comment = "Landscape has nothing to do with ubuntu, unfortunately."347 >>> comment = "Landscape has nothing to do with ubuntu, unfortunately."
338 >>> landscape.declineInvitationToBeMemberOf(ubuntu_team, comment)348 >>> landscape.declineInvitationToBeMemberOf(ubuntu_team, comment)
339 >>> flush_database_updates()349 >>> flush_database_updates()
340 >>> transaction.commit()350 >>> run_mail_jobs()
341351
342 >>> len(stub.test_emails)352 >>> len(stub.test_emails)
343 7353 7
@@ -363,7 +373,7 @@
363 >>> ignored = ubuntu_team.addMember(373 >>> ignored = ubuntu_team.addMember(
364 ... launchpad, reviewer=cprov, force_team_add=True)374 ... launchpad, reviewer=cprov, force_team_add=True)
365 >>> flush_database_updates()375 >>> flush_database_updates()
366 >>> transaction.commit()376 >>> run_mail_jobs()
367 >>> len(stub.test_emails)377 >>> len(stub.test_emails)
368 5378 5
369 >>> print_distinct_emails()379 >>> print_distinct_emails()
@@ -610,7 +620,7 @@
610 >>> membershipset.handleMembershipsExpiringToday(620 >>> membershipset.handleMembershipsExpiringToday(
611 ... reviewer=getUtility(ILaunchpadCelebrities).janitor)621 ... reviewer=getUtility(ILaunchpadCelebrities).janitor)
612 >>> flush_database_updates()622 >>> flush_database_updates()
613 >>> transaction.commit()623 >>> run_mail_jobs()
614624
615 >>> len(stub.test_emails)625 >>> len(stub.test_emails)
616 8626 8
@@ -690,7 +700,7 @@
690700
691 >>> login_person(karl)701 >>> login_person(karl)
692 >>> karl.renewTeamMembership(mirror_admins)702 >>> karl.renewTeamMembership(mirror_admins)
693 >>> transaction.commit()703 >>> run_mail_jobs()
694 >>> len(stub.test_emails)704 >>> len(stub.test_emails)
695 1705 1
696706
@@ -713,7 +723,7 @@
713approved to admin, but he won't get a notification of that.723approved to admin, but he won't get a notification of that.
714724
715 >>> team = personset.newTeam(mark, 'testteam', 'Test')725 >>> team = personset.newTeam(mark, 'testteam', 'Test')
716 >>> transaction.commit()726 >>> run_mail_jobs()
717 >>> len(stub.test_emails)727 >>> len(stub.test_emails)
718 0728 0
719729
@@ -730,6 +740,7 @@
730 >>> login('mark@example.com')740 >>> login('mark@example.com')
731 >>> setStatus(741 >>> setStatus(
732 ... cprov_membership, TeamMembershipStatus.ADMIN, reviewer=mark)742 ... cprov_membership, TeamMembershipStatus.ADMIN, reviewer=mark)
743 >>> run_mail_jobs()
733 >>> len(stub.test_emails)744 >>> len(stub.test_emails)
734 6745 6
735 >>> print_distinct_emails()746 >>> print_distinct_emails()
@@ -760,6 +771,7 @@
760 >>> jdub_membership = membershipset.getByPersonAndTeam(jdub, ubuntu_team)771 >>> jdub_membership = membershipset.getByPersonAndTeam(jdub, ubuntu_team)
761 >>> setStatus(jdub_membership, TeamMembershipStatus.APPROVED,772 >>> setStatus(jdub_membership, TeamMembershipStatus.APPROVED,
762 ... reviewer=jdub)773 ... reviewer=jdub)
774 >>> run_mail_jobs()
763 >>> len(stub.test_emails)775 >>> len(stub.test_emails)
764 5776 5
765 >>> print_distinct_emails()777 >>> print_distinct_emails()
@@ -784,6 +796,7 @@
784 ... mirror_admins, ubuntu_team)796 ... mirror_admins, ubuntu_team)
785 >>> setStatus(mirror_admins_membership, TeamMembershipStatus.DEACTIVATED,797 >>> setStatus(mirror_admins_membership, TeamMembershipStatus.DEACTIVATED,
786 ... reviewer=mark, silent=False)798 ... reviewer=mark, silent=False)
799 >>> run_mail_jobs()
787 >>> len(stub.test_emails)800 >>> len(stub.test_emails)
788 6801 6
789802
@@ -811,6 +824,7 @@
811 Approved824 Approved
812 >>> setStatus(dumper_hwdb_membership,825 >>> setStatus(dumper_hwdb_membership,
813 ... TeamMembershipStatus.DEACTIVATED, reviewer=mark, silent=True)826 ... TeamMembershipStatus.DEACTIVATED, reviewer=mark, silent=True)
827 >>> run_mail_jobs()
814 >>> len(stub.test_emails)828 >>> len(stub.test_emails)
815 0829 0
816 >>> print dumper_hwdb_membership.status.title830 >>> print dumper_hwdb_membership.status.title
817831
=== 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 22:26:48 +0000
@@ -5,6 +5,7 @@
55
6__metaclass__ = type6__metaclass__ = type
7__all__ = [7__all__ = [
8 'PersonTransferJobType',
8 'BugNotificationLevel',9 'BugNotificationLevel',
9 'DistroSeriesDifferenceStatus',10 'DistroSeriesDifferenceStatus',
10 'DistroSeriesDifferenceType',11 'DistroSeriesDifferenceType',
@@ -82,6 +83,7 @@
82 This difference has been resolved and versions are now equal.83 This difference has been resolved and versions are now equal.
83 """)84 """)
8485
86
85class DistroSeriesDifferenceType(DBEnumeratedType):87class DistroSeriesDifferenceType(DBEnumeratedType):
86 """Distribution series difference type."""88 """Distribution series difference type."""
8789
@@ -104,3 +106,13 @@
104106
105 This package is present in both series with different versions.107 This package is present in both series with different versions.
106 """)108 """)
109
110
111class PersonTransferJobType(DBEnumeratedType):
112 """Values that IPersonTransferJob.job_type can take."""
113
114 MEMBERSHIP_NOTIFICATION = DBItem(0, """
115 Add-member notification
116
117 Notify affected users of new team membership.
118 """)
107119
=== 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 22:26:48 +0000
@@ -0,0 +1,70 @@
1# Copyright 2010 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Interface for the Jobs system to change memberships or merge persons."""
5
6__metaclass__ = type
7__all__ = [
8 'IMembershipNotificationJob',
9 'IMembershipNotificationJobSource',
10 'IPersonTransferJob',
11 'IPersonTransferJobSource',
12 ]
13
14from zope.interface import Attribute
15from zope.schema import (
16 Int,
17 Object,
18 )
19
20from canonical.launchpad import _
21from lp.services.fields import PublicPersonChoice
22from lp.services.job.interfaces.job import (
23 IJob,
24 IJobSource,
25 IRunnableJob,
26 )
27
28
29class IPersonTransferJob(IRunnableJob):
30 """A Job related to team membership or a person merge."""
31
32 id = Int(
33 title=_('DB ID'), required=True, readonly=True,
34 description=_("The tracking number for this job."))
35
36 job = Object(
37 title=_('The common Job attributes'),
38 schema=IJob,
39 required=True)
40
41 minor_person = PublicPersonChoice(
42 title=_('The person being added to the major person/team'),
43 vocabulary='ValidPersonOrTeam',
44 required=True)
45
46 major_person = PublicPersonChoice(
47 title=_('The person or team receiving the minor person'),
48 vocabulary='ValidPersonOrTeam',
49 required=True)
50
51 metadata = Attribute('A dict of data about the job.')
52
53
54class IPersonTransferJobSource(IJobSource):
55 """An interface for acquiring IPersonTransferJobs."""
56
57 def create(minor_person, major_person, metadata):
58 """Create a new IPersonTransferJob."""
59
60
61class IMembershipNotificationJob(IPersonTransferJob):
62 """A Job to notify new members of a team of that change."""
63
64
65class IMembershipNotificationJobSource(IJobSource):
66 """An interface for acquiring IMembershipNotificationJobs."""
67
68 def create(member, team, reviewer, old_status, new_status,
69 last_change_comment=None):
70 """Create a new IMembershipNotificationJob."""
071
=== 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 22:26:48 +0000
@@ -0,0 +1,334 @@
1# Copyright 2010 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Job classes related to PersonTransferJob."""
5
6__metaclass__ = type
7__all__ = [
8 'MembershipNotificationJob',
9 'PersonTransferJob',
10 ]
11
12from lazr.delegates import delegates
13import simplejson
14from sqlobject import SQLObjectNotFound
15from storm.base import Storm
16from storm.expr import And
17from storm.locals import (
18 Int,
19 Reference,
20 Unicode,
21 )
22from zope.component import getUtility
23from zope.interface import (
24 classProvides,
25 implements,
26 )
27
28from canonical.config import config
29from canonical.database.enumcol import EnumCol
30from canonical.launchpad.helpers import (
31 get_contact_email_addresses,
32 get_email_template,
33 )
34from canonical.launchpad.interfaces.lpstorm import IMasterStore
35from canonical.launchpad.mail import (
36 format_address,
37 simple_sendmail,
38 )
39from canonical.launchpad.mailnotification import MailWrapper
40from canonical.launchpad.webapp import canonical_url
41from lp.registry.enum import PersonTransferJobType
42from lp.registry.interfaces.person import (
43 IPerson,
44 IPersonSet,
45 ITeam,
46 )
47from lp.registry.interfaces.persontransferjob import (
48 IMembershipNotificationJob,
49 IMembershipNotificationJobSource,
50 IPersonTransferJob,
51 IPersonTransferJobSource,
52 )
53from lp.registry.interfaces.teammembership import TeamMembershipStatus
54from lp.registry.model.person import Person
55from lp.services.job.model.job import Job
56from lp.services.job.runner import BaseRunnableJob
57
58
59class PersonTransferJob(Storm):
60 """Base class for team membership and person merge jobs."""
61
62 implements(IPersonTransferJob)
63
64 __storm_table__ = 'PersonTransferJob'
65
66 id = Int(primary=True)
67
68 job_id = Int(name='job')
69 job = Reference(job_id, Job.id)
70
71 major_person_id = Int(name='major_person')
72 major_person = Reference(major_person_id, Person.id)
73
74 minor_person_id = Int(name='minor_person')
75 minor_person = Reference(minor_person_id, Person.id)
76
77 job_type = EnumCol(enum=PersonTransferJobType, notNull=True)
78
79 _json_data = Unicode('json_data')
80
81 @property
82 def metadata(self):
83 return simplejson.loads(self._json_data)
84
85 def __init__(self, minor_person, major_person, job_type, metadata):
86 """Constructor.
87
88 :param minor_person: The person or team being added to or removed
89 from the major_person.
90 :param major_person: The person or team that is receiving or losing
91 the minor person.
92 :param job_type: The specific membership action being performed.
93 :param metadata: The type-specific variables, as a JSON-compatible
94 dict.
95 """
96 super(PersonTransferJob, self).__init__()
97 self.job = Job()
98 self.job_type = job_type
99 self.major_person = major_person
100 self.minor_person = minor_person
101
102 json_data = simplejson.dumps(metadata)
103 # XXX AaronBentley 2009-01-29 bug=322819: This should be a bytestring,
104 # but the DB representation is unicode.
105 self._json_data = json_data.decode('utf-8')
106
107 @classmethod
108 def get(cls, key):
109 """Return the instance of this class whose key is supplied."""
110 store = IMasterStore(PersonTransferJob)
111 instance = store.get(cls, key)
112 if instance is None:
113 raise SQLObjectNotFound(
114 'No occurrence of %s has key %s' % (cls.__name__, key))
115 return instance
116
117
118class PersonTransferJobDerived(BaseRunnableJob):
119 """Intermediate class for deriving from PersonTransferJob.
120
121 Storm classes can't simply be subclassed or you can end up with
122 multiple objects referencing the same row in the db. This class uses
123 lazr.delegates, which is a little bit simpler than storm's
124 infoheritance solution to the problem. Subclasses need to override
125 the run() method.
126 """
127
128 delegates(IPersonTransferJob)
129 classProvides(IPersonTransferJobSource)
130
131 def __init__(self, job):
132 self.context = job
133
134 def __repr__(self):
135 return (
136 '<%(job_type)s branch job (%(id)s) for %(minor_person)s '
137 'as part of %(major_person)s. status=%(status)s>' % {
138 'job_type': self.context.job_type.name,
139 'id': self.context.id,
140 'minor_person': self.minor_person.name,
141 'major_person': self.major_person.name,
142 'status': self.job.status,
143 })
144
145 @classmethod
146 def create(cls, minor_person, major_person, metadata):
147 """See `IPersonTransferJob`."""
148 if not IPerson.providedBy(minor_person):
149 raise TypeError("minor_person must be IPerson: %s"
150 % repr(minor_person))
151 if not IPerson.providedBy(major_person):
152 raise TypeError("major_person must be IPerson: %s"
153 % repr(major_person))
154 job = PersonTransferJob(
155 minor_person=minor_person,
156 major_person=major_person,
157 job_type=cls.class_job_type,
158 metadata=metadata)
159 return cls(job)
160
161 @classmethod
162 def iterReady(cls):
163 """Iterate through all ready PersonTransferJobs."""
164 store = IMasterStore(PersonTransferJob)
165 jobs = store.find(
166 PersonTransferJob,
167 And(PersonTransferJob.job_type == cls.class_job_type,
168 PersonTransferJob.job_id.is_in(Job.ready_jobs)))
169 return (cls(job) for job in jobs)
170
171 def getOopsVars(self):
172 """See `IRunnableJob`."""
173 vars = BaseRunnableJob.getOopsVars(self)
174 vars.extend([
175 ('major_person_name', self.context.major_person.name),
176 ('minor_person_name', self.context.minor_person.name),
177 ])
178 return vars
179
180
181class MembershipNotificationJob(PersonTransferJobDerived):
182 """A Job that sends notifications about team membership changes."""
183
184 implements(IMembershipNotificationJob)
185 classProvides(IMembershipNotificationJobSource)
186
187 class_job_type = PersonTransferJobType.MEMBERSHIP_NOTIFICATION
188
189 @classmethod
190 def create(cls, member, team, reviewer, old_status, new_status,
191 last_change_comment=None):
192 if not ITeam.providedBy(team):
193 raise TypeError('team must be ITeam: %s' % repr(team))
194 if not IPerson.providedBy(reviewer):
195 raise TypeError('reviewer must be IPerson: %s' % repr(reviewer))
196 if old_status not in TeamMembershipStatus:
197 raise TypeError("old_status must be TeamMembershipStatus: %s"
198 % repr(old_status))
199 if new_status not in TeamMembershipStatus:
200 raise TypeError("new_status must be TeamMembershipStatus: %s"
201 % repr(new_status))
202 metadata = {
203 'reviewer': reviewer.id,
204 'old_status': old_status.name,
205 'new_status': new_status.name,
206 'last_change_comment': last_change_comment,
207 }
208 return super(MembershipNotificationJob, cls).create(
209 minor_person=member, major_person=team, metadata=metadata)
210
211 @property
212 def member(self):
213 return self.minor_person
214
215 @property
216 def team(self):
217 return self.major_person
218
219 @property
220 def reviewer(self):
221 return getUtility(IPersonSet).get(self.metadata['reviewer'])
222
223 @property
224 def old_status(self):
225 return TeamMembershipStatus.items[self.metadata['old_status']]
226
227 @property
228 def new_status(self):
229 return TeamMembershipStatus.items[self.metadata['new_status']]
230
231 @property
232 def last_change_comment(self):
233 return self.metadata['last_change_comment']
234
235 def run(self):
236 """See `IMembershipNotificationJob`."""
237 from canonical.launchpad.scripts import log
238 from_addr = format_address(
239 self.team.displayname, config.canonical.noreply_from_address)
240 admin_emails = self.team.getTeamAdminsEmailAddresses()
241 # person might be a self.team, so we can't rely on its preferredemail.
242 self.member_email = get_contact_email_addresses(self.member)
243 # Make sure we don't send the same notification twice to anybody.
244 for email in self.member_email:
245 if email in admin_emails:
246 admin_emails.remove(email)
247
248 if self.reviewer != self.member:
249 self.reviewer_name = self.reviewer.unique_displayname
250 else:
251 # The user himself changed his self.membership.
252 self.reviewer_name = 'the user himself'
253
254 if self.last_change_comment:
255 comment = ("\n%s said:\n %s\n" % (
256 self.reviewer.displayname, self.last_change_comment.strip()))
257 else:
258 comment = ""
259
260 replacements = {
261 'member_name': self.member.unique_displayname,
262 'recipient_name': self.member.displayname,
263 'team_name': self.team.unique_displayname,
264 'team_url': canonical_url(self.team),
265 'old_status': self.old_status.title,
266 'new_status': self.new_status.title,
267 'reviewer_name': self.reviewer_name,
268 'comment': comment}
269
270 template_name = 'membership-statuschange'
271 subject = (
272 'Membership change: %(member)s in %(team)s'
273 % {
274 'member': self.member.name,
275 'team': self.team.name,
276 })
277 if self.new_status == TeamMembershipStatus.EXPIRED:
278 template_name = 'membership-expired'
279 subject = '%s expired from team' % self.member.name
280 elif (self.new_status == TeamMembershipStatus.APPROVED and
281 self.old_status != TeamMembershipStatus.ADMIN):
282 if self.old_status == TeamMembershipStatus.INVITED:
283 subject = ('Invitation to %s accepted by %s'
284 % (self.member.name, self.reviewer.name))
285 template_name = 'membership-invitation-accepted'
286 elif self.old_status == TeamMembershipStatus.PROPOSED:
287 subject = '%s approved by %s' % (
288 self.member.name, self.reviewer.name)
289 else:
290 subject = '%s added by %s' % (
291 self.member.name, self.reviewer.name)
292 elif self.new_status == TeamMembershipStatus.INVITATION_DECLINED:
293 subject = ('Invitation to %s declined by %s'
294 % (self.member.name, self.reviewer.name))
295 template_name = 'membership-invitation-declined'
296 elif self.new_status == TeamMembershipStatus.DEACTIVATED:
297 subject = '%s deactivated by %s' % (
298 self.member.name, self.reviewer.name)
299 elif self.new_status == TeamMembershipStatus.ADMIN:
300 subject = '%s made admin by %s' % (
301 self.member.name, self.reviewer.name)
302 elif self.new_status == TeamMembershipStatus.DECLINED:
303 subject = '%s declined by %s' % (
304 self.member.name, self.reviewer.name)
305 else:
306 # Use the default template and subject.
307 pass
308
309 if len(admin_emails) != 0:
310 admin_template = get_email_template(
311 "%s-bulk.txt" % template_name)
312 for address in admin_emails:
313 recipient = getUtility(IPersonSet).getByEmail(address)
314 replacements['recipient_name'] = recipient.displayname
315 msg = MailWrapper().format(
316 admin_template % replacements, force_wrap=True)
317 simple_sendmail(from_addr, address, subject, msg)
318
319 # The self.member can be a self.self.team without any
320 # self.members, and in this case we won't have a single email
321 # address to send this notification to.
322 if self.member_email and self.reviewer != self.member:
323 if self.member.isTeam():
324 template = '%s-bulk.txt' % template_name
325 else:
326 template = '%s-personal.txt' % template_name
327 self.member_template = get_email_template(template)
328 for address in self.member_email:
329 recipient = getUtility(IPersonSet).getByEmail(address)
330 replacements['recipient_name'] = recipient.displayname
331 msg = MailWrapper().format(
332 self.member_template % replacements, force_wrap=True)
333 simple_sendmail(from_addr, address, subject, msg)
334 log.debug('MembershipNotificationJob sent email')
0335
=== 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 22:26:48 +0000
@@ -5,6 +5,7 @@
55
6__metaclass__ = type6__metaclass__ = type
7__all__ = [7__all__ = [
8 'sendStatusChangeNotification',
8 'TeamMembership',9 'TeamMembership',
9 'TeamMembershipSet',10 'TeamMembershipSet',
10 'TeamParticipation',11 'TeamParticipation',
@@ -51,6 +52,9 @@
51 TeamMembershipRenewalPolicy,52 TeamMembershipRenewalPolicy,
52 validate_public_person,53 validate_public_person,
53 )54 )
55from lp.registry.interfaces.persontransferjob import (
56 IMembershipNotificationJobSource,
57 )
54from lp.registry.interfaces.teammembership import (58from lp.registry.interfaces.teammembership import (
55 CyclicalTeamMembershipError,59 CyclicalTeamMembershipError,
56 DAYS_BEFORE_EXPIRATION_WARNING_IS_SENT,60 DAYS_BEFORE_EXPIRATION_WARNING_IS_SENT,
@@ -400,96 +404,11 @@
400 """Send a status change notification to all team admins and the404 """Send a status change notification to all team admins and the
401 member whose membership's status changed.405 member whose membership's status changed.
402 """406 """
403 team = self.team
404 member = self.person
405 reviewer = self.last_changed_by407 reviewer = self.last_changed_by
406 from_addr = format_address(
407 team.displayname, config.canonical.noreply_from_address)
408 new_status = self.status408 new_status = self.status
409 admins_emails = team.getTeamAdminsEmailAddresses()409 getUtility(IMembershipNotificationJobSource).create(
410 # self.person might be a team, so we can't rely on its preferredemail.410 self.person, self.team, reviewer, old_status, new_status,
411 member_email = get_contact_email_addresses(member)411 self.last_change_comment)
412 # Make sure we don't send the same notification twice to anybody.
413 for email in member_email:
414 if email in admins_emails:
415 admins_emails.remove(email)
416
417 if reviewer != member:
418 reviewer_name = reviewer.unique_displayname
419 else:
420 # The user himself changed his membership.
421 reviewer_name = 'the user himself'
422
423 if self.last_change_comment:
424 comment = ("\n%s said:\n %s\n" % (
425 reviewer.displayname, self.last_change_comment.strip()))
426 else:
427 comment = ""
428
429 replacements = {
430 'member_name': member.unique_displayname,
431 'recipient_name': member.displayname,
432 'team_name': team.unique_displayname,
433 'team_url': canonical_url(team),
434 'old_status': old_status.title,
435 'new_status': new_status.title,
436 'reviewer_name': reviewer_name,
437 'comment': comment}
438
439 template_name = 'membership-statuschange'
440 subject = ('Membership change: %(member)s in %(team)s'
441 % {'member': member.name, 'team': team.name})
442 if new_status == TeamMembershipStatus.EXPIRED:
443 template_name = 'membership-expired'
444 subject = '%s expired from team' % member.name
445 elif (new_status == TeamMembershipStatus.APPROVED and
446 old_status != TeamMembershipStatus.ADMIN):
447 if old_status == TeamMembershipStatus.INVITED:
448 subject = ('Invitation to %s accepted by %s'
449 % (member.name, reviewer.name))
450 template_name = 'membership-invitation-accepted'
451 elif old_status == TeamMembershipStatus.PROPOSED:
452 subject = '%s approved by %s' % (member.name, reviewer.name)
453 else:
454 subject = '%s added by %s' % (member.name, reviewer.name)
455 elif new_status == TeamMembershipStatus.INVITATION_DECLINED:
456 subject = ('Invitation to %s declined by %s'
457 % (member.name, reviewer.name))
458 template_name = 'membership-invitation-declined'
459 elif new_status == TeamMembershipStatus.DEACTIVATED:
460 subject = '%s deactivated by %s' % (member.name, reviewer.name)
461 elif new_status == TeamMembershipStatus.ADMIN:
462 subject = '%s made admin by %s' % (member.name, reviewer.name)
463 elif new_status == TeamMembershipStatus.DECLINED:
464 subject = '%s declined by %s' % (member.name, reviewer.name)
465 else:
466 # Use the default template and subject.
467 pass
468
469 if admins_emails:
470 admins_template = get_email_template(
471 "%s-bulk.txt" % template_name)
472 for address in admins_emails:
473 recipient = getUtility(IPersonSet).getByEmail(address)
474 replacements['recipient_name'] = recipient.displayname
475 msg = MailWrapper().format(
476 admins_template % replacements, force_wrap=True)
477 simple_sendmail(from_addr, address, subject, msg)
478
479 # The member can be a team without any members, and in this case we
480 # won't have a single email address to send this notification to.
481 if member_email and reviewer != member:
482 if member.isTeam():
483 template = '%s-bulk.txt' % template_name
484 else:
485 template = '%s-personal.txt' % template_name
486 member_template = get_email_template(template)
487 for address in member_email:
488 recipient = getUtility(IPersonSet).getByEmail(address)
489 replacements['recipient_name'] = recipient.displayname
490 msg = MailWrapper().format(
491 member_template % replacements, force_wrap=True)
492 simple_sendmail(from_addr, address, subject, msg)
493412
494413
495class TeamMembershipSet:414class TeamMembershipSet:
496415
=== 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 22:26:48 +0000
@@ -0,0 +1,62 @@
1# Copyright 2010 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Tests for PersonTransferJobs."""
5
6__metaclass__ = type
7
8from canonical.testing import LaunchpadZopelessLayer
9from lp.registry.enum import PersonTransferJobType
10from lp.registry.model.persontransferjob import (
11 PersonTransferJob,
12 PersonTransferJobDerived,
13 )
14from lp.testing import TestCaseWithFactory
15
16
17class PersonTransferJobTestCase(TestCaseWithFactory):
18 """Test case for basic PersonTransferJob class."""
19
20 layer = LaunchpadZopelessLayer
21
22 def test_instantiate(self):
23 # PersonTransferJob.__init__() instantiates a
24 # PersonTransferJob instance.
25 person = self.factory.makePerson()
26 team = self.factory.makeTeam()
27
28 metadata = ('some', 'arbitrary', 'metadata')
29 person_transfer_job = PersonTransferJob(
30 person,
31 team,
32 PersonTransferJobType.MEMBERSHIP_NOTIFICATION,
33 metadata)
34
35 self.assertEqual(person, person_transfer_job.minor_person)
36 self.assertEqual(team, person_transfer_job.major_person)
37 self.assertEqual(
38 PersonTransferJobType.MEMBERSHIP_NOTIFICATION,
39 person_transfer_job.job_type)
40
41 # When we actually access the PersonTransferJob's metadata it
42 # gets unserialized from JSON, so the representation returned by
43 # person_transfer_job.metadata will be different from what we
44 # originally passed in.
45 metadata_expected = [u'some', u'arbitrary', u'metadata']
46 self.assertEqual(metadata_expected, person_transfer_job.metadata)
47
48
49class PersonTransferJobDerivedTestCase(TestCaseWithFactory):
50 """Test case for the PersonTransferJobDerived class."""
51
52 layer = LaunchpadZopelessLayer
53
54 def test_create_explodes(self):
55 # PersonTransferJobDerived.create() will blow up because it
56 # needs to be subclassed to work properly.
57 person = self.factory.makePerson()
58 team = self.factory.makeTeam()
59 metadata = {'foo': 'bar'}
60 self.assertRaises(
61 AttributeError,
62 PersonTransferJobDerived.create, person, team, metadata)
063
=== 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 22:26:48 +0000
@@ -10,7 +10,14 @@
1010
11import transaction11import transaction
1212
13from zope.component import getUtility
14
15from lp.registry.interfaces.persontransferjob import (
16 IMembershipNotificationJobSource,
17 )
18from lp.services.job.runner import JobRunner
13from lp.services.mail import stub19from lp.services.mail import stub
20from lp.testing.logger import MockLogger
1421
1522
16def pop_notifications(sort_key=None, commit=True):23def pop_notifications(sort_key=None, commit=True):
@@ -91,8 +98,25 @@
91 print body98 print body
92 print "-"*4099 print "-"*40
93100
101
94def print_distinct_emails(include_reply_to=False, include_rationale=True):102def print_distinct_emails(include_reply_to=False, include_rationale=True):
95 """A convenient shortcut for `print_emails`(group_similar=True)."""103 """A convenient shortcut for `print_emails`(group_similar=True)."""
96 return print_emails(group_similar=True,104 return print_emails(group_similar=True,
97 include_reply_to=include_reply_to,105 include_reply_to=include_reply_to,
98 include_rationale=include_rationale)106 include_rationale=include_rationale)
107
108
109def run_mail_jobs():
110 """Process job queues that send out emails.
111
112 If a new job type is added that sends emails, this function can be
113 extended to run those jobs, so that testing emails doesn't require a
114 bunch of different function calls to process different queues.
115 """
116 # Commit the transaction to make sure that the JobRunner can find
117 # the queued jobs.
118 transaction.commit()
119 job_source = getUtility(IMembershipNotificationJobSource)
120 logger = MockLogger()
121 runner = JobRunner.fromReady(job_source, logger)
122 runner.runAll()

Subscribers

People subscribed via source and target branches

to status/vote changes: