Merge lp:~linaro-infrastructure/launchpad/notify-workitems-changes into lp:launchpad

Proposed by Mattias Backman
Status: Merged
Approved by: Aaron Bentley
Approved revision: no longer in the source branch.
Merged at revision: 14997
Proposed branch: lp:~linaro-infrastructure/launchpad/notify-workitems-changes
Merge into: lp:launchpad
Diff against target: 261 lines (+146/-4)
3 files modified
lib/lp/blueprints/browser/specification.py (+19/-0)
lib/lp/blueprints/mail/notifications.py (+12/-1)
lib/lp/blueprints/model/tests/test_specification.py (+115/-3)
To merge this branch: bzr merge lp:~linaro-infrastructure/launchpad/notify-workitems-changes
Reviewer Review Type Date Requested Status
Aaron Bentley (community) Approve
Review via email: mp+98231@code.launchpad.net

Commit message

[r=abentley] Add email notifications for Work Item changes and fix the +workitems page.

Description of the change

This branch adds email notifications for Work Items changes on Blueprints. To get the same format users are used to from the Whiteboard we use the same mechanisms to send a diff of the workitems_text property.

I've had to add an extra transaction.commit() to the production code in updateWorkItems() to be able to get notifications for just adding Work Items. Also changing order of entire blocks of Work Items sometimes produce weird diffs without it. Any tips about how to get rid of that commit() is appreciated.

To post a comment you must log in.
Revision history for this message
Guilherme Salgado (salgado) wrote :

Unfortunately, I don't think this actually fixes the bug. AIUI, the email notifications were not being sent because nothing emits an ObjectModifiedEvent(spec) after the spec's work items are changed (which is expected because there are no changes to the spec itself, only to things related to it) and your changes doesn't seem to cause that event to be fired either. You have the notify(ObjectModifiedEvent(...)) call in your tests, but in production that event is not going to be fired anywhere since the Specification object itself is not changed. My suggestion was to have updateWorkItems() fire the event, in the same way that Product._setLicenses() (which is very similar to our case in that it changes objects related to the Product but not the Product itself) does.

Revision history for this message
Mattias Backman (mabac) wrote :

On Mon, Mar 19, 2012 at 7:22 PM, Guilherme Salgado
<email address hidden> wrote:
> Unfortunately, I don't think this actually fixes the bug. AIUI, the email notifications were not being sent because nothing emits an ObjectModifiedEvent(spec) after the spec's work items are changed (which is expected because there are no changes to the spec itself, only to things related to it) and your changes doesn't seem to cause that event to be fired either.  You have the notify(ObjectModifiedEvent(...)) call in your tests, but in production that event is not going to be fired anywhere since the Specification object itself is not changed.  My suggestion was to have updateWorkItems() fire the event, in the same way that Product._setLicenses() (which is very similar to our case in that it changes objects related to the Product but not the Product itself) does.

That's the mystery here. ;) Since I started writing the tests, I ended
up with a call to notify() in updateWorkItems(). Later when I tried to
run the test Launchpad locally I got two notifications for each change
so I removed my call to notify(). Something else is actually seeing
the change on a Specification property and fires the notification in
the running system.

The reason for not getting emails seems to be that nothing in
notify_specification_modified() added appropriate lines for the
workitems delta.

The automagical notify does not happen when running the tests though,
so I followed the example in
lp.bugs.mail.tests.test_assignee_notification_message() which changes
a bugtask property and then fires it's own notification.

> --
> https://code.launchpad.net/~linaro-infrastructure/launchpad/notify-workitems-changes/+merge/98231
> Your team Linaro Infrastructure is subscribed to branch lp:~linaro-infrastructure/launchpad/notify-workitems-changes.

Revision history for this message
Guilherme Salgado (salgado) wrote :

Oh, so I was wrong all along, thinking the event was what we were missing, and you didn't tell me? ;)

So, now that you pointed me in the right direction I can see where this is coming from... The event is fired by lazr.restful whenever someone calls a remote operation (like updateStatus(), which is exported as a write operation), so that's why you don't get the event when you call updateStatus() directly. I suggest you add a comment to the test explaining why you need to fire the event yourself and that in production this is not needed. However, if someone edits the workitems using the +workitems page, no notification will be sent because that code doesn't call updateStatus() via lazr.restful and so no event is fired. We might need to have that page itself fire an ObjectModifiedEvent...

