Merge lp:~sinzui/launchpad/project-notify-3 into lp:launchpad

Proposed by Curtis Hovey on 2012-03-19
Status: Merged
Approved by: Curtis Hovey on 2012-03-19
Approved revision: no longer in the source branch.
Merged at revision: 14981
Proposed branch: lp:~sinzui/launchpad/project-notify-3
Merge into: lp:launchpad
Prerequisite: lp:~sinzui/launchpad/project-notify-1
Diff against target: 465 lines (+436/-0)
4 files modified
lib/lp/registry/enums.py (+32/-0)
lib/lp/registry/interfaces/productjob.py (+67/-0)
lib/lp/registry/model/productjob.py (+151/-0)
lib/lp/registry/tests/test_productjob.py (+186/-0)
To merge this branch: bzr merge lp:~sinzui/launchpad/project-notify-3
Reviewer Review Type Date Requested Status
Benji York (community) code 2012-03-19 Approve on 2012-03-19
Review via email: mp+98282@code.launchpad.net

Commit Message

Add base productjob model classes and tests.

Description of the Change

    Launchpad bug: https://bugs.launchpad.net/bugs/956246
    Pre-implementation: abentley, jcsackett

This branch adds a productjob models to track automated and manual jobs
that are scheduled for a product. The immediate use is for an automated
system that send out commercial subscription expiration emails at 4 weeks
and 1 week before expiration. After expiration an automated process will
update or deactivate the project and send an email.

This branch adds the base classes for the job, but the specifc jobs
we need are deferred to my next branch.

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

RULES

    * Create base and derived classes for ProductJob that can be
      used to create specific classes to for tasks like sending
      a commericial subscription expiration email.

QA

    None because this branch only contains the classes needed by other
    branches.

LINT

    lib/lp/registry/enums.py
    lib/lp/registry/interfaces/productjob.py
    lib/lp/registry/model/productjob.py
    lib/lp/registry/tests/test_productjob.py

TEST

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

IMPLEMENTATION

Update enums with the actual types of job Lp needs to support.
    lib/lp/registry/enums.py

Created the base and derived job classes based on the person transfer job.
I the branch was already large after adding and testing the support classes
so I decided to submit this for merging separately from the real work.
    lib/lp/registry/interfaces/productjob.py
    lib/lp/registry/model/productjob.py
    lib/lp/registry/tests/test_productjob.py

To post a comment you must log in.
Benji York (benji) wrote :

