Merge lp:~sinzui/launchpad/project-notify-4 into lp:launchpad

Proposed by Curtis Hovey on 2012-03-23
Status: Merged
Approved by: Curtis Hovey on 2012-03-24
Approved revision: no longer in the source branch.
Merged at revision: 15007
Proposed branch: lp:~sinzui/launchpad/project-notify-4
Merge into: lp:launchpad
Diff against target: 455 lines (+380/-2)
3 files modified
lib/lp/registry/interfaces/productjob.py (+55/-0)
lib/lp/registry/model/productjob.py (+137/-0)
lib/lp/registry/tests/test_productjob.py (+188/-2)
To merge this branch: bzr merge lp:~sinzui/launchpad/project-notify-4
Reviewer Review Type Date Requested Status
Brad Crittenden (community) 2012-03-23 Approve on 2012-03-23
Review via email: mp+99094@code.launchpad.net

Commit Message

Add generic ProductNotificationJob to send email to product maintainers.

Description of the Change

Launchpad bug: https://bugs.launchpad.net/bugs/956246
Pre-implementation: abentley, jcsackett

This branch adds a generic ProductNotificationJob track automated and manual
jobs that are scheduled for a product. The immediate use is for an automated
system that send out commercial subscription expiration emails at 4 weeks
and 1 week before expiration. After expiration an automated process will
update or deactivate the project and send an email.

A successful design will allow Lp support staff to create jobs during
project review such as to send an email about licensing issues, or
deactivate a problem project that also sends an email.

Subsequent branches will subclass ProductNotificationJob to send automated
emails to product maintainers

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

RULES

    * Create a generic notification job the sends email to product
      maintainers.

QA

    None as this branch only contains the classes needed by other
    branches.

LINT

    lib/lp/registry/interfaces/productjob.py
    lib/lp/registry/model/productjob.py
    lib/lp/registry/tests/test_productjob.py

TEST

    ./bin/test -vvc -t ProductNotificationJob lp.registry.tests.test_productjob

IMPLEMENTATION

While I tried to write an actual job that did something, I discovered that
There was a lot of work needed to ensure only the correct people are
notified and provided the correct basic information. Subsequent branches
will only need to extend the run() method nd augment the message_data to
create specific notification jobs.
    lib/lp/registry/interfaces/productjob.py
    lib/lp/registry/model/productjob.py
    lib/lp/registry/tests/test_productjob.py

To post a comment you must log in.
Brad Crittenden (bac) wrote :

Hi Curtis,

This system is going to be wonderful! I wish we'd done it a long time ago.

Here are some comments on the branch, mostly hygiene.

* s/then/that in """A job then sends a notification about a product."""

* s/geBodyAndHeaders/getBodyAndHeaders

* s/messages_data/message_data

* s/Sent an email/Send an email

* For IProductNotificationJobSource.create you don't document the 'subject' param.

* s/Subclasses that are updating products can want to make changes to the product before or after upcalling this classes' run() method./
    Subclasses that are updating products may make changes to the product before or after calling this class' run() method./

Is that what you intended to say?

* I think assertTrue() is more readable than assertIs(True,...). Do you prefer assertIs?

* I find the test of 'commercial' in the template name to be fragile. Couldn't you make it more explicit by adding a param to the create() method?