Now, it looks like the work item changes are not always flushed to the DB when notify_specification_modified is fired (as the pdb session below shows), so that would explain why the diff looks wrong in some cases.

(this is within notify_specification_modified)
(Pdb) p spec.workitems_text
''
(Pdb) !from lp.services.database.sqlbase import flush_database_updates
(Pdb) p flush_database_updates()
None
(Pdb) p spec.workitems_text
u'Work items:\nfoo: TODO\nbar: DONE'

I wouldn't expect this tot happen because spec.workitems_text will do a DB query and before executing that storm will flush the updates to the DB. As you've found out, calling transaction.commit() solves that, but that is not something we should be doing (we should commit only at the end of the request processing routine, if everything succeeds). A better way to workaround this is to call flush_database_updates(), probably in updateWorkItems(), but first I'll try to figure out why we need that so that we can at least leave a comment explaining it.

Revision history for this message
Guilherme Salgado (salgado) wrote :

<lifeless> salgado: IIRC the only reason a select wouldn't see new objects is if both the select and the store.add for the objects *both* happen within a block_implicit_flushes() decorator

In our case the select runs inside a block_implicit_flushes() but the store.add() happens before that. Still, I think this is probably why we're seeing this behavior

Revision history for this message
Guilherme Salgado (salgado) wrote :

<lifeless> you might want to figure out why it has that decorator
<lifeless> this smells to me of some bug patched over
<lifeless> if it was e.g. a truism that all event handlers must not permit implicit flushes, we'd have glued it into the event subscription code to avoid the repetition
<lifeless> salgado: I would look in annotate for the commit adding the decorator and see what it was part of; look at the MP too probably; and definitely try removing the decorator and seeing what happens

Revision history for this message
Mattias Backman (mabac) wrote :

Removing @block_implicit_flushes worked just fine, all tests pass and nothing seems broken when running locally. Now the diffs look good and we get notifications for all changes without extra commits.

I added code to send notifications from the +workitems page and in the process fixed it so that is also saves the changes.

Revision history for this message
Aaron Bentley (abentley) :
review: Approve
Revision history for this message
Aaron Bentley (abentley) wrote :

