Merge lp:~julian-edwards/launchpad/publisher-config-db-schema into lp:launchpad

Proposed by Julian Edwards
Status: Rejected
Rejected by: Julian Edwards
Proposed branch: lp:~julian-edwards/launchpad/publisher-config-db-schema
Merge into: lp:launchpad
Diff against target: 1119 lines (+645/-45)
26 files modified
database/schema/launchpad_session.sql (+25/-0)
database/schema/patch-2208-99-0.sql (+14/-0)
database/schema/security.cfg (+1/-0)
lib/lp/archivepublisher/config.py (+0/-4)
lib/lp/archivepublisher/deathrow.py (+2/-7)
lib/lp/archivepublisher/interfaces/publisherconfig.py (+58/-0)
lib/lp/archivepublisher/model/publisherconfig.py (+68/-0)
lib/lp/archivepublisher/publishing.py (+1/-6)
lib/lp/archivepublisher/tests/test_publisherconfig.py (+66/-0)
lib/lp/archivepublisher/zcml/configure.zcml (+23/-1)
lib/lp/bugs/configure.zcml (+4/-0)
lib/lp/bugs/doc/bugnotification-sending.txt (+9/-3)
lib/lp/bugs/enum.py (+4/-4)
lib/lp/bugs/mail/bugnotificationrecipients.py (+11/-0)
lib/lp/bugs/model/bugnotification.py (+11/-0)
lib/lp/bugs/model/structuralsubscription.py (+4/-3)
lib/lp/scripts/garbo.py (+73/-8)
lib/lp/scripts/tests/test_garbo.py (+104/-5)
lib/lp/services/configure.zcml (+1/-0)
lib/lp/services/session/adapters.py (+40/-0)
lib/lp/services/session/configure.zcml (+12/-0)
lib/lp/services/session/interfaces.py (+15/-0)
lib/lp/services/session/model.py (+47/-0)
lib/lp/services/session/tests/test_session.py (+32/-0)
lib/lp/testing/factory.py (+15/-0)
lib/lp/testing/tests/test_standard_test_template.py (+5/-4)
To merge this branch: bzr merge lp:~julian-edwards/launchpad/publisher-config-db-schema
Reviewer Review Type Date Requested Status
Julian Edwards (community) Needs Resubmitting
Benji York (community) code Approve
Robert Collins db Pending
Stuart Bishop db Pending
Review via email: mp+52411@code.launchpad.net

Description of the change

= Summary =
New PublisherConfig table

== Proposed fix ==
This branch adds a new PublisherConfig table which will eventually deprecate
the archivepublisher config section.

The schema allows for separate configurations for each hosted distribution
which is required as part of the Derived Distributions feature. When we start
hosting multiple distributions, the single configuration that currently exists
for the purposes of publishing Ubuntu will not suffice. The configured
options are:

 * The base path for the archive
 * The URL to the archive

It's entirely possible that custom distros will be hosted in an entirely
different disk area to Ubuntu, so we need to retain this configurability for
each distro.

The intention is to add a trivial LaunchpadEditForm page after this lands so
that we can set up the data, fix the rest of the code to use it, and delete
the original config.

== Implementation details ==
Fairly trivial schema change plus new model code.

== Tests ==
bin/test -cvv test_publisherconfig

== Demo and Q/A ==
n/a yet

To post a comment you must log in.
Revision history for this message
Benji York (benji) wrote :

This looks good.

The only thing I thought you might want to know was that the imports in
this section of lib/lp/archivepublisher/model/publisherconfig.py aren't
sorted:

    from storm.locals import (
        Int,
        Reference,
        Storm,
        RawStr,
        )

review: Approve (code)
Revision history for this message
Julian Edwards (julian-edwards) wrote :

Benji, thanks to "bzr send" this MP is targeted to devel instead of db-devel.... I'm going to file a new one if you wouldn't mind blessing it!

