← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/async-upload-notifications into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/async-upload-notifications into lp:launchpad.

Commit message:
Send queue accept/reject notifications from a job rather than directly from the webapp.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #566339 in Launchpad itself: "Cannot accept package which would notify private email addresses"
  https://bugs.launchpad.net/launchpad/+bug/566339

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/async-upload-notifications/+merge/270078

Send queue accept/reject notifications from a job rather than directly from the webapp.

Like https://code.launchpad.net/~cjwatson/launchpad/async-branch-notifications/+merge/269788, but for uploads.  Most upload notifications already run from scripts or jobs, but acceptFromQueue/rejectFromQueue were exceptions to this.

The resurrected ArchiveJob is arguably a slightly weird choice as we don't use the archive context, but it's not terribly inappropriate for general Soyuz jobs and it saves bothering with a new table.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/async-upload-notifications into lp:launchpad.
=== modified file 'database/schema/security.cfg'
--- database/schema/security.cfg	2015-08-26 16:07:10 +0000
+++ database/schema/security.cfg	2015-09-03 15:33:27 +0000
@@ -1332,6 +1332,7 @@
 public.answercontact                    = SELECT
 public.archive                          = SELECT, INSERT, UPDATE
 public.archivearch                      = SELECT, INSERT, UPDATE
+public.archivejob                       = SELECT, INSERT
 public.archivepermission                = SELECT
 public.binarypackagebuild               = SELECT, INSERT, UPDATE
 public.binarypackagefile                = SELECT, INSERT
@@ -1444,6 +1445,7 @@
 public.answercontact                    = SELECT
 public.archive                          = SELECT, UPDATE
 public.archivearch                      = SELECT, UPDATE
+public.archivejob                       = SELECT, INSERT, UPDATE
 public.archivepermission                = SELECT
 public.binarypackagebuild               = SELECT, INSERT, UPDATE
 public.binarypackagefile                = SELECT, UPDATE

=== modified file 'lib/lp/services/config/schema-lazr.conf'
--- lib/lp/services/config/schema-lazr.conf	2015-09-02 16:50:04 +0000
+++ lib/lp/services/config/schema-lazr.conf	2015-09-03 15:33:27 +0000
@@ -1745,6 +1745,7 @@
     ICommercialExpiredJobSource,
     IGitRepositoryModifiedMailJobSource,
     IMembershipNotificationJobSource,
+    IPackageUploadNotificationJobSource,
     IPersonDeactivateJobSource,
     IPersonMergeJobSource,
     IPlainPackageCopyJobSource,
@@ -1813,6 +1814,11 @@
 module: lp.soyuz.interfaces.packagetranslationsuploadjob
 dbuser: upload_package_translations_job
 
+[IPackageUploadNotificationJobSource]
+module: lp.soyuz.interfaces.archivejob
+dbuser: queued
+crontab_group: FREQUENT
+
 [IPersonMergeJobSource]
 module: lp.registry.interfaces.persontransferjob
 dbuser: person-merge-job

=== modified file 'lib/lp/soyuz/configure.zcml'
--- lib/lp/soyuz/configure.zcml	2015-05-19 02:45:25 +0000
+++ lib/lp/soyuz/configure.zcml	2015-09-03 15:33:27 +0000
@@ -417,6 +417,26 @@
             interface="lp.soyuz.interfaces.archive.IArchiveSet"/>
     </securedutility>
 
+    <!-- ArchiveJob -->
+
+    <class class="lp.soyuz.model.archivejob.ArchiveJob">
+        <allow interface="lp.soyuz.interfaces.archivejob.IArchiveJob"/>
+    </class>
+    <class class="lp.soyuz.model.archivejob.PackageUploadNotificationJob">
+        <allow interface="lp.soyuz.interfaces.archivejob.IArchiveJob"/>
+        <allow interface="lp.soyuz.interfaces.archivejob.IPackageUploadNotificationJob"/>
+    </class>
+    <securedutility
+        component="lp.soyuz.model.archivejob.ArchiveJob"
+        provides="lp.soyuz.interfaces.archivejob.IArchiveJobSource">
+        <allow interface="lp.soyuz.interfaces.archivejob.IArchiveJobSource"/>
+    </securedutility>
+    <securedutility
+        component="lp.soyuz.model.archivejob.PackageUploadNotificationJob"
+        provides="lp.soyuz.interfaces.archivejob.IPackageUploadNotificationJobSource">
+        <allow interface="lp.soyuz.interfaces.archivejob.IPackageUploadNotificationJobSource"/>
+    </securedutility>
+
     <!-- ArchivePermission -->
 
     <class

