Merge ~pappacena/launchpad:comment-editing-model into launchpad:master

Proposed by Thiago F. Pappacena
Status: Merged
Approved by: Thiago F. Pappacena
Approved revision: d478e4282dba9fd4e0ce9ba65d5565cd9bcb0b37
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~pappacena/launchpad:comment-editing-model
Merge into: launchpad:master
Diff against target: 729 lines (+523/-16)
10 files modified
database/schema/security.cfg (+3/-1)
lib/lp/bugs/browser/tests/test_bugcomment.py (+6/-2)
lib/lp/security.py (+19/-0)
lib/lp/services/messages/configure.zcml (+24/-4)
lib/lp/services/messages/interfaces/message.py (+30/-4)
lib/lp/services/messages/interfaces/messagerevision.py (+69/-0)
lib/lp/services/messages/model/message.py (+89/-2)
lib/lp/services/messages/model/messagerevision.py (+92/-0)
lib/lp/services/messages/tests/test_message.py (+141/-3)
lib/lp/services/messages/tests/test_messagerevision.py (+50/-0)
Reviewer Review Type Date Requested Status
Colin Watson Approve
Review via email: mp+401894@code.launchpad.net

Commit message

Mapping database initial structure for message editing

Description of the change

More changes will be added in a future MP in order to adjust BugComment, CodeReviewComment and QuestionMessage to use the new methods. These changes were splitted to make the review process easier.

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) wrote :

Addressed all the comments.

Revision history for this message
Colin Watson (cjwatson) :
review: Approve
Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

