Merge lp:~cjwatson/launchpad/das-filter-model into lp:launchpad

Proposed by Colin Watson
Status: Merged
Merged at revision: 19058
Proposed branch: lp:~cjwatson/launchpad/das-filter-model
Merge into: lp:launchpad
Prerequisite: lp:~cjwatson/launchpad/packageset-is-source-included
Diff against target: 789 lines (+588/-2)
13 files modified
database/schema/security.cfg (+3/-0)
lib/lp/_schema_circular_imports.py (+3/-1)
lib/lp/registry/scripts/closeaccount.py (+1/-0)
lib/lp/security.py (+19/-0)
lib/lp/soyuz/configure.zcml (+20/-0)
lib/lp/soyuz/doc/distroarchseries.txt (+34/-0)
lib/lp/soyuz/enums.py (+17/-1)
lib/lp/soyuz/interfaces/distroarchseries.py (+59/-0)
lib/lp/soyuz/interfaces/distroarchseriesfilter.py (+127/-0)
lib/lp/soyuz/model/distroarchseries.py (+30/-0)
lib/lp/soyuz/model/distroarchseriesfilter.py (+124/-0)
lib/lp/soyuz/tests/test_distroarchseriesfilter.py (+128/-0)
lib/lp/testing/factory.py (+23/-0)
To merge this branch: bzr merge lp:~cjwatson/launchpad/das-filter-model
Reviewer Review Type Date Requested Status
William Grant code Approve
Review via email: mp+372261@code.launchpad.net

Commit message

Add basic model for per-DAS filtering of build creation.

Description of the change

This isn't yet used or exposed on the webservice; that will come in future MPs.

The corresponding DB patch is in https://code.launchpad.net/~cjwatson/launchpad/db-das-filter/+merge/372260.

