Merge lp:~wallyworld/launchpad/revoke-access-delete-subscriptions-job into lp:launchpad

Proposed by Ian Booth on 2012-05-01
Status: Merged
Approved by: Ian Booth on 2012-05-25
Approved revision: no longer in the source branch.
Merged at revision: 15359
Proposed branch: lp:~wallyworld/launchpad/revoke-access-delete-subscriptions-job
Merge into: lp:launchpad
Diff against target: 684 lines (+628/-0)
7 files modified
configs/development/launchpad-lazr.conf (+3/-0)
configs/testrunner/launchpad-lazr.conf (+3/-0)
lib/lp/registry/configure.zcml (+15/-0)
lib/lp/registry/interfaces/sharingjob.py (+88/-0)
lib/lp/registry/model/sharingjob.py (+297/-0)
lib/lp/registry/tests/test_sharingjob.py (+214/-0)
lib/lp/services/config/schema-lazr.conf (+8/-0)
To merge this branch: bzr merge lp:~wallyworld/launchpad/revoke-access-delete-subscriptions-job
Reviewer Review Type Date Requested Status
Aaron Bentley (community) 2012-05-01 Approve on 2012-05-01
Richard Harding (community) code 2012-05-01 Approve on 2012-05-01
Review via email: mp+104198@code.launchpad.net

Commit Message

Infrastructure to support sharing jobs and initial remove subscriptions job implementation.

Description of the Change

== Implementation ==

This is the first branch which introduces the job infrastructure needed to run sharing/disclosure jobs. The first job is one which removes subscriptions for artifacts for which access has been revoked. There are 3 scenarios:

1. Revoke access to an individual artifact
2. Revoke access to artifacts of a given information type
3. Revoke access to an entire project/distro

This branch covers the first case above. The job is not wired up yet.

This branch requires that a db-devel branch land first in order to provide the necessary database table: lp:~wallyworld/launchpad/sharingjob-table

The job is implemented to use the new celery framework. Tests are provided to check that the job runs standalone (without celery). I'm not sure if these are required; they can be removed if required.

The job needs to unsubscribe a user from a bug or branch. unsubscribe() on IBug requires "Edit", for branches it requires "View" from what I can tell. removeSecurityProxy() is used to allow the job to work for bugs since there is no zope request which can be used to grant permission.

On error, the job emails the requestor and pillar maintainer about the error.

== Tests ==

A number of newly written tests for the job are provided.

== Lint ==

Checking for conflicts and issues in changed files.

Linting changed files:
  configs/development/launchpad-lazr.conf
  configs/testrunner/launchpad-lazr.conf
  lib/lp/registry/configure.zcml
  lib/lp/registry/interfaces/sharingjob.py
  lib/lp/registry/model/sharingjob.py
  lib/lp/registry/tests/test_sharingjob.py
  lib/lp/services/config/schema-lazr.conf

./configs/development/launchpad-lazr.conf
      81: Line exceeds 80 characters.
     114: Line exceeds 80 characters.
./configs/testrunner/launchpad-lazr.conf
     126: Line exceeds 80 characters.
./lib/lp/services/config/schema-lazr.conf
     489: Line exceeds 80 characters.
    1100: Line exceeds 80 characters.
    1107: Line exceeds 80 characters.
    1710: Line exceeds 80 characters.

To post a comment you must log in.
Richard Harding (rharding) wrote :

Ian, this looks good to me. I'm going to go ahead and ask Aaron to take a peek as well in case he's got any tips or things I might miss since the celery job bits are pretty new to me still.

review: Approve (code)
Aaron Bentley (abentley) wrote :

This looks very good to me. I hope the Celery changes make sense. There are a few quibbles:

In the tests using CeleryJobLayer, there is a risk that the transaction could be committed, causing Celery to run the job even when you weren't expecting that, and perhaps causing odd leakage into other tests. To avoid this, I recommend using the FeatureFixture only in test cases that run the job via Celery.

I no longer believe that we need to have *Derived classes. AFAICT, Storm permits one table to have many classes, so I believe RemoveSubscriptionsJob and the putative AddSubscriptionsJob could subclass SharingJob directly. However, I have not tried this myself yet, so this is only a suggestion. Perhaps something to try in a follow-up branch.

I am not sure whether oops prefixes are still required.