Pushed the requested changes. This should be good to go now.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/database/schema/security.cfg b/database/schema/security.cfg
2index b13d4a8..8225ec9 100644
3--- a/database/schema/security.cfg
4+++ b/database/schema/security.cfg
5@@ -232,7 +232,9 @@ public.logintoken = SELECT, INSERT, UPDATE, DELETE
6 public.mailinglist = SELECT, INSERT, UPDATE, DELETE
7 public.mailinglistsubscription = SELECT, INSERT, UPDATE, DELETE
8 public.messageapproval = SELECT, INSERT, UPDATE, DELETE
9-public.messagechunk = SELECT, INSERT
10+public.messagechunk = SELECT, INSERT, DELETE
11+public.messagerevision = SELECT, INSERT, UPDATE, DELETE
12+public.messagerevisionchunk = SELECT, INSERT, DELETE
13 public.milestone = SELECT, INSERT, UPDATE, DELETE
14 public.milestonetag = SELECT, INSERT, UPDATE, DELETE
15 public.mirrorcdimagedistroseries = SELECT, INSERT, DELETE
16diff --git a/lib/lp/bugs/browser/tests/test_bugcomment.py b/lib/lp/bugs/browser/tests/test_bugcomment.py
17index c1877a5..e5baac1 100644
18--- a/lib/lp/bugs/browser/tests/test_bugcomment.py
19+++ b/lib/lp/bugs/browser/tests/test_bugcomment.py
20@@ -1,4 +1,4 @@
21-# Copyright 2010-2018 Canonical Ltd. This software is licensed under the
22+# Copyright 2010-2021 Canonical Ltd. This software is licensed under the
23 # GNU Affero General Public License version 3 (see the file LICENSE).
24
25 """Tests for the bugcomment module."""
26@@ -35,6 +35,7 @@ from lp.testing import (
27 BrowserTestCase,
28 celebrity_logged_in,
29 login_person,
30+ person_logged_in,
31 TestCase,
32 TestCaseWithFactory,
33 verifyObject,
34@@ -300,7 +301,10 @@ class TestBugCommentImplementsInterface(TestCaseWithFactory):
35 bug_message = self.factory.makeBugComment()
36 bugtask = bug_message.bugs[0].bugtasks[0]
37 bug_comment = BugComment(1, bug_message, bugtask)
38- verifyObject(IBugComment, bug_comment)
39+ # Runs verifyObject logged in as the bug owner, so we don't fail on
40+ # attributes that are not public to everyone.
41+ with person_logged_in(bug_message.owner):
42+ verifyObject(IBugComment, bug_comment)
43
44 def test_download_url(self):
45 """download_url is provided and works as expected."""
46diff --git a/lib/lp/security.py b/lib/lp/security.py
47index 412f497..1eeca4e 100644
48--- a/lib/lp/security.py
49+++ b/lib/lp/security.py
50@@ -187,6 +187,7 @@ from lp.services.identity.interfaces.account import IAccount
51 from lp.services.identity.interfaces.emailaddress import IEmailAddress
52 from lp.services.librarian.interfaces import ILibraryFileAliasWithParent
53 from lp.services.messages.interfaces.message import IMessage
54+from lp.services.messages.interfaces.messagerevision import IMessageRevision
55 from lp.services.oauth.interfaces import (
56 IOAuthAccessToken,
57 IOAuthRequestToken,
58@@ -3182,6 +3183,24 @@ class SetMessageVisibility(AuthorizationBase):
59 return (user.in_admin or user.in_registry_experts)
60
61
62+class EditMessage(AuthorizationBase):
63+ permission = 'launchpad.Edit'
64+ usedfor = IMessage
65+
66+ def checkAuthenticated(self, user):
67+ """Only message owner can edit it."""
68+ return user.isOwner(self.obj)
69+
70+
71+class EditMessageRevision(DelegatedAuthorization):
72+ permission = 'launchpad.Edit'
73+ usedfor = IMessageRevision
74+
75+ def __init__(self, obj):
76+ super(EditMessageRevision, self).__init__(
77+ obj, obj.message, 'launchpad.Edit')
78+
79+
80 class ViewPublisherConfig(AdminByAdminsTeam):
81 usedfor = IPublisherConfig
82
83diff --git a/lib/lp/services/messages/configure.zcml b/lib/lp/services/messages/configure.zcml
84index e989fe2..19cb3c3 100644
85--- a/lib/lp/services/messages/configure.zcml
86+++ b/lib/lp/services/messages/configure.zcml
87@@ -1,4 +1,4 @@
88-<!-- Copyright 2009-2011 Canonical Ltd. This software is licensed under the
89+<!-- Copyright 2009-2021 Canonical Ltd. This software is licensed under the
90 GNU Affero General Public License version 3 (see the file LICENSE).
91 -->
92
93@@ -10,9 +10,9 @@
94 i18n_domain="launchpad">
95
96 <!-- Message -->
97- <class
98- class="lp.services.messages.model.message.Message">
99- <allow interface="lp.services.messages.interfaces.message.IMessage" />
100+ <class class="lp.services.messages.model.message.Message">
101+ <allow
102+ interface="lp.services.messages.interfaces.message.IMessageView" />
103 <!-- setVisible is required to allow IBug.setCommentVisibility() to
104 change the visibility attribute whilst still ensuring restricted
105 access to the attribute via the API.-->
106@@ -22,6 +22,26 @@
107 <require
108 permission="launchpad.Admin"
109 set_attributes="visible"/>
110+ <require
111+ permission="launchpad.Edit"
112+ interface="lp.services.messages.interfaces.message.IMessageEdit" />
113+ </class>
114+
115+ <!-- MessageRevision -->
116+ <class
117+ class="lp.services.messages.model.messagerevision.MessageRevision">
118+ <allow
119+ interface="lp.services.messages.interfaces.messagerevision.IMessageRevisionView"/>
120+ <require
121+ permission="launchpad.Edit"
122+ interface="lp.services.messages.interfaces.messagerevision.IMessageRevisionEdit"/>
123+ </class>
124+
125+ <!-- MessageChunk -->
126+ <class
127+ class="lp.services.messages.model.messagerevision.MessageRevisionChunk">
128+ <allow
129+ interface="lp.services.messages.interfaces.messagerevision.IMessageRevisionChunk"/>
130 </class>
131
132 <class class="lp.services.messages.interfaces.message.IndexedMessage">
133diff --git a/lib/lp/services/messages/interfaces/message.py b/lib/lp/services/messages/interfaces/message.py
134index 7e18e7d..70d50b7 100644
135--- a/lib/lp/services/messages/interfaces/message.py
136+++ b/lib/lp/services/messages/interfaces/message.py
137@@ -1,4 +1,4 @@
138-# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
139+# Copyright 2009-2021 Canonical Ltd. This software is licensed under the
140 # GNU Affero General Public License version 3 (see the file LICENSE).
141
142 __metaclass__ = type
143@@ -49,9 +49,19 @@ from lp.services.librarian.interfaces import ILibraryFileAlias
144 from lp.services.webservice.apihelpers import patch_reference_property
145
146
147-@exported_as_webservice_entry('message')
148-class IMessage(Interface):
149- """A message.
150+class IMessageEdit(Interface):
151+
152+ def editContent(new_content):
153+ """Edit the content of this message, generating a new message
154+ revision with the old content.
155+ """
156+
157+ def deleteContent():
158+ """Deletes this message content."""
159+
160+
161+class IMessageView(Interface):
162+ """Public attributes for message.
163
164 This is like an email (RFC822) message, though it could be created through
165 the web as well.
166@@ -61,6 +71,15 @@ class IMessage(Interface):
167 datecreated = exported(
168 Datetime(title=_('Date Created'), required=True, readonly=True),
169 exported_as='date_created')
170+
171+ date_last_edited = Datetime(
172+ title=_('When this message was last edited'), required=False,
173+ readonly=True)
174+
175+ date_deleted = Datetime(
176+ title=_('When this message was deleted'), required=False,
177+ readonly=True)
178+
179 subject = exported(
180 TextLine(title=_('Subject'), required=True, readonly=True))
181
182@@ -87,6 +106,8 @@ class IMessage(Interface):
183
184 chunks = Attribute(_('Message pieces'))
185
186+ revisions = Attribute(_('Message revision history'))
187+
188 text_contents = exported(
189 Text(title=_('All the text/plain chunks joined together as a '
190 'unicode string.')),
191@@ -115,6 +136,11 @@ class IMessage(Interface):
192 """Return None because messages are not threaded over the API."""
193
194
195+@exported_as_webservice_entry('message')
196+class IMessage(IMessageEdit, IMessageView):
197+ """A Message."""
198+
199+
200 # Fix for self-referential schema.
201 patch_reference_property(IMessage, 'parent', IMessage)
202
203diff --git a/lib/lp/services/messages/interfaces/messagerevision.py b/lib/lp/services/messages/interfaces/messagerevision.py
204new file mode 100644
205index 0000000..49d3b9f
206--- /dev/null
207+++ b/lib/lp/services/messages/interfaces/messagerevision.py
208@@ -0,0 +1,69 @@
209+# Copyright 2019-2021 Canonical Ltd. This software is licensed under the
210+# GNU Affero General Public License version 3 (see the file LICENSE).
211+
212+"""Message revision history."""
213+
214+from __future__ import absolute_import, print_function, unicode_literals
215+
216+__all__ = [
217+ 'IMessageRevision',
218+ 'IMessageRevisionChunk',
219+ ]
220+
221+from lazr.restful.fields import Reference
222+from zope.interface import (
223+ Attribute,
224+ Interface,
225+ )
226+from zope.schema import (
227+ Datetime,
228+ Int,
229+ Text,
230+ )
231+
232+from lp import _
233+from lp.services.messages.interfaces.message import IMessage
234+
235+
236+class IMessageRevisionView(Interface):
237+ """IMessageRevision readable attributes."""
238+ id = Int(title=_("ID"), required=True, readonly=True)
239+
240+ revision = Int(title=_("Revision number"), required=True, readonly=True)
241+
242+ content = Text(
243+ title=_("The message at the given revision"),
244+ required=True, readonly=True)
245+
246+ message = Reference(
247+ title=_('The current message of this revision.'),
248+ schema=IMessage, required=True, readonly=True)
249+
250+ date_created = Datetime(
251+ title=_("The time when this message revision was created."),
252+ required=True, readonly=True)
253+
254+ date_deleted = Datetime(
255+ title=_("The time when this message revision was created."),
256+ required=False, readonly=True)
257+
258+ chunks = Attribute(_('Message pieces'))
259+
260+
261+class IMessageRevisionEdit(Interface):
262+ """IMessageRevision editable attributes."""
263+
264+ def deleteContent():
265+ """Deletes this MessageRevision content."""
266+
267+
268+class IMessageRevision(IMessageRevisionView, IMessageRevisionEdit):
269+ """A historical revision of a IMessage."""
270+
271+
272+class IMessageRevisionChunk(Interface):
273+ id = Int(title=_('ID'), required=True, readonly=True)
274+ messagerevision = Int(
275+ title=_('MessageRevision'), required=True, readonly=True)
276+ sequence = Int(title=_('Sequence order'), required=True, readonly=True)
277+ content = Text(title=_('Text content'), required=True, readonly=True)
278diff --git a/lib/lp/services/messages/model/message.py b/lib/lp/services/messages/model/message.py
279index e592daa..be685d6 100644
280--- a/lib/lp/services/messages/model/message.py
281+++ b/lib/lp/services/messages/model/message.py
282@@ -1,4 +1,4 @@
283-# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
284+# Copyright 2009-2021 Canonical Ltd. This software is licensed under the
285 # GNU Affero General Public License version 3 (see the file LICENSE).
286
287 __metaclass__ = type
288@@ -40,7 +40,9 @@ from sqlobject import (
289 from storm.locals import (
290 And,
291 DateTime,
292+ Desc,
293 Int,
294+ Max,
295 Reference,
296 Store,
297 Storm,
298@@ -71,7 +73,14 @@ from lp.services.messages.interfaces.message import (
299 IUserToUserEmail,
300 UnknownSender,
301 )
302-from lp.services.propertycache import cachedproperty
303+from lp.services.messages.model.messagerevision import (
304+ MessageRevision,
305+ MessageRevisionChunk,
306+ )
307+from lp.services.propertycache import (
308+ cachedproperty,
309+ get_property_cache,
310+ )
311
312
313 def utcdatetime_from_field(field_value):
314@@ -100,6 +109,8 @@ class Message(SQLBase):
315 _table = 'Message'
316 _defaultOrder = '-id'
317 datecreated = UtcDateTimeCol(notNull=True, default=UTC_NOW)
318+ date_deleted = UtcDateTimeCol(notNull=False, default=None)
319+ date_last_edited = UtcDateTimeCol(notNull=False, default=None)
320 subject = StringCol(notNull=False, default=None)
321 owner = ForeignKey(
322 dbName='owner', foreignKey='Person',
323@@ -164,6 +175,82 @@ class Message(SQLBase):
324 """See `IMessage`."""
325 return None
326
327+ @cachedproperty
328+ def revisions(self):
329+ """See `IMessage`."""
330+ return list(Store.of(self).find(
331+ MessageRevision,
332+ MessageRevision.message == self
333+ ).order_by(Desc(MessageRevision.revision)))
334+
335+ def editContent(self, new_content):
336+ """See `IMessage`."""
337+ store = Store.of(self)
338+
339+ # Move the old content to a new revision.
340+ date_created = (
341+ self.date_last_edited if self.date_last_edited is not None
342+ else self.datecreated)
343+ current_rev_num = store.find(
344+ Max(MessageRevision.revision),
345+ MessageRevision.message == self).one()
346+ rev_num = (current_rev_num or 0) + 1
347+ rev = MessageRevision(
348+ message=self, revision=rev_num, date_created=date_created)
349+ self.date_last_edited = UTC_NOW
350+ store.add(rev)
351+
352+ # Move the current text content to the recently created revision.
353+ used_seq_numbers = set()
354+ for chunk in self._chunks:
355+ if chunk.blob is None:
356+ revision_chunk = MessageRevisionChunk(
357+ rev, chunk.sequence, chunk.content)
358+ store.add(revision_chunk)
359+ store.remove(chunk)
360+ else:
361+ used_seq_numbers.add(chunk.sequence)
362+
363+ # Spot sequence number gaps.
364+ # If there is a gap in sequence numbers, use it. Otherwise, use the
365+ # max sequence number + 1.
366+ min_gap = None
367+ for i in range(1, len(used_seq_numbers) + 1):
368+ if i not in used_seq_numbers:
369+ min_gap = i
370+ break
371+ if min_gap is None:
372+ new_seq = max(used_seq_numbers) + 1 if len(used_seq_numbers) else 1
373+ else:
374+ new_seq = min_gap
375+
376+ # Create the new content.
377+ new_chunk = MessageChunk(
378+ message=self, sequence=new_seq, content=new_content)
379+ store.add(new_chunk)
380+
381+ store.flush()
382+
383+ # Clean up caches.
384+ del get_property_cache(self).text_contents
385+ del get_property_cache(self).chunks
386+ del get_property_cache(self).revisions
387+ return rev
388+
389+ def deleteContent(self):
390+ """See `IMessage`."""
391+ store = Store.of(self)
392+ store.find(MessageChunk, MessageChunk.message == self).remove()
393+ revs = [i.id for i in self.revisions]
394+ store.find(
395+ MessageRevisionChunk,
396+ MessageRevisionChunk.message_revision_id.is_in(revs)).remove()
397+ store.find(MessageRevision, MessageRevision.message == self).remove()
398+ del get_property_cache(self).text_contents
399+ del get_property_cache(self).chunks
400+ del get_property_cache(self).revisions
401+ self.date_deleted = UTC_NOW
402+
403
404 def get_parent_msgids(parsed_message):
405 """Returns a list of message ids the mail was a reply to.
406diff --git a/lib/lp/services/messages/model/messagerevision.py b/lib/lp/services/messages/model/messagerevision.py
407new file mode 100644
408index 0000000..f4a1466
409--- /dev/null
410+++ b/lib/lp/services/messages/model/messagerevision.py
411@@ -0,0 +1,92 @@
412+# Copyright 2019-2021 Canonical Ltd. This software is licensed under the
413+# GNU Affero General Public License version 3 (see the file LICENSE).
414+
415+"""Message revision history."""
416+
417+from __future__ import absolute_import, print_function, unicode_literals
418+
419+__metaclass__ = type
420+__all__ = [
421+ 'MessageRevision',
422+ 'MessageRevisionChunk',
423+ ]
424+
425+import pytz
426+from storm.locals import (
427+ DateTime,
428+ Int,
429+ Reference,
430+ Unicode,
431+ )
432+from zope.interface import implementer
433+
434+from lp.services.database.constants import UTC_NOW
435+from lp.services.database.interfaces import IStore
436+from lp.services.database.stormbase import StormBase
437+from lp.services.messages.interfaces.messagerevision import (
438+ IMessageRevision,
439+ IMessageRevisionChunk,
440+ )
441+from lp.services.propertycache import (
442+ cachedproperty,
443+ get_property_cache,
444+ )
445+
446+
447+@implementer(IMessageRevision)
448+class MessageRevision(StormBase):
449+ """A historical revision of a IMessage."""
450+
451+ __storm_table__ = 'MessageRevision'
452+
453+ id = Int(primary=True)
454+
455+ message_id = Int(name='message', allow_none=False)
456+ message = Reference(message_id, 'Message.id')
457+
458+ revision = Int(name='revision', allow_none=False)
459+
460+ date_created = DateTime(
461+ name="date_created", tzinfo=pytz.UTC, allow_none=False)
462+ date_deleted = DateTime(
463+ name="date_deleted", tzinfo=pytz.UTC, allow_none=True)
464+
465+ def __init__(self, message, revision, date_created, date_deleted=None):
466+ self.message = message
467+ self.revision = revision
468+ self.date_created = date_created
469+ self.date_deleted = date_deleted
470+
471+ @cachedproperty
472+ def chunks(self):
473+ return list(IStore(self).find(
474+ MessageRevisionChunk, message_revision=self))
475+
476+ @property
477+ def content(self):
478+ return '\n\n'.join(i.content for i in self.chunks)
479+
480+ def deleteContent(self):
481+ store = IStore(self)
482+ store.find(MessageRevisionChunk, message_revision=self).remove()
483+ self.date_deleted = UTC_NOW
484+ del get_property_cache(self).chunks
485+
486+
487+@implementer(IMessageRevisionChunk)
488+class MessageRevisionChunk(StormBase):
489+ __storm_table__ = 'MessageRevisionChunk'
490+
491+ id = Int(primary=True)
492+
493+ message_revision_id = Int(name='messagerevision', allow_none=False)
494+ message_revision = Reference(message_revision_id, 'MessageRevision.id')
495+
496+ sequence = Int(name='sequence', allow_none=False)
497+
498+ content = Unicode(name="content", allow_none=False)
499+
500+ def __init__(self, message_revision, sequence, content):
501+ self.message_revision = message_revision
502+ self.sequence = sequence
503+ self.content = content
504diff --git a/lib/lp/services/messages/tests/test_message.py b/lib/lp/services/messages/tests/test_message.py
505index a9e1042..9c2bbc7 100644
506--- a/lib/lp/services/messages/tests/test_message.py
507+++ b/lib/lp/services/messages/tests/test_message.py
508@@ -1,4 +1,4 @@
509-# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
510+# Copyright 2009-2021 Canonical Ltd. This software is licensed under the
511 # GNU Affero General Public License version 3 (see the file LICENSE).
512
513 __metaclass__ = type
514@@ -13,15 +13,31 @@ from email.utils import (
515 )
516
517 import six
518+from testtools.matchers import (
519+ Equals,
520+ Is,
521+ MatchesStructure,
522+ )
523 import transaction
524+from zope.security.interfaces import Unauthorized
525+from zope.security.proxy import ProxyFactory
526
527 from lp.services.compat import message_as_bytes
528-from lp.services.messages.model.message import MessageSet
529+from lp.services.database.interfaces import IStore
530+from lp.services.database.sqlbase import get_transaction_timestamp
531+from lp.services.messages.model.message import (
532+ MessageChunk,
533+ MessageSet,
534+ )
535 from lp.testing import (
536 login,
537+ person_logged_in,
538 TestCaseWithFactory,
539 )
540-from lp.testing.layers import LaunchpadFunctionalLayer
541+from lp.testing.layers import (
542+ DatabaseFunctionalLayer,
543+ LaunchpadFunctionalLayer,
544+ )
545
546
547 class TestMessageSet(TestCaseWithFactory):
548@@ -169,3 +185,125 @@ class TestMessageSet(TestCaseWithFactory):
549 'Treating unknown encoding "booga" as latin-1.'):
550 result = MessageSet.decode(self.high_characters, 'booga')
551 self.assertEqual(self.high_characters.decode('latin-1'), result)
552+
553+
554+class TestMessageEditing(TestCaseWithFactory):
555+ """Test editing scenarios for Message objects."""
556+
557+ layer = DatabaseFunctionalLayer
558+
559+ def makeMessage(self, owner=None, content=None):
560+ if owner is None:
561+ owner = self.factory.makePerson()
562+ msg = self.factory.makeMessage(owner=owner, content=content)
563+ return ProxyFactory(msg)
564+
565+ def test_non_owner_cannot_edit_message(self):
566+ msg = self.makeMessage()
567+ someone_else = self.factory.makePerson()
568+ with person_logged_in(someone_else):
569+ self.assertRaises(Unauthorized, getattr, msg, "editContent")
570+
571+ def test_msg_owner_can_edit(self):
572+ owner = self.factory.makePerson()
573+ msg = self.makeMessage(owner=owner, content="initial content")
574+ with person_logged_in(owner):
575+ msg.editContent("This is the new content")
576+ self.assertEqual("This is the new content", msg.text_contents)
577+ self.assertEqual(1, len(msg.revisions))
578+ self.assertThat(msg.revisions[0], MatchesStructure(
579+ content=Equals("initial content"),
580+ revision=Equals(1),
581+ message=Equals(msg),
582+ date_created=Equals(msg.datecreated),
583+ date_deleted=Is(None)))
584+
585+ def test_multiple_edits_revisions(self):
586+ owner = self.factory.makePerson()
587+ msg = self.makeMessage(owner=owner, content="initial content")
588+ with person_logged_in(owner):
589+ msg.editContent("first edit")
590+ first_edit_date = msg.date_last_edited
591+ self.assertEqual("first edit", msg.text_contents)
592+ self.assertEqual(1, len(msg.revisions))
593+ self.assertThat(msg.revisions[0], MatchesStructure(
594+ content=Equals("initial content"),
595+ revision=Equals(1),
596+ message=Equals(msg),
597+ date_created=Equals(msg.datecreated),
598+ date_deleted=Is(None)))
599+
600+ with person_logged_in(owner):
601+ msg.editContent("final form")
602+ self.assertEqual("final form", msg.text_contents)
603+ self.assertEqual(2, len(msg.revisions))
604+ self.assertThat(msg.revisions[0], MatchesStructure(
605+ content=Equals("first edit"),
606+ revision=Equals(2),
607+ message=Equals(msg),
608+ date_created=Equals(first_edit_date),
609+ date_deleted=Is(None)))
610+ self.assertThat(msg.revisions[1], MatchesStructure(
611+ content=Equals("initial content"),
612+ revision=Equals(1),
613+ message=Equals(msg),
614+ date_created=Equals(msg.datecreated),
615+ date_deleted=Is(None)))
616+
617+ def test_edit_message_with_blobs(self):
618+ # Messages with blobs should keep the blobs untouched when the
619+ # content is edited.
620+ owner = self.factory.makePerson()
621+ msg = self.makeMessage(owner=owner, content="initial content")
622+ files = [self.factory.makeLibraryFileAlias(db_only=True)
623+ for _ in range(2)]
624+ store = IStore(msg)
625+ for seq, blob in enumerate(files):
626+ store.add(MessageChunk(message=msg, sequence=seq + 2, blob=blob))
627+
628+ with person_logged_in(owner):
629+ msg.editContent("final form")
630+ self.assertThat(msg.revisions[0], MatchesStructure(
631+ content=Equals("initial content"),
632+ revision=Equals(1),
633+ message=Equals(msg),
634+ date_created=Equals(msg.datecreated),
635+ date_deleted=Is(None)))
636+
637+ # Check that current message chunks are 3: the 2 old blobs, and the
638+ # new text message.
639+ self.assertEqual(3, len(msg.chunks))
640+ # Make sure we avoid gaps in sequence.
641+ self.assertEqual([1, 2, 3], sorted([i.sequence for i in msg.chunks]))
642+ self.assertThat(msg.chunks[0], MatchesStructure(
643+ content=Equals("final form"),
644+ sequence=Equals(1),
645+ ))
646+ self.assertEqual(files, [i.blob for i in msg.chunks[1:]])
647+
648+ # Check revision chunks. It should be the old text message.
649+ rev_chunks = msg.revisions[0].chunks
650+ self.assertEqual(1, len(rev_chunks))
651+ self.assertThat(rev_chunks[0], MatchesStructure(
652+ sequence=Equals(1),
653+ content=Equals("initial content")))
654+
655+ def test_non_owner_cannot_delete_message(self):
656+ owner = self.factory.makePerson()
657+ msg = self.makeMessage(owner=owner, content="initial content")
658+ someone_else = self.factory.makePerson()
659+ with person_logged_in(someone_else):
660+ self.assertRaises(Unauthorized, getattr, msg, "deleteContent")
661+
662+ def test_delete_message(self):
663+ owner = self.factory.makePerson()
664+ msg = self.makeMessage(owner=owner, content="initial content")
665+ with person_logged_in(owner):
666+ msg.editContent("new content")
667+ with person_logged_in(owner):
668+ msg.deleteContent()
669+ self.assertEqual('', msg.text_contents)
670+ self.assertEqual(0, len(msg.chunks))
671+ self.assertEqual(
672+ get_transaction_timestamp(IStore(msg)), msg.date_deleted)
673+ self.assertEqual(0, len(msg.revisions))
674diff --git a/lib/lp/services/messages/tests/test_messagerevision.py b/lib/lp/services/messages/tests/test_messagerevision.py
675new file mode 100644
676index 0000000..3b411c9
677--- /dev/null
678+++ b/lib/lp/services/messages/tests/test_messagerevision.py
679@@ -0,0 +1,50 @@
680+# Copyright 2009-2021 Canonical Ltd. This software is licensed under the
681+# GNU Affero General Public License version 3 (see the file LICENSE).
682+
683+__metaclass__ = type
684+
685+from lp.services.database.interfaces import IStore
686+from lp.services.database.sqlbase import get_transaction_timestamp
687+from zope.security.interfaces import Unauthorized
688+from zope.security.proxy import ProxyFactory
689+
690+from lp.testing import (
691+ person_logged_in,
692+ TestCaseWithFactory,
693+ )
694+from lp.testing.layers import (
695+ DatabaseFunctionalLayer,
696+ )
697+
698+
699+class TestMessageRevision(TestCaseWithFactory):
700+ """Test scenarios for MessageRevision objects."""
701+
702+ layer = DatabaseFunctionalLayer
703+
704+ def makeMessage(self):
705+ msg = self.factory.makeMessage()
706+ return ProxyFactory(msg)
707+
708+ def makeMessageRevision(self):
709+ msg = self.makeMessage()
710+ with person_logged_in(msg.owner):
711+ msg.editContent('something edited #%s' % len(msg.revisions))
712+ return msg.revisions[-1]
713+
714+ def test_non_owner_cannot_delete_message_revision_content(self):
715+ rev = self.makeMessageRevision()
716+ someone_else = self.factory.makePerson()
717+ with person_logged_in(someone_else):
718+ self.assertRaises(Unauthorized, getattr, rev, "deleteContent")
719+
720+ def test_msg_owner_can_delete_message_revision_content(self):
721+ rev = self.makeMessageRevision()
722+ msg = rev.message
723+ with person_logged_in(rev.message.owner):
724+ rev.deleteContent()
725+ self.assertEqual(1, len(msg.revisions))
726+ self.assertEqual("", rev.content)
727+ self.assertEqual(0, len(rev.chunks))
728+ self.assertEqual(
729+ get_transaction_timestamp(IStore(rev)), rev.date_deleted)