review: Needs Resubmitting

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'database/schema/launchpad_session.sql'
2--- database/schema/launchpad_session.sql 2010-09-10 09:45:45 +0000
3+++ database/schema/launchpad_session.sql 2011-03-07 15:37:44 +0000
4@@ -29,3 +29,28 @@
5 GRANT SELECT, INSERT, UPDATE, DELETE ON TimeLimitedToken TO session;
6 -- And the garbo needs to run on it too.
7 GRANT SELECT, DELETE ON TimeLimitedToken TO session;
8+
9+
10+-- This helper needs to exist in the session database so the BulkPruner
11+-- can clean up unwanted sessions.
12+CREATE OR REPLACE FUNCTION cursor_fetch(cur refcursor, n integer)
13+RETURNS SETOF record LANGUAGE plpgsql AS
14+$$
15+DECLARE
16+ r record;
17+ count integer;
18+BEGIN
19+ FOR count IN 1..n LOOP
20+ FETCH FORWARD FROM cur INTO r;
21+ IF NOT FOUND THEN
22+ RETURN;
23+ END IF;
24+ RETURN NEXT r;
25+ END LOOP;
26+END;
27+$$;
28+
29+COMMENT ON FUNCTION cursor_fetch(refcursor, integer) IS
30+'Fetch the next n items from a cursor. Work around for not being able to use FETCH inside a SELECT statement.';
31+
32+GRANT EXECUTE ON FUNCTION cursor_fetch(refcursor, integer) TO session;
33
34=== added file 'database/schema/patch-2208-99-0.sql'
35--- database/schema/patch-2208-99-0.sql 1970-01-01 00:00:00 +0000
36+++ database/schema/patch-2208-99-0.sql 2011-03-07 15:37:44 +0000
37@@ -0,0 +1,14 @@
38+SET client_min_messages=ERROR;
39+
40+CREATE TABLE PublisherConfig (
41+ id serial PRIMARY KEY,
42+ distribution integer NOT NULL CONSTRAINT publisherconfig__distribution__fk REFERENCES distribution,
43+ root_dir text NOT NULL,
44+ base_url text NOT NULL,
45+ copy_base_url text NOT NULL
46+);
47+
48+CREATE UNIQUE INDEX publisherconfig__distribution__idx
49+ ON PublisherConfig(distribution);
50+
51+INSERT INTO LaunchpadDatabaseRevision VALUES (2208, 99, 0);
52
53=== modified file 'database/schema/security.cfg'
54--- database/schema/security.cfg 2011-03-07 07:27:56 +0000
55+++ database/schema/security.cfg 2011-03-07 15:37:44 +0000
56@@ -260,6 +260,7 @@
57 public.productrelease = SELECT, INSERT, UPDATE, DELETE
58 public.productreleasefile = SELECT, INSERT, DELETE
59 public.productseriescodeimport = SELECT, INSERT, UPDATE
60+public.publisherconfig = SELECT, INSERT, UPDATE, DELETE
61 public.project = SELECT
62 public.projectbounty = SELECT, INSERT, UPDATE
63 public.questionbug = SELECT, INSERT, DELETE
64
65=== modified file 'lib/lp/archivepublisher/config.py'
66--- lib/lp/archivepublisher/config.py 2010-10-17 13:35:20 +0000
67+++ lib/lp/archivepublisher/config.py 2011-03-07 15:37:44 +0000
68@@ -83,10 +83,6 @@
69 return pubconf
70
71
72-class LucilleConfigError(Exception):
73- """Lucille configuration was not present."""
74-
75-
76 class Config(object):
77 """Manage a publisher configuration from the database. (Read Only)
78 This class provides a useful abstraction so that if we change
79
80=== modified file 'lib/lp/archivepublisher/deathrow.py'
81--- lib/lp/archivepublisher/deathrow.py 2010-10-17 13:35:20 +0000
82+++ lib/lp/archivepublisher/deathrow.py 2011-03-07 15:37:44 +0000
83@@ -17,7 +17,6 @@
84 from lp.archivepublisher import ELIGIBLE_DOMINATION_STATES
85 from lp.archivepublisher.config import (
86 getPubConfig,
87- LucilleConfigError,
88 )
89 from lp.archivepublisher.diskpool import DiskPool
90 from lp.archivepublisher.utils import process_in_batches
91@@ -40,12 +39,8 @@
92 the one provided by the publishing-configuration, it will be only
93 used for PRIMARY archives.
94 """
95- log.debug("Grab Lucille config.")
96- try:
97- pubconf = getPubConfig(archive)
98- except LucilleConfigError, info:
99- log.error(info)
100- raise
101+ log.debug("Grab publisher config.")
102+ pubconf = getPubConfig(archive)
103
104 if (pool_root_override is not None and
105 archive.purpose == ArchivePurpose.PRIMARY):
106
107=== added file 'lib/lp/archivepublisher/interfaces/publisherconfig.py'
108--- lib/lp/archivepublisher/interfaces/publisherconfig.py 1970-01-01 00:00:00 +0000
109+++ lib/lp/archivepublisher/interfaces/publisherconfig.py 2011-03-07 15:37:44 +0000
110@@ -0,0 +1,58 @@
111+# Copyright 2011 Canonical Ltd. This software is licensed under the
112+# GNU Affero General Public License version 3 (see the file LICENSE).
113+
114+# pylint: disable-msg=E0211,E0213
115+
116+"""PublisherConfig interface."""
117+
118+__metaclass__ = type
119+
120+__all__ = [
121+ 'IPublisherConfig',
122+ 'IPublisherConfigSet',
123+ ]
124+
125+from lazr.restful.fields import Reference
126+from zope.interface import Interface
127+from zope.schema import (
128+ Int,
129+ TextLine,
130+ )
131+
132+from canonical.launchpad import _
133+from lp.registry.interfaces.distribution import IDistribution
134+
135+
136+class IPublisherConfig(Interface):
137+ """`PublisherConfig` interface."""
138+
139+ id = Int(title=_('ID'), required=True, readonly=True)
140+
141+ distribution = Reference(
142+ IDistribution, title=_("Distribution"), required=True,
143+ description=_("The Distribution for this configuration."))
144+
145+ root_dir = TextLine(
146+ title=_("Root Directory"), required=True,
147+ description=_("The root directory for published archives."))
148+
149+ base_url = TextLine(
150+ title=_("Base URL"), required=True,
151+ description=_("The base URL for published archives"))
152+
153+ copy_base_url = TextLine(
154+ title=_("Copy Base URL"), required=True,
155+ description=_("The base URL for published copy archives"))
156+
157+
158+class IPublisherConfigSet(Interface):
159+ """`PublisherConfigSet` interface."""
160+
161+ def new(distribution, root_dir, base_url, copy_base_url):
162+ """Create a new `PublisherConfig`."""
163+
164+ def getByDistribution(distribution):
165+ """Get the config for a a distribution.
166+
167+ :param distribution: An `IDistribution`
168+ """
169
170=== added file 'lib/lp/archivepublisher/model/publisherconfig.py'
171--- lib/lp/archivepublisher/model/publisherconfig.py 1970-01-01 00:00:00 +0000
172+++ lib/lp/archivepublisher/model/publisherconfig.py 2011-03-07 15:37:44 +0000
173@@ -0,0 +1,68 @@
174+# Copyright 2011 Canonical Ltd. This software is licensed under the
175+# GNU Affero General Public License version 3 (see the file LICENSE).
176+
177+"""Database class for table PublisherConfig."""
178+
179+__metaclass__ = type
180+
181+__all__ = [
182+ 'PublisherConfig',
183+ 'PublisherConfigSet',
184+ ]
185+
186+from storm.locals import (
187+ Int,
188+ RawStr,
189+ Reference,
190+ Storm,
191+ )
192+from zope.interface import implements
193+
194+from canonical.launchpad.interfaces.lpstorm import (
195+ IMasterStore,
196+ )
197+from lp.archivepublisher.interfaces.publisherconfig import (
198+ IPublisherConfig,
199+ IPublisherConfigSet,
200+ )
201+
202+
203+class PublisherConfig(Storm):
204+ """See `IArchiveAuthToken`."""
205+ implements(IPublisherConfig)
206+ __storm_table__ = 'PublisherConfig'
207+
208+ id = Int(primary=True)
209+
210+ distribution_id = Int(name='distribution', allow_none=False)
211+ distribution = Reference(distribution_id, 'Distribution.id')
212+
213+ root_dir = RawStr(name='root_dir', allow_none=False)
214+
215+ base_url = RawStr(name='base_url', allow_none=False)
216+
217+ copy_base_url = RawStr(name='copy_base_url', allow_none=False)
218+
219+
220+class PublisherConfigSet:
221+ """See `IPublisherConfigSet`."""
222+ implements(IPublisherConfigSet)
223+ title = "Soyuz Publisher Configurations"
224+
225+ def new(self, distribution, root_dir, base_url, copy_base_url):
226+ """Make and return a new `PublisherConfig`."""
227+ store = IMasterStore(PublisherConfig)
228+ pubconf = PublisherConfig()
229+ pubconf.distribution = distribution
230+ pubconf.root_dir = root_dir
231+ pubconf.base_url = base_url
232+ pubconf.copy_base_url = copy_base_url
233+ store.add(pubconf)
234+ return pubconf
235+
236+ def getByDistribution(self, distribution):
237+ """See `IArchiveAuthTokenSet`."""
238+ store = IMasterStore(PublisherConfig)
239+ return store.find(
240+ PublisherConfig,
241+ PublisherConfig.distribution_id == distribution.id).one()
242
243=== modified file 'lib/lp/archivepublisher/publishing.py'
244--- lib/lp/archivepublisher/publishing.py 2011-02-04 09:07:36 +0000
245+++ lib/lp/archivepublisher/publishing.py 2011-03-07 15:37:44 +0000
246@@ -21,7 +21,6 @@
247 from lp.archivepublisher import HARDCODED_COMPONENT_ORDER
248 from lp.archivepublisher.config import (
249 getPubConfig,
250- LucilleConfigError,
251 )
252 from lp.archivepublisher.diskpool import DiskPool
253 from lp.archivepublisher.domination import Dominator
254@@ -120,11 +119,7 @@
255 else:
256 log.debug("Finding configuration for '%s' PPA."
257 % archive.owner.name)
258- try:
259- pubconf = getPubConfig(archive)
260- except LucilleConfigError, info:
261- log.error(info)
262- raise
263+ pubconf = getPubConfig(archive)
264
265 disk_pool = _getDiskPool(pubconf, log)
266
267
268=== added file 'lib/lp/archivepublisher/tests/test_publisherconfig.py'
269--- lib/lp/archivepublisher/tests/test_publisherconfig.py 1970-01-01 00:00:00 +0000
270+++ lib/lp/archivepublisher/tests/test_publisherconfig.py 2011-03-07 15:37:44 +0000
271@@ -0,0 +1,66 @@
272+# Copyright 2011 Canonical Ltd. This software is licensed under the
273+# GNU Affero General Public License version 3 (see the file LICENSE).
274+
275+"""Tests for publisherConfig model class."""
276+
277+__metaclass__ = type
278+
279+
280+from storm.store import Store
281+from storm.exceptions import IntegrityError
282+from zope.component import getUtility
283+from zope.interface.verify import verifyObject
284+
285+from canonical.testing.layers import ZopelessDatabaseLayer
286+from lp.archivepublisher.interfaces.publisherconfig import (
287+ IPublisherConfig,
288+ IPublisherConfigSet,
289+ )
290+from lp.testing import TestCaseWithFactory
291+
292+
293+class TestPublisherConfig(TestCaseWithFactory):
294+ """Test the `PublisherConfig` model."""
295+ layer = ZopelessDatabaseLayer
296+
297+ def setUp(self):
298+ TestCaseWithFactory.setUp(self)
299+ self.distribution = self.factory.makeDistribution(name='conftest')
300+
301+ def test_verify_interface(self):
302+ # Test the interface for the model.
303+ pubconf = self.factory.makePublisherConfig()
304+ verified = verifyObject(IPublisherConfig, pubconf)
305+ self.assertTrue(verified)
306+
307+ def test_properties(self):
308+ # Test the model properties.
309+ ROOT_DIR = "rootdir/test"
310+ BASE_URL = "http://base.url"
311+ COPY_BASE_URL = "http://base.url"
312+ pubconf = self.factory.makePublisherConfig(
313+ distribution=self.distribution,
314+ root_dir=ROOT_DIR,
315+ base_url=BASE_URL,
316+ copy_base_url=COPY_BASE_URL,
317+ )
318+
319+ self.assertEqual(self.distribution.name, pubconf.distribution.name)
320+ self.assertEqual(ROOT_DIR, pubconf.root_dir)
321+ self.assertEqual(BASE_URL, pubconf.base_url)
322+ self.assertEqual(COPY_BASE_URL, pubconf.copy_base_url)
323+
324+ def test_one_config_per_distro(self):
325+ # Only one config for each distro is allowed.
326+ pubconf = self.factory.makePublisherConfig(self.distribution)
327+ pubconf2 = self.factory.makePublisherConfig(self.distribution)
328+ store = Store.of(pubconf)
329+ self.assertRaises(IntegrityError, store.flush)
330+
331+ def test_getByDistribution(self):
332+ # Test that IPublisherConfigSet.getByDistribution works.
333+ pubconf = self.factory.makePublisherConfig(
334+ distribution=self.distribution)
335+ pubconf = getUtility(IPublisherConfigSet).getByDistribution(
336+ self.distribution)
337+ self.assertEqual(self.distribution.name, pubconf.distribution.name)
338
339=== modified file 'lib/lp/archivepublisher/zcml/configure.zcml'
340--- lib/lp/archivepublisher/zcml/configure.zcml 2009-07-13 18:15:02 +0000
341+++ lib/lp/archivepublisher/zcml/configure.zcml 2011-03-07 15:37:44 +0000
342@@ -2,8 +2,30 @@
343 GNU Affero General Public License version 3 (see the file LICENSE).
344 -->
345
346-<configure xmlns="http://namespaces.zope.org/zope">
347+<configure
348+ xmlns="http://namespaces.zope.org/zope"
349+ xmlns:browser="http://namespaces.zope.org/browser"
350+ xmlns:i18n="http://namespaces.zope.org/i18n"
351+ xmlns:webservice="http://namespaces.canonical.com/webservice"
352+ xmlns:xmlrpc="http://namespaces.zope.org/xmlrpc"
353+ i18n_domain="launchpad">
354+
355 <include package="lp.archivepublisher.zcml"
356 file="archivesigningkey.zcml" />
357+
358+ <securedutility
359+ class="lp.archivepublisher.model.publisherconfig.PublisherConfigSet"
360+ provides="lp.archivepublisher.interfaces.publisherconfig.IPublisherConfigSet">
361+ <allow
362+ interface="lp.archivepublisher.interfaces.publisherconfig.IPublisherConfigSet"/>
363+ </securedutility>
364+
365+ <class
366+ class="lp.archivepublisher.model.publisherconfig.PublisherConfig">
367+ <allow
368+ interface="lp.archivepublisher.interfaces.publisherconfig.IPublisherConfig" />
369+ </class>
370+
371+
372 </configure>
373
374
375=== modified file 'lib/lp/bugs/configure.zcml'
376--- lib/lp/bugs/configure.zcml 2011-03-02 23:08:54 +0000
377+++ lib/lp/bugs/configure.zcml 2011-03-07 15:37:44 +0000
378@@ -1056,6 +1056,10 @@
379 class="lp.bugs.mail.bugnotificationrecipients.BugNotificationRecipients">
380 <allow
381 interface="canonical.launchpad.interfaces.launchpad.INotificationRecipientSet"/>
382+ <!-- BugNotificationRecipients provides the following
383+ attributes/methods in addition. -->
384+ <allow
385+ attributes="subscription_filters addFilter"/>
386 </class>
387 <securedutility
388 provides="lp.bugs.interfaces.bugnotification.IBugNotificationSet"
389
390=== modified file 'lib/lp/bugs/doc/bugnotification-sending.txt'
391--- lib/lp/bugs/doc/bugnotification-sending.txt 2011-02-22 10:44:48 +0000
392+++ lib/lp/bugs/doc/bugnotification-sending.txt 2011-03-07 15:37:44 +0000
393@@ -21,8 +21,10 @@
394
395 >>> def print_notification_headers(email_notification):
396 ... for header in ['To', 'From', 'Subject',
397- ... 'X-Launchpad-Message-Rationale']:
398- ... print "%s: %s" % (header, email_notification[header])
399+ ... 'X-Launchpad-Message-Rationale',
400+ ... 'X-Launchpad-Subscription-Filter']:
401+ ... if email_notification[header]:
402+ ... print "%s: %s" % (header, email_notification[header])
403
404 >>> def print_notification(email_notification):
405 ... print_notification_headers(email_notification)
406@@ -676,7 +678,6 @@
407 been set properly.
408
409 >>> from lp.bugs.model.bugnotification import BugNotification
410- >>> from lp.bugs.enum import BugNotificationStatus
411 >>> for notification in BugNotification.select(orderBy='id')[-8:]:
412 ... if notification.is_comment:
413 ... identifier = 'comment'
414@@ -1247,6 +1248,7 @@
415 >>> with lp_dbuser():
416 ... filter = subscription_no_priv.newBugFilter()
417 ... filter.bug_notification_level = BugNotificationLevel.COMMENTS
418+ ... filter.description = u"Allow-comments filter"
419
420 >>> comment = getUtility(IMessageSet).fromText(
421 ... 'subject', 'another comment.', sample_person,
422@@ -1279,12 +1281,14 @@
423 From: Sample Person <...@bugs.launchpad.net>
424 Subject: [Bug 1] subject
425 X-Launchpad-Message-Rationale: Subscriber (Mozilla Firefox)
426+ X-Launchpad-Subscription-Filter: Allow-comments filter
427 <BLANKLINE>
428 another comment.
429 <BLANKLINE>
430 --
431 You received this bug notification because you are subscribed to Mozilla
432 Firefox.
433+ Matching filters: Allow-comments filter
434 ...
435 ----------------------------------------------------------------------
436 To: support@ubuntu.com
437@@ -1390,6 +1394,7 @@
438 From: Sample Person <...@bugs.launchpad.net>
439 Subject: [Bug 1] subject
440 X-Launchpad-Message-Rationale: Subscriber (Mozilla Firefox)
441+ X-Launchpad-Subscription-Filter: Allow-comments filter
442 <BLANKLINE>
443 no comment for no-priv.
444 <BLANKLINE>
445@@ -1400,6 +1405,7 @@
446 --
447 You received this bug notification because you are subscribed to Mozilla
448 Firefox.
449+ Matching filters: Allow-comments filter
450 ...
451 ----------------------------------------------------------------------
452 To: support@ubuntu.com
453
454=== modified file 'lib/lp/bugs/enum.py'
455--- lib/lp/bugs/enum.py 2011-03-05 00:06:26 +0000
456+++ lib/lp/bugs/enum.py 2011-03-07 15:37:44 +0000
457@@ -57,18 +57,18 @@
458
459 class BugNotificationStatus(DBEnumeratedType):
460 """The status of a bug notification.
461-
462+
463 A notification may be pending, sent, or omitted."""
464
465 PENDING = DBItem(10, """
466 Pending
467-
468+
469 The notification has not yet been sent.
470 """)
471
472 OMITTED = DBItem(20, """
473 Omitted
474-
475+
476 The system considered sending the notification, but omitted it.
477 This is generally because the action reported by the notification
478 was immediately undone.
479@@ -76,6 +76,6 @@
480
481 SENT = DBItem(30, """
482 Sent
483-
484+
485 The notification has been sent.
486 """)
487
488=== modified file 'lib/lp/bugs/mail/bugnotificationrecipients.py'
489--- lib/lp/bugs/mail/bugnotificationrecipients.py 2011-03-02 14:07:23 +0000
490+++ lib/lp/bugs/mail/bugnotificationrecipients.py 2011-03-07 15:37:44 +0000
491@@ -58,6 +58,7 @@
492 """
493 NotificationRecipientSet.__init__(self)
494 self.duplicateof = duplicateof
495+ self.subscription_filters = set()
496
497 def _addReason(self, person, reason, header):
498 """Adds a reason (text and header) for a person.
499@@ -127,3 +128,13 @@
500 else:
501 text = "are the registrant for %s" % upstream.displayname
502 self._addReason(person, text, reason)
503+
504+ def update(self, recipient_set):
505+ """See `INotificationRecipientSet`."""
506+ super(BugNotificationRecipients, self).update(recipient_set)
507+ self.subscription_filters.update(
508+ recipient_set.subscription_filters)
509+
510+ def addFilter(self, subscription_filter):
511+ if subscription_filter is not None:
512+ self.subscription_filters.add(subscription_filter)
513
514=== modified file 'lib/lp/bugs/model/bugnotification.py'
515--- lib/lp/bugs/model/bugnotification.py 2011-02-25 16:19:58 +0000
516+++ lib/lp/bugs/model/bugnotification.py 2011-03-07 15:37:44 +0000
517@@ -150,12 +150,23 @@
518 reason_body, reason_header = recipients.getReason(recipient)
519 sql_values.append('(%s, %s, %s, %s)' % sqlvalues(
520 bug_notification, recipient, reason_header, reason_body))
521+
522 # We add all the recipients in a single SQL statement to make
523 # this a bit more efficient for bugs with many subscribers.
524 store.execute("""
525 INSERT INTO BugNotificationRecipient
526 (bug_notification, person, reason_header, reason_body)
527 VALUES %s;""" % ', '.join(sql_values))
528+
529+ if len(recipients.subscription_filters) > 0:
530+ filter_link_sql = [
531+ "(%s, %s)" % sqlvalues(bug_notification, filter.id)
532+ for filter in recipients.subscription_filters]
533+ store.execute("""
534+ INSERT INTO BugNotificationFilter
535+ (bug_notification, bug_subscription_filter)
536+ VALUES %s;""" % ", ".join(filter_link_sql))
537+
538 return bug_notification
539
540
541
542=== modified file 'lib/lp/bugs/model/structuralsubscription.py'
543--- lib/lp/bugs/model/structuralsubscription.py 2011-03-04 02:29:09 +0000
544+++ lib/lp/bugs/model/structuralsubscription.py 2011-03-07 15:37:44 +0000
545@@ -585,14 +585,15 @@
546 else:
547 subscribers = []
548 query_results = source.find(
549- (Person, StructuralSubscription),
550- *constraints).config(distinct=True)
551- for person, subscription in query_results:
552+ (Person, StructuralSubscription, BugSubscriptionFilter),
553+ *constraints)
554+ for person, subscription, filter in query_results:
555 # Set up results.
556 if person not in recipients:
557 subscribers.append(person)
558 recipients.addStructuralSubscriber(
559 person, subscription.target)
560+ recipients.addFilter(filter)
561 return subscribers
562
563
564
565=== modified file 'lib/lp/scripts/garbo.py'
566--- lib/lp/scripts/garbo.py 2011-02-23 10:28:53 +0000
567+++ lib/lp/scripts/garbo.py 2011-03-07 15:37:44 +0000
568@@ -74,6 +74,7 @@
569 LaunchpadCronScript,
570 SilentLaunchpadScriptFailure,
571 )
572+from lp.services.session.model import SessionData
573 from lp.translations.interfaces.potemplate import IPOTemplateSet
574 from lp.translations.model.potranslation import POTranslation
575
576@@ -107,10 +108,14 @@
577 # from. Must be overridden.
578 target_table_class = None
579
580- # The column name in target_table we use as the integer key. May be
581- # overridden.
582+ # The column name in target_table we use as the key. The type must
583+ # match that returned by the ids_to_prune_query and the
584+ # target_table_key_type. May be overridden.
585 target_table_key = 'id'
586
587+ # SQL type of the target_table_key. May be overridden.
588+ target_table_key_type = 'integer'
589+
590 # An SQL query returning a list of ids to remove from target_table.
591 # The query must return a single column named 'id' and should not
592 # contain duplicates. Must be overridden.
593@@ -119,10 +124,23 @@
594 # See `TunableLoop`. May be overridden.
595 maximum_chunk_size = 10000
596
597+ # Optional extra WHERE clause fragment for the deletion to skip
598+ # arbitrary rows flagged for deletion. For example, skip rows
599+ # that might have been modified since the set of ids_to_prune
600+ # was calculated.
601+ extra_prune_clause = None
602+
603+ def getStore(self):
604+ """The master Store for the table we are pruning.
605+
606+ May be overridden.
607+ """
608+ return IMasterStore(self.target_table_class)
609+
610 def __init__(self, log, abort_time=None):
611 super(BulkPruner, self).__init__(log, abort_time)
612
613- self.store = IMasterStore(self.target_table_class)
614+ self.store = self.getStore()
615 self.target_table_name = self.target_table_class.__storm_table__
616
617 # Open the cursor.
618@@ -138,12 +156,20 @@
619
620 def __call__(self, chunk_size):
621 """See `ITunableLoop`."""
622+ if self.extra_prune_clause:
623+ extra = "AND (%s)" % self.extra_prune_clause
624+ else:
625+ extra = ""
626 result = self.store.execute("""
627- DELETE FROM %s WHERE %s IN (
628+ DELETE FROM %s
629+ WHERE %s IN (
630 SELECT id FROM
631- cursor_fetch('bulkprunerid', %d) AS f(id integer))
632+ cursor_fetch('bulkprunerid', %d) AS f(id %s))
633+ %s
634 """
635- % (self.target_table_name, self.target_table_key, chunk_size))
636+ % (
637+ self.target_table_name, self.target_table_key,
638+ chunk_size, self.target_table_key_type, extra))
639 self._num_removed = result.rowcount
640 transaction.commit()
641
642@@ -157,9 +183,7 @@
643
644 XXX bug=723596 StuartBishop: This job only needs to run once per month.
645 """
646-
647 target_table_class = POTranslation
648-
649 ids_to_prune_query = """
650 SELECT POTranslation.id AS id FROM POTranslation
651 EXCEPT (
652@@ -186,6 +210,45 @@
653 """
654
655
656+class SessionPruner(BulkPruner):
657+ """Base class for session removal."""
658+
659+ target_table_class = SessionData
660+ target_table_key = 'client_id'
661+ target_table_key_type = 'text'
662+
663+
664+class AntiqueSessionPruner(SessionPruner):
665+ """Remove sessions not accessed for 60 days"""
666+
667+ ids_to_prune_query = """
668+ SELECT client_id AS id FROM SessionData
669+ WHERE last_accessed < CURRENT_TIMESTAMP - CAST('60 days' AS interval)
670+ """
671+
672+
673+class UnusedSessionPruner(SessionPruner):
674+ """Remove sessions older than 1 day with no authentication credentials."""
675+
676+ ids_to_prune_query = """
677+ SELECT client_id AS id FROM SessionData
678+ WHERE
679+ last_accessed < CURRENT_TIMESTAMP - CAST('1 day' AS interval)
680+ AND client_id NOT IN (
681+ SELECT client_id
682+ FROM SessionPkgData
683+ WHERE
684+ product_id = 'launchpad.authenticateduser'
685+ AND key='logintime')
686+ """
687+
688+ # Don't delete a session if it has been used between calculating
689+ # the list of sessions to remove and the current iteration.
690+ prune_extra_clause = """
691+ last_accessed < CURRENT_TIMESTAMP - CAST('1 day' AS interval)
692+ """
693+
694+
695 class OAuthNoncePruner(TunableLoop):
696 """An ITunableLoop to prune old OAuthNonce records.
697
698@@ -977,6 +1040,8 @@
699 RevisionCachePruner,
700 BugHeatUpdater,
701 BugWatchScheduler,
702+ AntiqueSessionPruner,
703+ UnusedSessionPruner,
704 ]
705 experimental_tunable_loops = []
706
707
708=== modified file 'lib/lp/scripts/tests/test_garbo.py'
709--- lib/lp/scripts/tests/test_garbo.py 2011-02-28 11:50:34 +0000
710+++ lib/lp/scripts/tests/test_garbo.py 2011-03-07 15:37:44 +0000
711@@ -10,7 +10,6 @@
712 datetime,
713 timedelta,
714 )
715-import operator
716 import time
717
718 from pytz import UTC
719@@ -18,7 +17,10 @@
720 Min,
721 SQL,
722 )
723-from storm.locals import Storm, Int
724+from storm.locals import (
725+ Int,
726+ Storm,
727+ )
728 from storm.store import Store
729 import transaction
730 from zope.component import getUtility
731@@ -69,13 +71,19 @@
732 PersonCreationRationale,
733 )
734 from lp.scripts.garbo import (
735+ AntiqueSessionPruner,
736 BulkPruner,
737 DailyDatabaseGarbageCollector,
738 HourlyDatabaseGarbageCollector,
739 OpenIDConsumerAssociationPruner,
740+ UnusedSessionPruner,
741 )
742 from lp.services.job.model.job import Job
743 from lp.services.log.logger import BufferLogger
744+from lp.services.session.model import (
745+ SessionData,
746+ SessionPkgData,
747+ )
748 from lp.testing import (
749 TestCase,
750 TestCaseWithFactory,
751@@ -181,6 +189,97 @@
752 pruner(chunk_size)
753
754
755+class TestSessionPruner(TestCase):
756+ layer = ZopelessDatabaseLayer
757+
758+ def setUp(self):
759+ super(TestCase, self).setUp()
760+
761+ # Session database isn't reset between tests. We need to do this
762+ # manually.
763+ nuke_all_sessions = IMasterStore(SessionData).find(SessionData).remove
764+ nuke_all_sessions()
765+ self.addCleanup(nuke_all_sessions)
766+
767+ recent = datetime.now(UTC)
768+ yesterday = recent - timedelta(days=1)
769+ ancient = recent - timedelta(days=61)
770+
771+ def make_session(client_id, accessed, authenticated=None):
772+ session_data = SessionData()
773+ session_data.client_id = client_id
774+ session_data.last_accessed = accessed
775+ IMasterStore(SessionData).add(session_data)
776+
777+ if authenticated:
778+ session_pkg_data = SessionPkgData()
779+ session_pkg_data.client_id = client_id
780+ session_pkg_data.product_id = u'launchpad.authenticateduser'
781+ session_pkg_data.key = u'logintime'
782+ session_pkg_data.pickle = 'value is ignored'
783+ IMasterStore(SessionPkgData).add(session_pkg_data)
784+
785+ make_session(u'recent_auth', recent, True)
786+ make_session(u'recent_unauth', recent, False)
787+ make_session(u'yesterday_auth', yesterday, True)
788+ make_session(u'yesterday_unauth', yesterday, False)
789+ make_session(u'ancient_auth', ancient, True)
790+ make_session(u'ancient_unauth', ancient, False)
791+
792+ def sessionExists(self, client_id):
793+ store = IMasterStore(SessionData)
794+ return not store.find(
795+ SessionData, SessionData.client_id == client_id).is_empty()
796+
797+ def test_antique_session_pruner(self):
798+ chunk_size = 2
799+ log = BufferLogger()
800+ pruner = AntiqueSessionPruner(log)
801+ try:
802+ while not pruner.isDone():
803+ pruner(chunk_size)
804+ finally:
805+ pruner.cleanUp()
806+
807+ expected_sessions = set([
808+ u'recent_auth',
809+ u'recent_unauth',
810+ u'yesterday_auth',
811+ u'yesterday_unauth',
812+ # u'ancient_auth',
813+ # u'ancient_unauth',
814+ ])
815+
816+ found_sessions = set(
817+ IMasterStore(SessionData).find(SessionData.client_id))
818+
819+ self.assertEqual(expected_sessions, found_sessions)
820+
821+ def test_unused_session_pruner(self):
822+ chunk_size = 2
823+ log = BufferLogger()
824+ pruner = UnusedSessionPruner(log)
825+ try:
826+ while not pruner.isDone():
827+ pruner(chunk_size)
828+ finally:
829+ pruner.cleanUp()
830+
831+ expected_sessions = set([
832+ u'recent_auth',
833+ u'recent_unauth',
834+ u'yesterday_auth',
835+ # u'yesterday_unauth',
836+ u'ancient_auth',
837+ # u'ancient_unauth',
838+ ])
839+
840+ found_sessions = set(
841+ IMasterStore(SessionData).find(SessionData.client_id))
842+
843+ self.assertEqual(expected_sessions, found_sessions)
844+
845+
846 class TestGarbo(TestCaseWithFactory):
847 layer = LaunchpadZopelessLayer
848
849@@ -209,7 +308,7 @@
850 return collector
851
852 def test_OAuthNoncePruner(self):
853- now = datetime.utcnow().replace(tzinfo=UTC)
854+ now = datetime.now(UTC)
855 timestamps = [
856 now - timedelta(days=2), # Garbage
857 now - timedelta(days=1) - timedelta(seconds=60), # Garbage
858@@ -287,7 +386,7 @@
859 self.failUnless(earliest >= now - 24*60*60, 'Still have old nonces')
860
861 def test_CodeImportResultPruner(self):
862- now = datetime.utcnow().replace(tzinfo=UTC)
863+ now = datetime.now(UTC)
864 store = IMasterStore(CodeImportResult)
865
866 results_to_keep_count = (
867@@ -344,7 +443,7 @@
868 >= now - timedelta(days=30))
869
870 def test_CodeImportEventPruner(self):
871- now = datetime.utcnow().replace(tzinfo=UTC)
872+ now = datetime.now(UTC)
873 store = IMasterStore(CodeImportResult)
874
875 LaunchpadZopelessLayer.switchDbUser('testadmin')
876
877=== modified file 'lib/lp/services/configure.zcml'
878--- lib/lp/services/configure.zcml 2011-03-04 17:05:09 +0000
879+++ lib/lp/services/configure.zcml 2011-03-07 15:37:44 +0000
880@@ -16,5 +16,6 @@
881 <include package=".salesforce" />
882 <include package=".scripts" />
883 <include package=".search" />
884+ <include package=".session" />
885 <include package=".worlddata" />
886 </configure>
887
888=== added directory 'lib/lp/services/session'
889=== added file 'lib/lp/services/session/__init__.py'
890=== added file 'lib/lp/services/session/adapters.py'
891--- lib/lp/services/session/adapters.py 1970-01-01 00:00:00 +0000
892+++ lib/lp/services/session/adapters.py 2011-03-07 15:37:44 +0000
893@@ -0,0 +1,40 @@
894+# Copyright 2011 Canonical Ltd. This software is licensed under the
895+# GNU Affero General Public License version 3 (see the file LICENSE).
896+
897+"""Session adapters."""
898+
899+__metaclass__ = type
900+__all__ = []
901+
902+
903+from zope.component import adapter
904+from zope.interface import implementer
905+
906+from canonical.database.sqlbase import session_store
907+from canonical.launchpad.interfaces.lpstorm import (
908+ IMasterStore,
909+ ISlaveStore,
910+ IStore,
911+ )
912+from lp.services.session.interfaces import IUseSessionStore
913+
914+
915+@adapter(IUseSessionStore)
916+@implementer(IMasterStore)
917+def session_master_store(cls):
918+ """Adapt a Session database object to an `IMasterStore`."""
919+ return session_store()
920+
921+
922+@adapter(IUseSessionStore)
923+@implementer(ISlaveStore)
924+def session_slave_store(cls):
925+ """Adapt a Session database object to an `ISlaveStore`."""
926+ return session_store()
927+
928+
929+@adapter(IUseSessionStore)
930+@implementer(IStore)
931+def session_default_store(cls):
932+ """Adapt an Session database object to an `IStore`."""
933+ return session_store()
934
935=== added file 'lib/lp/services/session/configure.zcml'
936--- lib/lp/services/session/configure.zcml 1970-01-01 00:00:00 +0000
937+++ lib/lp/services/session/configure.zcml 2011-03-07 15:37:44 +0000
938@@ -0,0 +1,12 @@
939+<!-- Copyright 2011 Canonical Ltd. This software is licensed under the
940+ GNU Affero General Public License version 3 (see the file LICENSE).
941+-->
942+<configure
943+ xmlns="http://namespaces.zope.org/zope"
944+ xmlns:browser="http://namespaces.zope.org/browser"
945+ xmlns:i18n="http://namespaces.zope.org/i18n"
946+ i18n_domain="launchpad">
947+ <adapter factory=".adapters.session_master_store" />
948+ <adapter factory=".adapters.session_slave_store" />
949+ <adapter factory=".adapters.session_default_store" />
950+</configure>
951
952=== added file 'lib/lp/services/session/interfaces.py'
953--- lib/lp/services/session/interfaces.py 1970-01-01 00:00:00 +0000
954+++ lib/lp/services/session/interfaces.py 2011-03-07 15:37:44 +0000
955@@ -0,0 +1,15 @@
956+# Copyright 2011 Canonical Ltd. This software is licensed under the
957+# GNU Affero General Public License version 3 (see the file LICENSE).
958+
959+"""Session interfaces."""
960+
961+__metaclass__ = type
962+__all__ = ['IUseSessionStore']
963+
964+
965+from zope.interface import Interface
966+
967+
968+class IUseSessionStore(Interface):
969+ """Marker interface for Session Storm database classes and instances."""
970+ pass
971
972=== added file 'lib/lp/services/session/model.py'
973--- lib/lp/services/session/model.py 1970-01-01 00:00:00 +0000
974+++ lib/lp/services/session/model.py 2011-03-07 15:37:44 +0000
975@@ -0,0 +1,47 @@
976+# Copyright 2011 Canonical Ltd. This software is licensed under the
977+# GNU Affero General Public License version 3 (see the file LICENSE).
978+
979+"""Session Storm database classes"""
980+
981+__metaclass__ = type
982+__all__ = ['SessionData', 'SessionPkgData']
983+
984+from storm.locals import (
985+ Pickle,
986+ Storm,
987+ Unicode,
988+ )
989+from zope.interface import (
990+ classProvides,
991+ implements,
992+ )
993+
994+from canonical.database.datetimecol import UtcDateTimeCol
995+from lp.services.session.interfaces import IUseSessionStore
996+
997+
998+class SessionData(Storm):
999+ """A user's Session."""
1000+
1001+ classProvides(IUseSessionStore)
1002+ implements(IUseSessionStore)
1003+
1004+ __storm_table__ = 'SessionData'
1005+ client_id = Unicode(primary=True)
1006+ created = UtcDateTimeCol()
1007+ last_accessed = UtcDateTimeCol()
1008+
1009+
1010+class SessionPkgData(Storm):
1011+ """Data storage for a Session."""
1012+
1013+ classProvides(IUseSessionStore)
1014+ implements(IUseSessionStore)
1015+
1016+ __storm_table__ = 'SessionPkgData'
1017+ __storm_primary__ = 'client_id', 'product_id', 'key'
1018+
1019+ client_id = Unicode()
1020+ product_id = Unicode()
1021+ key = Unicode()
1022+ pickle = Pickle()
1023
1024=== added directory 'lib/lp/services/session/tests'
1025=== added file 'lib/lp/services/session/tests/__init__.py'
1026=== added file 'lib/lp/services/session/tests/test_session.py'
1027--- lib/lp/services/session/tests/test_session.py 1970-01-01 00:00:00 +0000
1028+++ lib/lp/services/session/tests/test_session.py 2011-03-07 15:37:44 +0000
1029@@ -0,0 +1,32 @@
1030+# Copyright 2011 Canonical Ltd. This software is licensed under the
1031+# GNU Affero General Public License version 3 (see the file LICENSE).
1032+
1033+"""Session tests."""
1034+
1035+__metaclass__ = type
1036+
1037+from canonical.launchpad.interfaces.lpstorm import (
1038+ IMasterStore,
1039+ ISlaveStore,
1040+ IStore,
1041+ )
1042+from canonical.testing.layers import DatabaseFunctionalLayer
1043+from lp.services.session.model import (
1044+ SessionData,
1045+ SessionPkgData,
1046+ )
1047+from lp.testing import TestCase
1048+
1049+
1050+class TestSessionModelAdapters(TestCase):
1051+ layer = DatabaseFunctionalLayer
1052+
1053+ def test_adapters(self):
1054+ for adapter in [IMasterStore, ISlaveStore, IStore]:
1055+ for cls in [SessionData, SessionPkgData]:
1056+ for obj in [cls, cls()]:
1057+ store = adapter(obj)
1058+ self.assert_(
1059+ 'session' in store.get_database()._dsn,
1060+ 'Unknown store returned adapting %r to %r'
1061+ % (obj, adapter))
1062
1063=== modified file 'lib/lp/testing/factory.py'
1064--- lib/lp/testing/factory.py 2011-03-06 23:15:05 +0000
1065+++ lib/lp/testing/factory.py 2011-03-07 15:37:44 +0000
1066@@ -101,6 +101,7 @@
1067 )
1068 from canonical.launchpad.webapp.sorting import sorted_version_numbers
1069 from lp.app.enums import ServiceUsage
1070+from lp.archivepublisher.interfaces.publisherconfig import IPublisherConfigSet
1071 from lp.archiveuploader.dscfile import DSCFile
1072 from lp.archiveuploader.uploadpolicy import BuildDaemonUploadPolicy
1073 from lp.blueprints.enums import (
1074@@ -3850,6 +3851,20 @@
1075 description = self.getUniqueString()
1076 return getUtility(ICveSet).new(sequence, description, cvestate)
1077
1078+ def makePublisherConfig(self, distribution=None, root_dir=None,
1079+ base_url=None, copy_base_url=None):
1080+ """Create a new `PublisherConfig` record."""
1081+ if distribution is None:
1082+ distribution = self.makeDistribution()
1083+ if root_dir is None:
1084+ root_dir = self.getUniqueString()
1085+ if base_url is None:
1086+ base_url = self.getUniqueString()
1087+ if copy_base_url is None:
1088+ copy_base_url = self.getUniqueString()
1089+ return getUtility(IPublisherConfigSet).new(
1090+ distribution, root_dir, base_url, copy_base_url)
1091+
1092
1093 # Some factory methods return simple Python types. We don't add
1094 # security wrappers for them, as well as for objects created by
1095
1096=== modified file 'lib/lp/testing/tests/test_standard_test_template.py'
1097--- lib/lp/testing/tests/test_standard_test_template.py 2011-02-02 13:19:02 +0000
1098+++ lib/lp/testing/tests/test_standard_test_template.py 2011-03-07 15:37:44 +0000
1099@@ -6,15 +6,16 @@
1100 __metaclass__ = type
1101
1102 from canonical.testing.layers import DatabaseFunctionalLayer
1103-from lp.testing import TestCase # or TestCaseWithFactory
1104+# or TestCaseWithFactory
1105+from lp.testing import TestCase
1106
1107
1108 class TestSomething(TestCase):
1109 # XXX: Sample test class. Replace with your own test class(es).
1110
1111- # XXX: Optional layer--see lib/canonical/testing/layers.py
1112- # Get the simplest layer that your test will work on, or if you
1113- # don't even use the database, don't set it at all.
1114+ # XXX: layer--see lib/canonical/testing/layers.py
1115+ # Get the simplest layer that your test will work on. For unit tests
1116+ # requiring no resources, this is BaseLayer.
1117 layer = DatabaseFunctionalLayer
1118
1119 # XXX: Sample test. Replace with your own test methods.