Merge ~pappacena/launchpad:comment-editing-revisions-api into launchpad:master

Proposed by Thiago F. Pappacena
Status: Merged
Approved by: Thiago F. Pappacena
Approved revision: 23031fa129265a613dd4d47bff4ac95729c4f611
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~pappacena/launchpad:comment-editing-revisions-api
Merge into: launchpad:master
Prerequisite: ~pappacena/launchpad:comment-editing-api
Diff against target: 746 lines (+328/-54)
21 files modified
lib/lp/_schema_circular_imports.py (+2/-0)
lib/lp/answers/browser/configure.zcml (+4/-0)
lib/lp/answers/browser/question.py (+15/-0)
lib/lp/answers/stories/webservice.txt (+1/-0)
lib/lp/bugs/browser/bugcomment.py (+15/-0)
lib/lp/bugs/browser/configure.zcml (+5/-1)
lib/lp/code/browser/codereviewcomment.py (+15/-0)
lib/lp/code/browser/configure.zcml (+4/-1)
lib/lp/code/stories/webservice/xx-branchmergeproposal.txt (+3/-0)
lib/lp/services/messages/browser/configure.zcml (+10/-0)
lib/lp/services/messages/browser/message.py (+4/-0)
lib/lp/services/messages/configure.zcml (+1/-0)
lib/lp/services/messages/interfaces/message.py (+11/-1)
lib/lp/services/messages/interfaces/messagerevision.py (+20/-6)
lib/lp/services/messages/interfaces/webservice.py (+3/-1)
lib/lp/services/messages/model/message.py (+11/-4)
lib/lp/services/messages/model/messagerevision.py (+21/-0)
lib/lp/services/messages/tests/scenarios.py (+41/-0)
lib/lp/services/messages/tests/test_message.py (+16/-35)
lib/lp/services/messages/tests/test_messagerevision.py (+120/-5)
lib/lp/services/webservice/wadl-to-refhtml.xsl (+6/-0)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+402285@code.launchpad.net

Commit message

API to get and delete comment's revision history for bug messages, answers and code review comments

