Merge lp:~abentley/launchpad/bugcomment-interface into lp:launchpad

Proposed by Aaron Bentley
Status: Merged
Merged at revision: 14683
Proposed branch: lp:~abentley/launchpad/bugcomment-interface
Merge into: lp:launchpad
Diff against target: 531 lines (+123/-95)
9 files modified
lib/lp/bugs/browser/bugcomment.py (+58/-42)
lib/lp/bugs/browser/bugtask.py (+6/-9)
lib/lp/bugs/browser/tests/bugtarget-filebug-views.txt (+15/-8)
lib/lp/bugs/browser/tests/test_bugcomment.py (+19/-1)
lib/lp/bugs/scripts/tests/test_bugimport.py (+4/-5)
lib/lp/bugs/tests/bugs-emailinterface.txt (+4/-2)
lib/lp/services/messages/interfaces/message.py (+1/-8)
lib/lp/services/messages/model/message.py (+12/-17)
lib/lp/testing/factory.py (+4/-3)
To merge this branch: bzr merge lp:~abentley/launchpad/bugcomment-interface
Reviewer Review Type Date Requested Status
Deryck Hodge (community) Approve
Review via email: mp+87808@code.launchpad.net

Commit message

Make BugComment implement IBugComment

Description of the change

= Summary =
Make BugComment provide IBugComment

== Proposed fix ==
Delegate IMessage (a base class of IBugComment) to BugComment._message

== Pre-implementation notes ==
Discussed with deryck, discussed eager-loading approach with lifeless

== Implementation details ==
BugComment claimed to implement IMessage, but verifyObject showed it did not. The 'followup_title' and 'has_new_title' IMessage properties turned out to be unused, so I deleted them. Ultimately, I decided to modernize our code by delegating to Message.

This means BugComment no longer directly provides title, datecreated, owner, rfc822msgid, chunks and visible. It provides bugattachments because, unlike IMessage, BugComments do not consider patches to be BugAttachments.

This caused database queries to increase, because BugComment had been acting as an eager-loaded of MessageChunks and BugAttachments. To restore this eager loading, I converted Message.bugattachments and Message.chunks into cachedproperties. Then I could pre-populate them from the original call sites.

I also adapted the comment hiding and truncation functionality so that the parameters are supplied in the constructor, and applied in text_for_display.

== Tests ==
bin/test -v test_bugcomment

== Demo and Q/A ==
None

= Launchpad lint =

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/bugs/browser/tests/test_bugcomment.py
  lib/lp/services/messages/model/message.py
  lib/lp/bugs/browser/bugcomment.py
  lib/lp/testing/factory.py
  lib/lp/services/messages/interfaces/message.py
  lib/lp/bugs/browser/bugtask.py

To post a comment you must log in.
Revision history for this message
Deryck Hodge (deryck) wrote :