LOC increased acceptable because this was part of work that had decreased LOC.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/blueprints/browser/specification.py'
2--- lib/lp/blueprints/browser/specification.py 2012-03-06 18:13:52 +0000
3+++ lib/lp/blueprints/browser/specification.py 2012-03-21 15:37:21 +0000
4@@ -45,6 +45,8 @@
5 Popen,
6 )
7
8+from lazr.lifecycle.event import ObjectModifiedEvent
9+from lazr.lifecycle.snapshot import Snapshot
10 from lazr.restful.interface import use_template
11 from lazr.restful.interfaces import (
12 IFieldHTMLRenderer,
13@@ -58,11 +60,13 @@
14 from zope.app.form.browser.itemswidgets import DropdownWidget
15 from zope.component import getUtility
16 from zope.error.interfaces import IErrorReportingUtility
17+from zope.event import notify
18 from zope.formlib import form
19 from zope.formlib.form import Fields
20 from zope.interface import (
21 implementer,
22 Interface,
23+ providedBy,
24 )
25 from zope.schema import (
26 Bool,
27@@ -110,6 +114,7 @@
28 from lp.registry.interfaces.distribution import IDistribution
29 from lp.registry.interfaces.product import IProduct
30 from lp.services.config import config
31+from lp.services.fields import WorkItemsText
32 from lp.services.propertycache import cachedproperty
33 from lp.services.webapp import (
34 canonical_url,
35@@ -690,6 +695,12 @@
36 "The state of progress being made on the actual "
37 "implementation or delivery of this feature."))
38
39+ workitems_text = WorkItemsText(
40+ title=_('Work Items'), required=True,
41+ description=_(
42+ "Work items for this specification input in a text format. "
43+ "Your changes will override the current work items."))
44+
45
46 class SpecificationEditView(LaunchpadEditFormView):
47
48@@ -729,6 +740,14 @@
49 field_names = ['workitems_text']
50 custom_widget('workitems_text', TextAreaWidget, height=15)
51
52+ @action(_('Change'), name='change')
53+ def change_action(self, action, data):
54+ old_spec = Snapshot(self.context, providing=providedBy(self.context))
55+ self.context.setWorkItems(data['workitems_text'])
56+ notify(ObjectModifiedEvent(
57+ self.context, old_spec, edited_fields=['workitems_text']))
58+ self.next_url = canonical_url(self.context)
59+
60
61 class SpecificationEditPeopleView(SpecificationEditView):
62 label = 'Change the people involved'
63
64=== modified file 'lib/lp/blueprints/mail/notifications.py'
65--- lib/lp/blueprints/mail/notifications.py 2011-12-30 06:14:56 +0000
66+++ lib/lp/blueprints/mail/notifications.py 2012-03-21 15:37:21 +0000
67@@ -22,7 +22,6 @@
68 return '[Blueprint %s] %s' % (spec.name, spec.title)
69
70
71-@block_implicit_flushes
72 def notify_specification_modified(spec, event):
73 """Notify the related people that a specification has been modifed."""
74 user = IPerson(event.user)
75@@ -78,6 +77,18 @@
76 whiteboard_delta['old'], whiteboard_delta['new'], 72)
77 info_lines.append('Whiteboard changed:')
78 info_lines.append(whiteboard_diff)
79+ if spec_delta.workitems_text is not None:
80+ if info_lines:
81+ info_lines.append('')
82+ workitems_delta = spec_delta.workitems_text
83+ if workitems_delta['old'] is '':
84+ info_lines.append('Work items set to:')
85+ info_lines.append(mail_wrapper.format(workitems_delta['new']))
86+ else:
87+ workitems_diff = get_unified_diff(
88+ workitems_delta['old'], workitems_delta['new'], 72)
89+ info_lines.append('Work items changed:')
90+ info_lines.append(workitems_diff)
91
92 if not info_lines:
93 # The specification was modified, but we don't yet support
94
95=== modified file 'lib/lp/blueprints/model/tests/test_specification.py'
96--- lib/lp/blueprints/model/tests/test_specification.py 2012-03-08 16:01:35 +0000
97+++ lib/lp/blueprints/model/tests/test_specification.py 2012-03-21 15:37:21 +0000
98@@ -5,10 +5,15 @@
99
100 __metaclass__ = type
101
102+from lazr.lifecycle.event import ObjectModifiedEvent
103+from lazr.lifecycle.snapshot import Snapshot
104 from testtools.matchers import (
105 Equals,
106 MatchesStructure,
107 )
108+import transaction
109+from zope.event import notify
110+from zope.interface import providedBy
111 from zope.security.interfaces import Unauthorized
112
113 from lp.app.validators import LaunchpadValidationError
114@@ -18,6 +23,7 @@
115 )
116 from lp.blueprints.model.specificationworkitem import SpecificationWorkItem
117 from lp.registry.model.milestone import Milestone
118+from lp.services.mail import stub
119 from lp.services.webapp import canonical_url
120 from lp.testing import (
121 ANONYMOUS,
122@@ -148,6 +154,111 @@
123 % (u'http://ubuntu.com/foo', url, cleaned_title), str(e))
124
125
126+class TestSpecificationWorkItemsNotifications(TestCaseWithFactory):
127+ """ Test the notification related to SpecificationWorkItems on
128+ ISpecification."""
129+
130+ layer = DatabaseFunctionalLayer
131+
132+ def test_workitems_added_notification_message(self):
133+ """ Test that we get a notification for setting work items on a new
134+ specification."""
135+ stub.test_emails = []
136+ spec = self.factory.makeSpecification()
137+ old_spec = Snapshot(spec, providing=providedBy(spec))
138+ status = SpecificationWorkItemStatus.TODO
139+ new_work_item = {
140+ 'title': u'A work item',
141+ 'status': status,
142+ 'assignee': None,
143+ 'milestone': None,
144+ 'sequence': 0
145+ }
146+
147+ login_person(spec.owner)
148+ spec.updateWorkItems([new_work_item])
149+ # In production this notification is fired by lazr.restful for changes
150+ # in the specification form and notify(ObjectModifiedEvent(...)) for
151+ # changes in the +workitems form. We need to do it ourselves in this
152+ # test.
153+ notify(ObjectModifiedEvent(
154+ spec, old_spec, edited_fields=['workitems_text']))
155+ transaction.commit()
156+
157+ self.assertEqual(1, len(stub.test_emails))
158+ rationale = 'Work items set to:\nWork items:\n%s: %s' % (
159+ new_work_item['title'],
160+ new_work_item['status'].name)
161+ [email] = stub.test_emails
162+ # Actual message is part 2 of the e-mail.
163+ msg = email[2]
164+ self.assertIn(rationale, msg)
165+
166+ def test_workitems_deleted_notification_message(self):
167+ """ Test that we get a notification for deleting a work item."""
168+ stub.test_emails = []
169+ wi = self.factory.makeSpecificationWorkItem()
170+ spec = wi.specification
171+ old_spec = Snapshot(spec, providing=providedBy(spec))
172+ login_person(spec.owner)
173+ spec.updateWorkItems([])
174+ # In production this notification is fired by lazr.restful, but we
175+ # need to do it ourselves in this test.
176+ notify(ObjectModifiedEvent(
177+ spec, old_spec, edited_fields=['workitems_text']))
178+ transaction.commit()
179+
180+ self.assertEqual(1, len(stub.test_emails))
181+ rationale = '- %s: %s' % (wi.title, wi.status.name)
182+ [email] = stub.test_emails
183+ # Actual message is part 2 of the e-mail.
184+ msg = email[2]
185+ self.assertIn(rationale, msg)
186+
187+ def test_workitems_changed_notification_message(self):
188+ """ Test that we get a notification about a work item status change.
189+ This will be in the form of a line added and one deleted."""
190+ spec = self.factory.makeSpecification()
191+ original_status = SpecificationWorkItemStatus.TODO
192+ new_status = SpecificationWorkItemStatus.DONE
193+ original_work_item = {
194+ 'title': u'The same work item',
195+ 'status': original_status,
196+ 'assignee': None,
197+ 'milestone': None,
198+ 'sequence': 0
199+ }
200+ new_work_item = {
201+ 'title': u'The same work item',
202+ 'status': new_status,
203+ 'assignee': None,
204+ 'milestone': None,
205+ 'sequence': 0
206+ }
207+ login_person(spec.owner)
208+ spec.updateWorkItems([original_work_item])
209+ old_spec = Snapshot(spec, providing=providedBy(spec))
210+
211+ stub.test_emails = []
212+ spec.updateWorkItems([new_work_item])
213+ # In production this notification is fired by lazr.restful, but we
214+ # need to do it ourselves in this test.
215+ notify(ObjectModifiedEvent(
216+ spec, old_spec, edited_fields=['workitems_text']))
217+ transaction.commit()
218+
219+ self.assertEqual(1, len(stub.test_emails))
220+ rationale_removed = '- %s: %s' % (
221+ original_work_item['title'], original_work_item['status'].name)
222+ rationale_added = '+ %s: %s' % (
223+ new_work_item['title'], new_work_item['status'].name)
224+ [email] = stub.test_emails
225+ # Actual message is part 2 of the e-mail.
226+ msg = email[2]
227+ self.assertIn(rationale_removed, msg)
228+ self.assertIn(rationale_added, msg)
229+
230+
231 class TestSpecificationWorkItems(TestCaseWithFactory):
232 """Test the Workitem-related methods of ISpecification."""
233
234@@ -155,7 +266,7 @@
235
236 def setUp(self):
237 super(TestSpecificationWorkItems, self).setUp()
238- self.wi_header = self.factory.makeMilestone(
239+ self.wi_header = self.factory.makeMilestone(
240 name='none-milestone-as-header')
241
242 def assertWorkItemsTextContains(self, spec, items):
243@@ -260,7 +371,7 @@
244 items = [self.wi_header, work_item1, milestone, work_item2]
245 self.assertWorkItemsTextContains(spec, items)
246
247- def test_workitems_text_with_implicit_and_explicit_milestone_reversed(self):
248+ def test_workitems_text_with_implicit_and_explicit_milestone_reverse(self):
249 spec = self.factory.makeSpecification()
250 milestone = self.factory.makeMilestone(product=spec.product)
251 login_person(spec.owner)
252@@ -294,7 +405,8 @@
253 def test_workitems_text_with_assignee(self):
254 assignee = self.factory.makePerson()
255 work_item = self.factory.makeSpecificationWorkItem(assignee=assignee)
256- self.assertWorkItemsTextContains(work_item.specification, [self.wi_header, work_item])
257+ self.assertWorkItemsTextContains(
258+ work_item.specification, [self.wi_header, work_item])
259
260 def test_work_items_property(self):
261 spec = self.factory.makeSpecification()