This branch looks good. It also makes me wish we had an easier way of creating jobs.

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/registry/enums.py'
2--- lib/lp/registry/enums.py 2012-03-07 00:23:37 +0000
3+++ lib/lp/registry/enums.py 2012-03-20 13:26:37 +0000
4@@ -9,6 +9,7 @@
5 'DistroSeriesDifferenceType',
6 'InformationType',
7 'PersonTransferJobType',
8+ 'ProductJobType',
9 'SharingPermission',
10 ]
11
12@@ -155,3 +156,34 @@
13
14 Merge one person or team into another person or team.
15 """)
16+
17+
18+class ProductJobType(DBEnumeratedType):
19+ """Values that IProductJob.job_type can take."""
20+
21+ REVIEWER_NOTIFICATION = DBItem(0, """
22+ Reviewer notification
23+
24+ A notification sent by a project reviewer to the project maintainers.
25+ """)
26+
27+ COMMERCIAL_EXPIRATION_30_DAYS = DBItem(1, """
28+ Commercial subscription expires in 30 days.
29+
30+ A notification stating that the project's commercial subscription
31+ expires in 30 days.
32+ """)
33+
34+ COMMERCIAL_EXPIRATION_7_DAYS = DBItem(2, """
35+ Commercial subscription expires in 7 days.
36+
37+ A notification stating that the project's commercial subscription
38+ expires in 7 days.
39+ """)
40+
41+ COMMERCIAL_EXPIRED = DBItem(3, """
42+ Commercial subscription expired.
43+
44+ A notification stating that the project's commercial subscription
45+ expired.
46+ """)
47
48=== added file 'lib/lp/registry/interfaces/productjob.py'
49--- lib/lp/registry/interfaces/productjob.py 1970-01-01 00:00:00 +0000
50+++ lib/lp/registry/interfaces/productjob.py 2012-03-20 13:26:37 +0000
51@@ -0,0 +1,67 @@
52+# Copyright 2012 Canonical Ltd. This software is licensed under the
53+# GNU Affero General Public License version 3 (see the file LICENSE).
54+
55+"""Interfaces for the Jobs system to update products and send notifications."""
56+
57+__metaclass__ = type
58+__all__ = [
59+ 'IProductJob',
60+ 'IProductJobSource',
61+ ]
62+
63+from zope.interface import Attribute
64+from zope.schema import (
65+ Int,
66+ Object,
67+ )
68+
69+from lp import _
70+from lp.registry.interfaces.product import IProduct
71+
72+from lp.services.job.interfaces.job import (
73+ IJob,
74+ IJobSource,
75+ IRunnableJob,
76+ )
77+
78+
79+class IProductJob(IRunnableJob):
80+ """A Job related to an `IProduct`."""
81+
82+ id = Int(
83+ title=_('DB ID'), required=True, readonly=True,
84+ description=_("The tracking number of this job."))
85+
86+ job = Object(
87+ title=_('The common Job attributes'),
88+ schema=IJob,
89+ required=True)
90+
91+ product = Object(
92+ title=_('The product the job is for'),
93+ schema=IProduct,
94+ required=True)
95+
96+ metadata = Attribute('A dict of data for the job')
97+
98+
99+class IProductJobSource(IJobSource):
100+ """An interface for creating and finding `IProductJob`s."""
101+
102+ def create(product, metadata):
103+ """Create a new `IProductJob`.
104+
105+ :param product: An IProduct.
106+ :param metadata: a dict of configuration data for the job.
107+ The data must be JSON compatible keys and values.
108+ """
109+
110+ def find(product=None, date_since=None, job_type=None):
111+ """Find `IProductJob`s that match the specified criteria.
112+
113+ :param product: Match jobs for specific product.
114+ :param date_since: Match jobs since the specified date.
115+ :param job_type: Match jobs of a specific type. Type is expected
116+ to be a class name.
117+ :return: A `ResultSet` yielding `IProductJob`.
118+ """
119
120=== added file 'lib/lp/registry/model/productjob.py'
121--- lib/lp/registry/model/productjob.py 1970-01-01 00:00:00 +0000
122+++ lib/lp/registry/model/productjob.py 2012-03-20 13:26:37 +0000
123@@ -0,0 +1,151 @@
124+# Copyright 2012 Canonical Ltd. This software is licensed under the
125+# GNU Affero General Public License version 3 (see the file LICENSE).
126+
127+"""Jobs classes to update products and send notifications."""
128+
129+__metaclass__ = type
130+__all__ = [
131+ 'ProductJob',
132+ ]
133+
134+from lazr.delegates import delegates
135+import simplejson
136+from storm.expr import (
137+ And,
138+ )
139+from storm.locals import (
140+ Int,
141+ Reference,
142+ Unicode,
143+ )
144+from zope.interface import (
145+ classProvides,
146+ implements,
147+ )
148+
149+from lp.registry.enums import ProductJobType
150+from lp.registry.interfaces.product import (
151+ IProduct,
152+ )
153+from lp.registry.interfaces.productjob import (
154+ IProductJob,
155+ IProductJobSource,
156+ )
157+from lp.registry.model.product import Product
158+from lp.services.database.decoratedresultset import DecoratedResultSet
159+from lp.services.database.enumcol import EnumCol
160+from lp.services.database.lpstorm import (
161+ IMasterStore,
162+ IStore,
163+ )
164+from lp.services.database.stormbase import StormBase
165+from lp.services.job.model.job import Job
166+from lp.services.job.runner import BaseRunnableJob
167+
168+
169+class ProductJob(StormBase):
170+ """Base class for product jobs."""
171+
172+ implements(IProductJob)
173+
174+ __storm_table__ = 'ProductJob'
175+
176+ id = Int(primary=True)
177+
178+ job_id = Int(name='job')
179+ job = Reference(job_id, Job.id)
180+
181+ product_id = Int(name='product')
182+ product = Reference(product_id, Product.id)
183+
184+ job_type = EnumCol(enum=ProductJobType, notNull=True)
185+
186+ _json_data = Unicode('json_data')
187+
188+ @property
189+ def metadata(self):
190+ return simplejson.loads(self._json_data)
191+
192+ def __init__(self, product, job_type, metadata):
193+ """Constructor.
194+
195+ :param product: The product the job is for.
196+ :param job_type: The type job the product needs run.
197+ :param metadata: A dict of JSON-compatible data to pass to the job.
198+ """
199+ super(ProductJob, self).__init__()
200+ self.job = Job()
201+ self.product = product
202+ self.job_type = job_type
203+ json_data = simplejson.dumps(metadata)
204+ self._json_data = json_data.decode('utf-8')
205+
206+
207+class ProductJobDerived(BaseRunnableJob):
208+ """Intermediate class for deriving from ProductJob.
209+
210+ Storm classes can't simply be subclassed or you can end up with
211+ multiple objects referencing the same row in the db. This class uses
212+ lazr.delegates, which is a little bit simpler than storm's
213+ inheritance solution to the problem. Subclasses need to override
214+ the run() method.
215+ """
216+
217+ delegates(IProductJob)
218+ classProvides(IProductJobSource)
219+
220+ def __init__(self, job):
221+ self.context = job
222+
223+ def __repr__(self):
224+ return (
225+ "<{self.__class__.__name__} for {self.product.name} "
226+ "status={self.job.status}>").format(self=self)
227+
228+ @classmethod
229+ def create(cls, product, metadata):
230+ """See `IProductJob`."""
231+ if not IProduct.providedBy(product):
232+ raise TypeError("Product must be an IProduct: %s" % repr(product))
233+ job = ProductJob(
234+ product=product, job_type=cls.class_job_type, metadata=metadata)
235+ return cls(job)
236+
237+ @classmethod
238+ def find(cls, product, date_since=None, job_type=None):
239+ """See `IPersonMergeJobSource`."""
240+ conditions = [
241+ ProductJob.job_id == Job.id,
242+ ProductJob.product == product.id,
243+ ]
244+ if date_since is not None:
245+ conditions.append(
246+ Job.date_created >= date_since)
247+ if job_type is not None:
248+ conditions.append(
249+ ProductJob.job_type == job_type)
250+ return DecoratedResultSet(
251+ IStore(ProductJob).find(
252+ ProductJob, *conditions), cls)
253+
254+ @classmethod
255+ def iterReady(cls):
256+ """Iterate through all ready ProductJobs."""
257+ store = IMasterStore(ProductJob)
258+ jobs = store.find(
259+ ProductJob,
260+ And(ProductJob.job_type == cls.class_job_type,
261+ ProductJob.job_id.is_in(Job.ready_jobs)))
262+ return (cls(job) for job in jobs)
263+
264+ @property
265+ def log_name(self):
266+ return self.__class__.__name__
267+
268+ def getOopsVars(self):
269+ """See `IRunnableJob`."""
270+ vars = BaseRunnableJob.getOopsVars(self)
271+ vars.extend([
272+ ('product', self.context.product.name),
273+ ])
274+ return vars
275
276=== added file 'lib/lp/registry/tests/test_productjob.py'
277--- lib/lp/registry/tests/test_productjob.py 1970-01-01 00:00:00 +0000
278+++ lib/lp/registry/tests/test_productjob.py 2012-03-20 13:26:37 +0000
279@@ -0,0 +1,186 @@
280+# Copyright 2010 Canonical Ltd. This software is licensed under the
281+# GNU Affero General Public License version 3 (see the file LICENSE).
282+
283+"""Tests for ProductJobs."""
284+
285+__metaclass__ = type
286+
287+from datetime import (
288+ datetime,
289+ timedelta,
290+ )
291+
292+import pytz
293+
294+from zope.interface import (
295+ classProvides,
296+ implements,
297+ )
298+from zope.security.proxy import removeSecurityProxy
299+
300+from lp.registry.enums import ProductJobType
301+from lp.registry.interfaces.productjob import (
302+ IProductJob,
303+ IProductJobSource,
304+ )
305+from lp.registry.model.productjob import (
306+ ProductJob,
307+ ProductJobDerived,
308+ )
309+from lp.testing import TestCaseWithFactory
310+from lp.testing.layers import (
311+ DatabaseFunctionalLayer,
312+ LaunchpadZopelessLayer,
313+ )
314+
315+
316+class ProductJobTestCase(TestCaseWithFactory):
317+ """Test case for basic ProductJob class."""
318+
319+ layer = LaunchpadZopelessLayer
320+
321+ def test_init(self):
322+ product = self.factory.makeProduct()
323+ metadata = ('some', 'arbitrary', 'metadata')
324+ product_job = ProductJob(
325+ product, ProductJobType.REVIEWER_NOTIFICATION, metadata)
326+ self.assertEqual(product, product_job.product)
327+ self.assertEqual(
328+ ProductJobType.REVIEWER_NOTIFICATION, product_job.job_type)
329+ expected_json_data = '["some", "arbitrary", "metadata"]'
330+ self.assertEqual(expected_json_data, product_job._json_data)
331+
332+ def test_metadata(self):
333+ # The python structure stored as json is returned as python.
334+ product = self.factory.makeProduct()
335+ metadata = {
336+ 'a_list': ('some', 'arbitrary', 'metadata'),
337+ 'a_number': 1,
338+ 'a_string': 'string',
339+ }
340+ product_job = ProductJob(
341+ product, ProductJobType.REVIEWER_NOTIFICATION, metadata)
342+ metadata['a_list'] = list(metadata['a_list'])
343+ self.assertEqual(metadata, product_job.metadata)
344+
345+
346+class IProductThingJob(IProductJob):
347+ """An interface for testing derived job classes."""
348+
349+
350+class IProductThingJobSource(IProductJobSource):
351+ """An interface for testing derived job source classes."""
352+
353+
354+class FakeProductJob(ProductJobDerived):
355+ """A class that reuses other interfaces and types for testing."""
356+ class_job_type = ProductJobType.REVIEWER_NOTIFICATION
357+ implements(IProductThingJob)
358+ classProvides(IProductThingJobSource)
359+
360+
361+class OtherFakeProductJob(ProductJobDerived):
362+ """A class that reuses other interfaces and types for testing."""
363+ class_job_type = ProductJobType.COMMERCIAL_EXPIRED
364+ implements(IProductThingJob)
365+ classProvides(IProductThingJobSource)
366+
367+
368+class ProductJobDerivedTestCase(TestCaseWithFactory):
369+ """Test case for the ProductJobDerived class."""
370+
371+ layer = DatabaseFunctionalLayer
372+
373+ def test_repr(self):
374+ product = self.factory.makeProduct('fnord')
375+ metadata = {'foo': 'bar'}
376+ job = FakeProductJob.create(product, metadata)
377+ self.assertEqual(
378+ '<FakeProductJob for fnord status=Waiting>', repr(job))
379+
380+ def test_create_success(self):
381+ # Create an instance of ProductJobDerived that delegates to
382+ # ProductJob.
383+ product = self.factory.makeProduct()
384+ metadata = {'foo': 'bar'}
385+ self.assertIs(True, IProductJobSource.providedBy(ProductJobDerived))
386+ job = FakeProductJob.create(product, metadata)
387+ self.assertIsInstance(job, ProductJobDerived)
388+ self.assertIs(True, IProductJob.providedBy(job))
389+ self.assertIs(True, IProductJob.providedBy(job.context))
390+
391+ def test_create_raises_error(self):
392+ # ProductJobDerived.create() raises an error because it
393+ # needs to be subclassed to work properly.
394+ product = self.factory.makeProduct()
395+ metadata = {'foo': 'bar'}
396+ self.assertRaises(
397+ AttributeError, ProductJobDerived.create, product, metadata)
398+
399+ def test_iterReady(self):
400+ # iterReady finds job in the READY status that are of the same type.
401+ product = self.factory.makeProduct()
402+ metadata = {'foo': 'bar'}
403+ job_1 = FakeProductJob.create(product, metadata)
404+ job_2 = FakeProductJob.create(product, metadata)
405+ job_2.start()
406+ OtherFakeProductJob.create(product, metadata)
407+ jobs = list(FakeProductJob.iterReady())
408+ self.assertEqual(1, len(jobs))
409+ self.assertEqual(job_1, jobs[0])
410+
411+ def test_find_product(self):
412+ # Find all the jobs for a product regardless of date or job type.
413+ product = self.factory.makeProduct()
414+ metadata = {'foo': 'bar'}
415+ job_1 = FakeProductJob.create(product, metadata)
416+ job_2 = OtherFakeProductJob.create(product, metadata)
417+ FakeProductJob.create(self.factory.makeProduct(), metadata)
418+ jobs = list(ProductJobDerived.find(product=product))
419+ self.assertEqual(2, len(jobs))
420+ self.assertContentEqual([job_1.id, job_2.id], [job.id for job in jobs])
421+
422+ def test_find_job_type(self):
423+ # Find all the jobs for a product and job_type regardless of date.
424+ product = self.factory.makeProduct()
425+ metadata = {'foo': 'bar'}
426+ job_1 = FakeProductJob.create(product, metadata)
427+ job_2 = FakeProductJob.create(product, metadata)
428+ OtherFakeProductJob.create(product, metadata)
429+ jobs = list(ProductJobDerived.find(
430+ product, job_type=ProductJobType.REVIEWER_NOTIFICATION))
431+ self.assertEqual(2, len(jobs))
432+ self.assertContentEqual([job_1.id, job_2.id], [job.id for job in jobs])
433+
434+ def test_find_date_since(self):
435+ # Find all the jobs for a product since a date regardless of job_type.
436+ now = datetime.now(pytz.utc)
437+ seven_days_ago = now - timedelta(7)
438+ thirty_days_ago = now - timedelta(30)
439+ product = self.factory.makeProduct()
440+ metadata = {'foo': 'bar'}
441+ job_1 = FakeProductJob.create(product, metadata)
442+ removeSecurityProxy(job_1.job).date_created = thirty_days_ago
443+ job_2 = FakeProductJob.create(product, metadata)
444+ removeSecurityProxy(job_2.job).date_created = seven_days_ago
445+ job_3 = OtherFakeProductJob.create(product, metadata)
446+ removeSecurityProxy(job_3.job).date_created = now
447+ jobs = list(ProductJobDerived.find(product, date_since=seven_days_ago))
448+ self.assertEqual(2, len(jobs))
449+ self.assertContentEqual([job_2.id, job_3.id], [job.id for job in jobs])
450+
451+ def test_log_name(self):
452+ # The log_name is the name of the implementing class.
453+ product = self.factory.makeProduct('fnord')
454+ metadata = {'foo': 'bar'}
455+ job = FakeProductJob.create(product, metadata)
456+ self.assertEqual('FakeProductJob', job.log_name)
457+
458+ def test_getOopsVars(self):
459+ # The project name is added to the oops vars.
460+ product = self.factory.makeProduct('fnord')
461+ metadata = {'foo': 'bar'}
462+ job = FakeProductJob.create(product, metadata)
463+ oops_vars = job.getOopsVars()
464+ self.assertIs(True, len(oops_vars) > 1)
465+ self.assertIn(('product', product.name), oops_vars)