This looks fantastic, Aaron. This is a nice refactor, thanks!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/bugs/browser/bugcomment.py'
2--- lib/lp/bugs/browser/bugcomment.py 2012-01-01 02:58:52 +0000
3+++ lib/lp/bugs/browser/bugcomment.py 2012-01-17 14:36:25 +0000
4@@ -25,6 +25,7 @@
5 )
6 from operator import itemgetter
7
8+from lazr.delegates import delegates
9 from lazr.restful.interfaces import IWebServiceClientRequest
10 from pytz import utc
11 from zope.component import (
12@@ -36,15 +37,22 @@
13 implements,
14 Interface,
15 )
16+from zope.security.proxy import removeSecurityProxy
17
18+from lp.bugs.interfaces.bugattachment import BugAttachmentType
19 from lp.bugs.interfaces.bugmessage import IBugComment
20 from lp.services.config import config
21 from lp.services.features import getFeatureFlag
22 from lp.services.librarian.browser import ProxiedLibraryFileAlias
23+from lp.services.propertycache import (
24+ cachedproperty,
25+ get_property_cache,
26+ )
27 from lp.services.webapp import (
28 canonical_url,
29 LaunchpadView,
30 )
31+from lp.services.messages.interfaces.message import IMessage
32 from lp.services.webapp.breadcrumb import Breadcrumb
33 from lp.services.webapp.interfaces import ILaunchBag
34
35@@ -54,7 +62,7 @@
36
37 def build_comments_from_chunks(
38 bugtask, truncate=False, slice_info=None, show_spam_controls=False,
39- user=None):
40+ user=None, hide_first=False):
41 """Build BugComments from MessageChunks.
42
43 :param truncate: Perform truncation of large messages.
44@@ -64,11 +72,22 @@
45 # This would be better as part of indexed_messages eager loading.
46 comments = {}
47 for bugmessage, message, chunk in chunks:
48+ cache = get_property_cache(message)
49+ if getattr(cache, 'chunks', None) is None:
50+ cache.chunks = []
51+ cache.chunks.append(removeSecurityProxy(chunk))
52 bug_comment = comments.get(message.id)
53 if bug_comment is None:
54+ if bugmessage.index == 0 and hide_first:
55+ display = 'hide'
56+ elif truncate:
57+ display = 'truncate'
58+ else:
59+ display = 'full'
60 bug_comment = BugComment(
61- bugmessage.index, message, bugtask, visible=message.visible,
62- show_spam_controls=show_spam_controls, user=user)
63+ bugmessage.index, message, bugtask,
64+ show_spam_controls=show_spam_controls, user=user,
65+ display=display)
66 comments[message.id] = bug_comment
67 # This code path is currently only used from a BugTask view which
68 # has already loaded all the bug watches. If we start lazy loading
69@@ -78,12 +97,6 @@
70 bug_comment.bugwatch = bugmessage.bugwatch
71 bug_comment.synchronized = (
72 bugmessage.remote_comment_id is not None)
73- bug_comment.chunks.append(chunk)
74-
75- for comment in comments.values():
76- # Once we have all the chunks related to a comment populated,
77- # we get the text set up for display.
78- comment.setupText(truncate=truncate)
79 return comments
80
81
82@@ -176,22 +189,19 @@
83 """
84 implements(IBugComment)
85
86+ delegates(IMessage, '_message')
87+
88 def __init__(
89 self, index, message, bugtask, activity=None,
90- visible=True, show_spam_controls=False, user=None):
91+ show_spam_controls=False, user=None, display='full'):
92
93 self.index = index
94 self.bugtask = bugtask
95 self.bugwatch = None
96
97- self.title = message.title
98+ self._message = message
99 self.display_title = False
100- self.datecreated = message.datecreated
101- self.owner = message.owner
102- self.rfc822msgid = message.rfc822msgid
103
104- self.chunks = []
105- self.bugattachments = []
106 self.patches = []
107
108 if activity is None:
109@@ -200,13 +210,22 @@
110 self.activity = activity
111
112 self.synchronized = False
113- self.visible = visible
114 # We use a feature flag to control users deleting their own comments.
115 user_owns_comment = False
116 flag = 'disclosure.users_hide_own_bug_comments.enabled'
117 if bool(getFeatureFlag(flag)):
118 user_owns_comment = user is not None and user == self.owner
119 self.show_spam_controls = show_spam_controls or user_owns_comment
120+ if display == 'truncate':
121+ self.comment_limit = config.malone.max_comment_size
122+ else:
123+ self.comment_limit = None
124+ self.hide_text = (display == 'hide')
125+
126+ @cachedproperty
127+ def bugattachments(self):
128+ return [attachment for attachment in self._message.bugattachments if
129+ attachment.type != BugAttachmentType.PATCH]
130
131 @property
132 def show_for_admin(self):
133@@ -220,31 +239,28 @@
134 """
135 return not self.visible
136
137- def setupText(self, truncate=False):
138- """Set the text for display and truncate it if necessary.
139-
140- Note that this method must be called before either isIdenticalTo() or
141- isEmpty() are called, since to do otherwise would mean that they could
142- return false positives and negatives respectively.
143- """
144- comment_limit = config.malone.max_comment_size
145-
146- bits = [unicode(chunk.content)
147- for chunk in self.chunks
148- if chunk.content is not None and len(chunk.content) > 0]
149- text = self.text_contents = '\n\n'.join(bits)
150-
151- if truncate and comment_limit and len(text) > comment_limit:
152- # Note here that we truncate at comment_limit, and not
153- # comment_limit - 3; while it would be nice to account for
154- # the ellipsis, this breaks down when the comment limit is
155- # less than 3 (which can happen in a testcase) and it makes
156- # counting the strings harder.
157- self.text_for_display = "%s..." % text[:comment_limit]
158- self.was_truncated = True
159- else:
160- self.text_for_display = text
161- self.was_truncated = False
162+ @property
163+ def needs_truncation(self):
164+ if self.comment_limit is None:
165+ return False
166+ return len(self.text_contents) > self.comment_limit
167+
168+ @property
169+ def was_truncated(self):
170+ return self.needs_truncation
171+
172+ @cachedproperty
173+ def text_for_display(self):
174+ if self.hide_text:
175+ return ''
176+ if not self.needs_truncation:
177+ return self.text_contents
178+ # Note here that we truncate at comment_limit, and not
179+ # comment_limit - 3; while it would be nice to account for
180+ # the ellipsis, this breaks down when the comment limit is
181+ # less than 3 (which can happen in a testcase) and it makes
182+ # counting the strings harder.
183+ return "%s..." % self.text_contents[:self.comment_limit]
184
185 def isIdenticalTo(self, other):
186 """Compare this BugComment to another and return True if they are
187
188=== modified file 'lib/lp/bugs/browser/bugtask.py'
189--- lib/lp/bugs/browser/bugtask.py 2012-01-16 17:20:45 +0000
190+++ lib/lp/bugs/browser/bugtask.py 2012-01-17 14:36:25 +0000
191@@ -347,9 +347,12 @@
192 """
193 comments = build_comments_from_chunks(bugtask, truncate=truncate,
194 slice_info=slice_info, show_spam_controls=show_spam_controls,
195- user=user)
196+ user=user, hide_first=for_display)
197 # TODO: further fat can be shaved off here by limiting the attachments we
198 # query to those that slice_info would include.
199+ for comment in comments.values():
200+ get_property_cache(comment._message).bugattachments = []
201+
202 for attachment in bugtask.bug.attachments_unpopulated:
203 message_id = attachment.message.id
204 # All attachments are related to a message, so we can be
205@@ -359,8 +362,8 @@
206 break
207 if attachment.type == BugAttachmentType.PATCH:
208 comments[message_id].patches.append(attachment)
209- else:
210- comments[message_id].bugattachments.append(attachment)
211+ cache = get_property_cache(attachment.message)
212+ cache.bugattachments.append(attachment)
213 comments = sorted(comments.values(), key=attrgetter("index"))
214 current_title = bugtask.bug.title
215 for comment in comments:
216@@ -371,12 +374,6 @@
217 # this comment has a new title, so make that the rolling focus
218 current_title = comment.title
219 comment.display_title = True
220- if for_display and comments and comments[0].index == 0:
221- # We show the text of the first comment as the bug description,
222- # or via the special link "View original description", but we want
223- # to display attachments filed together with the bug in the
224- # comment list.
225- comments[0].text_for_display = ''
226 return comments
227
228
229
230=== modified file 'lib/lp/bugs/browser/tests/bugtarget-filebug-views.txt'
231--- lib/lp/bugs/browser/tests/bugtarget-filebug-views.txt 2011-12-24 17:49:30 +0000
232+++ lib/lp/bugs/browser/tests/bugtarget-filebug-views.txt 2012-01-17 14:36:25 +0000
233@@ -126,7 +126,8 @@
234 ... --boundary--
235 ... """
236 >>> import transaction
237- >>> from lp.services.temporaryblobstorage.interfaces import ITemporaryStorageManager
238+ >>> from lp.services.temporaryblobstorage.interfaces import (
239+ ... ITemporaryStorageManager)
240 >>> token = getUtility(ITemporaryStorageManager).new(debug_data)
241
242 We need to commit the transaction since the data will be stored in the
243@@ -167,7 +168,8 @@
244 >>> filebug_view.added_bug.title
245 u'Test Title'
246
247- >>> print filebug_view.added_bug.description #doctest: -NORMALIZE_WHITESPACE
248+ >>> description = filebug_view.added_bug.description
249+ >>> print description #doctest: -NORMALIZE_WHITESPACE
250 Test description.
251 <BLANKLINE>
252 This should be added to the description.
253@@ -226,7 +228,8 @@
254 >>> filebug_view.added_bug.title
255 u'Test Title'
256
257- >>> print filebug_view.added_bug.description #doctest: -NORMALIZE_WHITESPACE
258+ >>> description = filebug_view.added_bug.description
259+ >>> print description #doctest: -NORMALIZE_WHITESPACE
260 Test description.
261 <BLANKLINE>
262 This should be added to the description.
263@@ -331,7 +334,7 @@
264
265 >>> for comment in filebug_view.added_bug.messages[1:]:
266 ... print "Comment by %s: %s attachment(s)" % (
267- ... comment.owner.displayname, comment.bugattachments.count())
268+ ... comment.owner.displayname, len(comment.bugattachments))
269 Comment by No Privileges Person: 2 attachment(s)
270
271
272@@ -375,7 +378,8 @@
273 >>> filebug_view.added_bug.title
274 u'Test Title'
275
276- >>> print filebug_view.added_bug.description #doctest: -NORMALIZE_WHITESPACE
277+ >>> description = filebug_view.added_bug.description
278+ >>> print description #doctest: -NORMALIZE_WHITESPACE
279 Test description.
280 <BLANKLINE>
281 This bug should be private.
282@@ -438,7 +442,8 @@
283 >>> filebug_view.added_bug.title
284 u'Test Title'
285
286- >>> print filebug_view.added_bug.description #doctest: -NORMALIZE_WHITESPACE
287+ >>> description = filebug_view.added_bug.description
288+ >>> print description #doctest: -NORMALIZE_WHITESPACE
289 Test description.
290 <BLANKLINE>
291 This bug should be public.
292@@ -502,7 +507,8 @@
293 >>> filebug_view.added_bug.title
294 u'Test Title'
295
296- >>> print filebug_view.added_bug.description #doctest: -NORMALIZE_WHITESPACE
297+ >>> description = filebug_view.added_bug.description
298+ >>> print description #doctest: -NORMALIZE_WHITESPACE
299 Test description.
300 <BLANKLINE>
301 Other people are interested in this bug.
302@@ -572,7 +578,8 @@
303 >>> filebug_view.added_bug.title
304 u'Test Title'
305
306- >>> print filebug_view.added_bug.description #doctest: -NORMALIZE_WHITESPACE
307+ >>> description = filebug_view.added_bug.description
308+ >>> print description #doctest: -NORMALIZE_WHITESPACE
309 Test description.
310 <BLANKLINE>
311 This bug should be private, and Mark Shuttleworth subscribed.
312
313=== modified file 'lib/lp/bugs/browser/tests/test_bugcomment.py'
314--- lib/lp/bugs/browser/tests/test_bugcomment.py 2012-01-01 02:58:52 +0000
315+++ lib/lp/bugs/browser/tests/test_bugcomment.py 2012-01-17 14:36:25 +0000
316@@ -20,18 +20,24 @@
317 from zope.security.proxy import removeSecurityProxy
318
319 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
320-from lp.bugs.browser.bugcomment import group_comments_with_activity
321+from lp.bugs.interfaces.bugmessage import IBugComment
322+from lp.bugs.browser.bugcomment import (
323+ BugComment,
324+ group_comments_with_activity,
325+ )
326 from lp.coop.answersbugs.visibility import (
327 TestHideMessageControlMixin,
328 TestMessageVisibilityMixin,
329 )
330 from lp.services.features.testing import FeatureFixture
331+from lp.services.webapp.testing import verifyObject
332 from lp.testing import (
333 BrowserTestCase,
334 celebrity_logged_in,
335 login_person,
336 person_logged_in,
337 TestCase,
338+ TestCaseWithFactory,
339 )
340 from lp.testing.layers import DatabaseFunctionalLayer
341 from lp.testing.pages import find_tag_by_id
342@@ -313,3 +319,15 @@
343 itemprop='commentTime',
344 title=True,
345 datetime=iso_date))))
346+
347+
348+class TestBugCommentImplementsInterface(TestCaseWithFactory):
349+
350+ layer = DatabaseFunctionalLayer
351+
352+ def test_bug_comment_implements_interface(self):
353+ """Ensure BugComment implements IBugComment"""
354+ bug_message = self.factory.makeBugComment()
355+ bugtask = bug_message.bugs[0].bugtasks[0]
356+ bug_comment = BugComment(1, bug_message, bugtask)
357+ verifyObject(IBugComment, bug_comment)
358
359=== modified file 'lib/lp/bugs/scripts/tests/test_bugimport.py'
360--- lib/lp/bugs/scripts/tests/test_bugimport.py 2012-01-05 00:23:45 +0000
361+++ lib/lp/bugs/scripts/tests/test_bugimport.py 2012-01-17 14:36:25 +0000
362@@ -38,7 +38,6 @@
363 PersonCreationRationale,
364 )
365 from lp.registry.interfaces.product import IProductSet
366-from lp.registry.model.person import generate_nick
367 from lp.services.config import config
368 from lp.services.database.sqlbase import cursor
369 from lp.services.identity.interfaces.emailaddress import IEmailAddressSet
370@@ -532,7 +531,7 @@
371 message1.datecreated.isoformat(), '2004-10-12T12:00:00+00:00')
372 self.assertEqual(message1.subject, 'A test bug')
373 self.assertEqual(message1.text_contents, 'Original description')
374- self.assertEqual(message1.bugattachments.count(), 1)
375+ self.assertEqual(len(message1.bugattachments), 1)
376 attachment = message1.bugattachments[0]
377 self.assertEqual(attachment.type, BugAttachmentType.UNSPECIFIED)
378 self.assertEqual(attachment.title, 'Hello')
379@@ -558,7 +557,7 @@
380 message3.text_contents,
381 'A comment from mark about CVE-2005-2730\n\n'
382 ' * list item 1\n * list item 2\n\nAnother paragraph')
383- self.assertEqual(message3.bugattachments.count(), 2)
384+ self.assertEqual(len(message3.bugattachments), 2)
385 # grab the attachments in the appropriate order
386 [attachment1, attachment2] = list(message3.bugattachments)
387 if attachment1.type == BugAttachmentType.PATCH:
388@@ -583,7 +582,7 @@
389 message4.datecreated.isoformat(), '2005-01-01T14:00:00+00:00')
390 self.assertEqual(message4.subject, 'Re: A test bug')
391 self.assertEqual(message4.text_contents, '<empty comment>')
392- self.assertEqual(message4.bugattachments.count(), 0)
393+ self.assertEqual(len(message4.bugattachments), 0)
394
395 # Message 5:
396 self.assertEqual(
397@@ -592,7 +591,7 @@
398 message5.datecreated.isoformat(), '2005-01-01T15:00:00+00:00')
399 self.assertEqual(message5.subject, 'Re: A test bug')
400 self.assertEqual(message5.text_contents, '')
401- self.assertEqual(message5.bugattachments.count(), 1)
402+ self.assertEqual(len(message5.bugattachments), 1)
403 attachment = message5.bugattachments[0]
404 self.assertEqual(attachment.type, BugAttachmentType.UNSPECIFIED)
405 self.assertEqual(attachment.title, 'Hello')
406
407=== modified file 'lib/lp/bugs/tests/bugs-emailinterface.txt'
408--- lib/lp/bugs/tests/bugs-emailinterface.txt 2011-12-30 07:38:46 +0000
409+++ lib/lp/bugs/tests/bugs-emailinterface.txt 2012-01-17 14:36:25 +0000
410@@ -2103,7 +2103,9 @@
411 >>> sent_msg = email.message_from_string(raw_message)
412 >>> failure_msg, original_msg = sent_msg.get_payload()
413 >>> msg = original_msg.get_payload()[0]
414- >>> # Fudge due to new line added to the payload.
415+
416+Fudge due to new line added to the payload.
417+
418 >>> len(str(msg)) <= (max_return_size + 1)
419 True
420
421@@ -2367,7 +2369,7 @@
422 criteria described below).
423
424 >>> def print_attachments(attachments):
425- ... if attachments.count() == 0:
426+ ... if len(list(attachments)) == 0:
427 ... print "No attachments"
428 ... return
429 ... commit()
430
431=== modified file 'lib/lp/services/messages/interfaces/message.py'
432--- lib/lp/services/messages/interfaces/message.py 2011-12-24 16:54:44 +0000
433+++ lib/lp/services/messages/interfaces/message.py 2012-01-17 14:36:25 +0000
434@@ -85,7 +85,7 @@
435 schema=ILibraryFileAlias, required=False, readonly=True)
436 bugs = CollectionField(
437 title=_('Bug List'),
438- value_type=Reference(schema=Interface)) # Redefined in bug.py
439+ value_type=Reference(schema=Interface)) # Redefined in bug.py
440
441 chunks = Attribute(_('Message pieces'))
442
443@@ -94,16 +94,9 @@
444 'unicode string.')),
445 exported_as='content')
446
447- followup_title = TextLine(
448- title=_('Candidate title for a followup message.'),
449- readonly=True)
450 title = TextLine(
451 title=_('The message title, usually just the subject.'),
452 readonly=True)
453- has_new_title = Bool(
454- title=_("Whether or not the title of this message "
455- "is different to that of its parent."),
456- readonly=True)
457 visible = Bool(title=u"This message is visible or not.", required=False,
458 default=True)
459
460
461=== modified file 'lib/lp/services/messages/model/message.py'
462--- lib/lp/services/messages/model/message.py 2011-12-30 06:14:56 +0000
463+++ lib/lp/services/messages/model/message.py 2012-01-17 14:36:25 +0000
464@@ -126,10 +126,20 @@
465 rfc822msgid = StringCol(notNull=True)
466 bugs = SQLRelatedJoin('Bug', joinColumn='message', otherColumn='bug',
467 intermediateTable='BugMessage')
468- chunks = SQLMultipleJoin('MessageChunk', joinColumn='message')
469+ _chunks = SQLMultipleJoin('MessageChunk', joinColumn='message')
470+
471+ @cachedproperty
472+ def chunks(self):
473+ return list(self._chunks)
474+
475 raw = ForeignKey(foreignKey='LibraryFileAlias', dbName='raw',
476 default=None)
477- bugattachments = SQLMultipleJoin('BugAttachment', joinColumn='_message')
478+ _bugattachments = SQLMultipleJoin('BugAttachment', joinColumn='_message')
479+
480+ @cachedproperty
481+ def bugattachments(self):
482+ return list(self._bugattachments)
483+
484 visible = BoolCol(notNull=True, default=True)
485
486 def __repr__(self):
487@@ -143,26 +153,11 @@
488 self.visible = visible
489
490 @property
491- def followup_title(self):
492- """See IMessage."""
493- if self.title.lower().startswith('re: '):
494- return self.title
495- return 'Re: ' + self.title
496-
497- @property
498 def title(self):
499 """See IMessage."""
500 return self.subject
501
502 @property
503- def has_new_title(self):
504- """See IMessage."""
505- if self.parent is None:
506- return True
507- return self.title.lower().lstrip('re:').strip() != \
508- self.parent.title.lower().lstrip('re:').strip()
509-
510- @property
511 def sender(self):
512 """See IMessage."""
513 return self.owner
514
515=== modified file 'lib/lp/testing/factory.py'
516--- lib/lp/testing/factory.py 2012-01-15 11:50:42 +0000
517+++ lib/lp/testing/factory.py 2012-01-17 14:36:25 +0000
518@@ -1944,9 +1944,10 @@
519 subject = self.getUniqueString()
520 if body is None:
521 body = self.getUniqueString()
522- return bug.newMessage(owner=owner, subject=subject,
523- content=body, parent=None, bugwatch=bug_watch,
524- remote_comment_id=None)
525+ with person_logged_in(owner):
526+ return bug.newMessage(owner=owner, subject=subject, content=body,
527+ parent=None, bugwatch=bug_watch,
528+ remote_comment_id=None)
529
530 def makeBugAttachment(self, bug=None, owner=None, data=None,
531 comment=None, filename=None, content_type=None,