← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~sinzui/launchpad/queue-project-email into lp:launchpad

 

Curtis Hovey has proposed merging lp:~sinzui/launchpad/queue-project-email into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #721438 in Launchpad itself: "Automatically email projects with license/content issues"
  https://bugs.launchpad.net/launchpad/+bug/721438

For more details, see:
https://code.launchpad.net/~sinzui/launchpad/queue-project-email/+merge/108235

Pre-implementation: abentley, wgrant

Launchpad needs a daily process that queues the jobs to send emails
about expiring commercial licenses.

--------------------------------------------------------------------


RULES

    * Using daily-builds as a guide
      * Create a cronscript that gets each kind of product job source
      * It calls each job source to make the needed jobs.
      * Maybe the job sources need to be called in a specific order.


QA

    * Verify database/schema/security.py --no-revoke was run to
      add the commercialsubscription table to the product-job user.
    * Ask a WebOps to run ./cronscripts/daily_product_jobs.py -vvv
    * Verify that the script reports that 14 or more jobs were created.
    * Verify that a ThirtyDay... job were created for numerink.
    * Verify that an Expired... job were created for sausalito.
    * Verify that sausalito is deactivated.


LINT

    cronscripts/daily_product_jobs.py
    database/schema/security.cfg
    lib/lp/registry/interfaces/productjob.py
    lib/lp/registry/model/productjob.py
    lib/lp/registry/tests/test_productjob.py
    lib/lp/services/config/schema-lazr.conf


TEST

    ./bin/test -vvc lp.registry.tests.test_productjob


IMPLEMENTATION

Created a cronscript that calls ProductJobManager.createAllDailyJobs().
I discovered that I neglected to add the product-job user to the config.
The queries to find expiring products needed access to the
commercialsubscription which I added via security.cfg.
    cronscripts/daily_product_jobs.py
    database/schema/security.cfg
    lib/lp/services/config/schema-lazr.conf

I extracted the create() method from the three commercial job sources to
a base interface and added getExpiringProducts(). The ProductJobManager
requires that all the commercial jobs to have the same contracts.
    lib/lp/registry/interfaces/productjob.py

I added the ProductJobManager to orchestra job creation. This will grow
to handle "other/open source" and "other/I don't know" cases in the
future. ProductJobManager does the primary work for the cronscript. I
added getExpiringProducts() to the mixin class used by the commercial
jobs, then updated each class to provide _get_expiration_dates() that
returns the set of dates that constrain the search. In the tests I add
CommercialHelpers which is used by old and new tests to ensure the job
classes, the manager, and the cronscript has a common setup to ensure
they operate under the same conditions.
    lib/lp/registry/model/productjob.py
    lib/lp/registry/tests/test_productjob.py
-- 
https://code.launchpad.net/~sinzui/launchpad/queue-project-email/+merge/108235
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~sinzui/launchpad/queue-project-email into lp:launchpad.
=== added file 'cronscripts/daily_product_jobs.py'
--- cronscripts/daily_product_jobs.py	1970-01-01 00:00:00 +0000
+++ cronscripts/daily_product_jobs.py	2012-05-31 20:24:32 +0000
@@ -0,0 +1,37 @@
+#!/usr/bin/python -S
+#
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+"""Request jobs to update products and send emails."""
+
+__metaclass__ = type
+
+import _pythonpath
+
+import transaction
+
+from lp.registry.model.productjob import ProductJobManager
+from lp.services.config import config
+from lp.services.scripts.base import LaunchpadCronScript
+from lp.services.webapp.errorlog import globalErrorUtility
+
+
+class RequestProductJobs(LaunchpadCronScript):
+    """Create `ProductJobs` for products that need updating."""
+
+    def __init__(self):
+        name = 'daily_product_jobs'
+        dbuser = config.product_job.dbuser
+        LaunchpadCronScript.__init__(self, name, dbuser)
+
+    def main(self):
+        globalErrorUtility.configure(self.name)
+        manager = ProductJobManager(self.logger)
+        job_count = manager.createAllDailyJobs()
+        self.logger.info('Requested %d total product jobs.' % job_count)
+        transaction.commit()
+
+
+if __name__ == '__main__':
+    script = RequestProductJobs()
+    script.lock_and_run()

=== modified file 'database/schema/security.cfg'
--- database/schema/security.cfg	2012-05-31 01:57:07 +0000
+++ database/schema/security.cfg	2012-05-31 20:24:32 +0000
@@ -2327,6 +2327,7 @@
 [product-job]
 groups=script
 public.account                          = SELECT