=== modified file 'lib/lp/soyuz/doc/distroseriesqueue.txt'
--- lib/lp/soyuz/doc/distroseriesqueue.txt	2015-06-08 00:51:03 +0000
+++ lib/lp/soyuz/doc/distroseriesqueue.txt	2015-09-03 15:33:27 +0000
@@ -821,6 +821,20 @@
 Two emails are generated.  We won't look what is inside them here, that is
 well shown in nascentupload-announcements.txt.
 
+    >>> from lp.services.config import config
+    >>> from lp.services.job.runner import JobRunner
+    >>> from lp.soyuz.interfaces.archivejob import (
+    ...     IPackageUploadNotificationJobSource,
+    ...     )
+    >>> from lp.testing.dbuser import dbuser
+
+    >>> def run_package_upload_notification_jobs():
+    ...     job_source = getUtility(IPackageUploadNotificationJobSource)
+    ...     logger = DevNullLogger()
+    ...     with dbuser(config.IPackageUploadNotificationJobSource.dbuser):
+    ...         JobRunner.fromReady(job_source, logger).runAll()
+
+    >>> run_package_upload_notification_jobs()
     >>> [notification, announcement] = pop_notifications()
 
 When accepting single sources we also immediately create its
@@ -845,6 +859,7 @@
 
 One email is generated (see nascentupload-announcements.txt)
 
+    >>> run_package_upload_notification_jobs()
     >>> [notification] = pop_notifications()
 
 Clean up the librarian files:

