Merge lp:~sinzui/launchpad/person-merge-job-1 into lp:launchpad

Proposed by Curtis Hovey
Status: Merged
Approved by: Robert Collins
Approved revision: no longer in the source branch.
Merged at revision: 12783
Proposed branch: lp:~sinzui/launchpad/person-merge-job-1
Merge into: lp:launchpad
Diff against target: 385 lines (+187/-23)
10 files modified
lib/lp/registry/emailtemplates/team-merged.txt (+8/-0)
lib/lp/registry/interfaces/personnotification.py (+9/-1)
lib/lp/registry/interfaces/persontransferjob.py (+3/-0)
lib/lp/registry/model/person.py (+13/-9)
lib/lp/registry/model/personnotification.py (+21/-6)
lib/lp/registry/model/persontransferjob.py (+8/-1)
lib/lp/registry/scripts/personnotification.py (+3/-2)
lib/lp/registry/tests/test_person_merge_job.py (+18/-4)
lib/lp/registry/tests/test_personnotification.py (+38/-0)
lib/lp/registry/tests/test_team.py (+66/-0)
To merge this branch: bzr merge lp:~sinzui/launchpad/person-merge-job-1
Reviewer Review Type Date Requested Status
Robert Collins (community) Approve
Review via email: mp+54440@code.launchpad.net

Description of the change

PersonNotification supports teams.

    Launchpad bug: https://bugs.launchpad.net/bugs/728471
    Pre-implementation: jcsackett
    Test command: ./bin/test -vv \
      -t person-merge -t test_person_merge_job
      -t test_personnotification -t TestTeamGetTeamAdminsEmailAddresses

Before bug 736421 can be addressed, there are several infrastructure issues
that must be solved first:

    * Person.merge needs send success emails to teams.
    * PersonNotification.send() must support teams.
    * Team.getTeamAdminsEmailAddresses() must guarantee for admins.
    * The job.getErrorRecipients must return the team admin addresses.

--------------------------------------------------------------------

RULES

    * Update Team.getTeamAdminsEmailAddresses() to recurse through
      team owner to locate team admins with email addresses.
    * Update PersonNotification.send() to send to team admins if the
      person is a team.
      * Consider a property that can be tested because the method
        and PersonNotificationManager are testing person.preferredemail
    * Update Person.merge to send to teams
      * Add a team email template.
    * Refine PersonMergeJob.getErrorRecipients to provide the user or
      team email addresses.

QA

    * Merge a team with a team you admin on qastaging.
    * Run cronscripts/send-person-notifications.py on qastaging.
    * Verify a message to you is in the staging sent folder.

LINT

    lib/lp/registry/doc/person-merge.txt
    lib/lp/registry/emailtemplates/team-merged.txt
    lib/lp/registry/interfaces/personnotification.py
    lib/lp/registry/interfaces/persontransferjob.py
    lib/lp/registry/model/person.py
    lib/lp/registry/model/personnotification.py
    lib/lp/registry/model/persontransferjob.py
    lib/lp/registry/scripts/personnotification.py
    lib/lp/registry/tests/test_person_merge_job.py
    lib/lp/registry/tests/test_personnotification.py
    lib/lp/registry/tests/test_team.py

IMPLEMENTATION

Updated Team.getTeamAdminsEmailAddresses() to recurse through
team owner to locate team admins with email addresses. This applies the
same kind of loop used in contact-this-teams-owner, but uses the
getDirectTeamAdmins() method instead. Then solves cases where the immediate
admin is a team without a contact address.
    lib/lp/registry/model/person.py
    lib/lp/registry/tests/test_team.py

Updated PersonNotification.send() to send to team admins when the
person is a team. Added to_addresses and can_send properties so that
the send method and PersonNotificationManager can handles teams as easily
as users. The to_addresses property uses the revised
Team.getTeamAdminsEmailAddresses() method.
    lib/lp/registry/interfaces/personnotification.py
    lib/lp/registry/model/personnotification.py
    lib/lp/registry/scripts/personnotification.py
    lib/lp/registry/tests/test_personnotification.py

