← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~sinzui/launchpad/project-notify-5 into lp:launchpad

 

Curtis Hovey has proposed merging lp:~sinzui/launchpad/project-notify-5 into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~sinzui/launchpad/project-notify-5/+merge/102734

Pre-implementation: abentley, jcsackett

Lp needs to send 30-day and 7-day commercial subscription expiration
notices, and it needs to send a message after expiration. The job that
deals with after expiration needs to deactivate the commercial features.

I hoped to also provide the mechanism to create the job's but this branch
is large and very old.

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

RULES

    * Create a job that sends an expired commercial subscription email
      that also handles commercial feature deactivations:
      * When the project license is proprietary, deactivate the project.
      * When the project license is open source, deactivate private bugs
        and branches.
      * Do not make anything public...the information remains private,
        but new private information cannot be made.
    * Create a job that sends an commercial subscription expiration email
      notice 7 days before the expiration date.
    * Create a job that sends an commercial subscription expiration email
      notice 30 days before the expiration date.


QA

    None. My next branch will create a mechanism that created the 30, 7, and -1
    day job so that we can test them. This also allows us to put the draft
    emails in place while Dan provides the final draft.


LINT

    lib/lp/registry/emailtemplates/product-commercial-subscription-expiration.txt
    lib/lp/registry/emailtemplates/product-commercial-subscription-expired-open-source.txt
    lib/lp/registry/emailtemplates/product-commercial-subscription-expired-proprietary.txt
    lib/lp/registry/interfaces/productjob.py
    lib/lp/registry/model/productjob.py
    lib/lp/registry/tests/test_productjob.py
    lib/lp/testing/factory.py


TEST

    ./bin/test -vvc --layer=Database lp.registry.tests.test_productjob


IMPLEMENTATION

Created three email templates for the conditions we recognise. These are
drafts. I will ask Dan to revise them.
    lib/lp/registry/emailtemplates/product-commercial-subscription-expiration.txt
    lib/lp/registry/emailtemplates/product-commercial-subscription-expired-open-source.txt
    lib/lp/registry/emailtemplates/product-commercial-subscription-expired-proprietary.txt

Created three kinds of emails for 30 day, 7 day, and -1 day expiration
notifications. Commercial features are deactivated by the same job that sends
the -1 day expiration. I did not want to redefine CommercialExpiredJob's
email_template_name, I had planned to create a single email template for the
case, but for the sake of Dan and future editors, I decided not to inject
whole paragraphs to describe what changed.
    lib/lp/registry/interfaces/productjob.py
    lib/lp/registry/model/productjob.py
    lib/lp/registry/tests/test_productjob.py

Fixed the factory which permitted me to create multiple commercial
subscriptions for a product and caused Storm to raise an exception.
    lib/lp/testing/factory.py
-- 
https://code.launchpad.net/~sinzui/launchpad/project-notify-5/+merge/102734
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~sinzui/launchpad/project-notify-5 into lp:launchpad.
=== added file 'lib/lp/registry/emailtemplates/product-commercial-subscription-expiration.txt'
--- lib/lp/registry/emailtemplates/product-commercial-subscription-expiration.txt	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/emailtemplates/product-commercial-subscription-expiration.txt	2012-04-19 18:03:32 +0000
@@ -0,0 +1,47 @@
+Hello %(user_displayname)s,
+
+The commercial subscription for project '%(product_name)s' in
+Launchpad will expire soon.
+%(commercial_use_expiration)s
+
+You can renew the commercial subscription which costs
+US$250/year/project.  Follow the instructions presented on your
+project overview page to purchase a subscription voucher.
+%(product_url)s
+
+A commercial subscription allows you to host your commercial project
+on Launchpad in the same way as any other project. Project's with a
+commercial subscription may have private-by-default bugs, and you
+may also request the setup of private code hosting.
+
+As the maintainer of a project with a commercial subscription, you
+may create private teams with private mailing lists and private package
+archives. Find out more about this here:
+https://help.launchpad.net/CommercialHosting
+
+If '%(product_name)s' possessed an Other/Proprietary license at the time
+of expiration, the project will be deactivated. Otherwise, the
+commercial features will be deactivated. Things that are private will
+remain private, but no one will be able to create new things that are
+private.
+
+Launchpad is a collaboration site, free to use for projects with an
+approved open source license.  When you registered your project, you'd
+have seen a list of licenses presented. These are the licences we
+automatically recognise.
+
+If you have a different licence to one on the approved list, it must
+follow the guidelines we list on the following page in order to be
+approved: https://help.launchpad.net/Legal/ProjectLicensing
+
+Want to know more?
+Further information is on our FAQ "Can closed-source or proprietary
+projects use Launchpad?" The link is here:
+https://answers.launchpad.net/launchpad/+faq/208
+
+If the license for your project needs to be corrected, you can do this
+by following the 'Change Details' link on your project's overview page.
+
+Thanks,
+
+The Launchpad team.