To post a comment you must log in.
Revision history for this message
William Grant (wgrant) :
review: Approve (code)
Revision history for this message
Colin Watson (cjwatson) :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'database/schema/security.cfg'
2--- database/schema/security.cfg 2019-08-09 12:04:04 +0000
3+++ database/schema/security.cfg 2019-09-10 11:16:47 +0000
4@@ -176,6 +176,7 @@
5 public.distributionmirror = SELECT, INSERT, UPDATE, DELETE
6 public.distributionsourcepackage = SELECT, INSERT, UPDATE, DELETE
7 public.distributionsourcepackagecache = SELECT, INSERT
8+public.distroarchseriesfilter = SELECT, INSERT, UPDATE, DELETE
9 public.distroseriesdifference = SELECT, INSERT, UPDATE
10 public.distroseriesdifferencemessage = SELECT, INSERT, UPDATE
11 public.distroserieslanguage = SELECT, INSERT, UPDATE
12@@ -2221,6 +2222,7 @@
13 public.distributionsourcepackage = SELECT, INSERT, UPDATE, DELETE
14 public.distributionmirror = SELECT, UPDATE
15 public.distroarchseries = SELECT, UPDATE
16+public.distroarchseriesfilter = SELECT, UPDATE
17 public.distroseries = SELECT, UPDATE
18 public.emailaddress = SELECT, UPDATE, DELETE
19 public.faq = SELECT, UPDATE
20@@ -2356,6 +2358,7 @@
21 public.commercialsubscription = SELECT, UPDATE
22 public.diff = SELECT, DELETE
23 public.distributionsourcepackagecache = SELECT, INSERT
24+public.distroarchseriesfilter = SELECT
25 public.distroseries = SELECT, UPDATE
26 public.emailaddress = SELECT, UPDATE, DELETE
27 public.garbojobstate = SELECT, INSERT, UPDATE, DELETE
28
29=== modified file 'lib/lp/_schema_circular_imports.py'
30--- lib/lp/_schema_circular_imports.py 2018-09-25 16:41:21 +0000
31+++ lib/lp/_schema_circular_imports.py 2019-09-10 11:16:47 +0000
32@@ -1,4 +1,4 @@
33-# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
34+# Copyright 2009-2019 Canonical Ltd. This software is licensed under the
35 # GNU Affero General Public License version 3 (see the file LICENSE).
36
37 """Update the interface schema values due to circular imports.
38@@ -496,6 +496,8 @@
39 patch_reference_property(IDistroArchSeries, 'main_archive', IArchive)
40 patch_plain_parameter_type(
41 IDistroArchSeries, 'setChrootFromBuild', 'livefsbuild', ILiveFSBuild)
42+patch_plain_parameter_type(
43+ IDistroArchSeries, 'setSourceFilter', 'packageset', IPackageset)
44
45 # IGitRef
46 patch_reference_property(IGitRef, 'repository', IGitRepository)
47
48=== modified file 'lib/lp/registry/scripts/closeaccount.py'
49--- lib/lp/registry/scripts/closeaccount.py 2019-08-09 12:04:04 +0000
50+++ lib/lp/registry/scripts/closeaccount.py 2019-09-10 11:16:47 +0000
51@@ -114,6 +114,7 @@
52 ('codeimport', 'owner'),
53 ('codeimport', 'registrant'),
54 ('codeimportevent', 'person'),
55+ ('distroarchseriesfilter', 'creator'),
56 ('faq', 'last_updated_by'),
57 ('featureflagchangelogentry', 'person'),
58 ('gitactivity', 'changee'),
59
60=== modified file 'lib/lp/security.py'
61--- lib/lp/security.py 2019-07-01 12:48:37 +0000
62+++ lib/lp/security.py 2019-09-10 11:16:47 +0000
63@@ -229,6 +229,7 @@
64 IBinaryPackageReleaseDownloadCount,
65 )
66 from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
67+from lp.soyuz.interfaces.distroarchseriesfilter import IDistroArchSeriesFilter
68 from lp.soyuz.interfaces.livefs import ILiveFS
69 from lp.soyuz.interfaces.livefsbuild import ILiveFSBuild
70 from lp.soyuz.interfaces.packagecopyjob import IPlainPackageCopyJob
71@@ -1468,6 +1469,24 @@
72 or user.in_admin)
73
74
75+class ViewDistroArchSeriesFilter(DelegatedAuthorization):
76+ permission = 'launchpad.View'
77+ usedfor = IDistroArchSeriesFilter
78+
79+ def __init__(self, obj):
80+ super(ViewDistroArchSeriesFilter, self).__init__(
81+ obj, obj.distroarchseries, 'launchpad.View')
82+
83+
84+class EditDistroArchSeriesFilter(DelegatedAuthorization):
85+ permission = 'launchpad.Edit'
86+ usedfor = IDistroArchSeriesFilter
87+
88+ def __init__(self, obj):
89+ super(EditDistroArchSeriesFilter, self).__init__(
90+ obj, obj.distroarchseries, 'launchpad.Moderate')
91+
92+
93 class ViewAnnouncement(AuthorizationBase):
94 permission = 'launchpad.View'
95 usedfor = IAnnouncement
96
97=== modified file 'lib/lp/soyuz/configure.zcml'
98--- lib/lp/soyuz/configure.zcml 2019-03-12 19:12:29 +0000
99+++ lib/lp/soyuz/configure.zcml 2019-09-10 11:16:47 +0000
100@@ -616,6 +616,26 @@
101 set_schema="lp.soyuz.interfaces.distroarchseries.IPocketChroot"/>
102 </class>
103
104+ <!-- DistroArchSeriesFilter -->
105+ <class
106+ class="lp.soyuz.model.distroarchseriesfilter.DistroArchSeriesFilter">
107+ <allow
108+ interface="lp.soyuz.interfaces.distroarchseriesfilter.IDistroArchSeriesFilterView" />
109+ <require
110+ permission="launchpad.Edit"
111+ interface="lp.soyuz.interfaces.distroarchseriesfilter.IDistroArchSeriesFilterEdit" />
112+ </class>
113+ <securedutility
114+ class="lp.soyuz.model.distroarchseriesfilter.DistroArchSeriesFilterSet"
115+ provides="lp.soyuz.interfaces.distroarchseriesfilter.IDistroArchSeriesFilterSet">
116+ <allow
117+ interface="lp.soyuz.interfaces.distroarchseriesfilter.IDistroArchSeriesFilterSet" />
118+ </securedutility>
119+ <subscriber
120+ for="lp.soyuz.interfaces.distroarchseriesfilter.IDistroArchSeriesFilter
121+ lazr.lifecycle.interfaces.IObjectModifiedEvent"
122+ handler="lp.soyuz.model.distroarchseriesfilter.distro_arch_series_filter_modified" />
123+
124 <!-- Component -->
125
126 <class
127
128=== modified file 'lib/lp/soyuz/doc/distroarchseries.txt'
129--- lib/lp/soyuz/doc/distroarchseries.txt 2019-02-13 14:39:18 +0000
130+++ lib/lp/soyuz/doc/distroarchseries.txt 2019-09-10 11:16:47 +0000
131@@ -255,3 +255,37 @@
132 >>> print_architectures(hoary.buildable_architectures)
133 The Hoary Hedgehog Release for hppa (hppa) (ppa)
134 The Hoary Hedgehog Release for i386 (386) (official, ppa)
135+
136+An architecture can have an associated filter that controls which packages
137+are included in it. It has an `isSourceIncluded` method that allows
138+querying inclusion by `SourcePackageName`.
139+
140+ >>> from lp.soyuz.enums import DistroArchSeriesFilterSense
141+
142+ >>> spns = [factory.makeSourcePackageName() for _ in range(3)]
143+ >>> hoary.getDistroArchSeries('i386').isSourceIncluded(spns[0])
144+ True
145+
146+ >>> packageset_include = factory.makePackageset(distroseries=hoary)
147+ >>> packageset_include.add(spns[:2])
148+ >>> hoary.getDistroArchSeries('i386').setSourceFilter(
149+ ... packageset_include, DistroArchSeriesFilterSense.INCLUDE,
150+ ... factory.makePerson())
151+ >>> packageset_exclude = factory.makePackageset(distroseries=hoary)
152+ >>> packageset_exclude.add(spns[1:])
153+ >>> hoary.getDistroArchSeries('hppa').setSourceFilter(
154+ ... packageset_exclude, DistroArchSeriesFilterSense.EXCLUDE,
155+ ... factory.makePerson())
156+
157+ >>> hoary.getDistroArchSeries('i386').isSourceIncluded(spns[0])
158+ True
159+ >>> hoary.getDistroArchSeries('i386').isSourceIncluded(spns[1])
160+ True
161+ >>> hoary.getDistroArchSeries('i386').isSourceIncluded(spns[2])
162+ False
163+ >>> hoary.getDistroArchSeries('hppa').isSourceIncluded(spns[0])
164+ True
165+ >>> hoary.getDistroArchSeries('hppa').isSourceIncluded(spns[1])
166+ False
167+ >>> hoary.getDistroArchSeries('hppa').isSourceIncluded(spns[2])
168+ False
169
170=== modified file 'lib/lp/soyuz/enums.py'
171--- lib/lp/soyuz/enums.py 2017-03-29 09:28:09 +0000
172+++ lib/lp/soyuz/enums.py 2019-09-10 11:16:47 +0000
173@@ -1,4 +1,4 @@
174-# Copyright 2010-2017 Canonical Ltd. This software is licensed under the
175+# Copyright 2010-2019 Canonical Ltd. This software is licensed under the
176 # GNU Affero General Public License version 3 (see the file LICENSE).
177
178 """Enumerations used in the lp/soyuz modules."""
179@@ -13,6 +13,7 @@
180 'archive_suffixes',
181 'BinaryPackageFileType',
182 'BinaryPackageFormat',
183+ 'DistroArchSeriesFilterSense',
184 'IndexCompressionType',
185 'PackageCopyPolicy',
186 'PackageCopyStatus',
187@@ -597,3 +598,18 @@
188 GZIP = DBItem(1, "gzip")
189 BZIP2 = DBItem(2, "bzip2")
190 XZ = DBItem(3, "xz")
191+
192+
193+class DistroArchSeriesFilterSense(DBEnumeratedType):
194+
195+ INCLUDE = DBItem(1, """
196+ Include
197+
198+ Packages in this package set are included in the distro arch series.
199+ """)
200+
201+ EXCLUDE = DBItem(2, """
202+ Exclude
203+
204+ Packages in this package set are excluded from the distro arch series.
205+ """)
206
207=== modified file 'lib/lp/soyuz/interfaces/distroarchseries.py'
208--- lib/lp/soyuz/interfaces/distroarchseries.py 2019-07-30 11:38:18 +0000
209+++ lib/lp/soyuz/interfaces/distroarchseries.py 2019-09-10 11:16:47 +0000
210@@ -7,6 +7,7 @@
211
212 __all__ = [
213 'ChrootNotPublic',
214+ 'FilterSeriesMismatch',
215 'IDistroArchSeries',
216 'InvalidChrootUploaded',
217 'IPocketChroot',
218@@ -65,6 +66,19 @@
219 "Cannot set chroot from a private build.")
220
221
222+@error_status(httplib.BAD_REQUEST)
223+class FilterSeriesMismatch(Exception):
224+ """DAS and packageset distroseries do not match when setting a filter."""
225+
226+ def __init__(self, distroarchseries, packageset):
227+ super(Exception, self).__init__(
228+ "The requested package set is for %s and cannot be set as a "
229+ "filter for %s %s." % (
230+ packageset.distroseries.fullseriesname,
231+ distroarchseries.distroseries.fullseriesname,
232+ distroarchseries.architecturetag))
233+
234+
235 class IDistroArchSeriesPublic(IHasBuildRecords, IHasOwner):
236 """Public attributes for a DistroArchSeries."""
237
238@@ -216,6 +230,21 @@
239 this distro arch series.
240 """
241
242+ def getSourceFilter():
243+ """Get the filter for packages to build for this architecture, if any.
244+
245+ Packages are normally built for all available architectures, subject
246+ to any constraints in their `Architecture` field. If a filter is
247+ set, then it applies the additional constraint that packages not
248+ included by the filter will not be built for this architecture.
249+ """
250+
251+ def isSourceIncluded(sourcepackagename):
252+ """Is this source package included in this distro arch series?
253+
254+ :param sourcepackagename: An `ISourcePackageName` to check.
255+ """
256+
257
258 class IDistroArchSeriesModerate(Interface):
259
260@@ -263,6 +292,36 @@
261 tarball".
262 """
263
264+ def setSourceFilter(packageset, sense, creator):
265+ """Set a filter for packages to build for this architecture.
266+
267+ Packages are normally built for all available architectures, subject
268+ to any constraints in their `Architecture` field. If a filter is
269+ set, then it applies the additional constraint that packages not
270+ included by the filter will not be built for this architecture.
271+
272+ If the sense of the filter is "Include", then the filter only
273+ includes packages in the given package set. If the sense of the
274+ filter is "Exclude", then the filter only includes packages not in
275+ the given package set.
276+
277+ Later changes to the given package set will also affect any filters
278+ using it.
279+
280+ :param packageset: An `IPackageset` to use as a filter.
281+ :param sense: A `DistroArchSeriesFilterSense` item indicating
282+ whether the filter includes or excludes packages.
283+ :param creator: The `IPerson` who is creating this filter.
284+ """
285+
286+ def removeSourceFilter():
287+ """Remove any filter for packages to build for this architecture.
288+
289+ This causes packages to be built for this architecture when they
290+ might previously have been filtered, subject to any constraints in
291+ their `Architecture` field.
292+ """
293+
294
295 class IDistroArchSeries(IDistroArchSeriesPublic, IDistroArchSeriesModerate):
296 """An architecture for a distroseries."""
297
298=== added file 'lib/lp/soyuz/interfaces/distroarchseriesfilter.py'
299--- lib/lp/soyuz/interfaces/distroarchseriesfilter.py 1970-01-01 00:00:00 +0000
300+++ lib/lp/soyuz/interfaces/distroarchseriesfilter.py 2019-09-10 11:16:47 +0000
301@@ -0,0 +1,127 @@
302+# Copyright 2019 Canonical Ltd. This software is licensed under the
303+# GNU Affero General Public License version 3 (see the file LICENSE).
304+
305+"""Distro arch series filter interfaces."""
306+
307+from __future__ import absolute_import, print_function, unicode_literals
308+
309+__metaclass__ = type
310+__all__ = [
311+ 'IDistroArchSeriesFilter',
312+ 'IDistroArchSeriesFilterSet',
313+ 'NoSuchDistroArchSeriesFilter',
314+ ]
315+
316+from lazr.restful.fields import Reference
317+from zope.interface import Interface
318+from zope.schema import (
319+ Choice,
320+ Datetime,
321+ Int,
322+ )
323+
324+from lp import _
325+from lp.app.errors import NameLookupFailed
326+from lp.services.fields import PublicPersonChoice
327+from lp.soyuz.enums import DistroArchSeriesFilterSense
328+from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
329+from lp.soyuz.interfaces.packageset import IPackageset
330+
331+
332+class NoSuchDistroArchSeriesFilter(NameLookupFailed):
333+ """Raised when we try to look up a nonexistent DistroArchSeriesFilter."""
334+ _message_prefix = (
335+ "The given distro arch series has no DistroArchSeriesFilter")
336+
337+
338+class IDistroArchSeriesFilterView(Interface):
339+ """`IDistroArchSeriesFilter` attributes that require launchpad.View."""
340+
341+ id = Int(title=_("ID"), readonly=True, required=True)
342+
343+ distroarchseries = Reference(
344+ title=_("Distro arch series"), required=True, readonly=True,
345+ schema=IDistroArchSeries,
346+ description=_("The distro arch series that this filter is for."))
347+
348+ packageset = Reference(
349+ title=_("Package set"), required=True, readonly=True,
350+ schema=IPackageset,
351+ description=_(
352+ "The package set to be included in or excluded from this distro "
353+ "arch series."))
354+
355+ sense = Choice(
356+ title=_("Sense"),
357+ vocabulary=DistroArchSeriesFilterSense, required=True, readonly=True,
358+ description=_(
359+ "Whether the filter represents packages to include or exclude "
360+ "from the distro arch series."))
361+
362+ creator = PublicPersonChoice(
363+ title=_("Creator"), required=True, readonly=True,
364+ vocabulary="ValidPerson",
365+ description=_("The user who created this filter."))
366+
367+ date_created = Datetime(
368+ title=_("Date created"), required=True, readonly=True,
369+ description=_("The time when this filter was created."))
370+
371+ date_last_modified = Datetime(
372+ title=_("Date last modified"), required=True, readonly=True,
373+ description=_("The time when this filter was last modified."))
374+
375+ def isSourceIncluded(sourcepackagename):
376+ """Is this source package name included by this filter?
377+
378+ If the sense of the filter is INCLUDE, then this returns True iff
379+ the source package name is included in the related package set;
380+ otherwise, it returns True iff the source package name is not
381+ included in the related package set.
382+
383+ :param sourcepackagename: an `ISourcePackageName`.
384+ :return: True if the source is included by this filter, otherwise
385+ False.
386+ """
387+
388+
389+class IDistroArchSeriesFilterEdit(Interface):
390+ """`IDistroArchSeriesFilter` attributes that require launchpad.Edit."""
391+
392+ def destroySelf():
393+ """Delete this filter."""
394+
395+
396+class IDistroArchSeriesFilter(
397+ IDistroArchSeriesFilterView, IDistroArchSeriesFilterEdit):
398+ """A filter for packages to be included in or excluded from a DAS.
399+
400+ Since package sets can include other package sets, a single package set
401+ is flexible enough for this. However, one might reasonably want to
402+ either include some packages ("this architecture is obsolescent or
403+ experimental and we only want to build a few packages for it") or
404+ exclude some packages ("this architecture can't handle some packages so
405+ we want to make them go away centrally").
406+ """
407+
408+
409+class IDistroArchSeriesFilterSet(Interface):
410+ """An interface for multiple distro arch series filters."""
411+
412+ def new(distroarchseries, packageset, sense, creator, date_created=None):
413+ """Create an `IDistroArchSeriesFilter`."""
414+
415+ def getByDistroArchSeries(distroarchseries):
416+ """Return the filter for this distro arch series, if any.
417+
418+ :param distroarchseries: The `IDistroArchSeries` to search for.
419+ :return: An `IDistroArchSeriesFilter` instance.
420+ :raises NoSuchDistroArchSeriesFilter: if no filter is found.
421+ """
422+
423+ def findByPackageset(packageset):
424+ """Return any filters using this package set.
425+
426+ :param packageset: The `IPackageset` to search for.
427+ :return: A `ResultSet` of `IDistroArchSeriesFilter` instances.
428+ """
429
430=== modified file 'lib/lp/soyuz/model/distroarchseries.py'
431--- lib/lp/soyuz/model/distroarchseries.py 2019-07-30 11:38:18 +0000
432+++ lib/lp/soyuz/model/distroarchseries.py 2019-09-10 11:16:47 +0000
433@@ -55,10 +55,14 @@
434 from lp.soyuz.interfaces.buildrecords import IHasBuildRecords
435 from lp.soyuz.interfaces.distroarchseries import (
436 ChrootNotPublic,
437+ FilterSeriesMismatch,
438 IDistroArchSeries,
439 InvalidChrootUploaded,
440 IPocketChroot,
441 )
442+from lp.soyuz.interfaces.distroarchseriesfilter import (
443+ IDistroArchSeriesFilterSet,
444+ )
445 from lp.soyuz.interfaces.publishing import active_publishing_status
446 from lp.soyuz.model.binarypackagename import BinaryPackageName
447 from lp.soyuz.model.binarypackagerelease import BinaryPackageRelease
448@@ -341,6 +345,32 @@
449 def main_archive(self):
450 return self.distroseries.distribution.main_archive
451
452+ def getSourceFilter(self):
453+ """See `IDistroArchSeries`."""
454+ return getUtility(IDistroArchSeriesFilterSet).getByDistroArchSeries(
455+ self)
456+
457+ def setSourceFilter(self, packageset, sense, creator):
458+ """See `IDistroArchSeries`."""
459+ if self.distroseries != packageset.distroseries:
460+ raise FilterSeriesMismatch(self, packageset)
461+ self.removeSourceFilter()
462+ getUtility(IDistroArchSeriesFilterSet).new(
463+ self, packageset, sense, creator)
464+
465+ def removeSourceFilter(self):
466+ """See `IDistroArchSeries`."""
467+ dasf = self.getSourceFilter()
468+ if dasf is not None:
469+ dasf.destroySelf()
470+
471+ def isSourceIncluded(self, sourcepackagename):
472+ """See `IDistroArchSeries`."""
473+ dasf = self.getSourceFilter()
474+ if dasf is None:
475+ return True
476+ return dasf.isSourceIncluded(sourcepackagename)
477+
478
479 @implementer(IPocketChroot)
480 class PocketChroot(SQLBase):
481
482=== added file 'lib/lp/soyuz/model/distroarchseriesfilter.py'
483--- lib/lp/soyuz/model/distroarchseriesfilter.py 1970-01-01 00:00:00 +0000
484+++ lib/lp/soyuz/model/distroarchseriesfilter.py 2019-09-10 11:16:47 +0000
485@@ -0,0 +1,124 @@
486+# Copyright 2019 Canonical Ltd. This software is licensed under the
487+# GNU Affero General Public License version 3 (see the file LICENSE).
488+
489+"""Distro arch series filters."""
490+
491+from __future__ import absolute_import, print_function, unicode_literals
492+
493+__metaclass__ = type
494+__all__ = [
495+ 'DistroArchSeriesFilter',
496+ ]
497+
498+import pytz
499+from storm.locals import (
500+ DateTime,
501+ Int,
502+ Reference,
503+ Storm,
504+ )
505+from zope.interface import implementer
506+from zope.security.proxy import removeSecurityProxy
507+
508+from lp.services.database.constants import (
509+ DEFAULT,
510+ UTC_NOW,
511+ )
512+from lp.services.database.enumcol import DBEnum
513+from lp.services.database.interfaces import (
514+ IMasterStore,
515+ IStore,
516+ )
517+from lp.soyuz.enums import DistroArchSeriesFilterSense
518+from lp.soyuz.interfaces.distroarchseriesfilter import (
519+ IDistroArchSeriesFilter,
520+ IDistroArchSeriesFilterSet,
521+ )
522+
523+
524+def distro_arch_series_filter_modified(pss, event):
525+ """Update date_last_modified when a `DistroArchSeriesFilter` is modified.
526+
527+ This method is registered as a subscriber to `IObjectModifiedEvent`
528+ events on `DistroArchSeriesFilter`s.
529+ """
530+ removeSecurityProxy(pss).date_last_modified = UTC_NOW
531+
532+
533+@implementer(IDistroArchSeriesFilter)
534+class DistroArchSeriesFilter(Storm):
535+ """See `IDistroArchSeriesFilter`."""
536+
537+ __storm_table__ = "DistroArchSeriesFilter"
538+
539+ id = Int(primary=True)
540+
541+ distroarchseries_id = Int(name="distroarchseries", allow_none=False)
542+ distroarchseries = Reference(distroarchseries_id, "DistroArchSeries.id")
543+
544+ packageset_id = Int(name="packageset", allow_none=False)
545+ packageset = Reference(packageset_id, "Packageset.id")
546+
547+ sense = DBEnum(enum=DistroArchSeriesFilterSense, allow_none=False)
548+
549+ creator_id = Int(name="creator", allow_none=False)
550+ creator = Reference(creator_id, "Person.id")
551+
552+ date_created = DateTime(
553+ name="date_created", tzinfo=pytz.UTC, allow_none=False)
554+ date_last_modified = DateTime(
555+ name="date_last_modified", tzinfo=pytz.UTC, allow_none=False)
556+
557+ def __init__(self, distroarchseries, packageset, sense, creator,
558+ date_created=DEFAULT):
559+ """Construct a `DistroArchSeriesFilter`."""
560+ super(DistroArchSeriesFilter, self).__init__()
561+ self.distroarchseries = distroarchseries
562+ self.packageset = packageset
563+ self.sense = sense
564+ self.creator = creator
565+ self.date_created = date_created
566+ self.date_last_modified = date_created
567+
568+ def __repr__(self):
569+ return "<DistroArchSeriesFilter for %s>" % self.distroarchseries.title
570+
571+ def isSourceIncluded(self, sourcepackagename):
572+ """See `IDistroArchSeriesFilter`."""
573+ return (
574+ (self.sense == DistroArchSeriesFilterSense.INCLUDE) ==
575+ self.packageset.isSourceIncluded(sourcepackagename))
576+
577+ def destroySelf(self):
578+ """See `IDistroArchSeriesFilter`."""
579+ IStore(DistroArchSeriesFilter).remove(self)
580+
581+
582+@implementer(IDistroArchSeriesFilterSet)
583+class DistroArchSeriesFilterSet:
584+ """See `IDistroArchSeriesFilterSet`."""
585+
586+ def new(self, distroarchseries, packageset, sense, creator,
587+ date_created=DEFAULT):
588+ """See `IDistroArchSeriesFilterSet`.
589+
590+ The caller must check that the creator has suitable permissions on
591+ `distroarchseries`.
592+ """
593+ store = IMasterStore(DistroArchSeriesFilter)
594+ dasf = DistroArchSeriesFilter(
595+ distroarchseries, packageset, sense, creator,
596+ date_created=date_created)
597+ store.add(dasf)
598+ return dasf
599+
600+ def getByDistroArchSeries(self, distroarchseries):
601+ """See `IDistroArchSeriesFilterSet`."""
602+ return IStore(DistroArchSeriesFilter).find(
603+ DistroArchSeriesFilter,
604+ DistroArchSeriesFilter.distroarchseries == distroarchseries).one()
605+
606+ def findByPackageset(self, packageset):
607+ return IStore(DistroArchSeriesFilter).find(
608+ DistroArchSeriesFilter,
609+ DistroArchSeriesFilter.packageset == packageset)
610
611=== added file 'lib/lp/soyuz/tests/test_distroarchseriesfilter.py'
612--- lib/lp/soyuz/tests/test_distroarchseriesfilter.py 1970-01-01 00:00:00 +0000
613+++ lib/lp/soyuz/tests/test_distroarchseriesfilter.py 2019-09-10 11:16:47 +0000
614@@ -0,0 +1,128 @@
615+# Copyright 2019 Canonical Ltd. This software is licensed under the
616+# GNU Affero General Public License version 3 (see the file LICENSE).
617+
618+"""Test distro arch series filters."""
619+
620+from __future__ import absolute_import, print_function, unicode_literals
621+
622+__metaclass__ = type
623+
624+from testtools.matchers import MatchesStructure
625+from zope.component import getUtility
626+from zope.security.interfaces import Unauthorized
627+
628+from lp.services.database.interfaces import IStore
629+from lp.services.database.sqlbase import get_transaction_timestamp
630+from lp.soyuz.enums import DistroArchSeriesFilterSense
631+from lp.soyuz.interfaces.distroarchseriesfilter import (
632+ IDistroArchSeriesFilter,
633+ IDistroArchSeriesFilterSet,
634+ )
635+from lp.testing import (
636+ person_logged_in,
637+ TestCaseWithFactory,
638+ )
639+from lp.testing.layers import (
640+ DatabaseFunctionalLayer,
641+ ZopelessDatabaseLayer,
642+ )
643+
644+
645+class TestDistroArchSeriesFilter(TestCaseWithFactory):
646+
647+ layer = DatabaseFunctionalLayer
648+
649+ def test_implements_interfaces(self):
650+ # DistroArchSeriesFilter implements IDistroArchSeriesFilter.
651+ dasf = self.factory.makeDistroArchSeriesFilter()
652+ self.assertProvides(dasf, IDistroArchSeriesFilter)
653+
654+ def test___repr__(self):
655+ # `DistroArchSeriesFilter` objects have an informative __repr__.
656+ das = self.factory.makeDistroArchSeries()
657+ dasf = self.factory.makeDistroArchSeriesFilter(distroarchseries=das)
658+ self.assertEqual(
659+ "<DistroArchSeriesFilter for %s>" % das.title, repr(dasf))
660+
661+ def test_isSourceIncluded_include(self):
662+ # INCLUDE filters report that a source is included if it is in the
663+ # packageset.
664+ spns = [self.factory.makeSourcePackageName() for _ in range(3)]
665+ dasf = self.factory.makeDistroArchSeriesFilter(
666+ sense=DistroArchSeriesFilterSense.INCLUDE)
667+ dasf.packageset.add(spns[:2])
668+ self.assertTrue(dasf.isSourceIncluded(spns[0]))
669+ self.assertTrue(dasf.isSourceIncluded(spns[1]))
670+ self.assertFalse(dasf.isSourceIncluded(spns[2]))
671+
672+ def test_isSourceIncluded_exclude(self):
673+ # EXCLUDE filters report that a source is included if it is not in
674+ # the packageset.
675+ spns = [self.factory.makeSourcePackageName() for _ in range(3)]
676+ dasf = self.factory.makeDistroArchSeriesFilter(
677+ sense=DistroArchSeriesFilterSense.EXCLUDE)
678+ dasf.packageset.add(spns[:2])
679+ self.assertFalse(dasf.isSourceIncluded(spns[0]))
680+ self.assertFalse(dasf.isSourceIncluded(spns[1]))
681+ self.assertTrue(dasf.isSourceIncluded(spns[2]))
682+
683+ def test_destroySelf_unauthorized(self):
684+ # Ordinary users cannot delete a filter.
685+ das = self.factory.makeDistroArchSeries()
686+ self.factory.makeDistroArchSeriesFilter(distroarchseries=das)
687+ dasf = das.getSourceFilter()
688+ with person_logged_in(self.factory.makePerson()):
689+ self.assertRaises(Unauthorized, getattr, dasf, "destroySelf")
690+
691+ def test_destroySelf(self):
692+ # Owners of the DAS's archive can delete a filter.
693+ das = self.factory.makeDistroArchSeries()
694+ self.factory.makeDistroArchSeriesFilter(distroarchseries=das)
695+ dasf = das.getSourceFilter()
696+ with person_logged_in(das.main_archive.owner):
697+ dasf.destroySelf()
698+ self.assertIsNone(das.getSourceFilter())
699+
700+
701+class TestDistroArchSeriesFilterSet(TestCaseWithFactory):
702+
703+ layer = ZopelessDatabaseLayer
704+
705+ def test_class_implements_interface(self):
706+ # The DistroArchSeriesFilterSet class implements
707+ # IDistroArchSeriesFilterSet.
708+ self.assertProvides(
709+ getUtility(IDistroArchSeriesFilterSet), IDistroArchSeriesFilterSet)
710+
711+ def test_new(self):
712+ # The arguments passed when creating a filter are present on the new
713+ # object.
714+ das = self.factory.makeDistroArchSeries()
715+ packageset = self.factory.makePackageset(distroseries=das.distroseries)
716+ sense = DistroArchSeriesFilterSense.EXCLUDE
717+ creator = self.factory.makePerson()
718+ dasf = getUtility(IDistroArchSeriesFilterSet).new(
719+ distroarchseries=das, packageset=packageset, sense=sense,
720+ creator=creator)
721+ now = get_transaction_timestamp(IStore(dasf))
722+ self.assertThat(dasf, MatchesStructure.byEquality(
723+ distroarchseries=das, packageset=packageset, sense=sense,
724+ creator=creator, date_created=now, date_last_modified=now))
725+
726+ def test_getByDistroArchSeries(self):
727+ # getByDistroArchSeries returns the filter for a DAS, if any.
728+ das = self.factory.makeDistroArchSeries()
729+ dasf_set = getUtility(IDistroArchSeriesFilterSet)
730+ self.assertIsNone(dasf_set.getByDistroArchSeries(das))
731+ dasf = self.factory.makeDistroArchSeriesFilter(distroarchseries=das)
732+ self.assertEqual(dasf, dasf_set.getByDistroArchSeries(das))
733+
734+ def test_findByPackageset(self):
735+ # findByPackageset returns any filters using a package set.
736+ packageset = self.factory.makePackageset()
737+ dasf_set = getUtility(IDistroArchSeriesFilterSet)
738+ self.assertContentEqual([], dasf_set.findByPackageset(packageset))
739+ dasfs = [
740+ self.factory.makeDistroArchSeriesFilter(packageset=packageset)
741+ for _ in range(2)]
742+ self.assertContentEqual(dasfs, dasf_set.findByPackageset(packageset))
743
744=== modified file 'lib/lp/testing/factory.py'
745--- lib/lp/testing/factory.py 2019-09-05 13:23:34 +0000
746+++ lib/lp/testing/factory.py 2019-09-10 11:16:47 +0000
747@@ -297,6 +297,7 @@
748 ArchivePurpose,
749 BinaryPackageFileType,
750 BinaryPackageFormat,
751+ DistroArchSeriesFilterSense,
752 PackageDiffStatus,
753 PackagePublishingPriority,
754 PackagePublishingStatus,
755@@ -326,6 +327,7 @@
756 from lp.soyuz.model.distributionsourcepackagecache import (
757 DistributionSourcePackageCache,
758 )
759+from lp.soyuz.model.distroarchseriesfilter import DistroArchSeriesFilter
760 from lp.soyuz.model.files import BinaryPackageFile
761 from lp.soyuz.model.livefsbuild import LiveFSFile
762 from lp.soyuz.model.packagediff import PackageDiff
763@@ -4202,6 +4204,27 @@
764 run_with_login(owner, lambda: package_set.add(packages))
765 return package_set
766
767+ def makeDistroArchSeriesFilter(self, distroarchseries=None,
768+ packageset=None,
769+ sense=DistroArchSeriesFilterSense.INCLUDE,
770+ creator=None, date_created=DEFAULT):
771+ """Make a new `DistroArchSeriesFilter`."""
772+ if distroarchseries is None:
773+ if packageset is not None:
774+ distroseries = packageset.distroseries
775+ else:
776+ distroseries = None
777+ distroarchseries = self.makeDistroArchSeries(
778+ distroseries=distroseries)
779+ if packageset is None:
780+ packageset = self.makePackageset(
781+ distroseries=distroarchseries.distroseries)
782+ if creator is None:
783+ creator = self.makePerson()
784+ return DistroArchSeriesFilter(
785+ distroarchseries=distroarchseries, packageset=packageset,
786+ sense=sense, creator=creator, date_created=date_created)
787+
788 def getAnyPocket(self):
789 return PackagePublishingPocket.BACKPORTS
790