launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #27059
Re: [Merge] ~pappacena/launchpad:comment-editing-model into launchpad:master
Addressed all the comments.
Diff comments:
> diff --git a/database/schema/security.cfg b/database/schema/security.cfg
> index c3aba65..b712521 100644
> --- a/database/schema/security.cfg
> +++ b/database/schema/security.cfg
> @@ -232,7 +232,9 @@ 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, DELETE
> +public.messagerevisionchunk = SELECT, INSERT, UPDATE, DELETE
You are right. Fixing it.
> public.milestone = SELECT, INSERT, UPDATE, DELETE
> public.milestonetag = SELECT, INSERT, UPDATE, DELETE
> public.mirrorcdimagedistroseries = SELECT, INSERT, DELETE
> 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)
Right. Adding it.
> +
> +
> class ViewPublisherConfig(AdminByAdminsTeam):
> usedfor = IPublisherConfig
>
> diff --git a/lib/lp/services/messages/interfaces/messagerevision.py b/lib/lp/services/messages/interfaces/messagerevision.py
> new file mode 100644
> index 0000000..3b72673
> --- /dev/null
> +++ b/lib/lp/services/messages/interfaces/messagerevision.py
> @@ -0,0 +1,69 @@
> +# 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',
> + 'IMessageRevisionChunk',
> + ]
> +
> +from lazr.restful.fields import Reference
> +from zope.interface import (
> + Attribute,
> + 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)
> +
> + revision = Int(title=_("Revision number"), required=True, readonly=True)
> +
> + content = Text(
> + title=_("The message at the given revision"),
> + required=False, readonly=True)
Ok!
> +
> + 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)
> +
> + chunks = Attribute(_('Message pieces'))
> +
> +
> +class IMessageRevisionEdit(Interface):
> + """IMessageRevision editable attributes."""
> +
> + def deleteContent():
> + """Logically deletes this MessageRevision."""
Fixing this description.
> +
> +
> +class IMessageRevision(IMessageRevisionView, IMessageRevisionEdit):
> + """A historical revision of a IMessage."""
> +
> +
> +class IMessageRevisionChunk(Interface):
> + id = Int(title=_('ID'), required=True, readonly=True)
> + messagerevision = Int(
> + title=_('MessageRevision'), required=True, readonly=True)
> + sequence = Int(title=_('Sequence order'), required=True, readonly=True)
> + content = Text(title=_('Text content'), required=False, readonly=True)
Ok!
> diff --git a/lib/lp/services/messages/model/message.py b/lib/lp/services/messages/model/message.py
> index e592daa..b73e212 100644
> --- a/lib/lp/services/messages/model/message.py
> +++ b/lib/lp/services/messages/model/message.py
> @@ -164,6 +176,63 @@ 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.revision)))
> +
> + def editContent(self, new_content):
> + """See `IMessage`."""
> + store = Store.of(self)
> +
> + # Move the old content to a new revision.
> + date_created = (
> + self.date_last_edited if self.date_last_edited is not None
> + else self.datecreated)
> + current_rev_num = store.find(
> + Max(MessageRevision.revision),
> + MessageRevision.message == self).one()
> + rev_num = (current_rev_num or 0) + 1
> + rev = MessageRevision(
> + message=self, revision=rev_num, date_created=date_created)
> + self.date_last_edited = utc_now()
Ok!
> + store.add(rev)
> +
> + # Move the current text content to the recently created revision.
> + for chunk in self._chunks:
> + if chunk.blob is None:
> + revision_chunk = MessageRevisionChunk(
> + rev, chunk.sequence, chunk.content)
> + store.add(revision_chunk)
> + store.remove(chunk)
> +
> + # Create the new content.
> + new_chunk = MessageChunk(message=self, sequence=1, content=new_content)
You're right. I'll set the new chunk sequence to the bigger sequence + 1.
> + store.add(new_chunk)
> +
> + 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 deleteContent(self):
> + """See `IMessage`."""
> + store = Store.of(self)
> + store.find(MessageChunk, MessageChunk.message == self).remove()
> + for rev in self.revisions:
> + store.find(MessageRevisionChunk, message_revision=rev).remove()
Sure!
> + store.find(MessageRevision, MessageRevision.message == self).remove()
The idea is that deleting a message itself will remove everything about that message (content and history), keeping only the Message object to mark that a message existed there before.
On the other hand, deleting a message revision content will clear only its content, marking that something was edited at that time, but the content is no longer available (useful, for example, if you edited a message because you've mistakenly typed a password there and you don't want that password hanging around on the message history).
> + del get_property_cache(self).text_contents
> + del get_property_cache(self).chunks
> + del get_property_cache(self).revisions
> + 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..c8b6142
> --- /dev/null
> +++ b/lib/lp/services/messages/model/messagerevision.py
> @@ -0,0 +1,92 @@
> +# 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',
> + 'MessageRevisionChunk',
> + ]
> +
> +import pytz
> +from storm.locals import (
> + DateTime,
> + Int,
> + Reference,
> + Unicode,
> + )
> +from zope.interface import implementer
> +
> +from lp.services.database.interfaces import IStore
> +from lp.services.database.stormbase import StormBase
> +from lp.services.messages.interfaces.messagerevision import (
> + IMessageRevision,
> + IMessageRevisionChunk,
> + )
> +from lp.services.propertycache import (
> + cachedproperty,
> + get_property_cache,
> + )
> +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')
> +
> + revision = Int(name='revision', 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, revision, date_created, date_deleted=None):
> + self.message = message
> + self.revision = revision
> + self.date_created = date_created
> + self.date_deleted = date_deleted
> +
> + @cachedproperty
> + def chunks(self):
> + return list(IStore(self).find(
> + MessageRevisionChunk, message_revision=self))
> +
> + @property
> + def content(self):
> + return ''.join(i.content for i in self.chunks)
Ok!
> +
> + def deleteContent(self):
> + store = IStore(self)
> + store.find(MessageRevisionChunk, message_revision=self).remove()
> + self.date_deleted = utc_now()
Fixing it.
> + del get_property_cache(self).chunks
> +
> +
> +@implementer(IMessageRevisionChunk)
> +class MessageRevisionChunk(StormBase):
> + __storm_table__ = 'MessageRevisionChunk'
> +
> + id = Int(primary=True)
> +
> + message_revision_id = Int(name='messagerevision', allow_none=False)
> + message_revision = Reference(message_revision_id, 'MessageRevision.id')
> +
> + sequence = Int(name='sequence', allow_none=False)
> +
> + content = Unicode(name="content", allow_none=False)
> +
> + def __init__(self, message_revision, sequence, content):
> + self.message_revision = message_revision
> + self.sequence = sequence
> + 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..d015f87 100644
> --- a/lib/lp/services/messages/tests/test_message.py
> +++ b/lib/lp/services/messages/tests/test_message.py
> @@ -169,3 +185,122 @@ 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, "editContent")
> +
> + 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.editContent("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"),
> + revision=Equals(1),
> + 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.editContent("first edit")
> + first_edit_date = msg.date_last_edited
> + self.assertEqual("first edit", msg.text_contents)
> + self.assertEqual(1, len(msg.revisions))
> + self.assertThat(msg.revisions[0], MatchesStructure(
> + content=Equals("initial content"),
> + revision=Equals(1),
> + message=Equals(msg),
> + date_created=Equals(msg.datecreated),
> + date_deleted=Is(None)))
> +
> + with person_logged_in(owner):
> + msg.editContent("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"),
> + revision=Equals(2),
> + message=Equals(msg),
> + date_created=Equals(first_edit_date),
> + date_deleted=Is(None)))
> + self.assertThat(msg.revisions[1], MatchesStructure(
> + content=Equals("initial content"),
> + revision=Equals(1),
> + message=Equals(msg),
> + date_created=Equals(msg.datecreated),
> + date_deleted=Is(None)))
> +
> + def test_edit_message_with_blobs(self):
> + # Messages with blobs should keep the blobs untouched when the
> + # content is edited.
> + owner = self.factory.makePerson()
> + msg = self.makeMessage(owner=owner, content="initial content")
> + files = [self.factory.makeLibraryFileAlias(db_only=True)
> + for _ in range(2)]
> + store = IStore(msg)
> + for seq, blob in enumerate(files):
> + store.add(MessageChunk(message=msg, sequence=seq + 2, blob=blob))
> +
> + with person_logged_in(owner):
> + msg.editContent("final form")
> + self.assertThat(msg.revisions[0], MatchesStructure(
> + content=Equals("initial content"),
> + revision=Equals(1),
> + message=Equals(msg),
> + date_created=Equals(msg.datecreated),
> + date_deleted=Is(None)))
> +
> + # Check that current message chunks are 3: the 2 old blobs, and the
> + # new text message.
> + self.assertEqual(3, len(msg.chunks))
> + self.assertEqual("final form", msg.chunks[0].content)
> + self.assertEqual(files, [i.blob for i in msg.chunks[1:]])
> +
> + # Check revision chunks. It should be the old text message.
> + rev_chunks = msg.revisions[0].chunks
> + self.assertEqual(1, len(rev_chunks))
> + self.assertThat(rev_chunks[0], MatchesStructure(
> + sequence=Equals(1),
> + content=Equals("initial content")))
> +
> + 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, "deleteContent")
> +
> + def test_delete_message(self):
> + owner = self.factory.makePerson()
> + msg = self.makeMessage(owner=owner, content="initial content")
> + with person_logged_in(owner):
> + msg.editContent("new content")
> + with person_logged_in(owner):
> + before_delete = utc_now()
> + msg.deleteContent()
> + 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)
The exact assertion works fine. Thanks! Changing it.
> + self.assertEqual(0, len(msg.revisions))
I totally missed that. Adding it now!
--
https://code.launchpad.net/~pappacena/launchpad/+git/launchpad/+merge/401894
Your team Launchpad code reviewers is subscribed to branch ~pappacena/launchpad:comment-editing-model.
References