=== added file 'lib/lp/registry/emailtemplates/product-commercial-subscription-expired-open-source.txt'
--- lib/lp/registry/emailtemplates/product-commercial-subscription-expired-open-source.txt	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/emailtemplates/product-commercial-subscription-expired-open-source.txt	2012-04-19 18:03:32 +0000
@@ -0,0 +1,40 @@
+Hello %(user_displayname)s,
+
+The commercial subscription for project '%(product_name)s' in
+Launchpad has expired.
+%(commercial_use_expiration)s
+
+Commercial features were deactivated. Things that are private will
+remain private. New bugs and branches are public by default! Private
+branches cannot be linked to project series.
+
+A commercial subscription allows you to host your commercial project
+on Launchpad in the same way as any other project. Project's with a
+commercial subscription may have private-by-default bugs, and you
+may also request the setup of private code hosting.
+
+As the maintainer of a project with a commercial subscription, you
+may create private teams with private mailing lists and private package
+archives. Find out more about this here:
+https://help.launchpad.net/CommercialHosting
+
+Launchpad is a collaboration site, free to use for projects with an
+approved open source license.  When you registered your project, you'd
+have seen a list of licenses presented. These are the licences we
+automatically recognise.
+
+If you have a different licence to one on the approved list, it must
+follow the guidelines we list on the following page in order to be
+approved: https://help.launchpad.net/Legal/ProjectLicensing
+
+Want to know more?
+Further information is on our FAQ "Can closed-source or proprietary
+projects use Launchpad?" The link is here:
+https://answers.launchpad.net/launchpad/+faq/208
+
+If the license for your project needs to be corrected, you can do this
+by following the 'Change Details' link on your project's overview page.
+
+Thanks,
+
+The Launchpad team.

=== added file 'lib/lp/registry/emailtemplates/product-commercial-subscription-expired-proprietary.txt'
--- lib/lp/registry/emailtemplates/product-commercial-subscription-expired-proprietary.txt	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/emailtemplates/product-commercial-subscription-expired-proprietary.txt	2012-04-19 18:03:32 +0000
@@ -0,0 +1,41 @@
+Hello %(user_displayname)s,
+
+The commercial subscription for project '%(product_name)s' in
+Launchpad has expired.
+%(commercial_use_expiration)s
+
+'%(product_name)s' was deactivated because its license is
+Other/Proprietary and requires a commercial subscription to use
+Launchpad's services. If you wish to reactivate the project contact us
+at commercial@xxxxxxxxxxxxx to discuss your options.
+
+A commercial subscription allows you to host your commercial project
+on Launchpad in the same way as any other project. Project's with a
+commercial subscription may have private-by-default bugs, and you
+may also request the setup of private code hosting.
+
+As the maintainer of a project with a commercial subscription, you
+may create private teams with private mailing lists and private package
+archives. Find out more about this here:
+https://help.launchpad.net/CommercialHosting
+
+Launchpad is a collaboration site, free to use for projects with an
+approved open source license.  When you registered your project, you'd
+have seen a list of licenses presented. These are the licences we
+automatically recognise.
+
+If you have a different licence to one on the approved list, it must
+follow the guidelines we list on the following page in order to be
+approved: https://help.launchpad.net/Legal/ProjectLicensing
+
+Want to know more?
+Further information is on our FAQ "Can closed-source or proprietary
+projects use Launchpad?" The link is here:
+https://answers.launchpad.net/launchpad/+faq/208
+
+If the license for your project needs to be corrected, you can do this
+by following the 'Change Details' link on your project's overview page.
+
+Thanks,
+
+The Launchpad team.