Refined PersonMergeJob.getErrorRecipients to provide the user or
team email addresses. I had to explicitly define the interfaces because
of some nonsense in decorates() that is used to construct the PersonMergeJob.
The method uses the revised Team.getTeamAdminsEmailAddresses() method.
    lib/lp/registry/interfaces/persontransferjob.py
    lib/lp/registry/model/persontransferjob.py
    lib/lp/registry/tests/test_person_merge_job.py

Send merge success emails to the team. I added an email template and updated
the existing test to show it was sent. This implementation is not ideal; I
know that ~registry admins do not care to get team delete emails. I will
address this in my next branch which deal with integrating premerge/delete
logic into the model.
    lib/lp/registry/emailtemplates/team-merged.txt
    lib/lp/registry/model/person.py
    lib/lp/registry/doc/person-merge.txt

To post a comment you must log in.
Revision history for this message
Robert Collins (lifeless) wrote :

75 + def getErrorRecipients(self):
76 + """See `BaseRunnableJob`."""
77 +
78

thats a little odd - if the method is on the base interface, its redundant, and if its not on the base interface, a reference to that is pointless.

+ team_or_person = self
92 + # Recurse through team ownership until an email address is found.
93 + while len(to_addrs) == 0:
94 + if team_or_person.is_team:
95 + for admin in team_or_person.getDirectAdministrators():
96 + to_addrs.update(get_contact_email_addresses(admin))
97 + team_or_person = team_or_person.teamowner
98 + else:
99 + to_addrs.update(get_contact_email_addresses(team_or_person))

confused me a little. And once I wasn't confused I concluded it was buggy. Its buggy because it will only notify one administrator. (consider this: the owner is a team with two members. one member is a person, one member is another team with people in it.

The for loop will on the first pass, get one email address in to_addrs, and then go to the *owning team* of the owning team, and terminate. What should happen is that all the *members* of the owning team get mailed - we don't want to traverse owner of owner, just owner. (I think. Am I wrong?)

Perhaps I am wrong. Perhaps it actually says 'if the owning team is totally uncontactable, try harder'. Which seems like a pathological case - you'd need an empty team with only owners as an owning team. But we should handle it... perhaps by fixing emailPeople ? ['if you contact a team with no members the owner is contacted']

In fact, the more I think about it, the more that makes sense: emailPeople is wrong.

The rest seems uncontentious.

review: Needs Information
Revision history for this message
Curtis Hovey (sinzui) wrote :

Hi Robert.

The believe oddness of the getErrorRecipients() interface is caused by PersonTransferJobDerived which breaks the permission chain.

{{{
class PersonTransferJobDerived(BaseRunnableJob):
    delegates(IPersonTransferJob)
}}}

Thank you for looking at the loop to get the the admin email addresses. I see it is repeating the problems of "contact this team" that I wanted to avoid. We want to send an email to the most immediate members who know about the team, the action, and can do something about it. I think there are two issues in the loop.

