← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Thiago F. Pappacena has proposed merging ~pappacena/launchpad:comment-editing-model into launchpad:master.

Commit message:
Mapping database initial structure for message editing

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~pappacena/launchpad/+git/launchpad/+merge/401894
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~pappacena/launchpad:comment-editing-model into launchpad:master.
diff --git a/database/schema/security.cfg b/database/schema/security.cfg
index 1bbb845..c8b7d10 100644
--- a/database/schema/security.cfg
+++ b/database/schema/security.cfg
@@ -232,7 +232,8 @@ public.logintoken                       = SELECT, INSERT, UPDATE, DELETE
 public.mailinglist                      = SELECT, INSERT, UPDATE, DELETE
 public.mailinglistsubscription          = SELECT, INSERT, UPDATE, DELETE
 public.messageapproval                  = SELECT, INSERT, UPDATE, DELETE
-public.messagechunk                     = SELECT, INSERT
+public.messagechunk                     = SELECT, INSERT, DELETE
+public.messagerevision                  = SELECT, INSERT, UPDATE
 public.milestone                        = SELECT, INSERT, UPDATE, DELETE
 public.milestonetag                     = SELECT, INSERT, UPDATE, DELETE
 public.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 @@
-# Copyright 2010-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2010-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for the bugcomment module."""
@@ -35,6 +35,7 @@ from lp.testing import (
     BrowserTestCase,
     celebrity_logged_in,
     login_person,
+    person_logged_in,
     TestCase,
     TestCaseWithFactory,
     verifyObject,
@@ -300,7 +301,10 @@ class TestBugCommentImplementsInterface(TestCaseWithFactory):
         bug_message = self.factory.makeBugComment()
         bugtask = bug_message.bugs[0].bugtasks[0]
         bug_comment = BugComment(1, bug_message, bugtask)
-        verifyObject(IBugComment, bug_comment)
+        # Runs verifyObject logged in as the bug owner, so we don't fail on
+        # attributes that are not public to everyone.
+        with person_logged_in(bug_message.owner):
+            verifyObject(IBugComment, bug_comment)
 
     def test_download_url(self):
         """download_url is provided and works as expected."""
diff --git a/lib/lp/security.py b/lib/lp/security.py
index 412f497..cd88d5c 100644
--- a/lib/lp/security.py
+++ b/lib/lp/security.py
@@ -3182,6 +3182,15 @@ class SetMessageVisibility(AuthorizationBase):
         return (user.in_admin or user.in_registry_experts)
 
 
+class EditMessage(AuthorizationBase):
+    permission = 'launchpad.Edit'
+    usedfor = IMessage
+
+    def checkAuthenticated(self, user):
+        """Only message owner can edit it."""
+        return user.isOwner(self.obj)
+
+
 class ViewPublisherConfig(AdminByAdminsTeam):
     usedfor = IPublisherConfig
 
diff --git a/lib/lp/services/messages/configure.zcml b/lib/lp/services/messages/configure.zcml
index e989fe2..fcaffab 100644
--- a/lib/lp/services/messages/configure.zcml
+++ b/lib/lp/services/messages/configure.zcml
@@ -1,4 +1,4 @@
-<!-- Copyright 2009-2011 Canonical Ltd.  This software is licensed under the
+<!-- Copyright 2009-2021 Canonical Ltd.  This software is licensed under the
      GNU Affero General Public License version 3 (see the file LICENSE).
 -->
 
@@ -10,9 +10,9 @@
     i18n_domain="launchpad">
 
     <!-- Message -->
-    <class
-        class="lp.services.messages.model.message.Message">
-        <allow interface="lp.services.messages.interfaces.message.IMessage" />
+    <class class="lp.services.messages.model.message.Message">
+        <allow
+            interface="lp.services.messages.interfaces.message.IMessageView" />
         <!-- setVisible is required to allow IBug.setCommentVisibility() to
              change the visibility attribute whilst still ensuring restricted
              access to the attribute via the API.-->
@@ -22,6 +22,19 @@
         <require
             permission="launchpad.Admin"
             set_attributes="visible"/>
+        <require
+            permission="launchpad.Edit"
+            interface="lp.services.messages.interfaces.message.IMessageEdit" />
+    </class>
+
+    <!-- MessageRevision -->
+    <class
+        class="lp.services.messages.model.messagerevision.MessageRevision">
+        <allow
+            interface="lp.services.messages.interfaces.messagerevision.IMessageRevisionView"/>
+        <require
+            permission="launchpad.Edit"
+            interface="lp.services.messages.interfaces.messagerevision.IMessageRevisionEdit"/>
     </class>
 
     <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..0c54c84 100644
--- a/lib/lp/services/messages/interfaces/message.py
+++ b/lib/lp/services/messages/interfaces/message.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -49,9 +49,19 @@ from lp.services.librarian.interfaces import ILibraryFileAlias
 from lp.services.webservice.apihelpers import patch_reference_property
 
 
-@exported_as_webservice_entry('message')
-class IMessage(Interface):
-    """A message.
+class IMessageEdit(Interface):
+
+    def edit_content(new_content):
+        """Edit the content of this message, generating a new message
+        revision with the old content.
+        """
+
+    def delete_content():
+        """Deletes this message content."""
+
+
+class IMessageView(Interface):
+    """Public attributes for message.
 
     This is like an email (RFC822) message, though it could be created through
     the web as well.
@@ -61,6 +71,15 @@ class IMessage(Interface):
     datecreated = exported(
         Datetime(title=_('Date Created'), required=True, readonly=True),
         exported_as='date_created')
+
+    date_last_edit = Datetime(
+        title=_('When this message was last edited'), required=False,
+        readonly=True)
+
+    date_deleted = Datetime(
+        title=_('When this message was deleted'), required=False,
+        readonly=True)
+
     subject = exported(
         TextLine(title=_('Subject'), required=True, readonly=True))
 
@@ -87,6 +106,8 @@ class IMessage(Interface):
 
     chunks = Attribute(_('Message pieces'))
 
+    revisions = Attribute(_('Message revision history'))
+
     text_contents = exported(
         Text(title=_('All the text/plain chunks joined together as a '
                      'unicode string.')),
@@ -115,6 +136,11 @@ class IMessage(Interface):
         """Return None because messages are not threaded over the API."""
 
 
+@exported_as_webservice_entry('message')
+class IMessage(IMessageEdit, IMessageView):
+    """A Message."""
+
+
 # Fix for self-referential schema.
 patch_reference_property(IMessage, 'parent', IMessage)
 
diff --git a/lib/lp/services/messages/interfaces/messagerevision.py b/lib/lp/services/messages/interfaces/messagerevision.py
new file mode 100644
index 0000000..96aeb4d
--- /dev/null
+++ b/lib/lp/services/messages/interfaces/messagerevision.py
@@ -0,0 +1,53 @@
+# Copyright 2019-2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Message revision history."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__all__ = [
+    'IMessageRevision'
+    ]
+
+from lazr.restful.fields import Reference
+from zope.interface import Interface
+from zope.schema import (
+    Datetime,
+    Int,
+    Text,
+    )
+
+from lp import _
+from lp.services.messages.interfaces.message import IMessage
+
+
+class IMessageRevisionView(Interface):
+    """IMessageRevision readable attributes."""
+    id = Int(title=_("ID"), required=True, readonly=True)
+
+    content = Text(
+        title=_("The message at the given revision"),
+        required=False, readonly=True)
+
+    message = Reference(
+        title=_('The current message of this revision.'),
+        schema=IMessage, required=True, readonly=True)
+
+    date_created = Datetime(
+        title=_("The time when this message revision was created."),
+        required=True, readonly=True)
+
+    date_deleted = Datetime(
+        title=_("The time when this message revision was created."),
+        required=False, readonly=True)
+
+
+class IMessageRevisionEdit(Interface):
+    """IMessageRevision editable attributes."""
+
+    def destroySelf():
+        """Logically deletes this MessageRevision."""
+
+
+class IMessageRevision(IMessageRevisionView, IMessageRevisionEdit):
+    """A historical revision of a IMessage."""
diff --git a/lib/lp/services/messages/model/message.py b/lib/lp/services/messages/model/message.py
index e592daa..1966cc6 100644
--- a/lib/lp/services/messages/model/message.py
+++ b/lib/lp/services/messages/model/message.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -40,6 +40,7 @@ from sqlobject import (
 from storm.locals import (
     And,
     DateTime,
+    Desc,
     Int,
     Reference,
     Store,
@@ -71,7 +72,12 @@ from lp.services.messages.interfaces.message import (
     IUserToUserEmail,
     UnknownSender,
     )
-from lp.services.propertycache import cachedproperty
+from lp.services.messages.model.messagerevision import MessageRevision
+from lp.services.propertycache import (
+    cachedproperty,
+    get_property_cache,
+    )
+from lp.services.utils import utc_now
 
 
 def utcdatetime_from_field(field_value):
@@ -100,6 +106,8 @@ class Message(SQLBase):
     _table = 'Message'
     _defaultOrder = '-id'
     datecreated = UtcDateTimeCol(notNull=True, default=UTC_NOW)
+    date_deleted = UtcDateTimeCol(notNull=False, default=None)
+    date_last_edit = UtcDateTimeCol(notNull=False, default=None)
     subject = StringCol(notNull=False, default=None)
     owner = ForeignKey(
         dbName='owner', foreignKey='Person',
@@ -164,6 +172,47 @@ class Message(SQLBase):
         """See `IMessage`."""
         return None
 
+    @cachedproperty
+    def revisions(self):
+        """See `IMessage`."""
+        return list(Store.of(self).find(
+            MessageRevision,
+            MessageRevision.message == self
+        ).order_by(Desc(MessageRevision.date_created)))
+
+    def edit_content(self, new_content):
+        """See `IMessage`."""
+        store = Store.of(self)
+        old_content = self.text_contents
+        # Remove the current content.
+        for chunk in self._chunks:
+            store.remove(chunk)
+
+        # Create the new content.
+        new_chunk = MessageChunk(message=self, sequence=1, content=new_content)
+        store.add(new_chunk)
+
+        # Move the old content to a new revision.
+        date_created = (self.date_last_edit if self.date_last_edit is not None
+                        else self.datecreated)
+        rev = MessageRevision(message=self, content=old_content,
+                              date_created=date_created)
+        self.date_last_edit = utc_now()
+        store.add(rev)
+        store.flush()
+
+        # Clean up caches.
+        del get_property_cache(self).text_contents
+        del get_property_cache(self).chunks
+        del get_property_cache(self).revisions
+        return rev
+
+    def delete_content(self):
+        """See `IMessage`."""
+        for chunk in self._chunks:
+            chunk.destroySelf()
+        self.date_deleted = utc_now()
+
 
 def get_parent_msgids(parsed_message):
     """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
new file mode 100644
index 0000000..2ea188c
--- /dev/null
+++ b/lib/lp/services/messages/model/messagerevision.py
@@ -0,0 +1,52 @@
+# Copyright 2019-2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Message revision history."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'MessageRevision'
+    ]
+
+import pytz
+from storm.locals import (
+    DateTime,
+    Int,
+    Reference,
+    Unicode,
+    )
+from zope.interface import implementer
+
+from lp.services.database.stormbase import StormBase
+from lp.services.messages.interfaces.messagerevision import IMessageRevision
+from lp.services.utils import utc_now
+
+
+@implementer(IMessageRevision)
+class MessageRevision(StormBase):
+    """A historical revision of a IMessage."""
+
+    __storm_table__ = 'MessageRevision'
+
+    id = Int(primary=True)
+
+    message_id = Int(name='message', allow_none=False)
+    message = Reference(message_id, 'Message.id')
+
+    content = Unicode(name="content", allow_none=False)
+
+    date_created = DateTime(
+        name="date_created", tzinfo=pytz.UTC, allow_none=False)
+    date_deleted = DateTime(
+        name="date_deleted", tzinfo=pytz.UTC, allow_none=True)
+
+    def __init__(self, message, content, date_created, date_deleted=None):
+        self.message = message
+        self.content = content
+        self.date_created = date_created
+        self.date_deleted = date_deleted
+
+    def destroySelf(self):
+        self.date_deleted = utc_now()
diff --git a/lib/lp/services/messages/tests/test_message.py b/lib/lp/services/messages/tests/test_message.py
index a9e1042..49e15eb 100644
--- a/lib/lp/services/messages/tests/test_message.py
+++ b/lib/lp/services/messages/tests/test_message.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -13,15 +13,27 @@ from email.utils import (
     )
 
 import six
+from testtools.matchers import (
+    Equals,
+    Is,
+    MatchesStructure,
+    )
 import transaction
+from zope.security.interfaces import Unauthorized
+from zope.security.proxy import ProxyFactory
 
 from lp.services.compat import message_as_bytes
 from lp.services.messages.model.message import MessageSet
+from lp.services.utils import utc_now
 from lp.testing import (
     login,
+    person_logged_in,
     TestCaseWithFactory,
     )
-from lp.testing.layers import LaunchpadFunctionalLayer
+from lp.testing.layers import (
+    DatabaseFunctionalLayer,
+    LaunchpadFunctionalLayer,
+    )
 
 
 class TestMessageSet(TestCaseWithFactory):
@@ -169,3 +181,82 @@ class TestMessageSet(TestCaseWithFactory):
             'Treating unknown encoding "booga" as latin-1.'):
             result = MessageSet.decode(self.high_characters, 'booga')
         self.assertEqual(self.high_characters.decode('latin-1'), result)
+
+
+class TestMessageEditing(TestCaseWithFactory):
+    """Test editing scenarios for Message objects."""
+
+    layer = DatabaseFunctionalLayer
+
+    def makeMessage(self, owner=None, content=None):
+        if owner is None:
+            owner = self.factory.makePerson()
+        msg = self.factory.makeMessage(owner=owner, content=content)
+        return ProxyFactory(msg)
+
+    def test_non_owner_cannot_edit_message(self):
+        msg = self.makeMessage()
+        someone_else = self.factory.makePerson()
+        with person_logged_in(someone_else):
+            self.assertRaises(Unauthorized, getattr, msg, "edit_content")
+
+    def test_msg_owner_can_edit(self):
+        owner = self.factory.makePerson()
+        msg = self.makeMessage(owner=owner, content="initial content")
+        with person_logged_in(owner):
+            msg.edit_content("This is the new content")
+        self.assertEqual("This is the new content", msg.text_contents)
+        self.assertEqual(1, len(msg.revisions))
+        self.assertThat(msg.revisions[0], MatchesStructure(
+            content=Equals("initial content"),
+            message=Equals(msg),
+            date_created=Equals(msg.datecreated),
+            date_deleted=Is(None)))
+
+    def test_multiple_edits_revisions(self):
+        owner = self.factory.makePerson()
+        msg = self.makeMessage(owner=owner, content="initial content")
+        with person_logged_in(owner):
+            msg.edit_content("first edit")
+            first_edit_date = msg.date_last_edit
+        self.assertEqual("first edit", msg.text_contents)
+        self.assertEqual(1, len(msg.revisions))
+        self.assertThat(msg.revisions[0], MatchesStructure(
+            content=Equals("initial content"),
+            message=Equals(msg),
+            date_created=Equals(msg.datecreated),
+            date_deleted=Is(None)))
+
+        with person_logged_in(owner):
+            msg.edit_content("final form")
+        self.assertEqual("final form", msg.text_contents)
+        self.assertEqual(2, len(msg.revisions))
+        self.assertThat(msg.revisions[0], MatchesStructure(
+            content=Equals("first edit"),
+            message=Equals(msg),
+            date_created=Equals(first_edit_date),
+            date_deleted=Is(None)))
+        self.assertThat(msg.revisions[1], MatchesStructure(
+            content=Equals("initial content"),
+            message=Equals(msg),
+            date_created=Equals(msg.datecreated),
+            date_deleted=Is(None)))
+
+    def test_non_owner_cannot_delete_message(self):
+        owner = self.factory.makePerson()
+        msg = self.makeMessage(owner=owner, content="initial content")
+        someone_else = self.factory.makePerson()
+        with person_logged_in(someone_else):
+            self.assertRaises(Unauthorized, getattr, msg, "delete_content")
+
+    def test_delete_message(self):
+        owner = self.factory.makePerson()
+        msg = self.makeMessage(owner=owner, content="initial content")
+        with person_logged_in(owner):
+            before_delete = utc_now()
+            msg.delete_content()
+            after_delete = utc_now()
+        self.assertEqual('', msg.text_contents)
+        self.assertEqual(0, len(msg.chunks))
+        self.assertIsNotNone(msg.date_deleted)
+        self.assertTrue(after_delete > msg.date_deleted > before_delete)

Follow ups