To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) :
review: Approve
Revision history for this message
Thiago F. Pappacena (pappacena) :
Revision history for this message
Otto Co-Pilot (otto-copilot) wrote :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/_schema_circular_imports.py b/lib/lp/_schema_circular_imports.py
2index a61d39d..4b1ecff 100644
3--- a/lib/lp/_schema_circular_imports.py
4+++ b/lib/lp/_schema_circular_imports.py
5@@ -149,6 +149,7 @@ from lp.services.messages.interfaces.message import (
6 IMessage,
7 IUserToUserEmail,
8 )
9+from lp.services.messages.interfaces.messagerevision import IMessageRevision
10 from lp.services.webservice.apihelpers import (
11 patch_collection_property,
12 patch_collection_return_type,
13@@ -612,6 +613,7 @@ patch_reference_property(IIndexedMessage, 'inside', IBugTask)
14
15 # IMessage
16 patch_reference_property(IMessage, 'owner', IPerson)
17+patch_collection_property(IMessage, 'revisions', IMessageRevision)
18
19 # IUserToUserEmail
20 patch_reference_property(IUserToUserEmail, 'sender', IPerson)
21diff --git a/lib/lp/answers/browser/configure.zcml b/lib/lp/answers/browser/configure.zcml
22index 631e8b8..87a6b76 100644
23--- a/lib/lp/answers/browser/configure.zcml
24+++ b/lib/lp/answers/browser/configure.zcml
25@@ -282,6 +282,10 @@
26 module=".question"
27 classes="QuestionNavigation"
28 />
29+ <browser:navigation
30+ module=".question"
31+ classes="QuestionMessageNavigation"
32+ />
33
34 <browser:url
35 for="lp.answers.interfaces.questioncollection.IQuestionSet"
36diff --git a/lib/lp/answers/browser/question.py b/lib/lp/answers/browser/question.py
37index 814f329..68c4b59 100644
38--- a/lib/lp/answers/browser/question.py
39+++ b/lib/lp/answers/browser/question.py
40@@ -71,6 +71,7 @@ from lp.answers.interfaces.question import (
41 IQuestionLinkFAQForm,
42 )
43 from lp.answers.interfaces.questioncollection import IQuestionSet
44+from lp.answers.interfaces.questionmessage import IQuestionMessage
45 from lp.answers.interfaces.questiontarget import (
46 IAnswersFrontPageSearchForm,
47 IQuestionTarget,
48@@ -275,6 +276,20 @@ class QuestionNavigation(Navigation):
49 return None
50
51
52+class QuestionMessageNavigation(Navigation):
53+ """Navigation for the IQuestionMessage."""
54+
55+ usedfor = IQuestionMessage
56+
57+ @stepthrough('revisions')
58+ def traverse_revisions(self, revision):
59+ try:
60+ revision = int(revision)
61+ except ValueError:
62+ return None
63+ return self.context.getRevisionByNumber(revision)
64+
65+
66 class QuestionBreadcrumb(Breadcrumb):
67 """Builds a breadcrumb for an `IQuestion`."""
68
69diff --git a/lib/lp/answers/stories/webservice.txt b/lib/lp/answers/stories/webservice.txt
70index 8ab277c..0954ced 100644
71--- a/lib/lp/answers/stories/webservice.txt
72+++ b/lib/lp/answers/stories/webservice.txt
73@@ -242,6 +242,7 @@ that indicate how the message changed the question.
74 parent_link: None
75 question_link: 'http://api.launchpad.test/devel/my-project/+question/...'
76 resource_type_link: 'http://api.launchpad.test/devel/#question_message'
77+ revisions_collection_link: 'http://...'
78 self_link:
79 'http://api.launchpad.test/devel/my-project/+question/.../messages/1'
80 subject: 'Re: Q 1 great'
81diff --git a/lib/lp/bugs/browser/bugcomment.py b/lib/lp/bugs/browser/bugcomment.py
82index e40967f..6cc89f2 100644
83--- a/lib/lp/bugs/browser/bugcomment.py
84+++ b/lib/lp/bugs/browser/bugcomment.py
85@@ -49,6 +49,8 @@ from lp.services.propertycache import (
86 from lp.services.webapp import (
87 canonical_url,
88 LaunchpadView,
89+ Navigation,
90+ stepthrough,
91 )
92 from lp.services.webapp.breadcrumb import Breadcrumb
93 from lp.services.webapp.interfaces import ILaunchBag
94@@ -57,6 +59,19 @@ from lp.services.webapp.interfaces import ILaunchBag
95 COMMENT_ACTIVITY_GROUPING_WINDOW = timedelta(minutes=5)
96
97
98+class BugCommentNavigation(Navigation):
99+ """Navigation for the `IBugComment`."""
100+ usedfor = IBugComment
101+
102+ @stepthrough('revisions')
103+ def traverse_revisions(self, revision):
104+ try:
105+ revision = int(revision)
106+ except ValueError:
107+ return None
108+ return self.context.getRevisionByNumber(revision)
109+
110+
111 def build_comments_from_chunks(
112 bugtask, truncate=False, slice_info=None, show_spam_controls=False,
113 user=None, hide_first=False):
114diff --git a/lib/lp/bugs/browser/configure.zcml b/lib/lp/bugs/browser/configure.zcml
115index ac80819..d9c36a8 100644
116--- a/lib/lp/bugs/browser/configure.zcml
117+++ b/lib/lp/bugs/browser/configure.zcml
118@@ -1,4 +1,4 @@
119-<!-- Copyright 2010-2014 Canonical Ltd. This software is licensed under the
120+<!-- Copyright 2010-2021 Canonical Ltd. This software is licensed under the
121 GNU Affero General Public License version 3 (see the file LICENSE).
122 -->
123
124@@ -163,6 +163,10 @@
125 path_expression="string:comments/${index}"
126 attribute_to_parent="bugtask"
127 rootsite="bugs"/>
128+ <browser:navigation
129+ module=".bugcomment"
130+ classes="BugCommentNavigation"
131+ />
132 <browser:page
133 for="lp.bugs.interfaces.bugmessage.IBugComment"
134 name="+index"
135diff --git a/lib/lp/code/browser/codereviewcomment.py b/lib/lp/code/browser/codereviewcomment.py
136index 0d0496d..094cd79 100644
137--- a/lib/lp/code/browser/codereviewcomment.py
138+++ b/lib/lp/code/browser/codereviewcomment.py
139@@ -56,10 +56,25 @@ from lp.services.webapp import (
140 ContextMenu,
141 LaunchpadView,
142 Link,
143+ Navigation,
144+ stepthrough,
145 )
146 from lp.services.webapp.interfaces import ILaunchBag
147
148
149+class CodeReviewCommentNavigation(Navigation):
150+ """Navigation for the `ICodeReviewComment`."""
151+ usedfor = ICodeReviewComment
152+
153+ @stepthrough('revisions')
154+ def traverse_revisions(self, revision):
155+ try:
156+ revision = int(revision)
157+ except ValueError:
158+ return None
159+ return self.context.getRevisionByNumber(int(revision))
160+
161+
162 class ICodeReviewDisplayComment(IComment, ICodeReviewComment):
163 """Marker interface for displaying code review comments."""
164 message = Object(schema=IMessage, title=_('The message.'))
165diff --git a/lib/lp/code/browser/configure.zcml b/lib/lp/code/browser/configure.zcml
166index e7943c0..f7bab39 100644
167--- a/lib/lp/code/browser/configure.zcml
168+++ b/lib/lp/code/browser/configure.zcml
169@@ -1,4 +1,4 @@
170-<!-- Copyright 2009-2020 Canonical Ltd. This software is licensed under the
171+<!-- Copyright 2009-2021 Canonical Ltd. This software is licensed under the
172 GNU Affero General Public License version 3 (see the file LICENSE).
173 -->
174
175@@ -556,6 +556,9 @@
176 path_expression="string:comments/${id}"
177 attribute_to_parent="branch_merge_proposal"
178 rootsite="code"/>
179+ <browser:navigation
180+ module=".codereviewcomment"
181+ classes="CodeReviewCommentNavigation" />
182 <browser:defaultView
183 for="lp.code.interfaces.codereviewcomment.ICodeReviewComment"
184 name="+index"/>
185diff --git a/lib/lp/code/stories/webservice/xx-branchmergeproposal.txt b/lib/lp/code/stories/webservice/xx-branchmergeproposal.txt
186index 58b86e7..ccb1c9d 100644
187--- a/lib/lp/code/stories/webservice/xx-branchmergeproposal.txt
188+++ b/lib/lp/code/stories/webservice/xx-branchmergeproposal.txt
189@@ -206,6 +206,7 @@ The comments on a branch merge proposal are exposed through the API.
190 message_body: 'This is great work'
191 owner_link: 'http://...'
192 resource_type_link: 'http://.../#code_review_comment'
193+ revisions_collection_link: 'http://...'
194 self_link: 'http://.../~source/fooix/fix-it/+merge/.../comments/...'
195 title: 'Comment on proposed merge of lp://dev/~source/fooix/fix-it into lp://dev/~target/fooix/trunk'
196 vote: 'Approve'
197@@ -228,6 +229,7 @@ The comments on a branch merge proposal are exposed through the API.
198 message_body: 'This is mediocre work.'
199 owner_link: 'http://...'
200 resource_type_link: 'http://.../#code_review_comment'
201+ revisions_collection_link: 'http://...'
202 self_link: 'http://.../~source/fooix/fix-it/+merge/.../comments/...'
203 title: ...
204 vote: 'Abstain'
205@@ -306,6 +308,7 @@ Now the code review should be made.
206 message_body: 'This is great work'
207 owner_link: 'http://...'
208 resource_type_link: 'http://.../#code_review_comment'
209+ revisions_collection_link: 'http://...'
210 self_link: 'http://.../~source/fooix/fix-it/+merge/.../comments/...'
211 title: ...
212 vote: 'Approve'
213diff --git a/lib/lp/services/messages/browser/configure.zcml b/lib/lp/services/messages/browser/configure.zcml
214new file mode 100644
215index 0000000..0413ba9
216--- /dev/null
217+++ b/lib/lp/services/messages/browser/configure.zcml
218@@ -0,0 +1,10 @@
219+<configure
220+ xmlns="http://namespaces.zope.org/zope"
221+ xmlns:browser="http://namespaces.zope.org/browser"
222+ xmlns:i18n="http://namespaces.zope.org/i18n"
223+ i18n_domain="launchpad">
224+ <browser:url
225+ for="lp.services.messages.interfaces.messagerevision.IMessageRevision"
226+ path_expression="string:revisions/${revision}"
227+ attribute_to_parent="message_implementation" />
228+</configure>
229diff --git a/lib/lp/services/messages/browser/message.py b/lib/lp/services/messages/browser/message.py
230index 06800e6..1e4a6e7 100644
231--- a/lib/lp/services/messages/browser/message.py
232+++ b/lib/lp/services/messages/browser/message.py
233@@ -7,6 +7,7 @@ __metaclass__ = type
234
235 from zope.interface import implementer
236
237+from lp.bugs.interfaces.bugmessage import IBugMessage
238 from lp.services.messages.interfaces.message import IIndexedMessage
239 from lp.services.webapp.interfaces import ICanonicalUrlData
240
241@@ -28,6 +29,9 @@ class BugMessageCanonicalUrlData:
242
243 def __init__(self, bug, message):
244 self.inside = bug.default_bugtask
245+ if IBugMessage.providedBy(message):
246+ # bug.messages is a list of Message objects, not BugMessage.
247+ message = message.message
248 self.path = "comments/%d" % list(bug.messages).index(message)
249
250
251diff --git a/lib/lp/services/messages/configure.zcml b/lib/lp/services/messages/configure.zcml
252index 19cb3c3..867b260 100644
253--- a/lib/lp/services/messages/configure.zcml
254+++ b/lib/lp/services/messages/configure.zcml
255@@ -82,4 +82,5 @@
256 />
257
258 <webservice:register module="lp.services.messages.interfaces.webservice" />
259+ <include package=".browser"/>
260 </configure>
261diff --git a/lib/lp/services/messages/interfaces/message.py b/lib/lp/services/messages/interfaces/message.py
262index 6d4e186..598b665 100644
263--- a/lib/lp/services/messages/interfaces/message.py
264+++ b/lib/lp/services/messages/interfaces/message.py
265@@ -88,7 +88,14 @@ class IMessageCommon(Interface):
266 Reference(title=_('Person'), schema=Interface,
267 required=False, readonly=True))
268
269- revisions = Attribute(_('Message revision history'))
270+ revisions = exported(CollectionField(
271+ title=_("Message revision history"),
272+ description=_(
273+ "Revision history of this message, sorted in ascending order."),
274+ # Really IMessageRevision, patched in _schema_circular_imports.
275+ value_type=Reference(schema=Interface),
276+ required=False, readonly=True), as_of="devel")
277+
278 datecreated = exported(
279 Datetime(title=_('Date Created'), required=True, readonly=True),
280 exported_as='date_created')
281@@ -100,6 +107,9 @@ class IMessageCommon(Interface):
282 title=_('When this message was deleted'), required=False,
283 readonly=True))
284
285+ def getRevisionByNumber(revision_number):
286+ """Returns the revision with the given number."""
287+
288
289 class IMessageView(IMessageCommon):
290 """Public attributes for message.
291diff --git a/lib/lp/services/messages/interfaces/messagerevision.py b/lib/lp/services/messages/interfaces/messagerevision.py
292index 49d3b9f..3ee5a90 100644
293--- a/lib/lp/services/messages/interfaces/messagerevision.py
294+++ b/lib/lp/services/messages/interfaces/messagerevision.py
295@@ -10,6 +10,12 @@ __all__ = [
296 'IMessageRevisionChunk',
297 ]
298
299+from lazr.restful.declarations import (
300+ export_write_operation,
301+ exported,
302+ exported_as_webservice_entry,
303+ operation_for_version,
304+ )
305 from lazr.restful.fields import Reference
306 from zope.interface import (
307 Attribute,
308@@ -31,21 +37,26 @@ class IMessageRevisionView(Interface):
309
310 revision = Int(title=_("Revision number"), required=True, readonly=True)
311
312- content = Text(
313+ content = exported(Text(
314 title=_("The message at the given revision"),
315- required=True, readonly=True)
316+ required=True, readonly=True))
317
318 message = Reference(
319 title=_('The current message of this revision.'),
320 schema=IMessage, required=True, readonly=True)
321
322- date_created = Datetime(
323+ message_implementation = Reference(
324+ title=_('The message implementation (BugComment, QuestionMessage or '
325+ 'CodeReviewComment) related to this revision'),
326+ schema=IMessage, required=True, readonly=True)
327+
328+ date_created = exported(Datetime(
329 title=_("The time when this message revision was created."),
330- required=True, readonly=True)
331+ required=True, readonly=True))
332
333- date_deleted = Datetime(
334+ date_deleted = exported(Datetime(
335 title=_("The time when this message revision was created."),
336- required=False, readonly=True)
337+ required=False, readonly=True))
338
339 chunks = Attribute(_('Message pieces'))
340
341@@ -53,10 +64,13 @@ class IMessageRevisionView(Interface):
342 class IMessageRevisionEdit(Interface):
343 """IMessageRevision editable attributes."""
344
345+ @export_write_operation()
346+ @operation_for_version("devel")
347 def deleteContent():
348 """Deletes this MessageRevision content."""
349
350
351+@exported_as_webservice_entry(publish_web_link=False, as_of="devel")
352 class IMessageRevision(IMessageRevisionView, IMessageRevisionEdit):
353 """A historical revision of a IMessage."""
354
355diff --git a/lib/lp/services/messages/interfaces/webservice.py b/lib/lp/services/messages/interfaces/webservice.py
356index 94960a9..1dade00 100644
357--- a/lib/lp/services/messages/interfaces/webservice.py
358+++ b/lib/lp/services/messages/interfaces/webservice.py
359@@ -1,4 +1,4 @@
360-# Copyright 2011 Canonical Ltd. This software is licensed under the
361+# Copyright 2011-2021 Canonical Ltd. This software is licensed under the
362 # GNU Affero General Public License version 3 (see the file LICENSE).
363
364 """All the interfaces that are exposed through the webservice.
365@@ -12,10 +12,12 @@ which tells `lazr.restful` that it should look for webservice exports here.
366 __metaclass__ = type
367 __all__ = [
368 'IMessage',
369+ 'IMessageRevision',
370 ]
371
372 from lp import _schema_circular_imports
373 from lp.services.messages.interfaces.message import IMessage
374+from lp.services.messages.interfaces.messagerevision import IMessageRevision
375
376
377 _schema_circular_imports
378diff --git a/lib/lp/services/messages/model/message.py b/lib/lp/services/messages/model/message.py
379index d20ca2f..f6638b2 100644
380--- a/lib/lp/services/messages/model/message.py
381+++ b/lib/lp/services/messages/model/message.py
382@@ -174,13 +174,20 @@ class Message(SQLBase):
383 """See `IMessage`."""
384 return None
385
386+ @property
387+ def _revisions(self):
388+ return Store.of(self).find(
389+ MessageRevision,
390+ MessageRevision.message == self
391+ ).order_by(MessageRevision.revision)
392+
393 @cachedproperty
394 def revisions(self):
395 """See `IMessage`."""
396- return list(Store.of(self).find(
397- MessageRevision,
398- MessageRevision.message == self
399- ).order_by(MessageRevision.revision))
400+ return list(self._revisions)
401+
402+ def getRevisionByNumber(self, revision_number):
403+ return self._revisions.find(revision=revision_number).one()
404
405 def editContent(self, new_content):
406 """See `IMessage`."""
407diff --git a/lib/lp/services/messages/model/messagerevision.py b/lib/lp/services/messages/model/messagerevision.py
408index f4a1466..535ff66 100644
409--- a/lib/lp/services/messages/model/messagerevision.py
410+++ b/lib/lp/services/messages/model/messagerevision.py
411@@ -57,6 +57,27 @@ class MessageRevision(StormBase):
412 self.date_created = date_created
413 self.date_deleted = date_deleted
414
415+ @property
416+ def message_implementation(self):
417+ from lp.bugs.model.bugmessage import BugMessage
418+ from lp.code.model.codereviewcomment import CodeReviewComment
419+ from lp.answers.model.questionmessage import QuestionMessage
420+
421+ store = IStore(self)
422+ (identifier, ) = store.execute("""
423+ SELECT 'bug' FROM BugMessage WHERE message = %s
424+ UNION
425+ SELECT 'question' FROM QuestionMessage WHERE message = %s
426+ UNION
427+ SELECT 'mp' FROM CodeReviewMessage WHERE message = %s;
428+ """, params=[self.message_id] * 3).get_one()
429+ id_to_class = {
430+ "bug": BugMessage,
431+ "question": QuestionMessage,
432+ "mp": CodeReviewComment}
433+ klass = id_to_class[identifier]
434+ return store.find(klass, klass.message == self.message_id).one()
435+
436 @cachedproperty
437 def chunks(self):
438 return list(IStore(self).find(
439diff --git a/lib/lp/services/messages/tests/scenarios.py b/lib/lp/services/messages/tests/scenarios.py
440new file mode 100644
441index 0000000..f366574
442--- /dev/null
443+++ b/lib/lp/services/messages/tests/scenarios.py
444@@ -0,0 +1,41 @@
445+# Copyright 2009-2021 Canonical Ltd. This software is licensed under the
446+# GNU Affero General Public License version 3 (see the file LICENSE).
447+
448+__metaclass__ = type
449+
450+from testscenarios import WithScenarios
451+from zope.security.proxy import ProxyFactory
452+
453+from lp.bugs.model.bugmessage import BugMessage
454+from lp.services.database.interfaces import IStore
455+from lp.testing import (
456+ login_person,
457+ )
458+
459+
460+class MessageTypeScenariosMixin(WithScenarios):
461+
462+ scenarios = [
463+ ("bug", {"message_type": "bug"}),
464+ ("question", {"message_type": "question"}),
465+ ("MP comment", {"message_type": "mp"})
466+ ]
467+
468+ def setUp(self):
469+ super(MessageTypeScenariosMixin, self).setUp()
470+ self.person = self.factory.makePerson()
471+ login_person(self.person)
472+
473+ def makeMessage(self, content=None, **kwargs):
474+ owner = kwargs.pop('owner', self.person)
475+ if self.message_type == "bug":
476+ msg = self.factory.makeBugComment(
477+ owner=owner, body=content, **kwargs)
478+ return ProxyFactory(IStore(BugMessage).find(
479+ BugMessage, BugMessage.message == msg).one())
480+ elif self.message_type == "question":
481+ question = self.factory.makeQuestion()
482+ return question.giveAnswer(owner, content)
483+ elif self.message_type == "mp":
484+ return self.factory.makeCodeReviewComment(
485+ sender=owner, body=content)
486diff --git a/lib/lp/services/messages/tests/test_message.py b/lib/lp/services/messages/tests/test_message.py
487index 070bc91..c483305 100644
488--- a/lib/lp/services/messages/tests/test_message.py
489+++ b/lib/lp/services/messages/tests/test_message.py
490@@ -13,21 +13,18 @@ from email.utils import (
491 )
492
493 import six
494-from testscenarios import WithScenarios
495 from testtools.matchers import (
496+ ContainsDict,
497+ EndsWith,
498 Equals,
499 Is,
500 MatchesStructure,
501 )
502 import transaction
503 from zope.security.interfaces import Unauthorized
504-from zope.security.proxy import (
505- ProxyFactory,
506- removeSecurityProxy,
507- )
508+from zope.security.proxy import removeSecurityProxy
509
510 from lp.bugs.interfaces.bugmessage import IBugMessage
511-from lp.bugs.model.bugmessage import BugMessage
512 from lp.services.compat import message_as_bytes
513 from lp.services.database.interfaces import IStore
514 from lp.services.database.sqlbase import get_transaction_timestamp
515@@ -35,12 +32,12 @@ from lp.services.messages.model.message import (
516 MessageChunk,
517 MessageSet,
518 )
519+from lp.services.messages.tests.scenarios import MessageTypeScenariosMixin
520 from lp.services.webapp.interfaces import OAuthPermission
521 from lp.testing import (
522 admin_logged_in,
523 api_url,
524 login,
525- login_person,
526 person_logged_in,
527 TestCaseWithFactory,
528 )
529@@ -198,34 +195,6 @@ class TestMessageSet(TestCaseWithFactory):
530 self.assertEqual(self.high_characters.decode('latin-1'), result)
531
532
533-class MessageTypeScenariosMixin(WithScenarios):
534-
535- scenarios = [
536- ("bug", {"message_type": "bug"}),
537- ("question", {"message_type": "question"}),
538- ("MP comment", {"message_type": "mp"})
539- ]
540-
541- def setUp(self):
542- super(MessageTypeScenariosMixin, self).setUp()
543- self.person = self.factory.makePerson()
544- login_person(self.person)
545-
546- def makeMessage(self, content=None, **kwargs):
547- owner = kwargs.pop('owner', self.person)
548- if self.message_type == "bug":
549- msg = self.factory.makeBugComment(
550- owner=owner, body=content, **kwargs)
551- return ProxyFactory(IStore(BugMessage).find(
552- BugMessage, BugMessage.message == msg).one())
553- elif self.message_type == "question":
554- question = self.factory.makeQuestion()
555- return question.giveAnswer(owner, content)
556- elif self.message_type == "mp":
557- return self.factory.makeCodeReviewComment(
558- sender=owner, body=content)
559-
560-
561 class TestMessageEditing(MessageTypeScenariosMixin, TestCaseWithFactory):
562 """Test editing scenarios for Message objects."""
563
564@@ -366,6 +335,18 @@ class TestMessageEditingAPI(MessageTypeScenariosMixin, TestCaseWithFactory):
565 else:
566 return api_url(msg)
567
568+ def test_api_get_basic_structure(self):
569+ msg = self.makeMessage(content="some content")
570+ ws = self.getWebservice(self.person)
571+ url = self.getMessageAPIURL(msg)
572+ obj = ws.get(url).jsonBody()
573+ self.assertThat(obj, ContainsDict(dict(
574+ revisions_collection_link=EndsWith("/revisions"),
575+ date_last_edited=Is(None),
576+ date_deleted=Is(None),
577+ content=Equals("some content"),
578+ )))
579+
580 def test_edit_message(self):
581 msg = self.makeMessage(content="initial content")
582 ws = self.getWebservice(self.person)
583diff --git a/lib/lp/services/messages/tests/test_messagerevision.py b/lib/lp/services/messages/tests/test_messagerevision.py
584index 3b411c9..80d1a14 100644
585--- a/lib/lp/services/messages/tests/test_messagerevision.py
586+++ b/lib/lp/services/messages/tests/test_messagerevision.py
587@@ -3,18 +3,30 @@
588
589 __metaclass__ = type
590
591-from lp.services.database.interfaces import IStore
592-from lp.services.database.sqlbase import get_transaction_timestamp
593+from testtools.matchers import (
594+ ContainsDict,
595+ EndsWith,
596+ Equals,
597+ Is,
598+ MatchesListwise,
599+ Not,
600+ )
601 from zope.security.interfaces import Unauthorized
602 from zope.security.proxy import ProxyFactory
603
604+from lp.bugs.interfaces.bugmessage import IBugMessage
605+from lp.services.database.interfaces import IStore
606+from lp.services.database.sqlbase import get_transaction_timestamp
607+from lp.services.messages.tests.scenarios import MessageTypeScenariosMixin
608+from lp.services.webapp.interfaces import OAuthPermission
609 from lp.testing import (
610+ admin_logged_in,
611+ api_url,
612 person_logged_in,
613 TestCaseWithFactory,
614 )
615-from lp.testing.layers import (
616- DatabaseFunctionalLayer,
617- )
618+from lp.testing.layers import DatabaseFunctionalLayer
619+from lp.testing.pages import webservice_for_person
620
621
622 class TestMessageRevision(TestCaseWithFactory):
623@@ -48,3 +60,106 @@ class TestMessageRevision(TestCaseWithFactory):
624 self.assertEqual(0, len(rev.chunks))
625 self.assertEqual(
626 get_transaction_timestamp(IStore(rev)), rev.date_deleted)
627+
628+
629+class TestMessageRevisionAPI(MessageTypeScenariosMixin, TestCaseWithFactory):
630+ """Test editing scenarios for message revisions API."""
631+
632+ layer = DatabaseFunctionalLayer
633+
634+ def getWebservice(self, person):
635+ return webservice_for_person(
636+ person, permission=OAuthPermission.WRITE_PUBLIC,
637+ default_api_version="devel")
638+
639+ def getMessageAPIURL(self, msg):
640+ with admin_logged_in():
641+ if IBugMessage.providedBy(msg):
642+ # BugMessage has a special URL mapping that uses the
643+ # IMessage object itself.
644+ return api_url(msg.message)
645+ else:
646+ return api_url(msg)
647+
648+ def test_get_message_revision_list(self):
649+ msg = self.makeMessage(content="initial content")
650+ msg.editContent("new content 1")
651+ msg.editContent("final content")
652+ ws = self.getWebservice(self.person)
653+ url = self.getMessageAPIURL(msg)
654+ ws_message = ws.get(url).jsonBody()
655+
656+ revisions = ws.get(ws_message['revisions_collection_link']).jsonBody()
657+ self.assertThat(revisions, ContainsDict({
658+ "start": Equals(0),
659+ "total_size": Equals(2)}))
660+ self.assertThat(revisions["entries"], MatchesListwise([
661+ ContainsDict({
662+ "date_created": Not(Is(None)),
663+ "date_deleted": Is(None),
664+ "content": Equals("initial content"),
665+ "self_link": EndsWith("/revisions/1")
666+ }),
667+ ContainsDict({
668+ "date_created": Not(Is(None)),
669+ "date_deleted": Is(None),
670+ "content": Equals("new content 1"),
671+ "self_link": EndsWith("/revisions/2")
672+ })]))
673+
674+ def test_get_single_revision(self):
675+ msg = self.makeMessage(content="initial content")
676+ msg.editContent("new content 1")
677+ ws = self.getWebservice(self.person)
678+
679+ with person_logged_in(self.person):
680+ revision_url = api_url(msg.revisions[0])
681+ revision = ws.get(revision_url).jsonBody()
682+ self.assertThat(revision, ContainsDict({
683+ "date_created": Not(Is(None)),
684+ "date_deleted": Is(None),
685+ "content": Equals("initial content"),
686+ "self_link": EndsWith("/revisions/1")
687+ }))
688+
689+ def test_delete_revision_content(self):
690+ msg = self.makeMessage(content="initial content")
691+ msg.editContent("new content 1")
692+ msg.editContent("final content")
693+
694+ with person_logged_in(self.person):
695+ revision_url = api_url(msg.revisions[0])
696+
697+ ws = self.getWebservice(self.person)
698+ response = ws.named_post(revision_url, "deleteContent")
699+ self.assertEqual(200, response.status)
700+
701+ revision = ws.get(revision_url).jsonBody()
702+ self.assertThat(revision, ContainsDict({
703+ "date_created": Not(Is(None)),
704+ "date_deleted": Not(Is(None)),
705+ "content": Equals(""),
706+ "self_link": EndsWith("/revisions/1")
707+ }))
708+
709+ def test_delete_revision_content_denied_for_non_owners(self):
710+ msg = self.makeMessage(content="initial content")
711+ msg.editContent("new content 1")
712+ msg.editContent("final content")
713+ someone_else = self.factory.makePerson()
714+
715+ with person_logged_in(self.person):
716+ revision_url = api_url(msg.revisions[0])
717+
718+ ws = self.getWebservice(someone_else)
719+ response = ws.named_post(revision_url, "deleteContent")
720+ self.assertEqual(401, response.status)
721+
722+ revision = ws.get(revision_url).jsonBody()
723+ self.assertThat(revision, ContainsDict({
724+ "date_created": Not(Is(None)),
725+ "date_deleted": Is(None),
726+ "content": Equals("initial content"),
727+ "self_link": EndsWith("/revisions/1")
728+ }))
729+
730diff --git a/lib/lp/services/webservice/wadl-to-refhtml.xsl b/lib/lp/services/webservice/wadl-to-refhtml.xsl
731index ee5e2e6..d0fbf23 100644
732--- a/lib/lp/services/webservice/wadl-to-refhtml.xsl
733+++ b/lib/lp/services/webservice/wadl-to-refhtml.xsl
734@@ -406,6 +406,12 @@
735 <xsl:text>/comments/</xsl:text>
736 <var>&lt;index&gt;</var>
737 </xsl:when>
738+ <xsl:when test="@id = 'message_revision'">
739+ <xsl:text>/</xsl:text>
740+ <var>&lt;message-url&gt;</var>
741+ <xsl:text>/revisions/</xsl:text>
742+ <var>&lt;index&gt;</var>
743+ </xsl:when>
744 <xsl:when test="@id = 'milestone'">
745 <xsl:text>/</xsl:text>
746 <var>&lt;target.name&gt;</var>