=== modified file 'lib/lp/registry/interfaces/productjob.py'
--- lib/lp/registry/interfaces/productjob.py	2012-03-24 12:41:36 +0000
+++ lib/lp/registry/interfaces/productjob.py	2012-04-19 18:03:32 +0000
@@ -9,6 +9,12 @@
     'IProductJobSource',
     'IProductNotificationJob',
     'IProductNotificationJobSource',
+    'ICommercialExpiredJob',
+    'ICommercialExpiredJobSource',
+    'ISevenDayCommercialExpirationJob',
+    'ISevenDayCommercialExpirationJobSource',
+    'IThirtyDayCommercialExpirationJob',
+    'IThirtyDayCommercialExpirationJobSource',
     ]
 
 from zope.interface import Attribute
@@ -119,3 +125,53 @@
         :param reply_to_commercial: Set the reply_to property to the
             commercial email address.
         """
+
+
+class ISevenDayCommercialExpirationJob(IProductNotificationJob):
+    """A job that sends an email about an expiring commercial subscription."""
+
+
+class ISevenDayCommercialExpirationJobSource(IProductNotificationJobSource):
+    """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):
+    """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.
+
+    This job is responsible for deactivating the project if it has a
+    proprietary license or deactivating the commercial features if the
+    license is open.
+    """
+
+
+class ICommercialExpiredJobSource(IProductNotificationJobSource):
+    """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-03-24 12:36:13 +0000
+++ lib/lp/registry/model/productjob.py	2012-04-19 18:03:32 +0000
@@ -6,6 +6,9 @@
 __metaclass__ = type
 __all__ = [
     'ProductJob',
+    'CommercialExpiredJob',
+    'SevenDayCommercialExpirationJob',
+    'ThirtyDayCommercialExpirationJob',
     ]
 
 from lazr.delegates import delegates
@@ -21,15 +24,25 @@
     classProvides,
     implements,
     )
+from zope.security.proxy import removeSecurityProxy
 
 from lp.registry.enums import ProductJobType
 from lp.registry.interfaces.person import IPersonSet
-from lp.registry.interfaces.product import IProduct
+from lp.registry.interfaces.product import (
+    IProduct,
+    License,
+    )
 from lp.registry.interfaces.productjob import (
     IProductJob,
     IProductJobSource,
     IProductNotificationJob,
     IProductNotificationJobSource,
+    ICommercialExpiredJob,
+    ICommercialExpiredJobSource,
+    ISevenDayCommercialExpirationJob,
+    ISevenDayCommercialExpirationJobSource,
+    IThirtyDayCommercialExpirationJob,
+    IThirtyDayCommercialExpirationJobSource,
     )
 from lp.registry.model.product import Product
 from lp.services.config import config
@@ -282,3 +295,94 @@
             'Launchpad', config.canonical.noreply_from_address)
         self.sendEmailToMaintainer(
             self.email_template_name, self.subject, from_address)
+
+
+class CommericialExpirationMixin:
+
+    _email_template_name = 'product-commercial-subscription-expiration'
+    _subject_template = (
+        'The commercial subscription for %s in Launchpad is expiring')
+
+    @classmethod
+    def create(cls, product, reviewer):
+        """Create a job."""
+        subject = cls._subject_template % product.name
+        return super(CommericialExpirationMixin, cls).create(
+            product, cls._email_template_name, subject, reviewer,
+            reply_to_commercial=True)
+
+    @cachedproperty
+    def message_data(self):
+        """See `IProductNotificationJob`."""
+        data = super(CommericialExpirationMixin, self).message_data
+        commercial_subscription = self.product.commercial_subscription
+        iso_date = commercial_subscription.date_expires.date().isoformat()
+        extra_data = {
+            'commercial_use_expiration': iso_date,
+            }
+        data.update(extra_data)
+        return data
+
+
+class SevenDayCommercialExpirationJob(CommericialExpirationMixin,
+                                      ProductNotificationJob):
+    """A job that sends an email about an expiring commercial subscription."""
+
+    implements(ISevenDayCommercialExpirationJob)
+    classProvides(ISevenDayCommercialExpirationJobSource)
+    class_job_type = ProductJobType.COMMERCIAL_EXPIRATION_7_DAYS
+
+
+class ThirtyDayCommercialExpirationJob(CommericialExpirationMixin,
+                                       ProductNotificationJob):
+    """A job that sends an email about an expiring commercial subscription."""
+
+    implements(IThirtyDayCommercialExpirationJob)
+    classProvides(IThirtyDayCommercialExpirationJobSource)
+    class_job_type = ProductJobType.COMMERCIAL_EXPIRATION_30_DAYS
+
+
+class CommercialExpiredJob(CommericialExpirationMixin, ProductNotificationJob):
+    """A job that sends an email about an expired commercial subscription."""
+
+    implements(ICommercialExpiredJob)
+    classProvides(ICommercialExpiredJobSource)
+    class_job_type = ProductJobType.COMMERCIAL_EXPIRED
+
+    _email_template_name = ''  # email_template_name does not need this.
+    _subject_template = (
+        'The commercial subscription for %s in Launchpad expired')
+
+    @property
+    def _is_proprietary(self):
+        """Does the product have a proprietary license?"""
+        return License.OTHER_PROPRIETARY in self.product.licenses
+
+    @property
+    def email_template_name(self):
+        """See `IProductNotificationJob`.
+
+        The email template is determined by the product's licenses.
+        """
+        if self._is_proprietary:
+            return 'product-commercial-subscription-expired-proprietary'
+        return 'product-commercial-subscription-expired-open-source'
+
+    def _deactivateCommercialFeatures(self):
+        """Deactivate the project or just the commercial features it uses."""
+        if self._is_proprietary:
+            self.product.active = False
+        else:
+            removeSecurityProxy(self.product).private_bugs = False
+            for series in self.product.series:
+                if series.branch.private:
+                    removeSecurityProxy(series).branch = None
+
+    def run(self):
+        """See `ProductNotificationJob`."""
+        if self.product.has_current_commercial_subscription:
+            # The commercial subscription was renewed after this job was
+            # created. Nothing needs to be done.
+            return
+        super(CommercialExpiredJob, self).run()
+        self._deactivateCommercialFeatures()

=== modified file 'lib/lp/registry/tests/test_productjob.py'
--- lib/lp/registry/tests/test_productjob.py	2012-03-24 12:41:36 +0000
+++ lib/lp/registry/tests/test_productjob.py	2012-04-19 18:03:32 +0000
@@ -11,17 +11,28 @@
     )
 
 import pytz
+from zope.component import getUtility
 from zope.interface import (
     classProvides,
     implements,
     )
 from zope.security.proxy import removeSecurityProxy
 
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.registry.enums import ProductJobType
+from lp.registry.interfaces.product import (
+    License,
+    )
 from lp.registry.interfaces.productjob import (
     IProductJob,
     IProductJobSource,
     IProductNotificationJobSource,
+    ICommercialExpiredJob,
+    ICommercialExpiredJobSource,
+    ISevenDayCommercialExpirationJob,
+    ISevenDayCommercialExpirationJobSource,
+    IThirtyDayCommercialExpirationJob,
+    IThirtyDayCommercialExpirationJobSource,
     )
 from lp.registry.interfaces.person import TeamSubscriptionPolicy
 from lp.registry.interfaces.teammembership import TeamMembershipStatus
@@ -29,6 +40,9 @@
     ProductJob,
     ProductJobDerived,
     ProductNotificationJob,
+    CommercialExpiredJob,
+    SevenDayCommercialExpirationJob,
+    ThirtyDayCommercialExpirationJob,
     )
 from lp.testing import (
     person_logged_in,
@@ -225,6 +239,9 @@
         self.assertIs(
             True,
             IProductNotificationJobSource.providedBy(ProductNotificationJob))
+        self.assertEqual(
+            ProductJobType.REVIEWER_NOTIFICATION,
+            ProductNotificationJob.class_job_type)
         job = ProductNotificationJob.create(
             product, email_template_name, subject, reviewer,
             reply_to_commercial=False)
@@ -369,3 +386,183 @@
         self.assertEqual(subject, notifications[0]['Subject'])
         self.assertIn(
             'Launchpad <noreply@xxxxxxxxxxxxx>', notifications[0]['From'])
+
+
+class CommericialExpirationMixin:
+
+    layer = DatabaseFunctionalLayer
+
+    EXPIRE_SUBSCRIPTION = False
+
+    def make_notification_data(self, licenses=[License.MIT]):
+        product = self.factory.makeProduct(licenses=licenses)
+        if License.OTHER_PROPRIETARY not in product.licenses:
+            # The proprietary project was automatically given a CS.
+            self.factory.makeCommercialSubscription(product)
+        reviewer = getUtility(ILaunchpadCelebrities).janitor
+        return product, reviewer
+
+    def test_create(self):
+        # Create an instance of an commercial expiration job that stores
+        # the notification information.
+        product = self.factory.makeProduct()
+        reviewer = getUtility(ILaunchpadCelebrities).janitor
+        self.assertIs(
+            True,
+            self.JOB_SOURCE_INTERFACE.providedBy(self.JOB_CLASS))
+        self.assertEqual(
+            self.JOB_CLASS_TYPE, self.JOB_CLASS.class_job_type)
+        job = self.JOB_CLASS.create(product, reviewer)
+        self.assertIsInstance(job, self.JOB_CLASS)
+        self.assertIs(
+            True, self.JOB_INTERFACE.providedBy(job))
+        self.assertEqual(product, job.product)
+        self.assertEqual(job._subject_template % product.name, job.subject)
+        self.assertEqual(reviewer, job.reviewer)
+        self.assertEqual(True, job.reply_to_commercial)
+
+    def test_email_template_name(self):
+        # The class defines the email_template_name.
+        product, reviewer = self.make_notification_data()
+        job = self.JOB_CLASS.create(product, reviewer)
+        self.assertEqual(job.email_template_name, job._email_template_name)
+
+    def test_message_data(self):
+        # The commercial expiration data is added.
+        product, reviewer = self.make_notification_data()
+        job = self.JOB_CLASS.create(product, reviewer)
+        commercial_subscription = product.commercial_subscription
+        iso_date = commercial_subscription.date_expires.date().isoformat()
+        self.assertEqual(
+            iso_date, job.message_data['commercial_use_expiration'])
+
+    def test_run(self):
+        # Smoke test that run() can make the email from the template and data.
+        product, reviewer = self.make_notification_data(
+            licenses=[License.OTHER_PROPRIETARY])
+        commercial_subscription = product.commercial_subscription
+        if self.EXPIRE_SUBSCRIPTION:
+            expired_date = (
+                commercial_subscription.date_expires - timedelta(days=365))
+            removeSecurityProxy(
+                commercial_subscription).date_expires = expired_date
+        iso_date = commercial_subscription.date_expires.date().isoformat()
+        job = self.JOB_CLASS.create(product, reviewer)
+        pop_notifications()
+        job.run()
+        notifications = pop_notifications()
+        self.assertEqual(1, len(notifications))
+        self.assertIn(iso_date, notifications[0].get_payload())
+
+
+class SevenDayCommercialExpirationJobTestCase(CommericialExpirationMixin,
+                                              TestCaseWithFactory):
+    """Test case for the SevenDayCommercialExpirationJob class."""
+
+    JOB_INTERFACE = ISevenDayCommercialExpirationJob
+    JOB_SOURCE_INTERFACE = ISevenDayCommercialExpirationJobSource
+    JOB_CLASS = SevenDayCommercialExpirationJob
+    JOB_CLASS_TYPE = ProductJobType.COMMERCIAL_EXPIRATION_7_DAYS
+
+
+class ThirtyDayCommercialExpirationJobTestCase(CommericialExpirationMixin,
+                                               TestCaseWithFactory):
+    """Test case for the SevenDayCommercialExpirationJob class."""
+
+    JOB_INTERFACE = IThirtyDayCommercialExpirationJob
+    JOB_SOURCE_INTERFACE = IThirtyDayCommercialExpirationJobSource
+    JOB_CLASS = ThirtyDayCommercialExpirationJob
+    JOB_CLASS_TYPE = ProductJobType.COMMERCIAL_EXPIRATION_30_DAYS
+
+
+class CommercialExpiredJobTestCase(CommericialExpirationMixin,
+                                   TestCaseWithFactory):
+    """Test case for the CommercialExpiredJob class."""
+
+    EXPIRE_SUBSCRIPTION = True
+    JOB_INTERFACE = ICommercialExpiredJob
+    JOB_SOURCE_INTERFACE = ICommercialExpiredJobSource
+    JOB_CLASS = CommercialExpiredJob
+    JOB_CLASS_TYPE = ProductJobType.COMMERCIAL_EXPIRED
+
+    def test_is_proprietary_open_source(self):
+        product, reviewer = self.make_notification_data(licenses=[License.MIT])
+        job = CommercialExpiredJob.create(product, reviewer)
+        self.assertIs(False, job._is_proprietary)
+
+    def test_is_proprietary_proprietary(self):
+        product, reviewer = self.make_notification_data(
+            licenses=[License.OTHER_PROPRIETARY])
+        job = CommercialExpiredJob.create(product, reviewer)
+        self.assertIs(True, job._is_proprietary)
+
+    def test_email_template_name(self):
+        # Redefine the inherited test to verify the open source license case.
+        # The state of the product's license defines the email_template_name.
+        product, reviewer = self.make_notification_data(licenses=[License.MIT])
+        job = CommercialExpiredJob.create(product, reviewer)
+        self.assertEqual(
+            'product-commercial-subscription-expired-open-source',
+            job.email_template_name)
+
+    def test_email_template_name_proprietary(self):
+        # The state of the product's license defines the email_template_name.
+        product, reviewer = self.make_notification_data(
+            licenses=[License.OTHER_PROPRIETARY])
+        job = CommercialExpiredJob.create(product, reviewer)
+        self.assertEqual(
+            'product-commercial-subscription-expired-proprietary',
+            job.email_template_name)
+
+    def test_deactivateCommercialFeatures_proprietary(self):
+        # When the project is proprietary, the product is deactivated.
+        product, reviewer = self.make_notification_data(
+            licenses=[License.OTHER_PROPRIETARY])
+        job = CommercialExpiredJob.create(product, reviewer)
+        job._deactivateCommercialFeatures()
+        self.assertIs(False, product.active)
+
+    def test_deactivateCommercialFeatures_open_source(self):
+        # When the project is open source, the product's commercial features
+        # are deactivated.
+        product, reviewer = self.make_notification_data(licenses=[License.MIT])
+        public_branch = self.factory.makeBranch(
+            owner=product.owner, product=product)
+        private_branch = self.factory.makeBranch(
+            owner=product.owner, product=product, private=True)
+        with person_logged_in(product.owner):
+            product.setPrivateBugs(True, product.owner)
+            public_series = product.development_focus
+            public_series.branch = public_branch
+            private_series = product.newSeries(
+                product.owner, 'special', 'testing', branch=private_branch)
+        job = CommercialExpiredJob.create(product, reviewer)
+        job._deactivateCommercialFeatures()
+        self.assertIs(True, product.active)
+        self.assertIs(False, product.private_bugs)
+        self.assertEqual(public_branch, public_series.branch)
+        self.assertIs(None, private_series.branch)
+
+    def test_run_deactivation_performed(self):
+        # An email is sent and the deactivation steps are performed.
+        product, reviewer = self.make_notification_data(
+            licenses=[License.OTHER_PROPRIETARY])
+        expired_date = (
+            product.commercial_subscription.date_expires - timedelta(days=365))
+        removeSecurityProxy(
+            product.commercial_subscription).date_expires = expired_date
+        job = CommercialExpiredJob.create(product, reviewer)
+        job.run()
+        self.assertIs(False, product.active)
+
+    def test_run_deactivation_aborted(self):
+        # The deactivation steps and email are aborted if the commercial
+        # subscription was renewed after the job was created.
+        product, reviewer = self.make_notification_data(
+            licenses=[License.OTHER_PROPRIETARY])
+        job = CommercialExpiredJob.create(product, reviewer)
+        pop_notifications()
+        job.run()
+        notifications = pop_notifications()
+        self.assertEqual(0, len(notifications))
+        self.assertIs(True, product.active)

=== modified file 'lib/lp/testing/factory.py'
--- lib/lp/testing/factory.py	2012-04-10 20:24:43 +0000
+++ lib/lp/testing/factory.py	2012-04-19 18:03:32 +0000
@@ -4418,6 +4418,9 @@
 
     def makeCommercialSubscription(self, product, expired=False):
         """Create a commercial subscription for the given product."""
+        if CommercialSubscription.selectOneBy(product=product) is not None:
+            raise AssertionError(
+                "The product under test already has a CommercialSubscription.")
         if expired:
             expiry = datetime.now(pytz.UTC) - timedelta(days=1)
         else: