← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~pappacena/launchpad:archive-queue-audit-trail into launchpad:master

 

Thiago F. Pappacena has proposed merging ~pappacena/launchpad:archive-queue-audit-trail into launchpad:master.

Commit message:
Creating audit logs for package approval and rejection

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #885739 in Launchpad itself: "queue and override manipulations should have an audit trail"
  https://bugs.launchpad.net/launchpad/+bug/885739

For more details, see:
https://code.launchpad.net/~pappacena/launchpad/+git/launchpad/+merge/377717
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~pappacena/launchpad:archive-queue-audit-trail into launchpad:master.
diff --git a/database/schema/security.cfg b/database/schema/security.cfg
index 06da0d4..2844a30 100644
--- a/database/schema/security.cfg
+++ b/database/schema/security.cfg
@@ -1,4 +1,4 @@
-# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 #
 # Possible permissions: SELECT, INSERT, UPDATE, EXECUTE
@@ -1080,6 +1080,7 @@ public.packagesetgroup                          = SELECT, INSERT
 public.packagesetinclusion                      = SELECT, INSERT
 public.packagesetsources                        = SELECT, INSERT
 public.packageupload                            = SELECT, INSERT, UPDATE
+public.packageuploadlog                         = SELECT
 public.packageuploadsource                      = SELECT
 public.packageuploadbuild                       = SELECT
 public.packageuploadcustom                      = SELECT, INSERT
@@ -1242,6 +1243,7 @@ public.ociprojectname                   = SELECT, INSERT, UPDATE
 public.ociprojectseries                 = SELECT, INSERT, UPDATE, DELETE
 public.openididentifier                 = SELECT
 public.packageupload                    = SELECT, INSERT, UPDATE
+public.packageuploadlog                 = SELECT, INSERT
 public.packageuploadbuild               = SELECT, INSERT, UPDATE
 public.packageuploadcustom              = SELECT, INSERT, UPDATE
 public.packageuploadsource              = SELECT, INSERT, UPDATE
@@ -2293,6 +2295,7 @@ public.packagediff                      = SELECT, UPDATE
 public.packageset                       = SELECT, UPDATE
 public.packagesetgroup                  = SELECT, UPDATE
 public.packageupload                    = SELECT, UPDATE
+public.packageuploadlog                 = SELECT, UPDATE
 public.packaging                        = SELECT, UPDATE
 public.person                           = SELECT, UPDATE
 public.personlanguage                   = SELECT, UPDATE
diff --git a/lib/lp/soyuz/configure.zcml b/lib/lp/soyuz/configure.zcml
index 0e35ccb..f159d27 100644
--- a/lib/lp/soyuz/configure.zcml
+++ b/lib/lp/soyuz/configure.zcml
@@ -1,4 +1,4 @@
-<!-- Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
+<!-- Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
      GNU Affero General Public License version 3 (see the file LICENSE).
 -->
 
@@ -151,6 +151,7 @@
                 displayarchs
                 displayversion
                 isPPA
+                logs
                 package_copy_job
                 package_copy_job_id
                 package_name
@@ -183,6 +184,11 @@
             set_attributes="status distroseries pocket changesfile archive"/>
     </class>
     <class
+        class="lp.soyuz.model.queue.PackageUploadLog">
+        <allow
+            interface="lp.soyuz.interfaces.queue.IPackageUploadLog"/>
+    </class>
+    <class
         class="lp.soyuz.model.queue.PackageUploadSource">
         <allow
             interface="lp.soyuz.interfaces.queue.IPackageUploadSource"/>
diff --git a/lib/lp/soyuz/interfaces/queue.py b/lib/lp/soyuz/interfaces/queue.py
index c57da73..3b44a13 100644
--- a/lib/lp/soyuz/interfaces/queue.py
+++ b/lib/lp/soyuz/interfaces/queue.py
@@ -11,6 +11,7 @@ __all__ = [
     'IHasQueueItems',
     'IPackageUploadQueue',
     'IPackageUpload',
+    'IPackageUploadLog',
     'IPackageUploadBuild',
     'IPackageUploadSource',
     'IPackageUploadCustom',
@@ -149,6 +150,8 @@ class IPackageUpload(Interface):
             title=_('Date created'),
             description=_("The date this package upload was done.")))
 
+    logs = Attribute(_("The change log of this PackageUpload"))
+
     changesfile = Attribute("The librarian alias for the changes file "
                             "associated with this upload")
     changes_file_url = exported(
@@ -711,6 +714,20 @@ class IPackageUploadCustom(Interface):
         """
 
 
+class IPackageUploadLog(Interface):
+    package_upload = Attribute("Original package upload")
+
+    date_created = Attribute("When this action happened")
+
+    person = Attribute("Who did this action")
+
+    old_status = Attribute("Old status")
+
+    new_status = Attribute("New status")
+
+    comment = Attribute("User's comment about this change")
+
+
 class IPackageUploadSet(Interface):
     """Represents a set of IPackageUploads"""
 
diff --git a/lib/lp/soyuz/model/queue.py b/lib/lp/soyuz/model/queue.py
index 0fd1799..fcfdc2c 100644
--- a/lib/lp/soyuz/model/queue.py
+++ b/lib/lp/soyuz/model/queue.py
@@ -1,10 +1,11 @@
-# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
 __all__ = [
     'PackageUploadQueue',
     'PackageUpload',
+    'PackageUploadLog',
     'PackageUploadBuild',
     'PackageUploadSource',
     'PackageUploadCustom',
@@ -13,6 +14,7 @@ __all__ = [
 
 from itertools import chain
 
+import pytz
 from sqlobject import (
     ForeignKey,
     SQLMultipleJoin,
@@ -29,6 +31,8 @@ from storm.locals import (
     SQL,
     Unicode,
     )
+from storm.properties import DateTime
+from storm.references import ReferenceSet
 from storm.store import (
     EmptyResultSet,
     Store,
@@ -106,7 +110,7 @@ from lp.soyuz.interfaces.queue import (
     QueueInconsistentStateError,
     QueueSourceAcceptError,
     QueueStateWriteProtectedError,
-    )
+    IPackageUploadLog)
 from lp.soyuz.interfaces.section import ISectionSet
 from lp.soyuz.mail.packageupload import PackageUploadMailer
 from lp.soyuz.model.binarypackagename import BinaryPackageName
@@ -204,6 +208,20 @@ class PackageUpload(SQLBase):
             self.addSearchableNames([self.package_copy_job.package_name])
             self.addSearchableVersions([self.package_copy_job.package_version])
 
+    @property
+    def logs(self):
+        return Store.of(self).find(PackageUploadLog,
+                                   PackageUploadLog.package_upload == self)
+
+    def _addLog(self, person, new_status, comment=None):
+        return Store.of(self).add(PackageUploadLog(
+            package_upload=self,
+            person=person,
+            old_status=self.status,
+            new_status=new_status,
+            comment=comment
+        ))
+
     @cachedproperty
     def sources(self):
         return list(self._sources)
@@ -571,6 +589,7 @@ class PackageUpload(SQLBase):
 
     def acceptFromQueue(self, user=None):
         """See `IPackageUpload`."""
+        self._addLog(user, PackageUploadStatus.ACCEPTED, None)
         if self.package_copy_job is None:
             self._acceptNonSyncFromQueue()
         else:
@@ -581,6 +600,7 @@ class PackageUpload(SQLBase):
 
     def rejectFromQueue(self, user, comment=None):
         """See `IPackageUpload`."""
+        self._addLog(user, PackageUploadStatus.REJECTED, comment)
         self.setRejected()
         if self.package_copy_job is not None:
             # Circular imports :(
@@ -1153,6 +1173,24 @@ def get_properties_for_binary(bpr):
         }
 
 
+@implementer(IPackageUploadLog)
+class PackageUploadLog(SQLBase):
+    package_upload_id = Int(name='package_upload')
+    package_upload = Reference(package_upload_id, PackageUpload.id)
+
+    date_created = DateTime(tzinfo=pytz.UTC, allow_none=False,
+                            default=UTC_NOW)
+
+    person_id = Int(name='person', allow_none=True)
+    person = Reference(person_id, 'Person.id')
+
+    old_status = EnumCol(schema=PackageUploadStatus)
+
+    new_status = EnumCol(schema=PackageUploadStatus)
+
+    comment = Unicode(allow_none=True)
+
+
 @implementer(IPackageUploadBuild)
 class PackageUploadBuild(SQLBase):
     """A Queue item's related builds."""
diff --git a/lib/lp/soyuz/stories/soyuz/xx-queue-pages.txt b/lib/lp/soyuz/stories/soyuz/xx-queue-pages.txt
index 7417a7b..1a5f2cc 100644
--- a/lib/lp/soyuz/stories/soyuz/xx-queue-pages.txt
+++ b/lib/lp/soyuz/stories/soyuz/xx-queue-pages.txt
@@ -510,7 +510,7 @@ values:
     >>> filelist = find_tags_by_class(anon_browser.contents, 'queue-2')
     >>> for row in filelist:
     ...     print(extract_text(row))
-    pmount_1.0-1_all.deb (18 bytes) NEW 0.1-1 restricted admin extra
+    pmount_1.0-1_all.deb (18 bytes) NEW 0.1-1 restricted admin extra Accepted a moment ago by Sample Person
 
 'netapplet' has gone straight to the 'done' queue because it's a single
 source upload, and we can see its overridden values there:
@@ -546,6 +546,15 @@ Rejecting 'alsa-utils' source:
     netapplet...ddtp... -                                           Release 2006-...
     netapplet...dist... -                                           Release 2006-...
 
+    >>> upload_manager_browser.getControl(
+    ...     name="queue_state", index=0).displayValue=['Rejected']
+    >>> upload_manager_browser.getControl("Update").click()
+    >>> logs = find_tags_by_class(
+    ...     upload_manager_browser.contents, "log-content")
+    >>> for log in logs:
+    ...     print(extract_text(log))
+    Rejected...a moment ago...by Sample Person...Foo
+
 One rejection email is generated:
 
     >>> run_package_upload_notification_jobs()
@@ -599,6 +608,20 @@ button will be disabled.
     >>> upload_manager_browser.getControl(name="Reject").disabled
     True
 
+Accepting alsa again, and check that the package upload log has more rows
+
+    >>> upload_manager_browser.getControl(name="QUEUE_ID").value = ['4']
+    >>> upload_manager_browser.getControl(name="Accept").click()
+    >>> upload_manager_browser.getControl(
+    ...     name="queue_state", index=0).displayValue=['Accepted']
+    >>> upload_manager_browser.getControl("Update").click()
+    >>> logs = find_tags_by_class(
+    ...     upload_manager_browser.contents, "log-content")
+    >>> for log in logs:
+    ...     print(extract_text(log))
+    Rejected...a moment ago...by Sample Person...Foo
+    Accepted...a moment ago...by Sample Person...
+
 
 Clean up
 ========
diff --git a/lib/lp/soyuz/templates/distroseries-queue.pt b/lib/lp/soyuz/templates/distroseries-queue.pt
index 59b77d5..c210a0b 100644
--- a/lib/lp/soyuz/templates/distroseries-queue.pt
+++ b/lib/lp/soyuz/templates/distroseries-queue.pt
@@ -149,6 +149,18 @@
            </tbody>
            <tbody tal:attributes="class string:${filelist_class}">
              <metal:filelist use-macro="template/macros/package-filelist"/>
+             <tr class="log-content" tal:repeat="log packageupload/logs">
+               <td colspan="2" style="border: 0"></td>
+               <td colspan="8" style="border: 0">
+                 <span tal:content="log/new_status"></span>
+                 <span tal:attributes="title log/date_created/fmt:datetime"
+                       tal:content="log/date_created/fmt:displaydate" />
+                 <span tal:condition="log/person">
+                    by <span tal:content="structure log/person/fmt:link" />
+                 </span>
+                 <p tal:condition="log/comment" tal:content="log/comment" />
+               </td>
+             </tr>
            </tbody>
          </tal:block>
        </tal:batch>
diff --git a/lib/lp/soyuz/tests/test_packageupload.py b/lib/lp/soyuz/tests/test_packageupload.py
index 5f78fe2..c74ad9b 100644
--- a/lib/lp/soyuz/tests/test_packageupload.py
+++ b/lib/lp/soyuz/tests/test_packageupload.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Test Build features."""
@@ -14,7 +14,10 @@ from lazr.restfulclient.errors import (
     BadRequest,
     Unauthorized,
     )
-from testtools.matchers import Equals
+from testtools.matchers import (
+    Equals,
+    MatchesStructure,
+    )
 import transaction
 from zope.component import (
     getUtility,
@@ -84,6 +87,20 @@ class PackageUploadTestCase(TestCaseWithFactory):
         super(PackageUploadTestCase, self).setUp()
         self.test_publisher = SoyuzTestPublisher()
 
+    def test_add_log_entry(self):
+        upload = self.factory.makePackageUpload(
+            status=PackageUploadStatus.UNAPPROVED)
+        upload = removeSecurityProxy(upload)
+        self.assertEqual(upload.logs.count(), 0)
+
+        person = self.factory.makePerson()
+
+        upload._addLog(person, PackageUploadStatus.REJECTED, 'just because')
+
+        self.assertThat(upload.logs.one(), MatchesStructure.byEquality(
+            person=person, old_status=PackageUploadStatus.UNAPPROVED,
+            new_status=PackageUploadStatus.REJECTED, comment='just because'))
+
     def test_realiseUpload_for_overridden_component_archive(self):
         # If the component of an upload is overridden to 'Partner' for
         # example, then the new publishing record should be for the
@@ -353,6 +370,12 @@ class PackageUploadTestCase(TestCaseWithFactory):
         # it goes straight to DONE.)
         upload_one.acceptFromQueue()
         self.assertEqual("DONE", upload_one.status.name)
+
+        log = upload_one.logs.one()
+        self.assertThat(log, MatchesStructure.byEquality(
+            person=None, old_status=PackageUploadStatus.UNAPPROVED,
+            new_status=PackageUploadStatus.ACCEPTED, comment=None
+        ))
         transaction.commit()
 
         # Trying to accept the second fails.
@@ -361,9 +384,17 @@ class PackageUploadTestCase(TestCaseWithFactory):
         self.assertEqual("UNAPPROVED", upload_two.status.name)
 
         # Rejecting the second upload works.
-        upload_two.rejectFromQueue(self.factory.makePerson())
+        person = self.factory.makePerson()
+        upload_two.rejectFromQueue(person, 'Because yes')
         self.assertEqual("REJECTED", upload_two.status.name)
 
+        self.assertEqual(upload_two.logs.count(), 2)
+        log = upload_two.logs.order_by('id')[1]
+        self.assertThat(log, MatchesStructure.byEquality(
+            person=person, old_status=PackageUploadStatus.UNAPPROVED,
+            new_status=PackageUploadStatus.REJECTED, comment='Because yes'
+        ))
+
     def test_rejectFromQueue_source_sends_email(self):
         # Rejecting a source package sends an email to the uploader.
         self.test_publisher.prepareBreezyAutotest()