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 (community) 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
diff --git a/database/schema/security.cfg b/database/schema/security.cfg
index b13d4a8..8225ec9 100644
--- a/database/schema/security.cfg
+++ b/database/schema/security.cfg
@@ -232,7 +232,9 @@ public.logintoken = SELECT, INSERT, UPDATE, DELETE
232public.mailinglist = SELECT, INSERT, UPDATE, DELETE232public.mailinglist = SELECT, INSERT, UPDATE, DELETE
233public.mailinglistsubscription = SELECT, INSERT, UPDATE, DELETE233public.mailinglistsubscription = SELECT, INSERT, UPDATE, DELETE
234public.messageapproval = SELECT, INSERT, UPDATE, DELETE234public.messageapproval = SELECT, INSERT, UPDATE, DELETE
235public.messagechunk = SELECT, INSERT235public.messagechunk = SELECT, INSERT, DELETE
236public.messagerevision = SELECT, INSERT, UPDATE, DELETE
237public.messagerevisionchunk = SELECT, INSERT, DELETE
236public.milestone = SELECT, INSERT, UPDATE, DELETE238public.milestone = SELECT, INSERT, UPDATE, DELETE
237public.milestonetag = SELECT, INSERT, UPDATE, DELETE239public.milestonetag = SELECT, INSERT, UPDATE, DELETE
238public.mirrorcdimagedistroseries = SELECT, INSERT, DELETE240public.mirrorcdimagedistroseries = SELECT, INSERT, DELETE
diff --git a/lib/lp/bugs/browser/tests/test_bugcomment.py b/lib/lp/bugs/browser/tests/test_bugcomment.py
index c1877a5..e5baac1 100644
--- a/lib/lp/bugs/browser/tests/test_bugcomment.py
+++ b/lib/lp/bugs/browser/tests/test_bugcomment.py
@@ -1,4 +1,4 @@
1# Copyright 2010-2018 Canonical Ltd. This software is licensed under the1# Copyright 2010-2021 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Tests for the bugcomment module."""4"""Tests for the bugcomment module."""
@@ -35,6 +35,7 @@ from lp.testing import (
35 BrowserTestCase,35 BrowserTestCase,
36 celebrity_logged_in,36 celebrity_logged_in,
37 login_person,37 login_person,
38 person_logged_in,
38 TestCase,39 TestCase,
39 TestCaseWithFactory,40 TestCaseWithFactory,
40 verifyObject,41 verifyObject,
@@ -300,7 +301,10 @@ class TestBugCommentImplementsInterface(TestCaseWithFactory):
300 bug_message = self.factory.makeBugComment()301 bug_message = self.factory.makeBugComment()
301 bugtask = bug_message.bugs[0].bugtasks[0]302 bugtask = bug_message.bugs[0].bugtasks[0]
302 bug_comment = BugComment(1, bug_message, bugtask)303 bug_comment = BugComment(1, bug_message, bugtask)
303 verifyObject(IBugComment, bug_comment)304 # Runs verifyObject logged in as the bug owner, so we don't fail on
305 # attributes that are not public to everyone.
306 with person_logged_in(bug_message.owner):
307 verifyObject(IBugComment, bug_comment)
304308
305 def test_download_url(self):309 def test_download_url(self):
306 """download_url is provided and works as expected."""310 """download_url is provided and works as expected."""
diff --git a/lib/lp/security.py b/lib/lp/security.py
index 412f497..1eeca4e 100644
--- a/lib/lp/security.py
+++ b/lib/lp/security.py
@@ -187,6 +187,7 @@ from lp.services.identity.interfaces.account import IAccount
187from lp.services.identity.interfaces.emailaddress import IEmailAddress187from lp.services.identity.interfaces.emailaddress import IEmailAddress
188from lp.services.librarian.interfaces import ILibraryFileAliasWithParent188from lp.services.librarian.interfaces import ILibraryFileAliasWithParent
189from lp.services.messages.interfaces.message import IMessage189from lp.services.messages.interfaces.message import IMessage
190from lp.services.messages.interfaces.messagerevision import IMessageRevision
190from lp.services.oauth.interfaces import (191from lp.services.oauth.interfaces import (
191 IOAuthAccessToken,192 IOAuthAccessToken,
192 IOAuthRequestToken,193 IOAuthRequestToken,
@@ -3182,6 +3183,24 @@ class SetMessageVisibility(AuthorizationBase):
3182 return (user.in_admin or user.in_registry_experts)3183 return (user.in_admin or user.in_registry_experts)
31833184
31843185
3186class EditMessage(AuthorizationBase):
3187 permission = 'launchpad.Edit'
3188 usedfor = IMessage
3189
3190 def checkAuthenticated(self, user):
3191 """Only message owner can edit it."""
3192 return user.isOwner(self.obj)
3193
3194
3195class EditMessageRevision(DelegatedAuthorization):
3196 permission = 'launchpad.Edit'
3197 usedfor = IMessageRevision
3198
3199 def __init__(self, obj):
3200 super(EditMessageRevision, self).__init__(
3201 obj, obj.message, 'launchpad.Edit')
3202
3203
3185class ViewPublisherConfig(AdminByAdminsTeam):3204class ViewPublisherConfig(AdminByAdminsTeam):
3186 usedfor = IPublisherConfig3205 usedfor = IPublisherConfig
31873206
diff --git a/lib/lp/services/messages/configure.zcml b/lib/lp/services/messages/configure.zcml
index e989fe2..19cb3c3 100644
--- a/lib/lp/services/messages/configure.zcml
+++ b/lib/lp/services/messages/configure.zcml
@@ -1,4 +1,4 @@
1<!-- Copyright 2009-2011 Canonical Ltd. This software is licensed under the1<!-- Copyright 2009-2021 Canonical Ltd. This software is licensed under the
2 GNU Affero General Public License version 3 (see the file LICENSE).2 GNU Affero General Public License version 3 (see the file LICENSE).
3-->3-->
44
@@ -10,9 +10,9 @@
10 i18n_domain="launchpad">10 i18n_domain="launchpad">
1111
12 <!-- Message -->12 <!-- Message -->
13 <class13 <class class="lp.services.messages.model.message.Message">
14 class="lp.services.messages.model.message.Message">14 <allow
15 <allow interface="lp.services.messages.interfaces.message.IMessage" />15 interface="lp.services.messages.interfaces.message.IMessageView" />
16 <!-- setVisible is required to allow IBug.setCommentVisibility() to16 <!-- setVisible is required to allow IBug.setCommentVisibility() to
17 change the visibility attribute whilst still ensuring restricted17 change the visibility attribute whilst still ensuring restricted
18 access to the attribute via the API.-->18 access to the attribute via the API.-->
@@ -22,6 +22,26 @@
22 <require22 <require
23 permission="launchpad.Admin"23 permission="launchpad.Admin"
24 set_attributes="visible"/>24 set_attributes="visible"/>
25 <require
26 permission="launchpad.Edit"
27 interface="lp.services.messages.interfaces.message.IMessageEdit" />
28 </class>
29
30 <!-- MessageRevision -->
31 <class
32 class="lp.services.messages.model.messagerevision.MessageRevision">
33 <allow
34 interface="lp.services.messages.interfaces.messagerevision.IMessageRevisionView"/>
35 <require
36 permission="launchpad.Edit"
37 interface="lp.services.messages.interfaces.messagerevision.IMessageRevisionEdit"/>
38 </class>
39
40 <!-- MessageChunk -->
41 <class
42 class="lp.services.messages.model.messagerevision.MessageRevisionChunk">
43 <allow
44 interface="lp.services.messages.interfaces.messagerevision.IMessageRevisionChunk"/>
25 </class>45 </class>
2646
27 <class class="lp.services.messages.interfaces.message.IndexedMessage">47 <class class="lp.services.messages.interfaces.message.IndexedMessage">
diff --git a/lib/lp/services/messages/interfaces/message.py b/lib/lp/services/messages/interfaces/message.py
index 7e18e7d..70d50b7 100644
--- a/lib/lp/services/messages/interfaces/message.py
+++ b/lib/lp/services/messages/interfaces/message.py
@@ -1,4 +1,4 @@
1# Copyright 2009-2018 Canonical Ltd. This software is licensed under the1# Copyright 2009-2021 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4__metaclass__ = type4__metaclass__ = type
@@ -49,9 +49,19 @@ from lp.services.librarian.interfaces import ILibraryFileAlias
49from lp.services.webservice.apihelpers import patch_reference_property49from lp.services.webservice.apihelpers import patch_reference_property
5050
5151
52@exported_as_webservice_entry('message')52class IMessageEdit(Interface):
53class IMessage(Interface):53
54 """A message.54 def editContent(new_content):
55 """Edit the content of this message, generating a new message
56 revision with the old content.
57 """
58
59 def deleteContent():
60 """Deletes this message content."""
61
62
63class IMessageView(Interface):
64 """Public attributes for message.
5565
56 This is like an email (RFC822) message, though it could be created through66 This is like an email (RFC822) message, though it could be created through
57 the web as well.67 the web as well.
@@ -61,6 +71,15 @@ class IMessage(Interface):
61 datecreated = exported(71 datecreated = exported(
62 Datetime(title=_('Date Created'), required=True, readonly=True),72 Datetime(title=_('Date Created'), required=True, readonly=True),
63 exported_as='date_created')73 exported_as='date_created')
74
75 date_last_edited = Datetime(
76 title=_('When this message was last edited'), required=False,
77 readonly=True)
78
79 date_deleted = Datetime(
80 title=_('When this message was deleted'), required=False,
81 readonly=True)
82
64 subject = exported(83 subject = exported(
65 TextLine(title=_('Subject'), required=True, readonly=True))84 TextLine(title=_('Subject'), required=True, readonly=True))
6685
@@ -87,6 +106,8 @@ class IMessage(Interface):
87106
88 chunks = Attribute(_('Message pieces'))107 chunks = Attribute(_('Message pieces'))
89108
109 revisions = Attribute(_('Message revision history'))
110
90 text_contents = exported(111 text_contents = exported(
91 Text(title=_('All the text/plain chunks joined together as a '112 Text(title=_('All the text/plain chunks joined together as a '
92 'unicode string.')),113 'unicode string.')),
@@ -115,6 +136,11 @@ class IMessage(Interface):
115 """Return None because messages are not threaded over the API."""136 """Return None because messages are not threaded over the API."""
116137
117138
139@exported_as_webservice_entry('message')
140class IMessage(IMessageEdit, IMessageView):
141 """A Message."""
142
143
118# Fix for self-referential schema.144# Fix for self-referential schema.
119patch_reference_property(IMessage, 'parent', IMessage)145patch_reference_property(IMessage, 'parent', IMessage)
120146
diff --git a/lib/lp/services/messages/interfaces/messagerevision.py b/lib/lp/services/messages/interfaces/messagerevision.py
121new file mode 100644147new file mode 100644
index 0000000..49d3b9f
--- /dev/null
+++ b/lib/lp/services/messages/interfaces/messagerevision.py
@@ -0,0 +1,69 @@
1# Copyright 2019-2021 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Message revision history."""
5
6from __future__ import absolute_import, print_function, unicode_literals
7
8__all__ = [
9 'IMessageRevision',
10 'IMessageRevisionChunk',
11 ]
12
13from lazr.restful.fields import Reference
14from zope.interface import (
15 Attribute,
16 Interface,
17 )
18from zope.schema import (
19 Datetime,
20 Int,
21 Text,
22 )
23
24from lp import _
25from lp.services.messages.interfaces.message import IMessage
26
27
28class IMessageRevisionView(Interface):
29 """IMessageRevision readable attributes."""
30 id = Int(title=_("ID"), required=True, readonly=True)
31
32 revision = Int(title=_("Revision number"), required=True, readonly=True)
33
34 content = Text(
35 title=_("The message at the given revision"),
36 required=True, readonly=True)
37
38 message = Reference(
39 title=_('The current message of this revision.'),
40 schema=IMessage, required=True, readonly=True)
41
42 date_created = Datetime(
43 title=_("The time when this message revision was created."),
44 required=True, readonly=True)
45
46 date_deleted = Datetime(
47 title=_("The time when this message revision was created."),
48 required=False, readonly=True)
49
50 chunks = Attribute(_('Message pieces'))
51
52
53class IMessageRevisionEdit(Interface):
54 """IMessageRevision editable attributes."""
55
56 def deleteContent():
57 """Deletes this MessageRevision content."""
58
59
60class IMessageRevision(IMessageRevisionView, IMessageRevisionEdit):
61 """A historical revision of a IMessage."""
62
63
64class IMessageRevisionChunk(Interface):
65 id = Int(title=_('ID'), required=True, readonly=True)
66 messagerevision = Int(
67 title=_('MessageRevision'), required=True, readonly=True)
68 sequence = Int(title=_('Sequence order'), required=True, readonly=True)
69 content = Text(title=_('Text content'), required=True, readonly=True)
diff --git a/lib/lp/services/messages/model/message.py b/lib/lp/services/messages/model/message.py
index e592daa..be685d6 100644
--- a/lib/lp/services/messages/model/message.py
+++ b/lib/lp/services/messages/model/message.py
@@ -1,4 +1,4 @@
1# Copyright 2009-2020 Canonical Ltd. This software is licensed under the1# Copyright 2009-2021 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4__metaclass__ = type4__metaclass__ = type
@@ -40,7 +40,9 @@ from sqlobject import (
40from storm.locals import (40from storm.locals import (
41 And,41 And,
42 DateTime,42 DateTime,
43 Desc,
43 Int,44 Int,
45 Max,
44 Reference,46 Reference,
45 Store,47 Store,
46 Storm,48 Storm,
@@ -71,7 +73,14 @@ from lp.services.messages.interfaces.message import (
71 IUserToUserEmail,73 IUserToUserEmail,
72 UnknownSender,74 UnknownSender,
73 )75 )
74from lp.services.propertycache import cachedproperty76from lp.services.messages.model.messagerevision import (
77 MessageRevision,
78 MessageRevisionChunk,
79 )
80from lp.services.propertycache import (
81 cachedproperty,
82 get_property_cache,
83 )
7584
7685
77def utcdatetime_from_field(field_value):86def utcdatetime_from_field(field_value):
@@ -100,6 +109,8 @@ class Message(SQLBase):
100 _table = 'Message'109 _table = 'Message'
101 _defaultOrder = '-id'110 _defaultOrder = '-id'
102 datecreated = UtcDateTimeCol(notNull=True, default=UTC_NOW)111 datecreated = UtcDateTimeCol(notNull=True, default=UTC_NOW)
112 date_deleted = UtcDateTimeCol(notNull=False, default=None)
113 date_last_edited = UtcDateTimeCol(notNull=False, default=None)
103 subject = StringCol(notNull=False, default=None)114 subject = StringCol(notNull=False, default=None)
104 owner = ForeignKey(115 owner = ForeignKey(
105 dbName='owner', foreignKey='Person',116 dbName='owner', foreignKey='Person',
@@ -164,6 +175,82 @@ class Message(SQLBase):
164 """See `IMessage`."""175 """See `IMessage`."""
165 return None176 return None
166177
178 @cachedproperty
179 def revisions(self):
180 """See `IMessage`."""
181 return list(Store.of(self).find(
182 MessageRevision,
183 MessageRevision.message == self
184 ).order_by(Desc(MessageRevision.revision)))
185
186 def editContent(self, new_content):
187 """See `IMessage`."""
188 store = Store.of(self)
189
190 # Move the old content to a new revision.
191 date_created = (
192 self.date_last_edited if self.date_last_edited is not None
193 else self.datecreated)
194 current_rev_num = store.find(
195 Max(MessageRevision.revision),
196 MessageRevision.message == self).one()
197 rev_num = (current_rev_num or 0) + 1
198 rev = MessageRevision(
199 message=self, revision=rev_num, date_created=date_created)
200 self.date_last_edited = UTC_NOW
201 store.add(rev)
202
203 # Move the current text content to the recently created revision.
204 used_seq_numbers = set()
205 for chunk in self._chunks:
206 if chunk.blob is None:
207 revision_chunk = MessageRevisionChunk(
208 rev, chunk.sequence, chunk.content)
209 store.add(revision_chunk)
210 store.remove(chunk)
211 else:
212 used_seq_numbers.add(chunk.sequence)
213
214 # Spot sequence number gaps.
215 # If there is a gap in sequence numbers, use it. Otherwise, use the
216 # max sequence number + 1.
217 min_gap = None
218 for i in range(1, len(used_seq_numbers) + 1):
219 if i not in used_seq_numbers:
220 min_gap = i
221 break
222 if min_gap is None:
223 new_seq = max(used_seq_numbers) + 1 if len(used_seq_numbers) else 1
224 else:
225 new_seq = min_gap
226
227 # Create the new content.
228 new_chunk = MessageChunk(
229 message=self, sequence=new_seq, content=new_content)
230 store.add(new_chunk)
231
232 store.flush()
233
234 # Clean up caches.
235 del get_property_cache(self).text_contents
236 del get_property_cache(self).chunks
237 del get_property_cache(self).revisions
238 return rev
239
240 def deleteContent(self):
241 """See `IMessage`."""
242 store = Store.of(self)
243 store.find(MessageChunk, MessageChunk.message == self).remove()
244 revs = [i.id for i in self.revisions]
245 store.find(
246 MessageRevisionChunk,
247 MessageRevisionChunk.message_revision_id.is_in(revs)).remove()
248 store.find(MessageRevision, MessageRevision.message == self).remove()
249 del get_property_cache(self).text_contents
250 del get_property_cache(self).chunks
251 del get_property_cache(self).revisions
252 self.date_deleted = UTC_NOW
253
167254
168def get_parent_msgids(parsed_message):255def get_parent_msgids(parsed_message):
169 """Returns a list of message ids the mail was a reply to.256 """Returns a list of message ids the mail was a reply to.
diff --git a/lib/lp/services/messages/model/messagerevision.py b/lib/lp/services/messages/model/messagerevision.py
170new file mode 100644257new file mode 100644
index 0000000..f4a1466
--- /dev/null
+++ b/lib/lp/services/messages/model/messagerevision.py
@@ -0,0 +1,92 @@
1# Copyright 2019-2021 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Message revision history."""
5
6from __future__ import absolute_import, print_function, unicode_literals
7
8__metaclass__ = type
9__all__ = [
10 'MessageRevision',
11 'MessageRevisionChunk',
12 ]
13
14import pytz
15from storm.locals import (
16 DateTime,
17 Int,
18 Reference,
19 Unicode,
20 )
21from zope.interface import implementer
22
23from lp.services.database.constants import UTC_NOW
24from lp.services.database.interfaces import IStore
25from lp.services.database.stormbase import StormBase
26from lp.services.messages.interfaces.messagerevision import (
27 IMessageRevision,
28 IMessageRevisionChunk,
29 )
30from lp.services.propertycache import (
31 cachedproperty,
32 get_property_cache,
33 )
34
35
36@implementer(IMessageRevision)
37class MessageRevision(StormBase):
38 """A historical revision of a IMessage."""
39
40 __storm_table__ = 'MessageRevision'
41
42 id = Int(primary=True)
43
44 message_id = Int(name='message', allow_none=False)
45 message = Reference(message_id, 'Message.id')
46
47 revision = Int(name='revision', allow_none=False)
48
49 date_created = DateTime(
50 name="date_created", tzinfo=pytz.UTC, allow_none=False)
51 date_deleted = DateTime(
52 name="date_deleted", tzinfo=pytz.UTC, allow_none=True)
53
54 def __init__(self, message, revision, date_created, date_deleted=None):
55 self.message = message
56 self.revision = revision
57 self.date_created = date_created
58 self.date_deleted = date_deleted
59
60 @cachedproperty
61 def chunks(self):
62 return list(IStore(self).find(
63 MessageRevisionChunk, message_revision=self))
64
65 @property
66 def content(self):
67 return '\n\n'.join(i.content for i in self.chunks)
68
69 def deleteContent(self):
70 store = IStore(self)
71 store.find(MessageRevisionChunk, message_revision=self).remove()
72 self.date_deleted = UTC_NOW
73 del get_property_cache(self).chunks
74
75
76@implementer(IMessageRevisionChunk)
77class MessageRevisionChunk(StormBase):
78 __storm_table__ = 'MessageRevisionChunk'
79
80 id = Int(primary=True)
81
82 message_revision_id = Int(name='messagerevision', allow_none=False)
83 message_revision = Reference(message_revision_id, 'MessageRevision.id')
84
85 sequence = Int(name='sequence', allow_none=False)
86
87 content = Unicode(name="content", allow_none=False)
88
89 def __init__(self, message_revision, sequence, content):
90 self.message_revision = message_revision
91 self.sequence = sequence
92 self.content = content
diff --git a/lib/lp/services/messages/tests/test_message.py b/lib/lp/services/messages/tests/test_message.py
index a9e1042..9c2bbc7 100644
--- a/lib/lp/services/messages/tests/test_message.py
+++ b/lib/lp/services/messages/tests/test_message.py
@@ -1,4 +1,4 @@
1# Copyright 2009-2018 Canonical Ltd. This software is licensed under the1# Copyright 2009-2021 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4__metaclass__ = type4__metaclass__ = type
@@ -13,15 +13,31 @@ from email.utils import (
13 )13 )
1414
15import six15import six
16from testtools.matchers import (
17 Equals,
18 Is,
19 MatchesStructure,
20 )
16import transaction21import transaction
22from zope.security.interfaces import Unauthorized
23from zope.security.proxy import ProxyFactory
1724
18from lp.services.compat import message_as_bytes25from lp.services.compat import message_as_bytes
19from lp.services.messages.model.message import MessageSet26from lp.services.database.interfaces import IStore
27from lp.services.database.sqlbase import get_transaction_timestamp
28from lp.services.messages.model.message import (
29 MessageChunk,
30 MessageSet,
31 )
20from lp.testing import (32from lp.testing import (
21 login,33 login,
34 person_logged_in,
22 TestCaseWithFactory,35 TestCaseWithFactory,
23 )36 )
24from lp.testing.layers import LaunchpadFunctionalLayer37from lp.testing.layers import (
38 DatabaseFunctionalLayer,
39 LaunchpadFunctionalLayer,
40 )
2541
2642
27class TestMessageSet(TestCaseWithFactory):43class TestMessageSet(TestCaseWithFactory):
@@ -169,3 +185,125 @@ class TestMessageSet(TestCaseWithFactory):
169 'Treating unknown encoding "booga" as latin-1.'):185 'Treating unknown encoding "booga" as latin-1.'):
170 result = MessageSet.decode(self.high_characters, 'booga')186 result = MessageSet.decode(self.high_characters, 'booga')
171 self.assertEqual(self.high_characters.decode('latin-1'), result)187 self.assertEqual(self.high_characters.decode('latin-1'), result)
188
189
190class TestMessageEditing(TestCaseWithFactory):
191 """Test editing scenarios for Message objects."""
192
193 layer = DatabaseFunctionalLayer
194
195 def makeMessage(self, owner=None, content=None):
196 if owner is None:
197 owner = self.factory.makePerson()
198 msg = self.factory.makeMessage(owner=owner, content=content)
199 return ProxyFactory(msg)
200
201 def test_non_owner_cannot_edit_message(self):
202 msg = self.makeMessage()
203 someone_else = self.factory.makePerson()
204 with person_logged_in(someone_else):
205 self.assertRaises(Unauthorized, getattr, msg, "editContent")
206
207 def test_msg_owner_can_edit(self):
208 owner = self.factory.makePerson()
209 msg = self.makeMessage(owner=owner, content="initial content")
210 with person_logged_in(owner):
211 msg.editContent("This is the new content")
212 self.assertEqual("This is the new content", msg.text_contents)
213 self.assertEqual(1, len(msg.revisions))
214 self.assertThat(msg.revisions[0], MatchesStructure(
215 content=Equals("initial content"),
216 revision=Equals(1),
217 message=Equals(msg),
218 date_created=Equals(msg.datecreated),
219 date_deleted=Is(None)))
220
221 def test_multiple_edits_revisions(self):
222 owner = self.factory.makePerson()
223 msg = self.makeMessage(owner=owner, content="initial content")
224 with person_logged_in(owner):
225 msg.editContent("first edit")
226 first_edit_date = msg.date_last_edited
227 self.assertEqual("first edit", msg.text_contents)
228 self.assertEqual(1, len(msg.revisions))
229 self.assertThat(msg.revisions[0], MatchesStructure(
230 content=Equals("initial content"),
231 revision=Equals(1),
232 message=Equals(msg),
233 date_created=Equals(msg.datecreated),
234 date_deleted=Is(None)))
235
236 with person_logged_in(owner):
237 msg.editContent("final form")
238 self.assertEqual("final form", msg.text_contents)
239 self.assertEqual(2, len(msg.revisions))
240 self.assertThat(msg.revisions[0], MatchesStructure(
241 content=Equals("first edit"),
242 revision=Equals(2),
243 message=Equals(msg),
244 date_created=Equals(first_edit_date),
245 date_deleted=Is(None)))
246 self.assertThat(msg.revisions[1], MatchesStructure(
247 content=Equals("initial content"),
248 revision=Equals(1),
249 message=Equals(msg),
250 date_created=Equals(msg.datecreated),
251 date_deleted=Is(None)))
252
253 def test_edit_message_with_blobs(self):
254 # Messages with blobs should keep the blobs untouched when the
255 # content is edited.
256 owner = self.factory.makePerson()
257 msg = self.makeMessage(owner=owner, content="initial content")
258 files = [self.factory.makeLibraryFileAlias(db_only=True)
259 for _ in range(2)]
260 store = IStore(msg)
261 for seq, blob in enumerate(files):
262 store.add(MessageChunk(message=msg, sequence=seq + 2, blob=blob))
263
264 with person_logged_in(owner):
265 msg.editContent("final form")
266 self.assertThat(msg.revisions[0], MatchesStructure(
267 content=Equals("initial content"),
268 revision=Equals(1),
269 message=Equals(msg),
270 date_created=Equals(msg.datecreated),
271 date_deleted=Is(None)))
272
273 # Check that current message chunks are 3: the 2 old blobs, and the
274 # new text message.
275 self.assertEqual(3, len(msg.chunks))
276 # Make sure we avoid gaps in sequence.
277 self.assertEqual([1, 2, 3], sorted([i.sequence for i in msg.chunks]))
278 self.assertThat(msg.chunks[0], MatchesStructure(
279 content=Equals("final form"),
280 sequence=Equals(1),
281 ))
282 self.assertEqual(files, [i.blob for i in msg.chunks[1:]])
283
284 # Check revision chunks. It should be the old text message.
285 rev_chunks = msg.revisions[0].chunks
286 self.assertEqual(1, len(rev_chunks))
287 self.assertThat(rev_chunks[0], MatchesStructure(
288 sequence=Equals(1),
289 content=Equals("initial content")))
290
291 def test_non_owner_cannot_delete_message(self):
292 owner = self.factory.makePerson()
293 msg = self.makeMessage(owner=owner, content="initial content")
294 someone_else = self.factory.makePerson()
295 with person_logged_in(someone_else):
296 self.assertRaises(Unauthorized, getattr, msg, "deleteContent")
297
298 def test_delete_message(self):
299 owner = self.factory.makePerson()
300 msg = self.makeMessage(owner=owner, content="initial content")
301 with person_logged_in(owner):
302 msg.editContent("new content")
303 with person_logged_in(owner):
304 msg.deleteContent()
305 self.assertEqual('', msg.text_contents)
306 self.assertEqual(0, len(msg.chunks))
307 self.assertEqual(
308 get_transaction_timestamp(IStore(msg)), msg.date_deleted)
309 self.assertEqual(0, len(msg.revisions))
diff --git a/lib/lp/services/messages/tests/test_messagerevision.py b/lib/lp/services/messages/tests/test_messagerevision.py
172new file mode 100644310new file mode 100644
index 0000000..3b411c9
--- /dev/null
+++ b/lib/lp/services/messages/tests/test_messagerevision.py
@@ -0,0 +1,50 @@
1# Copyright 2009-2021 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4__metaclass__ = type
5
6from lp.services.database.interfaces import IStore
7from lp.services.database.sqlbase import get_transaction_timestamp
8from zope.security.interfaces import Unauthorized
9from zope.security.proxy import ProxyFactory
10
11from lp.testing import (
12 person_logged_in,
13 TestCaseWithFactory,
14 )
15from lp.testing.layers import (
16 DatabaseFunctionalLayer,
17 )
18
19
20class TestMessageRevision(TestCaseWithFactory):
21 """Test scenarios for MessageRevision objects."""
22
23 layer = DatabaseFunctionalLayer
24
25 def makeMessage(self):
26 msg = self.factory.makeMessage()
27 return ProxyFactory(msg)
28
29 def makeMessageRevision(self):
30 msg = self.makeMessage()
31 with person_logged_in(msg.owner):
32 msg.editContent('something edited #%s' % len(msg.revisions))
33 return msg.revisions[-1]
34
35 def test_non_owner_cannot_delete_message_revision_content(self):
36 rev = self.makeMessageRevision()
37 someone_else = self.factory.makePerson()
38 with person_logged_in(someone_else):
39 self.assertRaises(Unauthorized, getattr, rev, "deleteContent")
40
41 def test_msg_owner_can_delete_message_revision_content(self):
42 rev = self.makeMessageRevision()
43 msg = rev.message
44 with person_logged_in(rev.message.owner):
45 rev.deleteContent()
46 self.assertEqual(1, len(msg.revisions))
47 self.assertEqual("", rev.content)
48 self.assertEqual(0, len(rev.chunks))
49 self.assertEqual(
50 get_transaction_timestamp(IStore(rev)), rev.date_deleted)