+public.commercialsubscription           = SELECT
 public.emailaddress                     = SELECT
 public.job                              = SELECT, INSERT, UPDATE
 public.person                           = SELECT

=== modified file 'lib/lp/registry/interfaces/productjob.py'
--- lib/lp/registry/interfaces/productjob.py	2012-05-24 20:25:54 +0000
+++ lib/lp/registry/interfaces/productjob.py	2012-05-31 20:24:32 +0000
@@ -17,7 +17,10 @@
     'IThirtyDayCommercialExpirationJobSource',
     ]
 
-from zope.interface import Attribute
+from zope.interface import (
+    Attribute,
+    Interface,
+    )
 from zope.schema import (
     Int,
     Object,
@@ -127,35 +130,43 @@
         """
 
 
+class ExpirationSource(Interface):
+
+    def create(product, reviewer):
+        """Create a new job.
+
+        :param product: An IProduct.
+        :param reviewer: The user or agent sending the email.
+        """
+
+    def getExpiringProducts():
+        """Return the products that require a job to update them.
+
+        The products returned can passed to create() to make the job.
+        The products have a commercial subscription that expires within
+        the job's effective date range. The products returned do not
+        have a recent job; once the job is created, the product is
+        excluded from the future calls to this method.
+        """
+
+
 class ISevenDayCommercialExpirationJob(IProductNotificationJob):
     """A job that sends an email about an expiring commercial subscription."""
 
 
-class ISevenDayCommercialExpirationJobSource(IProductNotificationJobSource):
+class ISevenDayCommercialExpirationJobSource(IProductNotificationJobSource,
+                                             ExpirationSource):
     """An interface for creating `ISevenDayCommercialExpirationJob`s."""
 
-    def create(product, reviewer):
-        """Create a new `ISevenDayCommercialExpirationJob`.
-
-        :param product: An IProduct.
-        :param reviewer: The user or agent sending the email.
-        """
-
 
 class IThirtyDayCommercialExpirationJob(IProductNotificationJob):
     """A job that sends an email about an expiring commercial subscription."""
 
 
-class IThirtyDayCommercialExpirationJobSource(IProductNotificationJobSource):
+class IThirtyDayCommercialExpirationJobSource(IProductNotificationJobSource,
+                                              ExpirationSource):
     """An interface for creating `IThirtyDayCommercialExpirationJob`s."""
 
-    def create(product, reviewer):
-        """Create a new `IThirtyDayCommercialExpirationJob`.
-
-        :param product: An IProduct.
-        :param reviewer: The user or agent sending the email.
-        """
-
 
 class ICommercialExpiredJob(IProductNotificationJob):
     """A job that sends an email about an expired commercial subscription.
@@ -166,12 +177,6 @@
     """
 
 
-class ICommercialExpiredJobSource(IProductNotificationJobSource):
+class ICommercialExpiredJobSource(IProductNotificationJobSource,
+                                  ExpirationSource):
     """An interface for creating `IThirtyDayCommercialExpirationJob`s."""
-
-    def create(product, reviewer):
-        """Create a new `ICommercialExpiredJob`.
-
-        :param product: An IProduct.
-        :param reviewer: The user or agent sending the email.
-        """

=== modified file 'lib/lp/registry/model/productjob.py'
--- lib/lp/registry/model/productjob.py	2012-05-24 21:26:57 +0000
+++ lib/lp/registry/model/productjob.py	2012-05-31 20:24:32 +0000
@@ -6,14 +6,24 @@
 __metaclass__ = type
 __all__ = [
     'ProductJob',
+    'ProductJobManager',
     'CommercialExpiredJob',
     'SevenDayCommercialExpirationJob',
     'ThirtyDayCommercialExpirationJob',
     ]
 
+from datetime import (
+    datetime,
+    timedelta,
+    )
+from pytz import utc
 from lazr.delegates import delegates
 import simplejson
-from storm.expr import And
+from storm.expr import (
+    And,
+    Not,
+    Select,
+    )
 from storm.locals import (
     Int,
     Reference,
@@ -26,6 +36,7 @@
     )
 from zope.security.proxy import removeSecurityProxy
 
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.registry.enums import ProductJobType
 from lp.registry.interfaces.person import IPersonSet
 from lp.registry.interfaces.product import (
@@ -44,6 +55,7 @@
     IThirtyDayCommercialExpirationJob,
     IThirtyDayCommercialExpirationJobSource,
     )
+from lp.registry.model.commercialsubscription import CommercialSubscription
 from lp.registry.model.product import Product
 from lp.services.config import config
 from lp.services.database.decoratedresultset import DecoratedResultSet
@@ -69,6 +81,43 @@
 from lp.services.webapp.publisher import canonical_url
 
 
+class ProductJobManager:
+    """Creates jobs for product that need updating or notification."""
+
+    def __init__(self, logger):
+        self.logger = logger
+
+    def createAllDailyJobs(self):
+        """Create jobs for all products that have timed updates.
+
+        :return: The count of jobs that were created.
+        """
+        reviewer = getUtility(ILaunchpadCelebrities).janitor
+        total = 0
+        total += self.createDailyJobs(CommercialExpiredJob, reviewer)
+        total += self.createDailyJobs(
+            SevenDayCommercialExpirationJob, reviewer)
+        total += self.createDailyJobs(
+            ThirtyDayCommercialExpirationJob, reviewer)
+        return total
+
+    def createDailyJobs(self, job_class, reviewer):
+        """Create jobs for products that have timed updates.
+
+        :param job_class: A JobSource class that provides `ExpirationSource`.
+        :param reviewer: The user that is creating the job.
+        :return: The count of jobs that were created.
+        """
+        total = 0
+        for product in job_class.getExpiringProducts():
+            self.logger.debug(
+                'Creating a %s for %s' %
+                (job_class.__class__.__name__, product.name))
+            job_class.create(product, reviewer)
+            total += 1
+        return total
+
+
 class ProductJob(StormBase):
     """Base class for product jobs."""
 
@@ -305,12 +354,32 @@
 
     @classmethod
     def create(cls, product, reviewer):
-        """Create a job."""
+        """See `ExpirationSourceMixin`."""
         subject = cls._subject_template % product.name
         return super(CommericialExpirationMixin, cls).create(
             product, cls._email_template_name, subject, reviewer,
             reply_to_commercial=True)
 
+    @classmethod
+    def getExpiringProducts(cls):
+        """See `ExpirationSourceMixin`."""
+        earliest_date, latest_date, past_date = cls._get_expiration_dates()
+        recent_jobs = And(
+            ProductJob.job_type == cls.class_job_type,
+            ProductJob.job_id == Job.id,
+            Job.date_created > past_date,
+            )
+        conditions = [
+            Product.active == True,
+            CommercialSubscription.productID == Product.id,
+            CommercialSubscription.date_expires >= earliest_date,
+            CommercialSubscription.date_expires < latest_date,
+            Not(Product.id.is_in(Select(
+                ProductJob.product_id,
+                tables=[ProductJob, Job], where=recent_jobs))),
+            ]
+        return IStore(Product).find(Product, *conditions)
+
     @cachedproperty
     def message_data(self):
         """See `IProductNotificationJob`."""
@@ -332,6 +401,13 @@
     classProvides(ISevenDayCommercialExpirationJobSource)
     class_job_type = ProductJobType.COMMERCIAL_EXPIRATION_7_DAYS
 
+    @staticmethod
+    def _get_expiration_dates():
+        now = datetime.now(utc)
+        in_seven_days = now + timedelta(days=7)
+        seven_days_ago = now - timedelta(days=7)
+        return now, in_seven_days, seven_days_ago
+
 
 class ThirtyDayCommercialExpirationJob(CommericialExpirationMixin,
                                        ProductNotificationJob):
@@ -341,6 +417,15 @@
     classProvides(IThirtyDayCommercialExpirationJobSource)
     class_job_type = ProductJobType.COMMERCIAL_EXPIRATION_30_DAYS
 
+    @staticmethod
+    def _get_expiration_dates():
+        now = datetime.now(utc)
+        # Avoid overlay with the seven day notification.
+        in_twenty_three_days = now + timedelta(days=7)
+        in_thirty_days = now + timedelta(days=30)
+        thirty_days_ago = now - timedelta(days=30)
+        return in_twenty_three_days, in_thirty_days, thirty_days_ago
+
 
 class CommercialExpiredJob(CommericialExpirationMixin, ProductNotificationJob):
     """A job that sends an email about an expired commercial subscription."""
@@ -353,6 +438,13 @@
     _subject_template = (
         'The commercial subscription for %s in Launchpad expired')
 
+    @staticmethod
+    def _get_expiration_dates():
+        now = datetime.now(utc)
+        ten_years_ago = now - timedelta(days=3650)
+        thirty_days_ago = now - timedelta(days=30)
+        return ten_years_ago, now, thirty_days_ago
+
     @property
     def _is_proprietary(self):
         """Does the product have a proprietary licence?"""

=== modified file 'lib/lp/registry/tests/test_productjob.py'
--- lib/lp/registry/tests/test_productjob.py	2012-05-24 20:25:54 +0000
+++ lib/lp/registry/tests/test_productjob.py	2012-05-31 20:24:32 +0000
@@ -9,7 +9,7 @@
     datetime,
     timedelta,
     )
-
+import transaction
 import pytz
 from zope.component import getUtility
 from zope.interface import (
@@ -39,6 +39,7 @@
 from lp.registry.model.productjob import (
     ProductJob,
     ProductJobDerived,
+    ProductJobManager,
     ProductNotificationJob,
     CommercialExpiredJob,
     SevenDayCommercialExpirationJob,
@@ -51,11 +52,96 @@
 from lp.testing.layers import (
     DatabaseFunctionalLayer,
     LaunchpadZopelessLayer,
+    ZopelessAppServerLayer,
     )
 from lp.testing.mail_helpers import pop_notifications
+from lp.services.log.logger import BufferLogger
+from lp.services.scripts.tests import run_script
 from lp.services.webapp.publisher import canonical_url
 
 
+class CommercialHelpers:
+
+    def make_expiring_product(self, date_expires, job_class=None):
+        product = self.factory.makeProduct(
+            licenses=[License.OTHER_PROPRIETARY])
+        removeSecurityProxy(
+            product.commercial_subscription).date_expires = date_expires
+        if job_class:
+            reviewer = getUtility(ILaunchpadCelebrities).janitor
+            job_class.create(product, reviewer)
+        return product
+
+    def make_test_products(self):
+        products = {}
+        now = datetime.now(pytz.utc)
+        products['approved'] = self.factory.makeProduct(licenses=[License.MIT])
+        products['expired'] = self.make_expiring_product(now - timedelta(1))
+        products['expired_with_job'] = self.make_expiring_product(
+            now - timedelta(1), job_class=CommercialExpiredJob)
+        products['seven'] = self.make_expiring_product(now + timedelta(6))
+        products['seven_with_job'] = self.make_expiring_product(
+            now + timedelta(6), job_class=SevenDayCommercialExpirationJob)
+        products['thirty'] = self.make_expiring_product(now + timedelta(29))
+        products['thirty_with_job'] = self.make_expiring_product(
+            now + timedelta(29), job_class=ThirtyDayCommercialExpirationJob)
+        products['sixty'] = self.make_expiring_product(now + timedelta(60))
+        return products
+
+
+class ProductJobManagerTestCase(TestCaseWithFactory, CommercialHelpers):
+    """Test case for the ProductJobManager class."""
+    layer = DatabaseFunctionalLayer
+
+    @staticmethod
+    def make_manager():
+        logger = BufferLogger()
+        return ProductJobManager(logger)
+
+    def test_init(self):
+        # The logger was set.
+        manager = self.make_manager()
+        self.assertIsInstance(manager.logger, BufferLogger)
+
+    def test_createAllDailyJobs(self):
+        # 3 kinds of commercial expiration jobs are created.
+        self.make_test_products()
+        manager = self.make_manager()
+        self.assertEqual(3, manager.createAllDailyJobs())
+        log = manager.logger.getLogBuffer()
+        self.assertIn(CommercialExpiredJob.__class__.__name__, log)
+        self.assertIn(SevenDayCommercialExpirationJob.__class__.__name__, log)
+        self.assertIn(ThirtyDayCommercialExpirationJob.__class__.__name__, log)
+
+    def test_createDailyJobs(self):
+        # Commercial expiration jobs are created.
+        test_products = self.make_test_products()
+        manager = self.make_manager()
+        reviewer = self.factory.makePerson()
+        total = manager.createDailyJobs(CommercialExpiredJob, reviewer)
+        self.assertEqual(1, total)
+        self.assertIn(
+            'DEBUG Creating a %s for %s' %
+            (CommercialExpiredJob.__class__.__name__,
+             test_products['expired'].name),
+            manager.logger.getLogBuffer())
+
+
+class DailyProductJobsTestCase(TestCaseWithFactory, CommercialHelpers):
+    """Test case for the ProductJobManager class."""
+    layer = ZopelessAppServerLayer
+
+    def test_run(self):
+        # The script called ProductJobManager.createAllDailyJobs().
+        # This test uses the same setup as
+        # ProductJobManagerTestCase.test_createAllDailyJobs
+        self.make_test_products()
+        transaction.commit()
+        retcode, stdout, stderr = run_script(
+            'cronscripts/daily_product_jobs.py', [])
+        self.assertIn('Requested 3 total product jobs.', stderr)
+
+
 class ProductJobTestCase(TestCaseWithFactory):
     """Test case for basic ProductJob class."""
 
@@ -388,11 +474,12 @@
             'Launchpad <noreply@xxxxxxxxxxxxx>', notifications[0]['From'])
 
 
-class CommericialExpirationMixin:
+class CommericialExpirationMixin(CommercialHelpers):
 
     layer = DatabaseFunctionalLayer
 
     EXPIRE_SUBSCRIPTION = False
+    EXPECTED_PRODUCT = None
 
     def make_notification_data(self, licenses=[License.MIT]):
         product = self.factory.makeProduct(licenses=licenses)
@@ -402,6 +489,14 @@
         reviewer = getUtility(ILaunchpadCelebrities).janitor
         return product, reviewer
 
+    def test_getExpiringProducts(self):
+        # Get the products with an expiring commercial subscription in
+        # the job type's date range that do not already have a recent job.
+        test_products = self.make_test_products()
+        products = list(self.JOB_CLASS.getExpiringProducts())
+        self.assertEqual(1, len(products))
+        self.assertEqual(test_products[self.EXPECTED_PRODUCT], products[0])
+
     def test_create(self):
         # Create an instance of an commercial expiration job that stores
         # the notification information.
@@ -459,32 +554,53 @@
                                               TestCaseWithFactory):
     """Test case for the SevenDayCommercialExpirationJob class."""
 
+    EXPECTED_PRODUCT = 'seven'
     JOB_INTERFACE = ISevenDayCommercialExpirationJob
     JOB_SOURCE_INTERFACE = ISevenDayCommercialExpirationJobSource
     JOB_CLASS = SevenDayCommercialExpirationJob
     JOB_CLASS_TYPE = ProductJobType.COMMERCIAL_EXPIRATION_7_DAYS
 
+    def test_get_expiration_dates(self):
+        dates = SevenDayCommercialExpirationJob._get_expiration_dates()
+        earliest_date, latest_date, past_date = dates
+        self.assertEqual(timedelta(days=7), latest_date - earliest_date)
+        self.assertEqual(timedelta(days=14), latest_date - past_date)
+
 
 class ThirtyDayCommercialExpirationJobTestCase(CommericialExpirationMixin,
                                                TestCaseWithFactory):
     """Test case for the SevenDayCommercialExpirationJob class."""
 
+    EXPECTED_PRODUCT = 'thirty'
     JOB_INTERFACE = IThirtyDayCommercialExpirationJob
     JOB_SOURCE_INTERFACE = IThirtyDayCommercialExpirationJobSource
     JOB_CLASS = ThirtyDayCommercialExpirationJob
     JOB_CLASS_TYPE = ProductJobType.COMMERCIAL_EXPIRATION_30_DAYS
 
+    def test_get_expiration_dates(self):
+        dates = ThirtyDayCommercialExpirationJob._get_expiration_dates()
+        earliest_date, latest_date, past_date = dates
+        self.assertEqual(timedelta(days=23), latest_date - earliest_date)
+        self.assertEqual(timedelta(days=60), latest_date - past_date)
+
 
 class CommercialExpiredJobTestCase(CommericialExpirationMixin,
                                    TestCaseWithFactory):
     """Test case for the CommercialExpiredJob class."""
 
     EXPIRE_SUBSCRIPTION = True
+    EXPECTED_PRODUCT = 'expired'
     JOB_INTERFACE = ICommercialExpiredJob
     JOB_SOURCE_INTERFACE = ICommercialExpiredJobSource
     JOB_CLASS = CommercialExpiredJob
     JOB_CLASS_TYPE = ProductJobType.COMMERCIAL_EXPIRED
 
+    def test_get_expiration_dates(self):
+        dates = CommercialExpiredJob._get_expiration_dates()
+        earliest_date, latest_date, past_date = dates
+        self.assertEqual(timedelta(days=3650), latest_date - earliest_date)
+        self.assertEqual(timedelta(days=30), latest_date - past_date)
+
     def test_is_proprietary_open_source(self):
         product, reviewer = self.make_notification_data(licenses=[License.MIT])
         job = CommercialExpiredJob.create(product, reviewer)

=== modified file 'lib/lp/services/config/schema-lazr.conf'
--- lib/lp/services/config/schema-lazr.conf	2012-05-23 17:27:49 +0000
+++ lib/lp/services/config/schema-lazr.conf	2012-05-31 20:24:32 +0000
@@ -1472,6 +1472,12 @@
 dbuser: productreleasefinder
 
 
+[product_job]
+# The database user which will be used by this process.
+# datatype: string
+dbuser: product-job
+
+
 [profiling]
 # When set to True, the user is allowed to request profiles be run for
 # either individual pages or for every request sent.


Follow ups