1 getDirectAdministrators() does not (get direct administrators). I own several teams that I am not a member of. I am *not* an admin, but this method is injecting the team owner into the list. I can use team.adminmembers to get just the members with the status ADMIN. This method is repeating the mistakes of the past where a user want to contact a team, but we walk the hierarchy of owner instead. Mark gets email about teams that are 3 and 4 teams removed from him :( This implementation was chosen because send emails to the individual members of the owner team fails when the owners do not have email address (team). This is a rare case, but it affects prominent teams.

2 If I had written a test where one of the admins was a team without an email address, I would have noticed that the rules do not traverse admin teams as I want. The code needs to locate an email address for the admin team. Choosing to get the contact address of the members will fail for the same reason as owner described above. The code needs to traverse a hierarchy to get a person or team with an email address.

Lets discuss this when you have time later today. I will work on the next branch which is independent of this branch.

Revision history for this message
Curtis Hovey (sinzui) wrote :

Hi Robert
On Wed, 2011-03-23 at 01:55 +0000, Robert Collins wrote:
> + team_or_person = self
> 92 + # Recurse through team ownership until an email
> address is found.
> 93 + while len(to_addrs) == 0:
> 94 + if team_or_person.is_team:
> 95 + for admin in
> team_or_person.getDirectAdministrators():
> 96 +
> to_addrs.update(get_contact_email_addresses(admin))
> 97 + team_or_person = team_or_person.teamowner
> 98 + else:
> 99 +
> to_addrs.update(get_contact_email_addresses(team_or_person))

I switch to team.adminmembers to avoid special casing the team owner.
I removed the recursive loop to find an email address.
The code is very simple now, mostly because
get_contact_email_addresses(team) knows how to handle a team without a
contact address.

I updated the tests. Notably, I added a test to show there can be no
contact address when there are no admins. I added a test to verify the
case of an admining team without a contact address. I think this case
was missing in my first effort. There were no surprises that it worked
since get_contact_email_addresses is smart.

I have attached a diff, but you might want to read def
getTeamAdminsEmailAddresses and TestTeamGetTeamAdminsEmailAddresses to
verify the whole method is consistent.

1=== modified file 'lib/lp/registry/model/person.py'
2--- lib/lp/registry/model/person.py 2011-04-01 20:26:29 +0000
3+++ lib/lp/registry/model/person.py 2011-04-06 16:46:14 +0000
4@@ -1423,15 +1423,8 @@
5 """See `IPerson`."""
6 assert self.is_team
7 to_addrs = set()
8- team_or_person = self
9- # Recurse through team ownership until an email address is found.
10- while len(to_addrs) == 0:
11- if team_or_person.is_team:
12- for admin in team_or_person.getDirectAdministrators():
13- to_addrs.update(get_contact_email_addresses(admin))
14- team_or_person = team_or_person.teamowner
15- else:
16- to_addrs.update(get_contact_email_addresses(team_or_person))
17+ for admin in self.adminmembers:
18+ to_addrs.update(get_contact_email_addresses(admin))
19 return sorted(to_addrs)
20
21 def addMember(self, person, reviewer, comment=None, force_team_add=False,
22
23=== modified file 'lib/lp/registry/tests/test_team.py'
24--- lib/lp/registry/tests/test_team.py 2011-03-25 17:28:15 +0000
25+++ lib/lp/registry/tests/test_team.py 2011-04-06 17:04:26 +0000
26@@ -133,23 +133,20 @@
27 def setUp(self):
28 super(TestTeamGetTeamAdminsEmailAddresses, self).setUp()
29 self.team = self.factory.makeTeam(name='finch')
30- self.address = self.factory.makeEmail('team@eg.dom', self.team)
31 login_celebrity('admin')
32
33- def test_team_direct_owner(self):
34- # The team owner's email address is only email address.
35+ def test_admin_is_user(self):
36+ # The team owner is a user and admin who provides the email address.
37 email = self.team.teamowner.preferredemail.email
38 self.assertEqual([email], self.team.getTeamAdminsEmailAddresses())
39
40- def test_team_direct_owner_not_member(self):
41- # The team owner's email address is only email address, even when
42- # the owner is not a team member.
43- email = self.team.teamowner.preferredemail.email
44+ def test_no_admins(self):
45+ # A team without admins has no email addresses.
46 self.team.teamowner.leave(self.team)
47- self.assertEqual([email], self.team.getTeamAdminsEmailAddresses())
48+ self.assertEqual([], self.team.getTeamAdminsEmailAddresses())
49
50- def test_team_direct_admin(self):
51- # The team's owner and direct admins provide the email addresses.
52+ def test_admins_are_users_with_preferred_email_addresses(self):
53+ # The team's admins are users, and they provide the email addresses.
54 admin = self.factory.makePerson()
55 self.team.addMember(admin, self.team.teamowner)
56 for membership in self.team.member_memberships:
57@@ -160,35 +157,37 @@
58 self.assertEqual(
59 [email_1, email_2], self.team.getTeamAdminsEmailAddresses())
60
61- def test_team_indirect_owner(self):
62- # The team owner's of the owning team is only email address.
63- owning_team = self.factory.makeTeam()
64- member = self.team.teamowner
65- self.team.teamowner = owning_team
66- for membership in self.team.member_memberships:
67- membership.setStatus(
68- TeamMembershipStatus.APPROVED, owning_team.teamowner)
69- email = owning_team.teamowner.preferredemail.email
70- self.assertEqual([email], self.team.getTeamAdminsEmailAddresses())
71-
72- def test_team_indirect_admin(self):
73- # The team owner and admin of the owning team provide the
74- # email addresses.
75- owning_team = self.factory.makeTeam()
76- member = self.team.teamowner
77- self.team.teamowner = owning_team
78- for membership in self.team.member_memberships:
79- membership.setStatus(
80- TeamMembershipStatus.APPROVED, owning_team.teamowner)
81- admin = self.factory.makePerson()
82- owning_team.addMember(admin, owning_team.teamowner)
83- for membership in owning_team.member_memberships:
84- membership.setStatus(
85- TeamMembershipStatus.ADMIN, owning_team.teamowner)
86- email_1 = owning_team.teamowner.preferredemail.email
87- email_2 = admin.preferredemail.email
88- self.assertEqual(
89- [email_1, email_2], self.team.getTeamAdminsEmailAddresses())
90+ def setUpAdminingTeam(self, team):
91+ """Return a new team set as the admin of the provided team."""
92+ admin_team = self.factory.makeTeam()
93+ admin_member = self.factory.makePerson()
94+ admin_team.addMember(admin_member, admin_team.teamowner)
95+ team.addMember(
96+ admin_team, team.teamowner, force_team_add=True)
97+ for membership in team.member_memberships:
98+ membership.setStatus(
99+ TeamMembershipStatus.ADMIN, admin_team.teamowner)
100+ approved_member = self.factory.makePerson()
101+ team.addMember(approved_member, team.teamowner)
102+ team.teamowner.leave(team)
103+ return admin_team
104+
105+ def test_admins_are_teams_with_preferred_email_addresses(self):
106+ # The team's admin is a team without a contact address.
107+ # The admin team members provide the email addresses.
108+ admin_team = self.setUpAdminingTeam(self.team)
109+ admin_team.setContactAddress(
110+ self.factory.makeEmail('team@eg.dom', admin_team))
111+ self.assertEqual(
112+ ['team@eg.dom'], self.team.getTeamAdminsEmailAddresses())
113+
114+ def test_admins_are_teams_without_preferred_email_addresses(self):
115+ # The team's admin is a team with a contact address.
116+ admin_team = self.setUpAdminingTeam(self.team)
117+ emails = sorted(
118+ m.preferredemail.email for m in admin_team.activemembers)
119+ self.assertEqual(
120+ emails, self.team.getTeamAdminsEmailAddresses())
121
122
123 class TestDefaultRenewalPeriodIsRequiredForSomeTeams(TestCaseWithFactory):
124
Revision history for this message
Robert Collins (lifeless) wrote :

 def test_admins_are_teams_with_preferred_email_addresses(self):
106 + # The team's admin is a team without a contact address.
107 + # The admin team members provide the email addresses.
108 + admin_team = self.setUpAdminingTeam(self.team)
109 + admin_team.setContactAddress(
110 + self.factory.makeEmail('<email address hidden>', admin_team))
111 + self.assertEqual(
112 + ['<email address hidden>'], self.team.getTeamAdminsEmailAddresses())
113 +
114 + def test_admins_are_teams_without_preferred_email_addresses(self):

looks like you have them inverted: that is that the name and comment of the former test describes the behaviour of the latter test, and vice versa.

Other than that this looks good.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'lib/lp/registry/emailtemplates/team-merged.txt'
2--- lib/lp/registry/emailtemplates/team-merged.txt 1970-01-01 00:00:00 +0000
3+++ lib/lp/registry/emailtemplates/team-merged.txt 2011-04-08 02:56:00 +0000
4@@ -0,0 +1,8 @@
5+
6+The Launchpad team named '%(dupename)s' was merged into the team
7+named '%(person)s'. You can review the changes at:
8+
9+ https://launchpad.net/~%(person)s
10+
11+Regards,
12+Launchpad
13
14=== modified file 'lib/lp/registry/interfaces/personnotification.py'
15--- lib/lp/registry/interfaces/personnotification.py 2010-08-20 20:31:18 +0000
16+++ lib/lp/registry/interfaces/personnotification.py 2011-04-08 02:56:00 +0000
17@@ -11,7 +11,10 @@
18 'IPersonNotificationSet',
19 ]
20
21-from zope.interface import Interface
22+from zope.interface import (
23+ Attribute,
24+ Interface,
25+ )
26 from zope.schema import (
27 Datetime,
28 Object,
29@@ -38,6 +41,11 @@
30 body = Text(title=_("Notification body."))
31 subject = TextLine(title=_("Notification subject."))
32
33+ can_send = Attribute("Can the notification be sent?")
34+
35+ to_addresses = Attribute(
36+ "The list of addresses to send the notification to.")
37+
38 def destroySelf():
39 """Delete this notification."""
40
41
42=== modified file 'lib/lp/registry/interfaces/persontransferjob.py'
43--- lib/lp/registry/interfaces/persontransferjob.py 2011-03-25 23:42:33 +0000
44+++ lib/lp/registry/interfaces/persontransferjob.py 2011-04-08 02:56:00 +0000
45@@ -95,6 +95,9 @@
46 vocabulary='ValidPersonOrTeam',
47 required=True)
48
49+ def getErrorRecipients(self):
50+ """See `BaseRunnableJob`."""
51+
52
53 class IPersonMergeJobSource(IJobSource):
54 """An interface for acquiring IPersonMergeJobs."""
55
56=== modified file 'lib/lp/registry/model/person.py'
57--- lib/lp/registry/model/person.py 2011-03-31 19:10:35 +0000
58+++ lib/lp/registry/model/person.py 2011-04-08 02:56:00 +0000
59@@ -1423,8 +1423,8 @@
60 """See `IPerson`."""
61 assert self.is_team
62 to_addrs = set()
63- for person in self.getDirectAdministrators():
64- to_addrs.update(get_contact_email_addresses(person))
65+ for admin in self.adminmembers:
66+ to_addrs.update(get_contact_email_addresses(admin))
67 return sorted(to_addrs)
68
69 def addMember(self, person, reviewer, comment=None, force_team_add=False,
70@@ -4065,16 +4065,20 @@
71 """ % sqlvalues(to_person.accountID, from_person.accountID))
72
73 # Inform the user of the merge changes.
74- if not to_person.isTeam():
75+ if to_person.isTeam():
76+ mail_text = get_email_template(
77+ 'team-merged.txt', app='registry')
78+ subject = 'Launchpad teams merged'
79+ else:
80 mail_text = get_email_template(
81 'person-merged.txt', app='registry')
82- mail_text = mail_text % {
83- 'dupename': from_person.name,
84- 'person': to_person.name,
85- }
86 subject = 'Launchpad accounts merged'
87- getUtility(IPersonNotificationSet).addNotification(
88- to_person, subject, mail_text)
89+ mail_text = mail_text % {
90+ 'dupename': from_person.name,
91+ 'person': to_person.name,
92+ }
93+ getUtility(IPersonNotificationSet).addNotification(
94+ to_person, subject, mail_text)
95
96 def getValidPersons(self, persons):
97 """See `IPersonSet.`"""
98
99=== modified file 'lib/lp/registry/model/personnotification.py'
100--- lib/lp/registry/model/personnotification.py 2010-08-20 20:31:18 +0000
101+++ lib/lp/registry/model/personnotification.py 2011-04-08 02:56:00 +0000
102@@ -29,6 +29,7 @@
103 IPersonNotification,
104 IPersonNotificationSet,
105 )
106+from lp.services.propertycache import cachedproperty
107 from lp.services.mail.sendmail import (
108 format_address,
109 simple_sendmail,
110@@ -45,14 +46,29 @@
111 body = StringCol(notNull=True)
112 subject = StringCol(notNull=True)
113
114+ @cachedproperty
115+ def to_addresses(self):
116+ """See `IPersonNotification`."""
117+ if self.person.is_team:
118+ return self.person.getTeamAdminsEmailAddresses()
119+ elif self.person.preferredemail is None:
120+ return []
121+ else:
122+ return [format_address(
123+ self.person.displayname, self.person.preferredemail.email)]
124+
125+ @property
126+ def can_send(self):
127+ """See `IPersonNotification`."""
128+ return len(self.to_addresses) > 0
129+
130 def send(self):
131 """See `IPersonNotification`."""
132- assert self.person.preferredemail is not None, (
133- "Can't send a notification to a person without an email.")
134+ if not self.can_send:
135+ raise AssertionError(
136+ "Can't send a notification to a person without an email.")
137 from_addr = config.canonical.bounce_address
138- to_addr = format_address(
139- self.person.displayname, self.person.preferredemail.email)
140- simple_sendmail(from_addr, to_addr, self.subject, self.body)
141+ simple_sendmail(from_addr, self.to_addresses, self.subject, self.body)
142 self.date_emailed = datetime.now(pytz.timezone('UTC'))
143
144
145@@ -73,4 +89,3 @@
146 """See `IPersonNotificationSet`."""
147 return PersonNotification.select(
148 'date_created < %s' % sqlvalues(time_limit))
149-
150
151=== modified file 'lib/lp/registry/model/persontransferjob.py'
152--- lib/lp/registry/model/persontransferjob.py 2011-03-25 23:42:33 +0000
153+++ lib/lp/registry/model/persontransferjob.py 2011-04-08 02:56:00 +0000
154@@ -64,6 +64,7 @@
155 from lp.services.database.stormbase import StormBase
156 from lp.services.job.model.job import Job
157 from lp.services.job.runner import BaseRunnableJob
158+from lp.services.mail.sendmail import format_address_for_person
159
160
161 class PersonTransferJob(StormBase):
162@@ -393,6 +394,13 @@
163 def log_name(self):
164 return self.__class__.__name__
165
166+ def getErrorRecipients(self):
167+ """See `IPersonMergeJob`."""
168+ if self.to_person.is_team:
169+ return self.to_person.getTeamAdminsEmailAddresses()
170+ else:
171+ return [format_address_for_person(self.to_person)]
172+
173 def run(self):
174 """Perform the merge."""
175 from_person_name = self.from_person.name
176@@ -402,7 +410,6 @@
177 log.debug(
178 "%s is about to merge ~%s into ~%s", self.log_name,
179 from_person_name, to_person_name)
180-
181 getUtility(IPersonSet).merge(
182 from_person=self.from_person, to_person=self.to_person,
183 reviewer=self.reviewer)
184
185=== modified file 'lib/lp/registry/scripts/personnotification.py'
186--- lib/lp/registry/scripts/personnotification.py 2010-12-08 17:22:23 +0000
187+++ lib/lp/registry/scripts/personnotification.py 2011-04-08 02:56:00 +0000
188@@ -38,14 +38,15 @@
189 '%d notification(s) to send.' % pending_notifications.count())
190 for notification in pending_notifications:
191 person = notification.person
192- if person.preferredemail is None:
193+ if not notification.can_send:
194 unsent_notifications.append(notification)
195 self.logger.info(
196 "%s has no email address." % person.name)
197 continue
198 self.logger.info(
199 "Sending notification to %s <%s>."
200- % (person.name, removeSecurityProxy(person).preferredemail.email))
201+ % (person.name,
202+ removeSecurityProxy(person).preferredemail.email))
203 notification.send()
204 notifications_sent = True
205 # Commit after each email sent, so that we won't re-mail the
206
207=== modified file 'lib/lp/registry/tests/test_person_merge_job.py'
208--- lib/lp/registry/tests/test_person_merge_job.py 2011-03-25 23:42:33 +0000
209+++ lib/lp/registry/tests/test_person_merge_job.py 2011-04-08 02:56:00 +0000
210@@ -26,6 +26,7 @@
211 from lp.services.job.interfaces.job import JobStatus
212 from lp.services.job.model.job import Job
213 from lp.services.log.logger import BufferLogger
214+from lp.services.mail.sendmail import format_address_for_person
215 from lp.testing import (
216 run_script,
217 person_logged_in,
218@@ -39,10 +40,8 @@
219
220 def setUp(self):
221 super(TestPersonMergeJob, self).setUp()
222- self.from_person = self.factory.makePerson(
223- name='void', email='void@eg.dom')
224- self.to_person = self.factory.makePerson(
225- name='gestalt', email='gestalt@eg.dom')
226+ self.from_person = self.factory.makePerson(name='void')
227+ self.to_person = self.factory.makePerson(name='gestalt')
228 self.job_source = getUtility(IPersonMergeJobSource)
229 self.job = self.job_source.create(
230 from_person=self.from_person, to_person=self.to_person)
231@@ -59,6 +58,21 @@
232 self.assertEqual(self.to_person, self.job.major_person)
233 self.assertEqual({}, self.job.metadata)
234
235+ def test_getErrorRecipients_user(self):
236+ # The to_person is the recipient.
237+ email_id = format_address_for_person(self.to_person)
238+ self.assertEqual([email_id], self.job.getErrorRecipients())
239+
240+ def test_getErrorRecipients_team(self):
241+ # The to_person admins are the recipients.
242+ to_team = self.factory.makeTeam()
243+ from_team = self.factory.makeTeam()
244+ job = self.job_source.create(
245+ from_person=from_team, to_person=to_team,
246+ reviewer=to_team.teamowner)
247+ self.assertEqual(
248+ to_team.getTeamAdminsEmailAddresses(), job.getErrorRecipients())
249+
250 def test_enqueue(self):
251 # Newly created jobs are enqueued.
252 self.assertEqual([self.job], list(self.job_source.iterReady()))
253
254=== modified file 'lib/lp/registry/tests/test_personnotification.py'
255--- lib/lp/registry/tests/test_personnotification.py 2010-08-20 20:31:18 +0000
256+++ lib/lp/registry/tests/test_personnotification.py 2011-04-08 02:56:00 +0000
257@@ -27,6 +27,44 @@
258 from lp.testing import TestCaseWithFactory
259
260
261+class TestPersonNotification(TestCaseWithFactory):
262+ """Tests for PersonNotification."""
263+
264+ layer = DatabaseFunctionalLayer
265+
266+ def setUp(self):
267+ super(TestPersonNotification, self).setUp()
268+ self.notification_set = getUtility(IPersonNotificationSet)
269+
270+ def test_to_addresses_user(self):
271+ # The to_addresses list is the user's preferred email address.
272+ user = self.factory.makePerson()
273+ notification = self.notification_set.addNotification(
274+ user, 'subject', 'body')
275+ email = '%s <%s>' % (
276+ user.displayname, removeSecurityProxy(user.preferredemail).email)
277+ self.assertEqual([email], notification.to_addresses)
278+ self.assertTrue(notification.can_send)
279+
280+ def test_to_addresses_user_without_address(self):
281+ # The to_addresses list is empty and the notification cannot be sent.
282+ user = self.factory.makePerson()
283+ user.setPreferredEmail(None)
284+ notification = self.notification_set.addNotification(
285+ user, 'subject', 'body')
286+ self.assertEqual([], notification.to_addresses)
287+ self.assertFalse(notification.can_send)
288+
289+ def test_to_addresses_team(self):
290+ # The to_addresses list is the team admin addresses.
291+ team = self.factory.makeTeam()
292+ notification = self.notification_set.addNotification(
293+ team, 'subject', 'body')
294+ email = removeSecurityProxy(team.teamowner.preferredemail).email
295+ self.assertEqual([email], notification.to_addresses)
296+ self.assertTrue(notification.can_send)
297+
298+
299 class TestPersonNotificationManager(TestCaseWithFactory):
300 """Tests for the PersonNotificationManager use in scripts."""
301
302
303=== modified file 'lib/lp/registry/tests/test_team.py'
304--- lib/lp/registry/tests/test_team.py 2011-03-23 16:28:51 +0000
305+++ lib/lp/registry/tests/test_team.py 2011-04-08 02:56:00 +0000
306@@ -30,6 +30,7 @@
307 TeamMembershipRenewalPolicy,
308 TeamSubscriptionPolicy,
309 )
310+from lp.registry.interfaces.teammembership import TeamMembershipStatus
311 from lp.registry.model.persontransferjob import PersonTransferJob
312 from lp.soyuz.enums import ArchiveStatus
313 from lp.testing import (
314@@ -124,6 +125,71 @@
315 self.assertEqual([], self.getAllEmailAddresses())
316
317
318+class TestTeamGetTeamAdminsEmailAddresses(TestCaseWithFactory):
319+ """Test the rules of IPerson.getTeamAdminsEmailAddresses()."""
320+
321+ layer = DatabaseFunctionalLayer
322+
323+ def setUp(self):
324+ super(TestTeamGetTeamAdminsEmailAddresses, self).setUp()
325+ self.team = self.factory.makeTeam(name='finch')
326+ login_celebrity('admin')
327+
328+ def test_admin_is_user(self):
329+ # The team owner is a user and admin who provides the email address.
330+ email = self.team.teamowner.preferredemail.email
331+ self.assertEqual([email], self.team.getTeamAdminsEmailAddresses())
332+
333+ def test_no_admins(self):
334+ # A team without admins has no email addresses.
335+ self.team.teamowner.leave(self.team)
336+ self.assertEqual([], self.team.getTeamAdminsEmailAddresses())
337+
338+ def test_admins_are_users_with_preferred_email_addresses(self):
339+ # The team's admins are users, and they provide the email addresses.
340+ admin = self.factory.makePerson()
341+ self.team.addMember(admin, self.team.teamowner)
342+ for membership in self.team.member_memberships:
343+ membership.setStatus(
344+ TeamMembershipStatus.ADMIN, self.team.teamowner)
345+ email_1 = self.team.teamowner.preferredemail.email
346+ email_2 = admin.preferredemail.email
347+ self.assertEqual(
348+ [email_1, email_2], self.team.getTeamAdminsEmailAddresses())
349+
350+ def setUpAdminingTeam(self, team):
351+ """Return a new team set as the admin of the provided team."""
352+ admin_team = self.factory.makeTeam()
353+ admin_member = self.factory.makePerson()
354+ admin_team.addMember(admin_member, admin_team.teamowner)
355+ team.addMember(
356+ admin_team, team.teamowner, force_team_add=True)
357+ for membership in team.member_memberships:
358+ membership.setStatus(
359+ TeamMembershipStatus.ADMIN, admin_team.teamowner)
360+ approved_member = self.factory.makePerson()
361+ team.addMember(approved_member, team.teamowner)
362+ team.teamowner.leave(team)
363+ return admin_team
364+
365+ def test_admins_are_teams_with_preferred_email_addresses(self):
366+ # The team's admin is a team with a contact address.
367+ admin_team = self.setUpAdminingTeam(self.team)
368+ admin_team.setContactAddress(
369+ self.factory.makeEmail('team@eg.dom', admin_team))
370+ self.assertEqual(
371+ ['team@eg.dom'], self.team.getTeamAdminsEmailAddresses())
372+
373+ def test_admins_are_teams_without_preferred_email_addresses(self):
374+ # The team's admin is a team without a contact address.
375+ # The admin team members provide the email addresses.
376+ admin_team = self.setUpAdminingTeam(self.team)
377+ emails = sorted(
378+ m.preferredemail.email for m in admin_team.activemembers)
379+ self.assertEqual(
380+ emails, self.team.getTeamAdminsEmailAddresses())
381+
382+
383 class TestDefaultRenewalPeriodIsRequiredForSomeTeams(TestCaseWithFactory):
384
385 layer = DatabaseFunctionalLayer