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