* As usual, the tests are logical, readable, and seem to be thorough -- great!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/registry/interfaces/productjob.py'
2--- lib/lp/registry/interfaces/productjob.py 2012-03-22 23:21:24 +0000
3+++ lib/lp/registry/interfaces/productjob.py 2012-03-24 12:44:19 +0000
4@@ -7,6 +7,8 @@
5 __all__ = [
6 'IProductJob',
7 'IProductJobSource',
8+ 'IProductNotificationJob',
9+ 'IProductNotificationJobSource',
10 ]
11
12 from zope.interface import Attribute
13@@ -64,3 +66,56 @@
14 to be a class name.
15 :return: A `ResultSet` yielding `IProductJob`.
16 """
17+
18+
19+class IProductNotificationJob(IProductJob):
20+ """A job that sends a notification about a product."""
21+
22+ subject = Attribute('The subject line of the notification.')
23+ email_template_name = Attribute(
24+ 'The name of the email template to create the message body from.')
25+ reviewer = Attribute('The user or agent sending the email.')
26+ recipients = Attribute('An `INotificationRecipientSet`.')
27+ message_data = Attribute(
28+ 'A dict that is interpolated with the email template.')
29+ reply_to = Attribute('The optional address to set as the Reply-To.')
30+
31+ def getBodyAndHeaders(email_template, address, reply_to=None):
32+ """Return a tuple of email message body and headers.
33+
34+ The body is constructed from the email template and message_data.
35+ The headers are a dict that includes the X-Launchpad-Rationale.
36+
37+ :param email_template: A string that will be interpolated
38+ with message_data.
39+ :param address: The email address of the user the message is to.
40+ :reply_to: An optional email address to set as the Reply-To header.
41+ :return a tuple (string, dict):
42+ """
43+
44+ def sendEmailToMaintainer(template_name, subject, from_address):
45+ """Send an email to the product maintainer.
46+
47+ :param email_template_name: The name of the email template to
48+ use as the email body.
49+ :param subject: The subject line of the notification.
50+ :param from_address: The email address sending the email.
51+
52+ """
53+
54+
55+class IProductNotificationJobSource(IProductJobSource):
56+ """An interface for creating `IProductNotificationJob`s."""
57+
58+ def create(product, email_template_name, subject,
59+ reviewer, reply_to_commercial=False):
60+ """Create a new `IProductNotificationJob`.
61+
62+ :param product: An IProduct.
63+ :param email_template_name: The name of the email template without
64+ the extension.
65+ :param subject: The subject line of the notification.
66+ :param reviewer: The user or agent sending the email.
67+ :param reply_to_commercial: Set the reply_to property to the
68+ commercial email address.
69+ """
70
71=== modified file 'lib/lp/registry/model/productjob.py'
72--- lib/lp/registry/model/productjob.py 2012-03-22 23:21:24 +0000
73+++ lib/lp/registry/model/productjob.py 2012-03-24 12:44:19 +0000
74@@ -16,18 +16,23 @@
75 Reference,
76 Unicode,
77 )
78+from zope.component import getUtility
79 from zope.interface import (
80 classProvides,
81 implements,
82 )
83
84 from lp.registry.enums import ProductJobType
85+from lp.registry.interfaces.person import IPersonSet
86 from lp.registry.interfaces.product import IProduct
87 from lp.registry.interfaces.productjob import (
88 IProductJob,
89 IProductJobSource,
90+ IProductNotificationJob,
91+ IProductNotificationJobSource,
92 )
93 from lp.registry.model.product import Product
94+from lp.services.config import config
95 from lp.services.database.decoratedresultset import DecoratedResultSet
96 from lp.services.database.enumcol import EnumCol
97 from lp.services.database.lpstorm import (
98@@ -35,8 +40,20 @@
99 IStore,
100 )
101 from lp.services.database.stormbase import StormBase
102+from lp.services.propertycache import cachedproperty
103 from lp.services.job.model.job import Job
104 from lp.services.job.runner import BaseRunnableJob
105+from lp.services.mail.helpers import (
106+ get_email_template,
107+ )
108+from lp.services.mail.notificationrecipientset import NotificationRecipientSet
109+from lp.services.mail.mailwrapper import MailWrapper
110+from lp.services.mail.sendmail import (
111+ format_address,
112+ format_address_for_person,
113+ simple_sendmail,
114+ )
115+from lp.services.webapp.publisher import canonical_url
116
117
118 class ProductJob(StormBase):
119@@ -145,3 +162,123 @@
120 ('product', self.context.product.name),
121 ])
122 return vars
123+
124+
125+class ProductNotificationJob(ProductJobDerived):
126+ """A Job that send an email to the product maintainer."""
127+
128+ implements(IProductNotificationJob)
129+ classProvides(IProductNotificationJobSource)
130+ class_job_type = ProductJobType.REVIEWER_NOTIFICATION
131+
132+ @classmethod
133+ def create(cls, product, email_template_name,
134+ subject, reviewer, reply_to_commercial=False):
135+ """See `IProductNotificationJob`."""
136+ metadata = {
137+ 'email_template_name': email_template_name,
138+ 'subject': subject,
139+ 'reviewer_id': reviewer.id,
140+ 'reply_to_commercial': reply_to_commercial,
141+ }
142+ return super(ProductNotificationJob, cls).create(product, metadata)
143+
144+ @property
145+ def subject(self):
146+ """See `IProductNotificationJob`."""
147+ return self.metadata['subject']
148+
149+ @property
150+ def email_template_name(self):
151+ """See `IProductNotificationJob`."""
152+ return self.metadata['email_template_name']
153+
154+ @cachedproperty
155+ def reviewer(self):
156+ """See `IProductNotificationJob`."""
157+ return getUtility(IPersonSet).get(self.metadata['reviewer_id'])
158+
159+ @property
160+ def reply_to_commercial(self):
161+ """See `IProductNotificationJob`."""
162+ return self.metadata['reply_to_commercial']
163+
164+ @cachedproperty
165+ def reply_to(self):
166+ """See `IProductNotificationJob`."""
167+ if self.reply_to_commercial:
168+ return 'Commercial <commercial@launchpad.net>'
169+ return None
170+
171+ @cachedproperty
172+ def recipients(self):
173+ """See `IProductNotificationJob`."""
174+ maintainer = self.product.owner
175+ if maintainer.is_team:
176+ team_name = maintainer.displayname
177+ role = "an admin of %s which is the maintainer" % team_name
178+ users = maintainer.adminmembers
179+ else:
180+ role = "the maintainer"
181+ users = maintainer
182+ reason = (
183+ "You received this notification because you are %s of %s.\n%s" %
184+ (role, self.product.displayname, self.message_data['product_url']))
185+ header = 'Maintainer'
186+ notification_set = NotificationRecipientSet()
187+ notification_set.add(users, reason, header)
188+ return notification_set
189+
190+ @cachedproperty
191+ def message_data(self):
192+ """See `IProductNotificationJob`."""
193+ return {
194+ 'product_name': self.product.name,
195+ 'product_displayname': self.product.displayname,
196+ 'product_url': canonical_url(self.product),
197+ 'reviewer_name': self.reviewer.name,
198+ 'reviewer_displayname': self.reviewer.displayname,
199+ }
200+
201+ def getErrorRecipients(self):
202+ """See `BaseRunnableJob`."""
203+ return [format_address_for_person(self.reviewer)]
204+
205+ def getBodyAndHeaders(self, email_template, address, reply_to=None):
206+ """See `IProductNotificationJob`."""
207+ reason, rationale = self.recipients.getReason(address)
208+ maintainer = self.recipients._emailToPerson[address]
209+ message_data = dict(self.message_data)
210+ message_data['user_name'] = maintainer.name
211+ message_data['user_displayname'] = maintainer.displayname
212+ raw_body = email_template % message_data
213+ raw_body += '\n\n-- \n%s' % reason
214+ body = MailWrapper().format(raw_body, force_wrap=True)
215+ headers = {
216+ 'X-Launchpad-Project':
217+ '%(product_displayname)s (%(product_name)s)' % message_data,
218+ 'X-Launchpad-Message-Rationale': rationale,
219+ }
220+ if reply_to is not None:
221+ headers['Reply-To'] = reply_to
222+ return body, headers
223+
224+ def sendEmailToMaintainer(self, template_name, subject, from_address):
225+ """See `IProductNotificationJob`."""
226+ email_template = get_email_template(
227+ "%s.txt" % template_name, app='registry')
228+ for address in self.recipients.getEmails():
229+ body, headers = self.getBodyAndHeaders(
230+ email_template, address, self.reply_to)
231+ simple_sendmail(from_address, address, subject, body, headers)
232+
233+ def run(self):
234+ """See `BaseRunnableJob`.
235+
236+ Subclasses that are updating products may make changes to the product
237+ before or after calling this class' run() method.
238+ """
239+ from_address = format_address(
240+ 'Launchpad', config.canonical.noreply_from_address)
241+ self.sendEmailToMaintainer(
242+ self.email_template_name, self.subject, from_address)
243
244=== modified file 'lib/lp/registry/tests/test_productjob.py'
245--- lib/lp/registry/tests/test_productjob.py 2012-03-22 23:21:24 +0000
246+++ lib/lp/registry/tests/test_productjob.py 2012-03-24 12:44:19 +0000
247@@ -21,16 +21,25 @@
248 from lp.registry.interfaces.productjob import (
249 IProductJob,
250 IProductJobSource,
251+ IProductNotificationJobSource,
252 )
253+from lp.registry.interfaces.person import TeamSubscriptionPolicy
254+from lp.registry.interfaces.teammembership import TeamMembershipStatus
255 from lp.registry.model.productjob import (
256 ProductJob,
257 ProductJobDerived,
258- )
259-from lp.testing import TestCaseWithFactory
260+ ProductNotificationJob,
261+ )
262+from lp.testing import (
263+ person_logged_in,
264+ TestCaseWithFactory,
265+ )
266 from lp.testing.layers import (
267 DatabaseFunctionalLayer,
268 LaunchpadZopelessLayer,
269 )
270+from lp.testing.mail_helpers import pop_notifications
271+from lp.services.webapp.publisher import canonical_url
272
273
274 class ProductJobTestCase(TestCaseWithFactory):
275@@ -183,3 +192,180 @@
276 oops_vars = job.getOopsVars()
277 self.assertIs(True, len(oops_vars) > 1)
278 self.assertIn(('product', product.name), oops_vars)
279+
280+
281+class ProductNotificationJobTestCase(TestCaseWithFactory):
282+ """Test case for the ProductNotificationJob class."""
283+
284+ layer = DatabaseFunctionalLayer
285+
286+ def make_notification_data(self):
287+ product = self.factory.makeProduct()
288+ reviewer = self.factory.makePerson('reviewer@eg.com', name='reviewer')
289+ subject = "test subject"
290+ email_template_name = 'product-license-dont-know'
291+ return product, email_template_name, subject, reviewer
292+
293+ def make_maintainer_team(self, product):
294+ team = self.factory.makeTeam(
295+ owner=product.owner,
296+ subscription_policy=TeamSubscriptionPolicy.MODERATED)
297+ team_admin = self.factory.makePerson()
298+ with person_logged_in(team.teamowner):
299+ team.addMember(
300+ team_admin, team.teamowner, status=TeamMembershipStatus.ADMIN)
301+ product.owner = team
302+ return team, team_admin
303+
304+ def test_create(self):
305+ # Create an instance of ProductNotificationJob that stores
306+ # the notification information.
307+ data = self.make_notification_data()
308+ product, email_template_name, subject, reviewer = data
309+ self.assertIs(
310+ True,
311+ IProductNotificationJobSource.providedBy(ProductNotificationJob))
312+ job = ProductNotificationJob.create(
313+ product, email_template_name, subject, reviewer,
314+ reply_to_commercial=False)
315+ self.assertIsInstance(job, ProductNotificationJob)
316+ self.assertEqual(product, job.product)
317+ self.assertEqual(email_template_name, job.email_template_name)
318+ self.assertEqual(subject, job.subject)
319+ self.assertEqual(reviewer, job.reviewer)
320+ self.assertEqual(False, job.reply_to_commercial)
321+
322+ def test_getErrorRecipients(self):
323+ # The reviewer is the error recipient.
324+ data = self.make_notification_data()
325+ job = ProductNotificationJob.create(*data)
326+ self.assertEqual(
327+ ['Reviewer <reviewer@eg.com>'], job.getErrorRecipients())
328+
329+ def test_reply_to_commercial(self):
330+ # Commercial emails have the commercial@launchpad.net reply-to
331+ # by setting the reply_to_commercial arg to True.
332+ data = list(self.make_notification_data())
333+ data.append(True)
334+ job = ProductNotificationJob.create(*data)
335+ self.assertEqual('Commercial <commercial@launchpad.net>', job.reply_to)
336+
337+ def test_reply_to_non_commercial(self):
338+ # Non-commercial emails do not have a reply-to.
339+ data = list(self.make_notification_data())
340+ data.append(False)
341+ job = ProductNotificationJob.create(*data)
342+ self.assertIs(None, job.reply_to)
343+
344+ def test_recipients_user(self):
345+ # The product maintainer is the recipient.
346+ data = self.make_notification_data()
347+ job = ProductNotificationJob.create(*data)
348+ product, email_template_name, subject, reviewer = data
349+ recipients = job.recipients
350+ self.assertEqual([product.owner], recipients.getRecipients())
351+ reason, header = recipients.getReason(product.owner)
352+ self.assertEqual('Maintainer', header)
353+ self.assertIn(canonical_url(product), reason)
354+ self.assertIn(
355+ 'you are the maintainer of %s' % product.displayname, reason)
356+
357+ def test_recipients_team(self):
358+ # The product maintainer team admins are the recipient.
359+ data = self.make_notification_data()
360+ job = ProductNotificationJob.create(*data)
361+ product, email_template_name, subject, reviewer = data
362+ team, team_admin = self.make_maintainer_team(product)
363+ recipients = job.recipients
364+ self.assertContentEqual(
365+ [team.teamowner, team_admin], recipients.getRecipients())
366+ reason, header = recipients.getReason(team.teamowner)
367+ self.assertEqual('Maintainer', header)
368+ self.assertIn(canonical_url(product), reason)
369+ self.assertIn(
370+ 'you are an admin of %s which is the maintainer of %s' %
371+ (team.displayname, product.displayname),
372+ reason)
373+
374+ def test_message_data(self):
375+ # The message_data is a dict of interpolatable strings.
376+ data = self.make_notification_data()
377+ job = ProductNotificationJob.create(*data)
378+ product, email_template_name, subject, reviewer = data
379+ self.assertEqual(product.name, job.message_data['product_name'])
380+ self.assertEqual(
381+ product.displayname, job.message_data['product_displayname'])
382+ self.assertEqual(
383+ canonical_url(product), job.message_data['product_url'])
384+ self.assertEqual(reviewer.name, job.message_data['reviewer_name'])
385+ self.assertEqual(
386+ reviewer.displayname, job.message_data['reviewer_displayname'])
387+
388+ def test_getBodyAndHeaders_with_reply_to(self):
389+ # The body and headers contain reasons and rationales.
390+ data = self.make_notification_data()
391+ job = ProductNotificationJob.create(*data)
392+ product, email_template_name, subject, reviewer = data
393+ [address] = job.recipients.getEmails()
394+ email_template = (
395+ 'hello %(user_name)s %(product_name)s %(reviewer_name)s')
396+ reply_to = 'me@eg.dom'
397+ body, headers = job.getBodyAndHeaders(
398+ email_template, address, reply_to)
399+ self.assertIn(reviewer.name, body)
400+ self.assertIn(product.name, body)
401+ self.assertIn(product.owner.name, body)
402+ self.assertIn('\n\n--\nYou received', body)
403+ expected_headers = [
404+ ('X-Launchpad-Project', '%s (%s)' %
405+ (product.displayname, product.name)),
406+ ('X-Launchpad-Message-Rationale', 'Maintainer'),
407+ ('Reply-To', reply_to),
408+ ]
409+ self.assertContentEqual(expected_headers, headers.items())
410+
411+ def test_getBodyAndHeaders_without_reply_to(self):
412+ # The reply-to is an optional argument.
413+ data = self.make_notification_data()
414+ job = ProductNotificationJob.create(*data)
415+ product, email_template_name, subject, reviewer = data
416+ [address] = job.recipients.getEmails()
417+ email_template = 'hello'
418+ body, headers = job.getBodyAndHeaders(email_template, address)
419+ expected_headers = [
420+ ('X-Launchpad-Project', '%s (%s)' %
421+ (product.displayname, product.name)),
422+ ('X-Launchpad-Message-Rationale', 'Maintainer'),
423+ ]
424+ self.assertContentEqual(expected_headers, headers.items())
425+
426+ def test_sendEmailToMaintainer(self):
427+ # sendEmailToMaintainer() sends an email to the maintainers.
428+ data = self.make_notification_data()
429+ job = ProductNotificationJob.create(*data)
430+ product, email_template_name, subject, reviewer = data
431+ team, team_admin = self.make_maintainer_team(product)
432+ addresses = job.recipients.getEmails()
433+ pop_notifications()
434+ job.sendEmailToMaintainer(email_template_name, 'frog', 'me@eg.dom')
435+ notifications = pop_notifications()
436+ self.assertEqual(2, len(notifications))
437+ self.assertEqual(addresses[0], notifications[0]['To'])
438+ self.assertEqual(addresses[1], notifications[1]['To'])
439+ self.assertEqual('me@eg.dom', notifications[1]['From'])
440+ self.assertEqual('frog', notifications[1]['Subject'])
441+
442+ def test_run(self):
443+ # sendEmailToMaintainer() sends an email to the maintainers.
444+ data = self.make_notification_data()
445+ job = ProductNotificationJob.create(*data)
446+ product, email_template_name, subject, reviewer = data
447+ [address] = job.recipients.getEmails()
448+ pop_notifications()
449+ job.run()
450+ notifications = pop_notifications()
451+ self.assertEqual(1, len(notifications))
452+ self.assertEqual(address, notifications[0]['To'])
453+ self.assertEqual(subject, notifications[0]['Subject'])
454+ self.assertIn(
455+ 'Launchpad <noreply@launchpad.net>', notifications[0]['From'])