=== modified file 'lib/lp/soyuz/enums.py'
--- lib/lp/soyuz/enums.py	2013-07-02 04:02:09 +0000
+++ lib/lp/soyuz/enums.py	2015-09-03 15:33:27 +0000
@@ -1,10 +1,11 @@
-# Copyright 2010-2012 Canonical Ltd.  This software is licensed under the
+# Copyright 2010-2015 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Enumerations used in the lp/soyuz modules."""
 
 __metaclass__ = type
 __all__ = [
+    'ArchiveJobType',
     'ArchivePermissionType',
     'ArchivePurpose',
     'ArchiveStatus',
@@ -39,6 +40,19 @@
 re_bug_numbers = re.compile(r"\#?\s?(\d+)")
 
 
+class ArchiveJobType(DBEnumeratedType):
+    """Values that IArchiveJob.job_type can take."""
+
+    # 0 was once used for COPY_ARCHIVE.
+
+    PACKAGE_UPLOAD_NOTIFICATION = DBItem(1, """
+        Package upload notification
+
+        Send notifications about a package upload being accepted, rejected,
+        or held for approval.
+        """)
+
+
 class ArchivePermissionType(DBEnumeratedType):
     """Archive Permission Type.
 

=== added file 'lib/lp/soyuz/interfaces/archivejob.py'
--- lib/lp/soyuz/interfaces/archivejob.py	1970-01-01 00:00:00 +0000
+++ lib/lp/soyuz/interfaces/archivejob.py	2015-09-03 15:33:27 +0000
@@ -0,0 +1,66 @@
+# Copyright 2010-2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""ArchiveJob interfaces."""
+
+__metaclass__ = type
+
+__all__ = [
+    'IArchiveJob',
+    'IArchiveJobSource',
+    'IPackageUploadNotificationJob',
+    'IPackageUploadNotificationJobSource',
+    ]
+
+
+from zope.interface import (
+    Attribute,
+    Interface,
+    )
+from zope.schema import (
+    Int,
+    Object,
+    )
+
+from lp import _
+from lp.services.job.interfaces.job import (
+    IJob,
+    IJobSource,
+    IRunnableJob,
+    )
+from lp.soyuz.interfaces.archive import IArchive
+
+
+class IArchiveJob(Interface):
+    """A Job related to an Archive."""
+
+    id = Int(
+        title=_('DB ID'), required=True, readonly=True,
+        description=_("The tracking number for this job."))
+
+    archive = Object(
+        title=_('The archive this job is about.'), schema=IArchive,
+        required=True)
+
+    job = Object(
+        title=_('The common Job attributes'), schema=IJob, required=True)
+
+    metadata = Attribute('A dict of data about the job.')
+
+    def destroySelf():
+        """Destroy this object."""
+
+
+class IArchiveJobSource(IJobSource):
+    """An interface for acquiring IArchiveJobs."""
+
+    def create(archive):
+        """Create a new IArchiveJob for an archive."""
+
+
+class IPackageUploadNotificationJob(IRunnableJob):
+    """A Job to send package upload notifications."""
+
+
+class IPackageUploadNotificationJobSource(IArchiveJobSource):
+    """Interface for acquiring PackageUploadNotificationJobs."""

=== modified file 'lib/lp/soyuz/interfaces/queue.py'
--- lib/lp/soyuz/interfaces/queue.py	2015-09-02 12:05:15 +0000
+++ lib/lp/soyuz/interfaces/queue.py	2015-09-03 15:33:27 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Queue interfaces."""
@@ -400,12 +400,16 @@
         committed to have some updates actually written to the database.
         """
 
-    def notify(summary_text=None, changes_file_object=None, logger=None):
+    def notify(status=None, summary_text=None, changes_file_object=None,
+               logger=None):
         """Notify by email when there is a new distroseriesqueue entry.
 
         This will send new, accept, announce and rejection messages as
         appropriate.
 
+        :param status: The current `PackageUploadStatus` when this
+            notification was generated.  Defaults to `self.status`.
+
         :param summary_text: Any additional text to append to the auto-
             generated summary.  This is also the only text used if there is
             a rejection message generated.

=== added file 'lib/lp/soyuz/model/archivejob.py'
--- lib/lp/soyuz/model/archivejob.py	1970-01-01 00:00:00 +0000
+++ lib/lp/soyuz/model/archivejob.py	2015-09-03 15:33:27 +0000
@@ -0,0 +1,178 @@
+# Copyright 2010-2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+
+import json
+import logging
+import StringIO
+
+from lazr.delegates import delegate_to
+from storm.expr import And
+from storm.locals import (
+    Int,
+    Reference,
+    Unicode,
+    )
+from zope.component import getUtility
+from zope.interface import (
+    implementer,
+    provider,
+    )
+
+from lp.services.config import config
+from lp.services.database.enumcol import EnumCol
+from lp.services.database.interfaces import IMasterStore
+from lp.services.database.stormbase import StormBase
+from lp.services.job.model.job import (
+    EnumeratedSubclass,
+    Job,
+    )
+from lp.services.job.runner import BaseRunnableJob
+from lp.soyuz.enums import (
+    ArchiveJobType,
+    PackageUploadStatus,
+    )
+from lp.soyuz.interfaces.archivejob import (
+    IArchiveJob,
+    IArchiveJobSource,
+    IPackageUploadNotificationJob,
+    IPackageUploadNotificationJobSource,
+    )
+from lp.soyuz.interfaces.queue import IPackageUploadSet
+from lp.soyuz.model.archive import Archive
+
+
+@implementer(IArchiveJob)
+class ArchiveJob(StormBase):
+    """Base class for jobs related to Archives."""
+
+    __storm_table__ = 'ArchiveJob'
+
+    id = Int(primary=True)
+
+    job_id = Int(name='job')
+    job = Reference(job_id, Job.id)
+
+    archive_id = Int(name='archive')
+    archive = Reference(archive_id, Archive.id)
+
+    job_type = EnumCol(enum=ArchiveJobType, notNull=True)
+
+    _json_data = Unicode('json_data')
+
+    @property
+    def metadata(self):
+        return json.loads(self._json_data)
+
+    def __init__(self, archive, job_type, metadata):
+        """Create an ArchiveJob.
+
+        :param archive: the `IArchive` this job relates to.
+        :param job_type: the `ArchiveJobType` of this job.
+        :param metadata: the type-specific variables, as a json-compatible
+            dict.
+        """
+        super(ArchiveJob, self).__init__()
+        self.job = Job()
+        self.archive = archive
+        self.job_type = job_type
+        self._json_data = unicode(json.dumps(metadata, ensure_ascii=False))
+
+
+@delegate_to(IArchiveJob)
+@provider(IArchiveJobSource)
+class ArchiveJobDerived(BaseRunnableJob):
+    """Intermediate class for deriving from ArchiveJob."""
+
+    __metaclass__ = EnumeratedSubclass
+
+    def __init__(self, job):
+        self.context = job
+
+    @classmethod
+    def create(cls, archive, metadata=None):
+        """See `IArchiveJob`."""
+        if metadata is None:
+            metadata = {}
+        job = ArchiveJob(archive, cls.class_job_type, metadata)
+        derived = cls(job)
+        derived.celeryRunOnCommit()
+        return derived
+
+    @classmethod
+    def iterReady(cls):
+        """Iterate through all ready ArchiveJobs."""
+        store = IMasterStore(ArchiveJob)
+        jobs = store.find(
+            ArchiveJob,
+            And(ArchiveJob.job_type == cls.class_job_type,
+                ArchiveJob.job_id.is_in(Job.ready_jobs)))
+        return (cls(job) for job in jobs)
+
+    def getOopsVars(self):
+        """See `IRunnableJob`."""
+        vars = super(ArchiveJobDerived, self).getOopsVars()
+        vars.extend([
+            ('archive_id', self.context.archive.id),
+            ('archive_job_id', self.context.id),
+            ('archive_job_type', self.context.job_type.title),
+            ])
+        return vars
+
+
+@implementer(IPackageUploadNotificationJob)
+@provider(IPackageUploadNotificationJobSource)
+class PackageUploadNotificationJob(ArchiveJobDerived):
+
+    class_job_type = ArchiveJobType.PACKAGE_UPLOAD_NOTIFICATION
+
+    config = config.IPackageUploadNotificationJobSource
+
+    @classmethod
+    def create(cls, packageupload, summary_text=None):
+        """See `IPackageUploadNotificationJobSource`."""
+        metadata = {
+            'packageupload_id': packageupload.id,
+            'packageupload_status': packageupload.status.title,
+            'summary_text': summary_text,
+            }
+        return super(PackageUploadNotificationJob, cls).create(
+            packageupload.archive, metadata)
+
+    def getOopsVars(self):
+        """See `ArchiveJobDerived`."""
+        vars = super(PackageUploadNotificationJob, self).getOopsVars()
+        vars.extend([
+            ('packageupload_id', self.metadata['packageupload_id']),
+            ('packageupload_status', self.metadata['packageupload_status']),
+            ('summary_text', self.metadata['summary_text']),
+            ])
+        return vars
+
+    @property
+    def packageupload(self):
+        return getUtility(IPackageUploadSet).get(
+            self.metadata['packageupload_id'])
+
+    @property
+    def packageupload_status(self):
+        return PackageUploadStatus.getTermByToken(
+            self.metadata['packageupload_status']).value
+
+    @property
+    def summary_text(self):
+        return self.metadata['summary_text']
+
+    def run(self):
+        """See `IRunnableJob`."""
+        packageupload = self.packageupload
+        if packageupload.changesfile is None:
+            changes_file_object = None
+        else:
+            changes_file_object = StringIO.StringIO(
+                packageupload.changesfile.read())
+        logger = logging.getLogger()
+        packageupload.notify(
+            status=self.packageupload_status, summary_text=self.summary_text,
+            changes_file_object=changes_file_object, logger=logger)

=== modified file 'lib/lp/soyuz/model/queue.py'
--- lib/lp/soyuz/model/queue.py	2015-09-02 12:05:15 +0000
+++ lib/lp/soyuz/model/queue.py	2015-09-03 15:33:27 +0000
@@ -14,7 +14,6 @@
 from itertools import chain
 import os
 import shutil
-import StringIO
 import tempfile
 
 from sqlobject import (
@@ -91,6 +90,7 @@
     PriorityNotFound,
     SectionNotFound,
     )
+from lp.soyuz.interfaces.archivejob import IPackageUploadNotificationJobSource
 from lp.soyuz.interfaces.archivepermission import IArchivePermissionSet
 from lp.soyuz.interfaces.component import IComponentSet
 from lp.soyuz.interfaces.packagecopyjob import IPackageCopyJobSource
@@ -546,11 +546,7 @@
 
         self.setAccepted()
 
-        changes_file_object = StringIO.StringIO(self.changesfile.read())
-        # We explicitly allow unsigned uploads here since the .changes file
-        # is pulled from the librarian which are stripped of their
-        # signature just before being stored.
-        self.notify(changes_file_object=changes_file_object)
+        getUtility(IPackageUploadNotificationJobSource).create(self)
         self.syncUpdate()
 
         # If this is a single source upload we can create the
@@ -595,18 +591,12 @@
             # don't think we need them for sync rejections.
             return
 
-        if self.changesfile is None:
-            changes_file_object = None
-        else:
-            changes_file_object = StringIO.StringIO(self.changesfile.read())
         if comment:
             summary_text = "Rejected by %s: %s" % (user.displayname, comment)
         else:
             summary_text = "Rejected by %s." % user.displayname
-        # We allow unsigned uploads since they come from the librarian,
-        # which are now stored unsigned.
-        self.notify(
-            changes_file_object=changes_file_object, summary_text=summary_text)
+        getUtility(IPackageUploadNotificationJobSource).create(
+            self, summary_text=summary_text)
         self.syncUpdate()
         if bool(getFeatureFlag('auditor.enabled')):
             client = AuditorClient()
@@ -896,8 +886,11 @@
         else:
             return None
 
-    def notify(self, summary_text=None, changes_file_object=None, logger=None):
+    def notify(self, status=None, summary_text=None, changes_file_object=None,
+               logger=None):
         """See `IPackageUpload`."""
+        if status is None:
+            status = self.status
         status_action = {
             PackageUploadStatus.NEW: 'new',
             PackageUploadStatus.UNAPPROVED: 'unapproved',
@@ -912,7 +905,7 @@
             changesfile_content = 'No changes file content available.'
         blamee = self.findPersonToNotify()
         mailer = PackageUploadMailer.forAction(
-            status_action[self.status], blamee, self.sourcepackagerelease,
+            status_action[status], blamee, self.sourcepackagerelease,
             self.builds, self.customfiles, self.archive, self.distroseries,
             self.pocket, summary_text=summary_text, changes=changes,
             changesfile_content=changesfile_content,

=== modified file 'lib/lp/soyuz/stories/soyuz/xx-queue-pages.txt'
--- lib/lp/soyuz/stories/soyuz/xx-queue-pages.txt	2015-08-26 13:41:21 +0000
+++ lib/lp/soyuz/stories/soyuz/xx-queue-pages.txt	2015-09-03 15:33:27 +0000
@@ -304,8 +304,21 @@
 
 Swallow any email generated at the upload:
 
+    >>> from lp.services.config import config
+    >>> from lp.services.job.runner import JobRunner
     >>> from lp.services.mail import stub
+    >>> from lp.soyuz.interfaces.archivejob import (
+    ...     IPackageUploadNotificationJobSource,
+    ...     )
     >>> from lp.testing.mail_helpers import pop_notifications
+
+    >>> def run_package_upload_notification_jobs():
+    ...     with permissive_security_policy(
+    ...             config.IPackageUploadNotificationJobSource.dbuser):
+    ...         job_source = getUtility(IPackageUploadNotificationJobSource)
+    ...         JobRunner.fromReady(job_source).runAll()
+
+    >>> run_package_upload_notification_jobs()
     >>> swallow = pop_notifications()
 
 Set up a second browser on the same page to simulate accidentally posting to
@@ -347,6 +360,7 @@
 if it is someone other than the uploader) and (usually) an email to the
 distroseries' announcement list (see nascentupload-announcements.txt).
 
+    >>> run_package_upload_notification_jobs()
     >>> [changer_notification, signer_notification,
     ...  announcement] = pop_notifications()
     >>> print changer_notification['To']
@@ -368,6 +382,7 @@
 
 No emails are sent in this case:
 
+    >>> run_package_upload_notification_jobs()
     >>> len(stub.test_emails)
     0
 
@@ -513,6 +528,7 @@
 
 Rejecting 'alsa-utils' source:
 
+    >>> run_package_upload_notification_jobs()
     >>> stub.test_emails = []
 
     >>> upload_manager_browser.getControl(name="QUEUE_ID").value = ['4']
@@ -531,6 +547,7 @@
 
 One rejection email is generated:
 
+    >>> run_package_upload_notification_jobs()
     >>> [rejection] = pop_notifications()
     >>> rejection['Subject']
     '[ubuntu/breezy-autotest] alsa-utils 1.0.9a-4ubuntu1 (Rejected)'

=== added file 'lib/lp/soyuz/tests/test_archivejob.py'
--- lib/lp/soyuz/tests/test_archivejob.py	1970-01-01 00:00:00 +0000
+++ lib/lp/soyuz/tests/test_archivejob.py	2015-09-03 15:33:27 +0000
@@ -0,0 +1,113 @@
+# Copyright 2010-2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+from lp.services.job.runner import JobRunner
+from lp.services.mail.sendmail import format_address_for_person
+from lp.soyuz.enums import (
+    ArchiveJobType,
+    PackageUploadStatus,
+    )
+from lp.soyuz.model.archivejob import (
+    ArchiveJob,
+    ArchiveJobDerived,
+    PackageUploadNotificationJob,
+    )
+from lp.testing import TestCaseWithFactory
+from lp.testing.dbuser import dbuser
+from lp.testing.layers import (
+    LaunchpadZopelessLayer,
+    ZopelessDatabaseLayer,
+    )
+from lp.testing.mail_helpers import pop_notifications
+
+
+class TestArchiveJob(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+
+    def test_instantiate(self):
+        # ArchiveJob.__init__() instantiates a ArchiveJob instance.
+        archive = self.factory.makeArchive()
+
+        metadata = ('some', 'arbitrary', 'metadata')
+        archive_job = ArchiveJob(
+            archive, ArchiveJobType.PACKAGE_UPLOAD_NOTIFICATION, metadata)
+
+        self.assertEqual(archive, archive_job.archive)
+        self.assertEqual(
+            ArchiveJobType.PACKAGE_UPLOAD_NOTIFICATION, archive_job.job_type)
+
+        # When we actually access the ArchiveJob's metadata it gets
+        # deserialized from JSON, so the representation returned by
+        # archive_job.metadata will be different from what we originally
+        # passed in.
+        metadata_expected = [u'some', u'arbitrary', u'metadata']
+        self.assertEqual(metadata_expected, archive_job.metadata)
+
+
+class TestArchiveJobDerived(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+
+    def test_create_explodes(self):
+        # ArchiveJobDerived.create() will blow up because it needs to be
+        # subclassed to work properly.
+        archive = self.factory.makeArchive()
+        self.assertRaises(
+            AttributeError, ArchiveJobDerived.create, archive)
+
+
+class TestPackageUploadNotificationJob(TestCaseWithFactory):
+
+    layer = LaunchpadZopelessLayer
+
+    def test_getOopsVars(self):
+        upload = self.factory.makePackageUpload(
+            status=PackageUploadStatus.ACCEPTED)
+        job = PackageUploadNotificationJob.create(
+            upload, summary_text='Fake summary')
+        expected = [
+            ('job_id', job.context.job.id),
+            ('archive_id', upload.archive.id),
+            ('archive_job_id', job.context.id),
+            ('archive_job_type', 'Package upload notification'),
+            ('packageupload_id', upload.id),
+            ('packageupload_status', 'Accepted'),
+            ('summary_text', 'Fake summary'),
+            ]
+        self.assertEqual(expected, job.getOopsVars())
+
+    def test_metadata(self):
+        upload = self.factory.makePackageUpload(
+            status=PackageUploadStatus.ACCEPTED)
+        job = PackageUploadNotificationJob.create(
+            upload, summary_text='Fake summary')
+        expected = {
+            'packageupload_id': upload.id,
+            'packageupload_status': 'Accepted',
+            'summary_text': 'Fake summary',
+            }
+        self.assertEqual(expected, job.metadata)
+        self.assertEqual(upload, job.packageupload)
+        self.assertEqual(
+            PackageUploadStatus.ACCEPTED, job.packageupload_status)
+        self.assertEqual('Fake summary', job.summary_text)
+
+    def test_run(self):
+        # Running a job produces a notification.  Detailed tests of which
+        # notifications go to whom live in the PackageUpload and
+        # PackageUploadMailer tests.
+        upload = self.factory.makeSourcePackageUpload()
+        self.factory.makeComponentSelection(
+            upload.distroseries, upload.sourcepackagerelease.component)
+        upload.setAccepted()
+        job = PackageUploadNotificationJob.create(
+            upload, summary_text='Fake summary')
+        with dbuser(job.config.dbuser):
+            JobRunner([job]).runAll()
+        [email] = pop_notifications()
+        self.assertEqual(
+            format_address_for_person(upload.sourcepackagerelease.creator),
+            email['To'])
+        self.assertIn('(Accepted)', email['Subject'])
+        self.assertIn('Fake summary', email.get_payload()[0].get_payload())

=== modified file 'lib/lp/soyuz/tests/test_packagecopyjob.py'
--- lib/lp/soyuz/tests/test_packagecopyjob.py	2015-08-25 14:05:24 +0000
+++ lib/lp/soyuz/tests/test_packagecopyjob.py	2015-09-03 15:33:27 +0000
@@ -1716,13 +1716,20 @@
 
     layer = CeleryJobLayer
 
+    def setUp(self):
+        super(TestViaCelery, self).setUp()
+        # Turn on Celery handling of PCJs and the resulting notification jobs.
+        self.useFixture(FeatureFixture({
+            'jobs.celery.enabled_classes':
+                'PlainPackageCopyJob PackageUploadNotificationJob',
+        }))
+
+    def tearDown(self):
+        super(TestViaCelery, self).tearDown()
+        pop_remote_notifications()
+
     def test_run(self):
         # A proper test run synchronizes packages.
-        # Turn on Celery handling of PCJs.
-        self.useFixture(FeatureFixture({
-            'jobs.celery.enabled_classes': 'PlainPackageCopyJob',
-        }))
-
         job = create_proper_job(self.factory)
         self.assertEqual("libc", job.package_name)
         self.assertEqual("2.8-1", job.package_version)
@@ -1743,10 +1750,6 @@
     def test_resume_from_queue(self):
         # Accepting a suspended copy from the queue sends it back
         # through celery.
-        self.useFixture(FeatureFixture({
-            'jobs.celery.enabled_classes': 'PlainPackageCopyJob',
-        }))
-
         source_pub = self.factory.makeSourcePackagePublishingHistory(
             component=u"main", status=PackagePublishingStatus.PUBLISHED)
         target_series = self.factory.makeDistroSeries()

=== modified file 'lib/lp/soyuz/tests/test_packageupload.py'
--- lib/lp/soyuz/tests/test_packageupload.py	2015-08-26 13:41:21 +0000
+++ lib/lp/soyuz/tests/test_packageupload.py	2015-09-03 15:33:27 +0000
@@ -27,6 +27,7 @@
 from lp.services.config import config
 from lp.services.database.interfaces import IStore
 from lp.services.job.interfaces.job import JobStatus
+from lp.services.job.runner import JobRunner
 from lp.services.librarian.browser import ProxiedLibraryFileAlias
 from lp.services.mail import stub
 from lp.soyuz.adapters.overrides import SourceOverride
@@ -35,6 +36,7 @@
     PackageUploadCustomFormat,
     PackageUploadStatus,
     )
+from lp.soyuz.interfaces.archivejob import IPackageUploadNotificationJobSource
 from lp.soyuz.interfaces.archivepermission import IArchivePermissionSet
 from lp.soyuz.interfaces.component import IComponentSet
 from lp.soyuz.interfaces.queue import (
@@ -54,6 +56,7 @@
     StormStatementRecorder,
     TestCaseWithFactory,
     )
+from lp.testing.dbuser import dbuser
 from lp.testing.layers import (
     LaunchpadFunctionalLayer,
     LaunchpadZopelessLayer,
@@ -182,6 +185,13 @@
         transaction.commit()
         return upload, uploader
 
+    def runPackageUploadNotificationJob(self):
+        """Expect one package upload notification job, and run it."""
+        job_source = getUtility(IPackageUploadNotificationJobSource)
+        [job] = list(job_source.iterReady())
+        with dbuser(config.IPackageUploadNotificationJobSource.dbuser):
+            JobRunner([job]).runAll()
+
     def assertEmail(self, expected_to_addrs):
         """Pop an email from the stub queue and check its recipients."""
         _, to_addrs, _ = stub.test_emails.pop()
@@ -193,6 +203,7 @@
         self.test_publisher.prepareBreezyAutotest()
         upload, uploader = self.makeSourcePackageUpload()
         upload.acceptFromQueue()
+        self.runPackageUploadNotificationJob()
         self.assertEqual(2, len(stub.test_emails))
         # Emails sent are the uploader's notification and the announcement:
         self.assertEmail([uploader.preferredemail.email])
@@ -207,6 +218,7 @@
         upload, uploader = self.makeSourcePackageUpload(
             pocket=PackagePublishingPocket.BACKPORTS)
         upload.acceptFromQueue()
+        self.runPackageUploadNotificationJob()
         self.assertEqual(1, len(stub.test_emails))
         # Only one email is sent, to the person in the changed-by field.  No
         # announcement email is sent.
@@ -221,6 +233,7 @@
             pocket=PackagePublishingPocket.PROPOSED,
             section_name="translations")
         upload.acceptFromQueue()
+        self.runPackageUploadNotificationJob()
         self.assertEqual("DONE", upload.status.name)
         self.assertEqual(0, len(stub.test_emails))
 
@@ -267,6 +280,7 @@
         self.test_publisher.prepareBreezyAutotest()
         upload, _ = self.makeBuildPackageUpload()
         upload.acceptFromQueue()
+        self.runPackageUploadNotificationJob()
         self.assertEqual(0, len(stub.test_emails))
 
     def test_acceptFromQueue_handles_duplicates(self):
@@ -314,6 +328,7 @@
         self.test_publisher.prepareBreezyAutotest()
         upload, uploader = self.makeSourcePackageUpload()
         upload.rejectFromQueue(self.factory.makePerson())
+        self.runPackageUploadNotificationJob()
         self.assertEqual(1, len(stub.test_emails))
         self.assertEmail([uploader.preferredemail.email])
 
@@ -322,6 +337,7 @@
         self.test_publisher.prepareBreezyAutotest()
         upload, uploader = self.makeBuildPackageUpload()
         upload.rejectFromQueue(self.factory.makePerson())
+        self.runPackageUploadNotificationJob()
         self.assertEqual(1, len(stub.test_emails))
         self.assertEmail([uploader.preferredemail.email])
 
@@ -333,6 +349,7 @@
             pocket=PackagePublishingPocket.PROPOSED,
             section_name="translations")
         upload.rejectFromQueue(self.factory.makePerson())
+        self.runPackageUploadNotificationJob()
         self.assertEqual(0, len(stub.test_emails))
 
     def test_rejectFromQueue_source_with_reason(self):
@@ -342,6 +359,7 @@
         upload, uploader = self.makeSourcePackageUpload()
         person = self.factory.makePerson()
         upload.rejectFromQueue(user=person, comment='Because.')
+        self.runPackageUploadNotificationJob()
         self.assertEqual(1, len(stub.test_emails))
         self.assertIn(
             'Rejected:\nRejected by %s: Because.' % person.displayname,


Follow ups