Ideally, each job type will have its own database user, so that any problems in production are easier to debug.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'configs/development/launchpad-lazr.conf'
2--- configs/development/launchpad-lazr.conf 2012-05-02 01:31:53 +0000
3+++ configs/development/launchpad-lazr.conf 2012-05-02 23:26:21 +0000
4@@ -221,6 +221,9 @@
5 password: guest
6 virtual_host: /
7
8+[sharing_jobs]
9+error_dir: /var/tmp/sharing.test
10+
11 [txlongpoll]
12 launch: True
13 frontend_port: 22435
14
15=== modified file 'configs/testrunner/launchpad-lazr.conf'
16--- configs/testrunner/launchpad-lazr.conf 2012-05-02 01:31:53 +0000
17+++ configs/testrunner/launchpad-lazr.conf 2012-05-02 23:26:21 +0000
18@@ -162,6 +162,9 @@
19 [packaging_translations]
20 error_dir: /var/tmp/lperr.test
21
22+[sharing_jobs]
23+error_dir: /var/tmp/sharing.test
24+
25 [upgrade_branches]
26 error_dir: /var/tmp/codehosting.test
27
28
29=== modified file 'lib/lp/registry/configure.zcml'
30--- lib/lp/registry/configure.zcml 2012-04-12 04:39:58 +0000
31+++ lib/lp/registry/configure.zcml 2012-05-02 23:26:21 +0000
32@@ -1983,4 +1983,19 @@
33 <allow
34 interface="lp.registry.interfaces.accesspolicy.IAccessPolicyGrantFlatSource"/>
35 </securedutility>
36+
37+ <!-- Sharing jobs -->
38+ <class class=".model.sharingjob.RemoveSubscriptionsJob">
39+ <allow interface=".interfaces.sharingjob.IRemoveSubscriptionsJob"/>
40+ <allow attributes="
41+ context
42+ log_name"/>
43+ </class>
44+
45+ <securedutility
46+ component=".model.sharingjob.RemoveSubscriptionsJob"
47+ provides=".interfaces.sharingjob.IRemoveSubscriptionsJobSource">
48+ <allow interface=".interfaces.sharingjob.IRemoveSubscriptionsJobSource"/>
49+ </securedutility>
50+
51 </configure>
52
53=== added file 'lib/lp/registry/interfaces/sharingjob.py'
54--- lib/lp/registry/interfaces/sharingjob.py 1970-01-01 00:00:00 +0000
55+++ lib/lp/registry/interfaces/sharingjob.py 2012-05-02 23:26:21 +0000
56@@ -0,0 +1,88 @@
57+# Copyright 2012 Canonical Ltd. This software is licensed under the
58+# GNU Affero General Public License version 3 (see the file LICENSE).
59+
60+"""Interfaces for sharing jobs."""
61+
62+__metaclass__ = type
63+
64+__all__ = [
65+ 'IRemoveSubscriptionsJob',
66+ 'IRemoveSubscriptionsJobSource',
67+ 'ISharingJob',
68+ 'ISharingJobSource',
69+ ]
70+
71+from zope.interface import Attribute
72+from zope.schema import (
73+ Int,
74+ Object,
75+ )
76+
77+from lp import _
78+from lp.registry.interfaces.distribution import IDistribution
79+from lp.registry.interfaces.person import IPerson
80+from lp.registry.interfaces.product import IProduct
81+from lp.services.job.interfaces.job import (
82+ IJob,
83+ IJobSource,
84+ IRunnableJob,
85+ )
86+
87+
88+class ISharingJob(IRunnableJob):
89+ """A Job for sharing related tasks."""
90+
91+ id = Int(
92+ title=_('DB ID'), required=True, readonly=True,
93+ description=_("The tracking number for this job."))
94+
95+ job = Object(title=_('The common Job attributes'), schema=IJob,
96+ required=True)
97+
98+ product = Object(
99+ title=_('The product the job is for'),
100+ schema=IProduct)
101+
102+ distro = Object(
103+ title=_('The distribution the job is for'),
104+ schema=IDistribution)
105+
106+ grantee = Object(
107+ title=_('The grantee the job is for'),
108+ schema=IPerson)
109+
110+ metadata = Attribute('A dict of data about the job.')
111+
112+ def destroySelf():
113+ """Destroy this object."""
114+
115+ def getErrorRecipients(self):
116+ """See `BaseRunnableJob`."""
117+
118+ def pillar():
119+ """Either product or distro, whichever is not None."""
120+
121+ def requestor():
122+ """The person who initiated the job."""
123+
124+
125+class IRemoveSubscriptionsJob(ISharingJob):
126+ """Job to remove subscriptions to artifacts for which access is revoked."""
127+
128+
129+class ISharingJobSource(IJobSource):
130+ """Base interface for acquiring ISharingJobs."""
131+
132+ def create(pillar, grantee, metadata):
133+ """Create a new ISharingJob."""
134+
135+
136+class IRemoveSubscriptionsJobSource(ISharingJobSource):
137+ """An interface for acquiring IRemoveSubscriptionsJobs."""
138+
139+ def create(pillar, grantee, requestor, bugs=None, branches=None):
140+ """Create a new job to revoke access to the specified artifacts.
141+
142+ If bug and branches are both None, then all subscriptions the grantee
143+ may have to any pillar artifacts are removed.
144+ """
145
146=== added file 'lib/lp/registry/model/sharingjob.py'
147--- lib/lp/registry/model/sharingjob.py 1970-01-01 00:00:00 +0000
148+++ lib/lp/registry/model/sharingjob.py 2012-05-02 23:26:21 +0000
149@@ -0,0 +1,297 @@
150+# Copyright 2012 Canonical Ltd. This software is licensed under the
151+# GNU Affero General Public License version 3 (see the file LICENSE).
152+
153+
154+"""Job classes related to the sharing feature are in here."""
155+
156+__metaclass__ = type
157+
158+
159+__all__ = [
160+ 'RemoveSubscriptionsJob',
161+ ]
162+
163+import contextlib
164+import logging
165+
166+from lazr.delegates import delegates
167+from lazr.enum import (
168+ DBEnumeratedType,
169+ DBItem,
170+ )
171+import simplejson
172+from sqlobject import SQLObjectNotFound
173+from storm.expr import And
174+from storm.locals import (
175+ Int,
176+ Reference,
177+ Unicode,
178+ )
179+from storm.store import Store
180+from zope.component import getUtility
181+from zope.interface import (
182+ classProvides,
183+ implements,
184+ )
185+from zope.security.proxy import removeSecurityProxy
186+
187+from lp.bugs.interfaces.bug import IBugSet
188+from lp.code.interfaces.branchlookup import IBranchLookup
189+from lp.registry.interfaces.person import IPersonSet
190+from lp.registry.interfaces.product import IProduct
191+from lp.registry.interfaces.sharingjob import (
192+ IRemoveSubscriptionsJob,
193+ IRemoveSubscriptionsJobSource,
194+ ISharingJob,
195+ ISharingJobSource,
196+ )
197+from lp.registry.model.distribution import Distribution
198+from lp.registry.model.person import Person
199+from lp.registry.model.product import Product
200+from lp.services.config import config
201+from lp.services.database.enumcol import EnumCol
202+from lp.services.database.lpstorm import IStore
203+from lp.services.database.stormbase import StormBase
204+from lp.services.job.model.job import (
205+ EnumeratedSubclass,
206+ Job,
207+ )
208+from lp.services.job.runner import (
209+ BaseRunnableJob,
210+ )
211+from lp.services.mail.sendmail import format_address_for_person
212+from lp.services.webapp import errorlog
213+
214+
215+class SharingJobType(DBEnumeratedType):
216+ """Values that ISharingJob.job_type can take."""
217+
218+ REMOVE_SUBSCRIPTIONS = DBItem(0, """
219+ Remove subscriptions when access is revoked.
220+
221+ This job removes subscriptions to artifacts when access is
222+ revoked for a particular information type or artifact.
223+ """)
224+
225+
226+class SharingJob(StormBase):
227+ """Base class for jobs related to branch merge proposals."""
228+
229+ implements(ISharingJob)
230+
231+ __storm_table__ = 'SharingJob'
232+
233+ id = Int(primary=True)
234+
235+ job_id = Int('job')
236+ job = Reference(job_id, Job.id)
237+
238+ product_id = Int(name='product')
239+ product = Reference(product_id, Product.id)
240+
241+ distro_id = Int(name='distro')
242+ distro = Reference(distro_id, Distribution.id)
243+
244+ grantee_id = Int(name='grantee')
245+ grantee = Reference(grantee_id, Person.id)
246+
247+ job_type = EnumCol(enum=SharingJobType, notNull=True)
248+
249+ _json_data = Unicode('json_data')
250+
251+ @property
252+ def metadata(self):
253+ return simplejson.loads(self._json_data)
254+
255+ def __init__(self, job_type, pillar, grantee, metadata):
256+ """Constructor.
257+
258+ :param job_type: The BranchMergeProposalJobType of this job.
259+ :param metadata: The type-specific variables, as a JSON-compatible
260+ dict.
261+ """
262+ super(SharingJob, self).__init__()
263+ json_data = simplejson.dumps(metadata)
264+ self.job = Job()
265+ self.job_type = job_type
266+ self.grantee = grantee
267+ self.product = self.distro = None
268+ if IProduct.providedBy(pillar):
269+ self.product = pillar
270+ else:
271+ self.distro = pillar
272+ # XXX AaronBentley 2009-01-29 bug=322819: This should be a bytestring,
273+ # but the DB representation is unicode.
274+ self._json_data = json_data.decode('utf-8')
275+
276+ def destroySelf(self):
277+ Store.of(self).remove(self)
278+
279+ def makeDerived(self):
280+ return SharingJobDerived.makeSubclass(self)
281+
282+
283+class SharingJobDerived(BaseRunnableJob):
284+ """Intermediate class for deriving from SharingJob."""
285+
286+ __metaclass__ = EnumeratedSubclass
287+
288+ delegates(ISharingJob)
289+ classProvides(ISharingJobSource)
290+
291+ @staticmethod
292+ @contextlib.contextmanager
293+ def contextManager():
294+ """See `IJobSource`."""
295+ errorlog.globalErrorUtility.configure('sharing_jobs')
296+ yield
297+
298+ def __init__(self, job):
299+ self.context = job
300+
301+ def __repr__(self):
302+ return '<%(job_type)s job for %(grantee)s and %(pillar)s>' % {
303+ 'job_type': self.context.job_type.name,
304+ 'grantee': self.grantee.displayname,
305+ 'pillar': self.pillar.displayname,
306+ }
307+
308+ @property
309+ def pillar(self):
310+ if self.product:
311+ return self.product
312+ else:
313+ return self.distro
314+
315+ @property
316+ def log_name(self):
317+ return self.__class__.__name__
318+
319+ @classmethod
320+ def create(cls, pillar, grantee, metadata):
321+ base_job = SharingJob(cls.class_job_type, pillar, grantee, metadata)
322+ job = cls(base_job)
323+ job.celeryRunOnCommit()
324+ return job
325+
326+ @classmethod
327+ def get(cls, job_id):
328+ """Get a job by id.
329+
330+ :return: the SharingJob with the specified id, as the
331+ current SharingJobDereived subclass.
332+ :raises: SQLObjectNotFound if there is no job with the specified id,
333+ or its job_type does not match the desired subclass.
334+ """
335+ job = SharingJob.get(job_id)
336+ if job.job_type != cls.class_job_type:
337+ raise SQLObjectNotFound(
338+ 'No object found with id %d and type %s' % (job_id,
339+ cls.class_job_type.title))
340+ return cls(job)
341+
342+ @classmethod
343+ def iterReady(cls):
344+ """See `IJobSource`.
345+
346+ This version will emit any ready job based on SharingJob.
347+ """
348+ store = IStore(SharingJob)
349+ jobs = store.find(
350+ SharingJob,
351+ And(SharingJob.job_type == cls.class_job_type,
352+ SharingJob.job_id.is_in(Job.ready_jobs)))
353+ return (cls.makeSubclass(job) for job in jobs)
354+
355+ def getOopsVars(self):
356+ """See `IRunnableJob`."""
357+ vars = BaseRunnableJob.getOopsVars(self)
358+ vars.extend([
359+ ('sharing_job_id', self.context.id),
360+ ('sharing_job_type', self.context.job_type.title),
361+ ('grantee', self.grantee.name)])
362+ if self.product:
363+ vars.append(('product', self.product.name))
364+ if self.distro:
365+ vars.append(('distro', self.distro.name))
366+ return vars
367+
368+
369+class RemoveSubscriptionsJob(SharingJobDerived):
370+ """See `IRemoveSubscriptionsJob`."""
371+
372+ implements(IRemoveSubscriptionsJob)
373+ classProvides(IRemoveSubscriptionsJobSource)
374+ class_job_type = SharingJobType.REMOVE_SUBSCRIPTIONS
375+
376+ config = config.sharing_jobs
377+
378+ @classmethod
379+ def create(cls, pillar, grantee, requestor, bugs=None, branches=None):
380+ """See `IRemoveSubscriptionsJob`."""
381+
382+ bug_ids = [
383+ bug.id for bug in bugs or []
384+ ]
385+ branch_names = [
386+ branch.unique_name for branch in branches or []
387+ ]
388+ metadata = {
389+ 'bug_ids': bug_ids,
390+ 'branch_names': branch_names,
391+ 'requestor.id': requestor.id
392+ }
393+ return super(RemoveSubscriptionsJob, cls).create(
394+ pillar, grantee, metadata)
395+
396+ @property
397+ def requestor_id(self):
398+ return self.metadata['requestor.id']
399+
400+ @property
401+ def requestor(self):
402+ return getUtility(IPersonSet).get(self.requestor_id)
403+
404+ @property
405+ def bug_ids(self):
406+ return self.metadata['bug_ids']
407+
408+ @property
409+ def branch_names(self):
410+ return self.metadata['branch_names']
411+
412+ def getErrorRecipients(self):
413+ # If something goes wrong we want to let the requestor know as well
414+ # as the pillar maintainer.
415+ result = set()
416+ result.add(format_address_for_person(self.requestor))
417+ if self.pillar.owner.preferredemail:
418+ result.add(format_address_for_person(self.pillar.owner))
419+ return list(result)
420+
421+ def getOperationDescription(self):
422+ return ('removing subscriptions for artifacts '
423+ 'for %s on %s' %
424+ (self.grantee.displayname,
425+ self.pillar.displayname))
426+
427+ def run(self):
428+ """See `IRemoveSubscriptionsJob`."""
429+
430+ logger = logging.getLogger()
431+ logger.info(self.getOperationDescription())
432+
433+ # Unsubscribe grantee from the specified bugs.
434+ if self.bug_ids:
435+ bugs = getUtility(IBugSet).getByNumbers(self.bug_ids)
436+ for bug in bugs:
437+ removeSecurityProxy(bug).unsubscribe(
438+ self.grantee, self.requestor)
439+
440+ # Unsubscribe grantee from the specified branches.
441+ if self.branch_names:
442+ branches = [
443+ getUtility(IBranchLookup).getByUniqueName(branch_name)
444+ for branch_name in self.branch_names]
445+ for branch in branches:
446+ branch.unsubscribe(self.grantee, self.requestor)
447
448=== added file 'lib/lp/registry/tests/test_sharingjob.py'
449--- lib/lp/registry/tests/test_sharingjob.py 1970-01-01 00:00:00 +0000
450+++ lib/lp/registry/tests/test_sharingjob.py 2012-05-02 23:26:21 +0000
451@@ -0,0 +1,214 @@
452+# Copyright 2012 Canonical Ltd. This software is licensed under the
453+# GNU Affero General Public License version 3 (see the file LICENSE).
454+
455+"""Tests for SharingJobs."""
456+
457+__metaclass__ = type
458+
459+import transaction
460+
461+from zope.component import getUtility
462+from zope.security.proxy import removeSecurityProxy
463+
464+from lp.code.enums import (
465+ BranchSubscriptionNotificationLevel,
466+ CodeReviewNotificationLevel,
467+ )
468+from lp.registry.interfaces.sharingjob import (
469+ IRemoveSubscriptionsJobSource,
470+ ISharingJob,
471+ ISharingJobSource,
472+ )
473+from lp.registry.model.sharingjob import (
474+ RemoveSubscriptionsJob,
475+ SharingJob,
476+ SharingJobDerived,
477+ SharingJobType,
478+ )
479+from lp.services.features.testing import FeatureFixture
480+from lp.services.job.tests import block_on_job
481+from lp.services.mail.sendmail import format_address_for_person
482+from lp.testing import (
483+ person_logged_in,
484+ TestCaseWithFactory,
485+ )
486+from lp.testing.layers import (
487+ CeleryJobLayer,
488+ DatabaseFunctionalLayer,
489+ LaunchpadZopelessLayer,
490+ )
491+
492+
493+class SharingJobTestCase(TestCaseWithFactory):
494+ """Test case for basic SharingJob class."""
495+
496+ layer = LaunchpadZopelessLayer
497+
498+ def test_init(self):
499+ pillar = self.factory.makeProduct()
500+ grantee = self.factory.makePerson()
501+ metadata = ('some', 'arbitrary', 'metadata')
502+ sharing_job = SharingJob(
503+ SharingJobType.REMOVE_SUBSCRIPTIONS, pillar, grantee, metadata)
504+ self.assertEqual(
505+ SharingJobType.REMOVE_SUBSCRIPTIONS, sharing_job.job_type)
506+ self.assertEqual(pillar, sharing_job.product)
507+ self.assertEqual(grantee, sharing_job.grantee)
508+ expected_json_data = '["some", "arbitrary", "metadata"]'
509+ self.assertEqual(expected_json_data, sharing_job._json_data)
510+
511+ def test_metadata(self):
512+ # The python structure stored as json is returned as python.
513+ metadata = {
514+ 'a_list': ('some', 'arbitrary', 'metadata'),
515+ 'a_number': 1,
516+ 'a_string': 'string',
517+ }
518+ pillar = self.factory.makeProduct()
519+ grantee = self.factory.makePerson()
520+ sharing_job = SharingJob(
521+ SharingJobType.REMOVE_SUBSCRIPTIONS, pillar, grantee, metadata)
522+ metadata['a_list'] = list(metadata['a_list'])
523+ self.assertEqual(metadata, sharing_job.metadata)
524+
525+
526+class SharingJobDerivedTestCase(TestCaseWithFactory):
527+ """Test case for the SharingJobDerived class."""
528+
529+ layer = DatabaseFunctionalLayer
530+
531+ def _makeJob(self, prod_name=None, grantee_name=None):
532+ pillar = self.factory.makeProduct(name=prod_name)
533+ grantee = self.factory.makePerson(name=grantee_name)
534+ requestor = self.factory.makePerson()
535+ job = getUtility(IRemoveSubscriptionsJobSource).create(
536+ pillar, grantee, requestor)
537+ return job
538+
539+ def test_repr(self):
540+ job = self._makeJob('prod', 'fred')
541+ self.assertEqual(
542+ '<REMOVE_SUBSCRIPTIONS job for Fred and Prod>', repr(job))
543+
544+ def test_create_success(self):
545+ # Create an instance of SharingJobDerived that delegates to SharingJob.
546+ self.assertIs(True, ISharingJobSource.providedBy(SharingJobDerived))
547+ job = self._makeJob()
548+ self.assertIsInstance(job, SharingJobDerived)
549+ self.assertIs(True, ISharingJob.providedBy(job))
550+ self.assertIs(True, ISharingJob.providedBy(job.context))
551+
552+ def test_create_raises_error(self):
553+ # SharingJobDerived.create() raises an error because it
554+ # needs to be subclassed to work properly.
555+ pillar = self.factory.makeProduct()
556+ grantee = self.factory.makePerson()
557+ self.assertRaises(
558+ AttributeError, SharingJobDerived.create, pillar, grantee, {})
559+
560+ def test_iterReady(self):
561+ # iterReady finds job in the READY status that are of the same type.
562+ job_1 = self._makeJob()
563+ job_2 = self._makeJob()
564+ job_2.start()
565+ jobs = list(RemoveSubscriptionsJob.iterReady())
566+ self.assertEqual(1, len(jobs))
567+ self.assertEqual(job_1, jobs[0])
568+
569+ def test_log_name(self):
570+ # The log_name is the name of the implementing class.
571+ job = self._makeJob()
572+ self.assertEqual('RemoveSubscriptionsJob', job.log_name)
573+
574+ def test_getOopsVars(self):
575+ # The pillar and grantee name are added to the oops vars.
576+ pillar = self.factory.makeDistribution()
577+ grantee = self.factory.makePerson()
578+ requestor = self.factory.makePerson()
579+ job = getUtility(IRemoveSubscriptionsJobSource).create(
580+ pillar, grantee, requestor)
581+ oops_vars = job.getOopsVars()
582+ self.assertIs(True, len(oops_vars) > 4)
583+ self.assertIn(('distro', pillar.name), oops_vars)
584+ self.assertIn(('grantee', grantee.name), oops_vars)
585+
586+ def test_getErrorRecipients(self):
587+ # The pillar owner and job requestor are the error recipients.
588+ pillar = self.factory.makeDistribution()
589+ grantee = self.factory.makePerson()
590+ requestor = self.factory.makePerson()
591+ job = getUtility(IRemoveSubscriptionsJobSource).create(
592+ pillar, grantee, requestor)
593+ expected_emails = [
594+ format_address_for_person(person)
595+ for person in (pillar.owner, requestor)]
596+ self.assertContentEqual(
597+ expected_emails, job.getErrorRecipients())
598+
599+
600+class RemoveSubscriptionsJobTestCase(TestCaseWithFactory):
601+ """Test case for the RemoveSubscriptionsJob class."""
602+
603+ layer = CeleryJobLayer
604+
605+ def setUp(self):
606+ self.useFixture(FeatureFixture({
607+ 'jobs.celery.enabled_classes': 'RemoveSubscriptionsJob',
608+ }))
609+ super(RemoveSubscriptionsJobTestCase, self).setUp()
610+
611+ def test_create(self):
612+ # Create an instance of RemoveSubscriptionsJob that stores
613+ # the notification information.
614+ self.assertIs(
615+ True,
616+ IRemoveSubscriptionsJobSource.providedBy(RemoveSubscriptionsJob))
617+ self.assertEqual(
618+ SharingJobType.REMOVE_SUBSCRIPTIONS,
619+ RemoveSubscriptionsJob.class_job_type)
620+ pillar = self.factory.makeProduct()
621+ grantee = self.factory.makePerson()
622+ requestor = self.factory.makePerson()
623+ bug = self.factory.makeBug(product=pillar)
624+ branch = self.factory.makeBranch(product=pillar)
625+ job = getUtility(IRemoveSubscriptionsJobSource).create(
626+ pillar, grantee, requestor, [bug], [branch])
627+ naked_job = removeSecurityProxy(job)
628+ self.assertIsInstance(job, RemoveSubscriptionsJob)
629+ self.assertEqual(pillar, job.pillar)
630+ self.assertEqual(grantee, job.grantee)
631+ self.assertEqual(requestor.id, naked_job.requestor_id)
632+ self.assertContentEqual([bug.id], naked_job.bug_ids)
633+ self.assertContentEqual([branch.unique_name], naked_job.branch_names)
634+
635+ def test_unsubscribe_bugs(self):
636+ # The requested bug subscriptions are removed.
637+ pillar = self.factory.makeDistribution()
638+ grantee = self.factory.makePerson()
639+ owner = self.factory.makePerson()
640+ bug = self.factory.makeBug(owner=owner, distribution=pillar)
641+ with person_logged_in(owner):
642+ bug.subscribe(grantee, owner)
643+ self.assertContentEqual([owner, grantee], bug.getDirectSubscribers())
644+ getUtility(IRemoveSubscriptionsJobSource).create(
645+ pillar, grantee, owner, [bug])
646+ with block_on_job(self):
647+ transaction.commit()
648+ self.assertContentEqual([owner], bug.getDirectSubscribers())
649+
650+ def test_unsubscribe_branches(self):
651+ # The requested branch subscriptions are removed.
652+ pillar = self.factory.makeProduct()
653+ grantee = self.factory.makePerson()
654+ owner = self.factory.makePerson()
655+ branch = self.factory.makeBranch(owner=owner, product=pillar)
656+ with person_logged_in(owner):
657+ branch.subscribe(grantee,
658+ BranchSubscriptionNotificationLevel.NOEMAIL, None,
659+ CodeReviewNotificationLevel.NOEMAIL, owner)
660+ self.assertContentEqual([owner, grantee], list(branch.subscribers))
661+ getUtility(IRemoveSubscriptionsJobSource).create(
662+ pillar, grantee, owner, branches=[branch])
663+ with block_on_job(self):
664+ transaction.commit()
665+ self.assertContentEqual([owner], list(branch.subscribers))
666
667=== modified file 'lib/lp/services/config/schema-lazr.conf'
668--- lib/lp/services/config/schema-lazr.conf 2012-05-02 01:31:53 +0000
669+++ lib/lp/services/config/schema-lazr.conf 2012-05-02 23:26:21 +0000
670@@ -1558,6 +1558,14 @@
671 # datatype: string
672 virtual_host: none
673
674+[sharing_jobs]
675+# The database user which will be used by this process.
676+# datatype: string
677+dbuser: sharing-jobs
678+
679+# See [error_reports].
680+error_dir: none
681+
682 [txlongpoll]
683 # Should TxLongPoll be launched by default?
684 # datatype: boolean