Merge lp:~edwin-grubbs/launchpad/bug-615654-queue-addmember-emails into lp:launchpad/db-devel
- bug-615654-queue-addmember-emails
- Merge into db-devel
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 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Brad Crittenden (community) | code | Approve | |
Review via email: mp+35862@code.launchpad.net |
Commit message
Description of the change
Summary
-------
This branch adds the MembershipNotif
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/
lib/
lib/
lib/
lib/
Tests:
lib/
lib/
lib/
Tests
-----
./bin/test -vv -t 'teammembership
Edwin Grubbs (edwin-grubbs) wrote : | # |
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/
>> --- lib/lp/
>> +++ lib/lp/
>> @@ -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__ = [
>> + 'MembershipNoti
>> + 'PersonTransfer
>> + ]
>> +
>> +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.
>> +from canonical.
>> + get_contact_
>> + get_email_template,
>> + )
>> +from canonical.
>> + IMasterStore,
>> + IStore,
>> + )
>> +from canonical.
>> + format_address,
>> + simple_sendmail,
>> + )
>> +from canonical.
>> +from canonical.
>> +
>> +
>
> 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 PersonTransferJ
>> +from lp.registry.
>> + IPerson,
>> + IPersonSet,
>> + ITeam,
>> + )
>> +from lp.registry.
>> + IPersonTransferJob,
>> + IPersonTransfer
>> + IMembershipNoti
>> + IMembershipNoti
>> + )
>> +from lp.registry.
>> +from lp.registry.
>> +from lp.services.
>> +from lp.services.
>> +
>> +
>> +class PersonTransferJ
> [...]
>> + 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
1 | === modified file 'lib/lp/registry/configure.zcml' |
2 | --- lib/lp/registry/configure.zcml 2010-09-14 10:51:25 +0000 |
3 | +++ lib/lp/registry/configure.zcml 2010-09-17 22:26:48 +0000 |
4 | @@ -84,6 +84,23 @@ |
5 | __call__"/> |
6 | </class> |
7 | |
8 | + <!-- IPersonTransferJob --> |
9 | + <securedutility |
10 | + component="lp.registry.model.persontransferjob.MembershipNotificationJob" |
11 | + provides="lp.registry.interfaces.persontransferjob.IMembershipNotificationJobSource"> |
12 | + <allow |
13 | + interface="lp.registry.interfaces.persontransferjob.IMembershipNotificationJobSource"/> |
14 | + </securedutility> |
15 | + |
16 | + <class class="lp.registry.model.persontransferjob.PersonTransferJob"> |
17 | + <allow interface="lp.registry.interfaces.persontransferjob.IPersonTransferJob"/> |
18 | + </class> |
19 | + |
20 | + <class class="lp.registry.model.persontransferjob.MembershipNotificationJob"> |
21 | + <allow |
22 | + interface="lp.registry.interfaces.persontransferjob.IMembershipNotificationJob"/> |
23 | + </class> |
24 | + |
25 | <!-- Location --> |
26 | |
27 | <class |
28 | |
29 | === modified file 'lib/lp/registry/doc/teammembership-email-notification.txt' |
30 | --- lib/lp/registry/doc/teammembership-email-notification.txt 2010-03-26 00:45:36 +0000 |
31 | +++ lib/lp/registry/doc/teammembership-email-notification.txt 2010-09-17 22:26:48 +0000 |
32 | @@ -24,7 +24,7 @@ |
33 | |
34 | >>> from lp.services.mail import stub |
35 | >>> from lp.testing.mail_helpers import ( |
36 | - ... pop_notifications, print_distinct_emails) |
37 | + ... pop_notifications, print_distinct_emails, run_mail_jobs) |
38 | |
39 | >>> from zope.component import getUtility |
40 | >>> from canonical.launchpad.interfaces import ( |
41 | @@ -49,7 +49,7 @@ |
42 | >>> membership.status.title |
43 | 'Proposed' |
44 | |
45 | - >>> transaction.commit() |
46 | + >>> run_mail_jobs() |
47 | >>> len(stub.test_emails) |
48 | 5 |
49 | >>> print_distinct_emails(include_reply_to=True) |
50 | @@ -87,6 +87,11 @@ |
51 | # that team. |
52 | >>> login('mark@example.com') |
53 | >>> setStatus(membership, TeamMembershipStatus.DECLINED, reviewer=mark) |
54 | + |
55 | +addMember() has queued up a job to send out the emails. We'll run the |
56 | +job now. |
57 | + |
58 | + >>> run_mail_jobs() |
59 | >>> len(stub.test_emails) |
60 | 6 |
61 | |
62 | @@ -125,6 +130,7 @@ |
63 | |
64 | >>> setStatus(daf_membership, TeamMembershipStatus.APPROVED, |
65 | ... reviewer=mark, comment='This is a nice guy; I like him') |
66 | + >>> run_mail_jobs() |
67 | >>> stub.test_emails.sort(by_to_addrs) |
68 | >>> len(stub.test_emails) |
69 | 6 |
70 | @@ -158,6 +164,7 @@ |
71 | |
72 | >>> setStatus(daf_membership, TeamMembershipStatus.DEACTIVATED, |
73 | ... reviewer=mark) |
74 | + >>> run_mail_jobs() |
75 | >>> stub.test_emails.sort(by_to_addrs) |
76 | >>> len(stub.test_emails) |
77 | 6 |
78 | @@ -187,7 +194,7 @@ |
79 | |
80 | >>> admins = personset.getByName('admins') |
81 | >>> admins.join(ubuntu_team, requester=mark) |
82 | - >>> transaction.commit() |
83 | + >>> run_mail_jobs() |
84 | >>> len(stub.test_emails) |
85 | 5 |
86 | >>> print_distinct_emails(include_reply_to=True) |
87 | @@ -228,7 +235,10 @@ |
88 | >>> cprov = personset.getByName('cprov') |
89 | >>> marilize = personset.getByName('marilize') |
90 | >>> ignored = ubuntu_team.addMember(marilize, reviewer=cprov) |
91 | - >>> transaction.commit() |
92 | + >>> run_mail_jobs() |
93 | + |
94 | +Now, the emails have been sent. |
95 | + |
96 | >>> len(stub.test_emails) |
97 | 6 |
98 | >>> print_distinct_emails() |
99 | @@ -277,7 +287,7 @@ |
100 | >>> mirror_admins.getTeamAdminsEmailAddresses() |
101 | ['mark@example.com'] |
102 | >>> ignored = ubuntu_team.addMember(mirror_admins, reviewer=cprov) |
103 | - >>> transaction.commit() |
104 | + >>> run_mail_jobs() |
105 | >>> len(stub.test_emails) |
106 | 1 |
107 | >>> print_distinct_emails() |
108 | @@ -304,7 +314,7 @@ |
109 | >>> comment = "Of course I want to be part of ubuntu!" |
110 | >>> mirror_admins.acceptInvitationToBeMemberOf(ubuntu_team, comment) |
111 | >>> flush_database_updates() |
112 | - >>> transaction.commit() |
113 | + >>> run_mail_jobs() |
114 | |
115 | >>> len(stub.test_emails) |
116 | 6 |
117 | @@ -337,7 +347,7 @@ |
118 | >>> comment = "Landscape has nothing to do with ubuntu, unfortunately." |
119 | >>> landscape.declineInvitationToBeMemberOf(ubuntu_team, comment) |
120 | >>> flush_database_updates() |
121 | - >>> transaction.commit() |
122 | + >>> run_mail_jobs() |
123 | |
124 | >>> len(stub.test_emails) |
125 | 7 |
126 | @@ -363,7 +373,7 @@ |
127 | >>> ignored = ubuntu_team.addMember( |
128 | ... launchpad, reviewer=cprov, force_team_add=True) |
129 | >>> flush_database_updates() |
130 | - >>> transaction.commit() |
131 | + >>> run_mail_jobs() |
132 | >>> len(stub.test_emails) |
133 | 5 |
134 | >>> print_distinct_emails() |
135 | @@ -610,7 +620,7 @@ |
136 | >>> membershipset.handleMembershipsExpiringToday( |
137 | ... reviewer=getUtility(ILaunchpadCelebrities).janitor) |
138 | >>> flush_database_updates() |
139 | - >>> transaction.commit() |
140 | + >>> run_mail_jobs() |
141 | |
142 | >>> len(stub.test_emails) |
143 | 8 |
144 | @@ -690,7 +700,7 @@ |
145 | |
146 | >>> login_person(karl) |
147 | >>> karl.renewTeamMembership(mirror_admins) |
148 | - >>> transaction.commit() |
149 | + >>> run_mail_jobs() |
150 | >>> len(stub.test_emails) |
151 | 1 |
152 | |
153 | @@ -713,7 +723,7 @@ |
154 | approved to admin, but he won't get a notification of that. |
155 | |
156 | >>> team = personset.newTeam(mark, 'testteam', 'Test') |
157 | - >>> transaction.commit() |
158 | + >>> run_mail_jobs() |
159 | >>> len(stub.test_emails) |
160 | 0 |
161 | |
162 | @@ -730,6 +740,7 @@ |
163 | >>> login('mark@example.com') |
164 | >>> setStatus( |
165 | ... cprov_membership, TeamMembershipStatus.ADMIN, reviewer=mark) |
166 | + >>> run_mail_jobs() |
167 | >>> len(stub.test_emails) |
168 | 6 |
169 | >>> print_distinct_emails() |
170 | @@ -760,6 +771,7 @@ |
171 | >>> jdub_membership = membershipset.getByPersonAndTeam(jdub, ubuntu_team) |
172 | >>> setStatus(jdub_membership, TeamMembershipStatus.APPROVED, |
173 | ... reviewer=jdub) |
174 | + >>> run_mail_jobs() |
175 | >>> len(stub.test_emails) |
176 | 5 |
177 | >>> print_distinct_emails() |
178 | @@ -784,6 +796,7 @@ |
179 | ... mirror_admins, ubuntu_team) |
180 | >>> setStatus(mirror_admins_membership, TeamMembershipStatus.DEACTIVATED, |
181 | ... reviewer=mark, silent=False) |
182 | + >>> run_mail_jobs() |
183 | >>> len(stub.test_emails) |
184 | 6 |
185 | |
186 | @@ -811,6 +824,7 @@ |
187 | Approved |
188 | >>> setStatus(dumper_hwdb_membership, |
189 | ... TeamMembershipStatus.DEACTIVATED, reviewer=mark, silent=True) |
190 | + >>> run_mail_jobs() |
191 | >>> len(stub.test_emails) |
192 | 0 |
193 | >>> print dumper_hwdb_membership.status.title |
194 | |
195 | === modified file 'lib/lp/registry/enum.py' |
196 | --- lib/lp/registry/enum.py 2010-09-10 10:08:58 +0000 |
197 | +++ lib/lp/registry/enum.py 2010-09-17 22:26:48 +0000 |
198 | @@ -5,6 +5,7 @@ |
199 | |
200 | __metaclass__ = type |
201 | __all__ = [ |
202 | + 'PersonTransferJobType', |
203 | 'BugNotificationLevel', |
204 | 'DistroSeriesDifferenceStatus', |
205 | 'DistroSeriesDifferenceType', |
206 | @@ -82,6 +83,7 @@ |
207 | This difference has been resolved and versions are now equal. |
208 | """) |
209 | |
210 | + |
211 | class DistroSeriesDifferenceType(DBEnumeratedType): |
212 | """Distribution series difference type.""" |
213 | |
214 | @@ -104,3 +106,13 @@ |
215 | |
216 | This package is present in both series with different versions. |
217 | """) |
218 | + |
219 | + |
220 | +class PersonTransferJobType(DBEnumeratedType): |
221 | + """Values that IPersonTransferJob.job_type can take.""" |
222 | + |
223 | + MEMBERSHIP_NOTIFICATION = DBItem(0, """ |
224 | + Add-member notification |
225 | + |
226 | + Notify affected users of new team membership. |
227 | + """) |
228 | |
229 | === added file 'lib/lp/registry/interfaces/persontransferjob.py' |
230 | --- lib/lp/registry/interfaces/persontransferjob.py 1970-01-01 00:00:00 +0000 |
231 | +++ lib/lp/registry/interfaces/persontransferjob.py 2010-09-17 22:26:48 +0000 |
232 | @@ -0,0 +1,70 @@ |
233 | +# Copyright 2010 Canonical Ltd. This software is licensed under the |
234 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
235 | + |
236 | +"""Interface for the Jobs system to change memberships or merge persons.""" |
237 | + |
238 | +__metaclass__ = type |
239 | +__all__ = [ |
240 | + 'IMembershipNotificationJob', |
241 | + 'IMembershipNotificationJobSource', |
242 | + 'IPersonTransferJob', |
243 | + 'IPersonTransferJobSource', |
244 | + ] |
245 | + |
246 | +from zope.interface import Attribute |
247 | +from zope.schema import ( |
248 | + Int, |
249 | + Object, |
250 | + ) |
251 | + |
252 | +from canonical.launchpad import _ |
253 | +from lp.services.fields import PublicPersonChoice |
254 | +from lp.services.job.interfaces.job import ( |
255 | + IJob, |
256 | + IJobSource, |
257 | + IRunnableJob, |
258 | + ) |
259 | + |
260 | + |
261 | +class IPersonTransferJob(IRunnableJob): |
262 | + """A Job related to team membership or a person merge.""" |
263 | + |
264 | + id = Int( |
265 | + title=_('DB ID'), required=True, readonly=True, |
266 | + description=_("The tracking number for this job.")) |
267 | + |
268 | + job = Object( |
269 | + title=_('The common Job attributes'), |
270 | + schema=IJob, |
271 | + required=True) |
272 | + |
273 | + minor_person = PublicPersonChoice( |
274 | + title=_('The person being added to the major person/team'), |
275 | + vocabulary='ValidPersonOrTeam', |
276 | + required=True) |
277 | + |
278 | + major_person = PublicPersonChoice( |
279 | + title=_('The person or team receiving the minor person'), |
280 | + vocabulary='ValidPersonOrTeam', |
281 | + required=True) |
282 | + |
283 | + metadata = Attribute('A dict of data about the job.') |
284 | + |
285 | + |
286 | +class IPersonTransferJobSource(IJobSource): |
287 | + """An interface for acquiring IPersonTransferJobs.""" |
288 | + |
289 | + def create(minor_person, major_person, metadata): |
290 | + """Create a new IPersonTransferJob.""" |
291 | + |
292 | + |
293 | +class IMembershipNotificationJob(IPersonTransferJob): |
294 | + """A Job to notify new members of a team of that change.""" |
295 | + |
296 | + |
297 | +class IMembershipNotificationJobSource(IJobSource): |
298 | + """An interface for acquiring IMembershipNotificationJobs.""" |
299 | + |
300 | + def create(member, team, reviewer, old_status, new_status, |
301 | + last_change_comment=None): |
302 | + """Create a new IMembershipNotificationJob.""" |
303 | |
304 | === added file 'lib/lp/registry/model/persontransferjob.py' |
305 | --- lib/lp/registry/model/persontransferjob.py 1970-01-01 00:00:00 +0000 |
306 | +++ lib/lp/registry/model/persontransferjob.py 2010-09-17 22:26:48 +0000 |
307 | @@ -0,0 +1,334 @@ |
308 | +# Copyright 2010 Canonical Ltd. This software is licensed under the |
309 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
310 | + |
311 | +"""Job classes related to PersonTransferJob.""" |
312 | + |
313 | +__metaclass__ = type |
314 | +__all__ = [ |
315 | + 'MembershipNotificationJob', |
316 | + 'PersonTransferJob', |
317 | + ] |
318 | + |
319 | +from lazr.delegates import delegates |
320 | +import simplejson |
321 | +from sqlobject import SQLObjectNotFound |
322 | +from storm.base import Storm |
323 | +from storm.expr import And |
324 | +from storm.locals import ( |
325 | + Int, |
326 | + Reference, |
327 | + Unicode, |
328 | + ) |
329 | +from zope.component import getUtility |
330 | +from zope.interface import ( |
331 | + classProvides, |
332 | + implements, |
333 | + ) |
334 | + |
335 | +from canonical.config import config |
336 | +from canonical.database.enumcol import EnumCol |
337 | +from canonical.launchpad.helpers import ( |
338 | + get_contact_email_addresses, |
339 | + get_email_template, |
340 | + ) |
341 | +from canonical.launchpad.interfaces.lpstorm import IMasterStore |
342 | +from canonical.launchpad.mail import ( |
343 | + format_address, |
344 | + simple_sendmail, |
345 | + ) |
346 | +from canonical.launchpad.mailnotification import MailWrapper |
347 | +from canonical.launchpad.webapp import canonical_url |
348 | +from lp.registry.enum import PersonTransferJobType |
349 | +from lp.registry.interfaces.person import ( |
350 | + IPerson, |
351 | + IPersonSet, |
352 | + ITeam, |
353 | + ) |
354 | +from lp.registry.interfaces.persontransferjob import ( |
355 | + IMembershipNotificationJob, |
356 | + IMembershipNotificationJobSource, |
357 | + IPersonTransferJob, |
358 | + IPersonTransferJobSource, |
359 | + ) |
360 | +from lp.registry.interfaces.teammembership import TeamMembershipStatus |
361 | +from lp.registry.model.person import Person |
362 | +from lp.services.job.model.job import Job |
363 | +from lp.services.job.runner import BaseRunnableJob |
364 | + |
365 | + |
366 | +class PersonTransferJob(Storm): |
367 | + """Base class for team membership and person merge jobs.""" |
368 | + |
369 | + implements(IPersonTransferJob) |
370 | + |
371 | + __storm_table__ = 'PersonTransferJob' |
372 | + |
373 | + id = Int(primary=True) |
374 | + |
375 | + job_id = Int(name='job') |
376 | + job = Reference(job_id, Job.id) |
377 | + |
378 | + major_person_id = Int(name='major_person') |
379 | + major_person = Reference(major_person_id, Person.id) |
380 | + |
381 | + minor_person_id = Int(name='minor_person') |
382 | + minor_person = Reference(minor_person_id, Person.id) |
383 | + |
384 | + job_type = EnumCol(enum=PersonTransferJobType, notNull=True) |
385 | + |
386 | + _json_data = Unicode('json_data') |
387 | + |
388 | + @property |
389 | + def metadata(self): |
390 | + return simplejson.loads(self._json_data) |
391 | + |
392 | + def __init__(self, minor_person, major_person, job_type, metadata): |
393 | + """Constructor. |
394 | + |
395 | + :param minor_person: The person or team being added to or removed |
396 | + from the major_person. |
397 | + :param major_person: The person or team that is receiving or losing |
398 | + the minor person. |
399 | + :param job_type: The specific membership action being performed. |
400 | + :param metadata: The type-specific variables, as a JSON-compatible |
401 | + dict. |
402 | + """ |
403 | + super(PersonTransferJob, self).__init__() |
404 | + self.job = Job() |
405 | + self.job_type = job_type |
406 | + self.major_person = major_person |
407 | + self.minor_person = minor_person |
408 | + |
409 | + json_data = simplejson.dumps(metadata) |
410 | + # XXX AaronBentley 2009-01-29 bug=322819: This should be a bytestring, |
411 | + # but the DB representation is unicode. |
412 | + self._json_data = json_data.decode('utf-8') |
413 | + |
414 | + @classmethod |
415 | + def get(cls, key): |
416 | + """Return the instance of this class whose key is supplied.""" |
417 | + store = IMasterStore(PersonTransferJob) |
418 | + instance = store.get(cls, key) |
419 | + if instance is None: |
420 | + raise SQLObjectNotFound( |
421 | + 'No occurrence of %s has key %s' % (cls.__name__, key)) |
422 | + return instance |
423 | + |
424 | + |
425 | +class PersonTransferJobDerived(BaseRunnableJob): |
426 | + """Intermediate class for deriving from PersonTransferJob. |
427 | + |
428 | + Storm classes can't simply be subclassed or you can end up with |
429 | + multiple objects referencing the same row in the db. This class uses |
430 | + lazr.delegates, which is a little bit simpler than storm's |
431 | + infoheritance solution to the problem. Subclasses need to override |
432 | + the run() method. |
433 | + """ |
434 | + |
435 | + delegates(IPersonTransferJob) |
436 | + classProvides(IPersonTransferJobSource) |
437 | + |
438 | + def __init__(self, job): |
439 | + self.context = job |
440 | + |
441 | + def __repr__(self): |
442 | + return ( |
443 | + '<%(job_type)s branch job (%(id)s) for %(minor_person)s ' |
444 | + 'as part of %(major_person)s. status=%(status)s>' % { |
445 | + 'job_type': self.context.job_type.name, |
446 | + 'id': self.context.id, |
447 | + 'minor_person': self.minor_person.name, |
448 | + 'major_person': self.major_person.name, |
449 | + 'status': self.job.status, |
450 | + }) |
451 | + |
452 | + @classmethod |
453 | + def create(cls, minor_person, major_person, metadata): |
454 | + """See `IPersonTransferJob`.""" |
455 | + if not IPerson.providedBy(minor_person): |
456 | + raise TypeError("minor_person must be IPerson: %s" |
457 | + % repr(minor_person)) |
458 | + if not IPerson.providedBy(major_person): |
459 | + raise TypeError("major_person must be IPerson: %s" |
460 | + % repr(major_person)) |
461 | + job = PersonTransferJob( |
462 | + minor_person=minor_person, |
463 | + major_person=major_person, |
464 | + job_type=cls.class_job_type, |
465 | + metadata=metadata) |
466 | + return cls(job) |
467 | + |
468 | + @classmethod |
469 | + def iterReady(cls): |
470 | + """Iterate through all ready PersonTransferJobs.""" |
471 | + store = IMasterStore(PersonTransferJob) |
472 | + jobs = store.find( |
473 | + PersonTransferJob, |
474 | + And(PersonTransferJob.job_type == cls.class_job_type, |
475 | + PersonTransferJob.job_id.is_in(Job.ready_jobs))) |
476 | + return (cls(job) for job in jobs) |
477 | + |
478 | + def getOopsVars(self): |
479 | + """See `IRunnableJob`.""" |
480 | + vars = BaseRunnableJob.getOopsVars(self) |
481 | + vars.extend([ |
482 | + ('major_person_name', self.context.major_person.name), |
483 | + ('minor_person_name', self.context.minor_person.name), |
484 | + ]) |
485 | + return vars |
486 | + |
487 | + |
488 | +class MembershipNotificationJob(PersonTransferJobDerived): |
489 | + """A Job that sends notifications about team membership changes.""" |
490 | + |
491 | + implements(IMembershipNotificationJob) |
492 | + classProvides(IMembershipNotificationJobSource) |
493 | + |
494 | + class_job_type = PersonTransferJobType.MEMBERSHIP_NOTIFICATION |
495 | + |
496 | + @classmethod |
497 | + def create(cls, member, team, reviewer, old_status, new_status, |
498 | + last_change_comment=None): |
499 | + if not ITeam.providedBy(team): |
500 | + raise TypeError('team must be ITeam: %s' % repr(team)) |
501 | + if not IPerson.providedBy(reviewer): |
502 | + raise TypeError('reviewer must be IPerson: %s' % repr(reviewer)) |
503 | + if old_status not in TeamMembershipStatus: |
504 | + raise TypeError("old_status must be TeamMembershipStatus: %s" |
505 | + % repr(old_status)) |
506 | + if new_status not in TeamMembershipStatus: |
507 | + raise TypeError("new_status must be TeamMembershipStatus: %s" |
508 | + % repr(new_status)) |
509 | + metadata = { |
510 | + 'reviewer': reviewer.id, |
511 | + 'old_status': old_status.name, |
512 | + 'new_status': new_status.name, |
513 | + 'last_change_comment': last_change_comment, |
514 | + } |
515 | + return super(MembershipNotificationJob, cls).create( |
516 | + minor_person=member, major_person=team, metadata=metadata) |
517 | + |
518 | + @property |
519 | + def member(self): |
520 | + return self.minor_person |
521 | + |
522 | + @property |
523 | + def team(self): |
524 | + return self.major_person |
525 | + |
526 | + @property |
527 | + def reviewer(self): |
528 | + return getUtility(IPersonSet).get(self.metadata['reviewer']) |
529 | + |
530 | + @property |
531 | + def old_status(self): |
532 | + return TeamMembershipStatus.items[self.metadata['old_status']] |
533 | + |
534 | + @property |
535 | + def new_status(self): |
536 | + return TeamMembershipStatus.items[self.metadata['new_status']] |
537 | + |
538 | + @property |
539 | + def last_change_comment(self): |
540 | + return self.metadata['last_change_comment'] |
541 | + |
542 | + def run(self): |
543 | + """See `IMembershipNotificationJob`.""" |
544 | + from canonical.launchpad.scripts import log |
545 | + from_addr = format_address( |
546 | + self.team.displayname, config.canonical.noreply_from_address) |
547 | + admin_emails = self.team.getTeamAdminsEmailAddresses() |
548 | + # person might be a self.team, so we can't rely on its preferredemail. |
549 | + self.member_email = get_contact_email_addresses(self.member) |
550 | + # Make sure we don't send the same notification twice to anybody. |
551 | + for email in self.member_email: |
552 | + if email in admin_emails: |
553 | + admin_emails.remove(email) |
554 | + |
555 | + if self.reviewer != self.member: |
556 | + self.reviewer_name = self.reviewer.unique_displayname |
557 | + else: |
558 | + # The user himself changed his self.membership. |
559 | + self.reviewer_name = 'the user himself' |
560 | + |
561 | + if self.last_change_comment: |
562 | + comment = ("\n%s said:\n %s\n" % ( |
563 | + self.reviewer.displayname, self.last_change_comment.strip())) |
564 | + else: |
565 | + comment = "" |
566 | + |
567 | + replacements = { |
568 | + 'member_name': self.member.unique_displayname, |
569 | + 'recipient_name': self.member.displayname, |
570 | + 'team_name': self.team.unique_displayname, |
571 | + 'team_url': canonical_url(self.team), |
572 | + 'old_status': self.old_status.title, |
573 | + 'new_status': self.new_status.title, |
574 | + 'reviewer_name': self.reviewer_name, |
575 | + 'comment': comment} |
576 | + |
577 | + template_name = 'membership-statuschange' |
578 | + subject = ( |
579 | + 'Membership change: %(member)s in %(team)s' |
580 | + % { |
581 | + 'member': self.member.name, |
582 | + 'team': self.team.name, |
583 | + }) |
584 | + if self.new_status == TeamMembershipStatus.EXPIRED: |
585 | + template_name = 'membership-expired' |
586 | + subject = '%s expired from team' % self.member.name |
587 | + elif (self.new_status == TeamMembershipStatus.APPROVED and |
588 | + self.old_status != TeamMembershipStatus.ADMIN): |
589 | + if self.old_status == TeamMembershipStatus.INVITED: |
590 | + subject = ('Invitation to %s accepted by %s' |
591 | + % (self.member.name, self.reviewer.name)) |
592 | + template_name = 'membership-invitation-accepted' |
593 | + elif self.old_status == TeamMembershipStatus.PROPOSED: |
594 | + subject = '%s approved by %s' % ( |
595 | + self.member.name, self.reviewer.name) |
596 | + else: |
597 | + subject = '%s added by %s' % ( |
598 | + self.member.name, self.reviewer.name) |
599 | + elif self.new_status == TeamMembershipStatus.INVITATION_DECLINED: |
600 | + subject = ('Invitation to %s declined by %s' |
601 | + % (self.member.name, self.reviewer.name)) |
602 | + template_name = 'membership-invitation-declined' |
603 | + elif self.new_status == TeamMembershipStatus.DEACTIVATED: |
604 | + subject = '%s deactivated by %s' % ( |
605 | + self.member.name, self.reviewer.name) |
606 | + elif self.new_status == TeamMembershipStatus.ADMIN: |
607 | + subject = '%s made admin by %s' % ( |
608 | + self.member.name, self.reviewer.name) |
609 | + elif self.new_status == TeamMembershipStatus.DECLINED: |
610 | + subject = '%s declined by %s' % ( |
611 | + self.member.name, self.reviewer.name) |
612 | + else: |
613 | + # Use the default template and subject. |
614 | + pass |
615 | + |
616 | + if len(admin_emails) != 0: |
617 | + admin_template = get_email_template( |
618 | + "%s-bulk.txt" % template_name) |
619 | + for address in admin_emails: |
620 | + recipient = getUtility(IPersonSet).getByEmail(address) |
621 | + replacements['recipient_name'] = recipient.displayname |
622 | + msg = MailWrapper().format( |
623 | + admin_template % replacements, force_wrap=True) |
624 | + simple_sendmail(from_addr, address, subject, msg) |
625 | + |
626 | + # The self.member can be a self.self.team without any |
627 | + # self.members, and in this case we won't have a single email |
628 | + # address to send this notification to. |
629 | + if self.member_email and self.reviewer != self.member: |
630 | + if self.member.isTeam(): |
631 | + template = '%s-bulk.txt' % template_name |
632 | + else: |
633 | + template = '%s-personal.txt' % template_name |
634 | + self.member_template = get_email_template(template) |
635 | + for address in self.member_email: |
636 | + recipient = getUtility(IPersonSet).getByEmail(address) |
637 | + replacements['recipient_name'] = recipient.displayname |
638 | + msg = MailWrapper().format( |
639 | + self.member_template % replacements, force_wrap=True) |
640 | + simple_sendmail(from_addr, address, subject, msg) |
641 | + log.debug('MembershipNotificationJob sent email') |
642 | |
643 | === modified file 'lib/lp/registry/model/teammembership.py' |
644 | --- lib/lp/registry/model/teammembership.py 2010-08-20 20:31:18 +0000 |
645 | +++ lib/lp/registry/model/teammembership.py 2010-09-17 22:26:48 +0000 |
646 | @@ -5,6 +5,7 @@ |
647 | |
648 | __metaclass__ = type |
649 | __all__ = [ |
650 | + 'sendStatusChangeNotification', |
651 | 'TeamMembership', |
652 | 'TeamMembershipSet', |
653 | 'TeamParticipation', |
654 | @@ -51,6 +52,9 @@ |
655 | TeamMembershipRenewalPolicy, |
656 | validate_public_person, |
657 | ) |
658 | +from lp.registry.interfaces.persontransferjob import ( |
659 | + IMembershipNotificationJobSource, |
660 | + ) |
661 | from lp.registry.interfaces.teammembership import ( |
662 | CyclicalTeamMembershipError, |
663 | DAYS_BEFORE_EXPIRATION_WARNING_IS_SENT, |
664 | @@ -400,96 +404,11 @@ |
665 | """Send a status change notification to all team admins and the |
666 | member whose membership's status changed. |
667 | """ |
668 | - team = self.team |
669 | - member = self.person |
670 | reviewer = self.last_changed_by |
671 | - from_addr = format_address( |
672 | - team.displayname, config.canonical.noreply_from_address) |
673 | new_status = self.status |
674 | - admins_emails = team.getTeamAdminsEmailAddresses() |
675 | - # self.person might be a team, so we can't rely on its preferredemail. |
676 | - member_email = get_contact_email_addresses(member) |
677 | - # Make sure we don't send the same notification twice to anybody. |
678 | - for email in member_email: |
679 | - if email in admins_emails: |
680 | - admins_emails.remove(email) |
681 | - |
682 | - if reviewer != member: |
683 | - reviewer_name = reviewer.unique_displayname |
684 | - else: |
685 | - # The user himself changed his membership. |
686 | - reviewer_name = 'the user himself' |
687 | - |
688 | - if self.last_change_comment: |
689 | - comment = ("\n%s said:\n %s\n" % ( |
690 | - reviewer.displayname, self.last_change_comment.strip())) |
691 | - else: |
692 | - comment = "" |
693 | - |
694 | - replacements = { |
695 | - 'member_name': member.unique_displayname, |
696 | - 'recipient_name': member.displayname, |
697 | - 'team_name': team.unique_displayname, |
698 | - 'team_url': canonical_url(team), |
699 | - 'old_status': old_status.title, |
700 | - 'new_status': new_status.title, |
701 | - 'reviewer_name': reviewer_name, |
702 | - 'comment': comment} |
703 | - |
704 | - template_name = 'membership-statuschange' |
705 | - subject = ('Membership change: %(member)s in %(team)s' |
706 | - % {'member': member.name, 'team': team.name}) |
707 | - if new_status == TeamMembershipStatus.EXPIRED: |
708 | - template_name = 'membership-expired' |
709 | - subject = '%s expired from team' % member.name |
710 | - elif (new_status == TeamMembershipStatus.APPROVED and |
711 | - old_status != TeamMembershipStatus.ADMIN): |
712 | - if old_status == TeamMembershipStatus.INVITED: |
713 | - subject = ('Invitation to %s accepted by %s' |
714 | - % (member.name, reviewer.name)) |
715 | - template_name = 'membership-invitation-accepted' |
716 | - elif old_status == TeamMembershipStatus.PROPOSED: |
717 | - subject = '%s approved by %s' % (member.name, reviewer.name) |
718 | - else: |
719 | - subject = '%s added by %s' % (member.name, reviewer.name) |
720 | - elif new_status == TeamMembershipStatus.INVITATION_DECLINED: |
721 | - subject = ('Invitation to %s declined by %s' |
722 | - % (member.name, reviewer.name)) |
723 | - template_name = 'membership-invitation-declined' |
724 | - elif new_status == TeamMembershipStatus.DEACTIVATED: |
725 | - subject = '%s deactivated by %s' % (member.name, reviewer.name) |
726 | - elif new_status == TeamMembershipStatus.ADMIN: |
727 | - subject = '%s made admin by %s' % (member.name, reviewer.name) |
728 | - elif new_status == TeamMembershipStatus.DECLINED: |
729 | - subject = '%s declined by %s' % (member.name, reviewer.name) |
730 | - else: |
731 | - # Use the default template and subject. |
732 | - pass |
733 | - |
734 | - if admins_emails: |
735 | - admins_template = get_email_template( |
736 | - "%s-bulk.txt" % template_name) |
737 | - for address in admins_emails: |
738 | - recipient = getUtility(IPersonSet).getByEmail(address) |
739 | - replacements['recipient_name'] = recipient.displayname |
740 | - msg = MailWrapper().format( |
741 | - admins_template % replacements, force_wrap=True) |
742 | - simple_sendmail(from_addr, address, subject, msg) |
743 | - |
744 | - # The member can be a team without any members, and in this case we |
745 | - # won't have a single email address to send this notification to. |
746 | - if member_email and reviewer != member: |
747 | - if member.isTeam(): |
748 | - template = '%s-bulk.txt' % template_name |
749 | - else: |
750 | - template = '%s-personal.txt' % template_name |
751 | - member_template = get_email_template(template) |
752 | - for address in member_email: |
753 | - recipient = getUtility(IPersonSet).getByEmail(address) |
754 | - replacements['recipient_name'] = recipient.displayname |
755 | - msg = MailWrapper().format( |
756 | - member_template % replacements, force_wrap=True) |
757 | - simple_sendmail(from_addr, address, subject, msg) |
758 | + getUtility(IMembershipNotificationJobSource).create( |
759 | + self.person, self.team, reviewer, old_status, new_status, |
760 | + self.last_change_comment) |
761 | |
762 | |
763 | class TeamMembershipSet: |
764 | |
765 | === added file 'lib/lp/registry/tests/test_persontransferjob.py' |
766 | --- lib/lp/registry/tests/test_persontransferjob.py 1970-01-01 00:00:00 +0000 |
767 | +++ lib/lp/registry/tests/test_persontransferjob.py 2010-09-17 22:26:48 +0000 |
768 | @@ -0,0 +1,62 @@ |
769 | +# Copyright 2010 Canonical Ltd. This software is licensed under the |
770 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
771 | + |
772 | +"""Tests for PersonTransferJobs.""" |
773 | + |
774 | +__metaclass__ = type |
775 | + |
776 | +from canonical.testing import LaunchpadZopelessLayer |
777 | +from lp.registry.enum import PersonTransferJobType |
778 | +from lp.registry.model.persontransferjob import ( |
779 | + PersonTransferJob, |
780 | + PersonTransferJobDerived, |
781 | + ) |
782 | +from lp.testing import TestCaseWithFactory |
783 | + |
784 | + |
785 | +class PersonTransferJobTestCase(TestCaseWithFactory): |
786 | + """Test case for basic PersonTransferJob class.""" |
787 | + |
788 | + layer = LaunchpadZopelessLayer |
789 | + |
790 | + def test_instantiate(self): |
791 | + # PersonTransferJob.__init__() instantiates a |
792 | + # PersonTransferJob instance. |
793 | + person = self.factory.makePerson() |
794 | + team = self.factory.makeTeam() |
795 | + |
796 | + metadata = ('some', 'arbitrary', 'metadata') |
797 | + person_transfer_job = PersonTransferJob( |
798 | + person, |
799 | + team, |
800 | + PersonTransferJobType.MEMBERSHIP_NOTIFICATION, |
801 | + metadata) |
802 | + |
803 | + self.assertEqual(person, person_transfer_job.minor_person) |
804 | + self.assertEqual(team, person_transfer_job.major_person) |
805 | + self.assertEqual( |
806 | + PersonTransferJobType.MEMBERSHIP_NOTIFICATION, |
807 | + person_transfer_job.job_type) |
808 | + |
809 | + # When we actually access the PersonTransferJob's metadata it |
810 | + # gets unserialized from JSON, so the representation returned by |
811 | + # person_transfer_job.metadata will be different from what we |
812 | + # originally passed in. |
813 | + metadata_expected = [u'some', u'arbitrary', u'metadata'] |
814 | + self.assertEqual(metadata_expected, person_transfer_job.metadata) |
815 | + |
816 | + |
817 | +class PersonTransferJobDerivedTestCase(TestCaseWithFactory): |
818 | + """Test case for the PersonTransferJobDerived class.""" |
819 | + |
820 | + layer = LaunchpadZopelessLayer |
821 | + |
822 | + def test_create_explodes(self): |
823 | + # PersonTransferJobDerived.create() will blow up because it |
824 | + # needs to be subclassed to work properly. |
825 | + person = self.factory.makePerson() |
826 | + team = self.factory.makeTeam() |
827 | + metadata = {'foo': 'bar'} |
828 | + self.assertRaises( |
829 | + AttributeError, |
830 | + PersonTransferJobDerived.create, person, team, metadata) |
831 | |
832 | === modified file 'lib/lp/testing/mail_helpers.py' |
833 | --- lib/lp/testing/mail_helpers.py 2010-08-20 20:31:18 +0000 |
834 | +++ lib/lp/testing/mail_helpers.py 2010-09-17 22:26:48 +0000 |
835 | @@ -10,7 +10,14 @@ |
836 | |
837 | import transaction |
838 | |
839 | +from zope.component import getUtility |
840 | + |
841 | +from lp.registry.interfaces.persontransferjob import ( |
842 | + IMembershipNotificationJobSource, |
843 | + ) |
844 | +from lp.services.job.runner import JobRunner |
845 | from lp.services.mail import stub |
846 | +from lp.testing.logger import MockLogger |
847 | |
848 | |
849 | def pop_notifications(sort_key=None, commit=True): |
850 | @@ -91,8 +98,25 @@ |
851 | print body |
852 | print "-"*40 |
853 | |
854 | + |
855 | def print_distinct_emails(include_reply_to=False, include_rationale=True): |
856 | """A convenient shortcut for `print_emails`(group_similar=True).""" |
857 | return print_emails(group_similar=True, |
858 | include_reply_to=include_reply_to, |
859 | include_rationale=include_rationale) |
860 | + |
861 | + |
862 | +def run_mail_jobs(): |
863 | + """Process job queues that send out emails. |
864 | + |
865 | + If a new job type is added that sends emails, this function can be |
866 | + extended to run those jobs, so that testing emails doesn't require a |
867 | + bunch of different function calls to process different queues. |
868 | + """ |
869 | + # Commit the transaction to make sure that the JobRunner can find |
870 | + # the queued jobs. |
871 | + transaction.commit() |
872 | + job_source = getUtility(IMembershipNotificationJobSource) |
873 | + logger = MockLogger() |
874 | + runner = JobRunner.fromReady(job_source, logger) |
875 | + runner.runAll() |
Hi Edwin,
Very interesting branch. I've got some suggestions but overall it is great.
--Brad
> === added file 'lib/lp/ registry/ model/persontra nsferjob. py' registry/ model/persontra nsferjob. py 1970-01-01 00:00:00 +0000 registry/ model/persontra nsferjob. py 2010-09-17 18:59:05 +0000
> --- lib/lp/
> +++ lib/lp/
> @@ -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 ficationJob' , Job',
> +__all__ = [
> + 'MembershipNoti
> + 'PersonTransfer
> + ]
> +
> +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 database. enumcol import EnumCol launchpad. helpers import ( email_addresses , launchpad. interfaces. lpstorm import ( launchpad. mail import ( launchpad. mailnotificatio n import MailWrapper launchpad. webapp import canonical_url
> +from canonical.
> +from canonical.
> + get_contact_
> + get_email_template,
> + )
> +from canonical.
> + IMasterStore,
> + IStore,
> + )
> +from canonical.
> + format_address,
> + simple_sendmail,
> + )
> +from canonical.
> +from canonical.
> +
> +
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 PersonTransferJ obType interfaces. person import ( interfaces. persontransferj ob import ( JobSource, ficationJob, ficationJobSour ce, interfaces. teammembership import TeamMembershipS tatus model.person import Person job.model. job import Job job.runner import BaseRunnableJob ob(Storm) :
> +from lp.registry.
> + IPerson,
> + IPersonSet,
> + ITeam,
> + )
> +from lp.registry.
> + IPersonTransferJob,
> + IPersonTransfer
> + IMembershipNoti
> + IMembershipNoti
> + )
> +from lp.registry.
> +from lp.registry.
> +from lp.services.
> +from lp.services.
> +
> +
> +class PersonTransferJ
[...]
> + 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.
> + """ nsferJob, self).__init__()
> + super(PersonTra
> + self.job = Job()
> + self.job_type = job_type
> + self.major_person = major_person
> + self.minor_person = minor_person
> +
> + json_data = simplejson.dump...