← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/das-filter-model into lp:launchpad with lp:~cjwatson/launchpad/packageset-is-source-included as a prerequisite.

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

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1842658 in Launchpad itself: "Support central filtering of which packages build for some architectures"
  https://bugs.launchpad.net/launchpad/+bug/1842658

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/das-filter-model/+merge/372261

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.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/das-filter-model into lp:launchpad.
=== modified file 'database/schema/security.cfg'
--- database/schema/security.cfg	2019-08-09 12:04:04 +0000
+++ database/schema/security.cfg	2019-09-04 14:01:22 +0000
@@ -176,6 +176,7 @@
 public.distributionmirror               = SELECT, INSERT, UPDATE, DELETE
 public.distributionsourcepackage        = SELECT, INSERT, UPDATE, DELETE
 public.distributionsourcepackagecache   = SELECT, INSERT
+public.distroarchseriesfilter           = SELECT, INSERT, UPDATE, DELETE
 public.distroseriesdifference           = SELECT, INSERT, UPDATE
 public.distroseriesdifferencemessage    = SELECT, INSERT, UPDATE
 public.distroserieslanguage             = SELECT, INSERT, UPDATE
@@ -2221,6 +2222,7 @@
 public.distributionsourcepackage        = SELECT, INSERT, UPDATE, DELETE
 public.distributionmirror               = SELECT, UPDATE
 public.distroarchseries                 = SELECT, UPDATE
+public.distroarchseriesfilter           = SELECT, UPDATE
 public.distroseries                     = SELECT, UPDATE
 public.emailaddress                     = SELECT, UPDATE, DELETE
 public.faq                              = SELECT, UPDATE
@@ -2356,6 +2358,7 @@
 public.commercialsubscription           = SELECT, UPDATE
 public.diff                             = SELECT, DELETE
 public.distributionsourcepackagecache   = SELECT, INSERT
+public.distroarchseriesfilter           = SELECT
 public.distroseries                     = SELECT, UPDATE
 public.emailaddress                     = SELECT, UPDATE, DELETE
 public.garbojobstate                    = SELECT, INSERT, UPDATE, DELETE

=== modified file 'lib/lp/_schema_circular_imports.py'
--- lib/lp/_schema_circular_imports.py	2018-09-25 16:41:21 +0000
+++ lib/lp/_schema_circular_imports.py	2019-09-04 14:01:22 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Update the interface schema values due to circular imports.
@@ -496,6 +496,8 @@
 patch_reference_property(IDistroArchSeries, 'main_archive', IArchive)
 patch_plain_parameter_type(
     IDistroArchSeries, 'setChrootFromBuild', 'livefsbuild', ILiveFSBuild)
+patch_plain_parameter_type(
+    IDistroArchSeries, 'setFilter', 'packageset', IPackageset)
 
 # IGitRef
 patch_reference_property(IGitRef, 'repository', IGitRepository)

=== modified file 'lib/lp/registry/scripts/closeaccount.py'
--- lib/lp/registry/scripts/closeaccount.py	2019-08-09 12:04:04 +0000
+++ lib/lp/registry/scripts/closeaccount.py	2019-09-04 14:01:22 +0000
@@ -114,6 +114,7 @@
         ('codeimport', 'owner'),
         ('codeimport', 'registrant'),
         ('codeimportevent', 'person'),
+        ('distroarchseriesfilter', 'creator'),
         ('faq', 'last_updated_by'),
         ('featureflagchangelogentry', 'person'),
         ('gitactivity', 'changee'),

=== modified file 'lib/lp/security.py'
--- lib/lp/security.py	2019-07-01 12:48:37 +0000
+++ lib/lp/security.py	2019-09-04 14:01:22 +0000
@@ -229,6 +229,7 @@
     IBinaryPackageReleaseDownloadCount,
     )
 from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
+from lp.soyuz.interfaces.distroarchseriesfilter import IDistroArchSeriesFilter
 from lp.soyuz.interfaces.livefs import ILiveFS
 from lp.soyuz.interfaces.livefsbuild import ILiveFSBuild
 from lp.soyuz.interfaces.packagecopyjob import IPlainPackageCopyJob
@@ -1468,6 +1469,24 @@
             or user.in_admin)
 
 
+class ViewDistroArchSeriesFilter(DelegatedAuthorization):
+    permission = 'launchpad.View'
+    usedfor = IDistroArchSeriesFilter
+
+    def __init__(self, obj):
+        super(ViewDistroArchSeriesFilter, self).__init__(
+            obj, obj.distroarchseries, 'launchpad.View')
+
+
+class EditDistroArchSeriesFilter(DelegatedAuthorization):
+    permission = 'launchpad.Edit'
+    usedfor = IDistroArchSeriesFilter
+
+    def __init__(self, obj):
+        super(EditDistroArchSeriesFilter, self).__init__(
+            obj, obj.distroarchseries, 'launchpad.Moderate')
+
+
 class ViewAnnouncement(AuthorizationBase):
     permission = 'launchpad.View'
     usedfor = IAnnouncement

=== modified file 'lib/lp/soyuz/configure.zcml'
--- lib/lp/soyuz/configure.zcml	2019-03-12 19:12:29 +0000
+++ lib/lp/soyuz/configure.zcml	2019-09-04 14:01:22 +0000
@@ -616,6 +616,26 @@
             set_schema="lp.soyuz.interfaces.distroarchseries.IPocketChroot"/>
     </class>
 
+    <!-- DistroArchSeriesFilter -->
+    <class
+        class="lp.soyuz.model.distroarchseriesfilter.DistroArchSeriesFilter">
+        <allow
+            interface="lp.soyuz.interfaces.distroarchseriesfilter.IDistroArchSeriesFilterView" />
+        <require
+            permission="launchpad.Edit"
+            interface="lp.soyuz.interfaces.distroarchseriesfilter.IDistroArchSeriesFilterEdit" />
+    </class>
+    <securedutility
+        class="lp.soyuz.model.distroarchseriesfilter.DistroArchSeriesFilterSet"
+        provides="lp.soyuz.interfaces.distroarchseriesfilter.IDistroArchSeriesFilterSet">
+        <allow
+            interface="lp.soyuz.interfaces.distroarchseriesfilter.IDistroArchSeriesFilterSet" />
+    </securedutility>
+    <subscriber
+        for="lp.soyuz.interfaces.distroarchseriesfilter.IDistroArchSeriesFilter
+             lazr.lifecycle.interfaces.IObjectModifiedEvent"
+        handler="lp.soyuz.model.distroarchseriesfilter.distro_arch_series_filter_modified" />
+
     <!-- Component -->
 
     <class

=== modified file 'lib/lp/soyuz/doc/distroarchseries.txt'
--- lib/lp/soyuz/doc/distroarchseries.txt	2019-02-13 14:39:18 +0000
+++ lib/lp/soyuz/doc/distroarchseries.txt	2019-09-04 14:01:22 +0000
@@ -255,3 +255,37 @@
     >>> print_architectures(hoary.buildable_architectures)
     The Hoary Hedgehog Release for hppa (hppa) (ppa)
     The Hoary Hedgehog Release for i386 (386) (official, ppa)
+
+An architecture can have an associated filter that controls which packages
+are included in it.  It has an `isSourceIncluded` method that allows
+querying inclusion by `SourcePackageName`.
+
+    >>> from lp.soyuz.enums import DistroArchSeriesFilterSense
+
+    >>> spns = [factory.makeSourcePackageName() for _ in range(3)]
+    >>> hoary.getDistroArchSeries('i386').isSourceIncluded(spns[0])
+    True
+
+    >>> packageset_include = factory.makePackageset(distroseries=hoary)
+    >>> packageset_include.add(spns[:2])
+    >>> hoary.getDistroArchSeries('i386').setFilter(
+    ...     packageset_include, DistroArchSeriesFilterSense.INCLUDE,
+    ...     factory.makePerson())
+    >>> packageset_exclude = factory.makePackageset(distroseries=hoary)
+    >>> packageset_exclude.add(spns[1:])
+    >>> hoary.getDistroArchSeries('hppa').setFilter(
+    ...     packageset_exclude, DistroArchSeriesFilterSense.EXCLUDE,
+    ...     factory.makePerson())
+
+    >>> hoary.getDistroArchSeries('i386').isSourceIncluded(spns[0])
+    True
+    >>> hoary.getDistroArchSeries('i386').isSourceIncluded(spns[1])
+    True
+    >>> hoary.getDistroArchSeries('i386').isSourceIncluded(spns[2])
+    False
+    >>> hoary.getDistroArchSeries('hppa').isSourceIncluded(spns[0])
+    True
+    >>> hoary.getDistroArchSeries('hppa').isSourceIncluded(spns[1])
+    False
+    >>> hoary.getDistroArchSeries('hppa').isSourceIncluded(spns[2])
+    False

=== modified file 'lib/lp/soyuz/enums.py'
--- lib/lp/soyuz/enums.py	2017-03-29 09:28:09 +0000
+++ lib/lp/soyuz/enums.py	2019-09-04 14:01:22 +0000
@@ -1,4 +1,4 @@
-# Copyright 2010-2017 Canonical Ltd.  This software is licensed under the
+# Copyright 2010-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Enumerations used in the lp/soyuz modules."""
@@ -13,6 +13,7 @@
     'archive_suffixes',
     'BinaryPackageFileType',
     'BinaryPackageFormat',
+    'DistroArchSeriesFilterSense',
     'IndexCompressionType',
     'PackageCopyPolicy',
     'PackageCopyStatus',
@@ -597,3 +598,18 @@
     GZIP = DBItem(1, "gzip")
     BZIP2 = DBItem(2, "bzip2")
     XZ = DBItem(3, "xz")
+
+
+class DistroArchSeriesFilterSense(DBEnumeratedType):
+
+    INCLUDE = DBItem(1, """
+        Include
+
+        Packages in this package set are included in the distro arch series.
+        """)
+
+    EXCLUDE = DBItem(2, """
+        Exclude
+
+        Packages in this package set are excluded from the distro arch series.
+        """)

=== modified file 'lib/lp/soyuz/interfaces/distroarchseries.py'
--- lib/lp/soyuz/interfaces/distroarchseries.py	2019-07-30 11:38:18 +0000
+++ lib/lp/soyuz/interfaces/distroarchseries.py	2019-09-04 14:01:22 +0000
@@ -7,6 +7,7 @@
 
 __all__ = [
     'ChrootNotPublic',
+    'FilterSeriesMismatch',
     'IDistroArchSeries',
     'InvalidChrootUploaded',
     'IPocketChroot',
@@ -65,6 +66,19 @@
             "Cannot set chroot from a private build.")
 
 
+@error_status(httplib.BAD_REQUEST)
+class FilterSeriesMismatch(Exception):
+    """DAS and packageset distroseries do not match when setting a filter."""
+
+    def __init__(self, distroarchseries, packageset):
+        super(Exception, self).__init__(
+            "The requested package set is for %s and cannot be set as a "
+            "filter for %s %s." % (
+                packageset.distroseries.fullseriesname,
+                distroarchseries.distroseries.fullseriesname,
+                distroarchseries.architecturetag))
+
+
 class IDistroArchSeriesPublic(IHasBuildRecords, IHasOwner):
     """Public attributes for a DistroArchSeries."""
 
@@ -216,6 +230,21 @@
         this distro arch series.
         """
 
+    def getFilter():
+        """Get the filter for packages to build for this architecture, if any.
+
+        Packages are normally built for all available architectures, subject
+        to any constraints in their `Architecture` field.  If a filter is
+        set, then it applies the additional constraint that packages not
+        included by the filter will not be built for this architecture.
+        """
+
+    def isSourceIncluded(sourcepackagerelease):
+        """Is this source package included in this distro arch series?
+
+        :param sourcepackagerelease: An `ISourcePackageRelease` to check.
+        """
+
 
 class IDistroArchSeriesModerate(Interface):
 
@@ -263,6 +292,36 @@
         tarball".
         """
 
+    def setFilter(packageset, sense, creator):
+        """Set a filter for packages to build for this architecture.
+
+        Packages are normally built for all available architectures, subject
+        to any constraints in their `Architecture` field.  If a filter is
+        set, then it applies the additional constraint that packages not
+        included by the filter will not be built for this architecture.
+
+        If the sense of the filter is "Include", then the filter only
+        includes packages in the given package set.  If the sense of the
+        filter is "Exclude", then the filter only includes packages not in
+        the given package set.
+
+        Later changes to the given package set will also affect any filters
+        using it.
+
+        :param packageset: An `IPackageset` to use as a filter.
+        :param sense: A `DistroArchSeriesFilterSense` item indicating
+            whether the filter includes or excludes packages.
+        :param creator: The `IPerson` who is creating this filter.
+        """
+
+    def removeFilter():
+        """Remove any filter for packages to build for this architecture.
+
+        This causes packages to be built for this architecture when they
+        might previously have been filtered, subject to any constraints in
+        their `Architecture` field.
+        """
+
 
 class IDistroArchSeries(IDistroArchSeriesPublic, IDistroArchSeriesModerate):
     """An architecture for a distroseries."""

=== added file 'lib/lp/soyuz/interfaces/distroarchseriesfilter.py'
--- lib/lp/soyuz/interfaces/distroarchseriesfilter.py	1970-01-01 00:00:00 +0000
+++ lib/lp/soyuz/interfaces/distroarchseriesfilter.py	2019-09-04 14:01:22 +0000
@@ -0,0 +1,127 @@
+# Copyright 2019 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Distro arch series filter interfaces."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'IDistroArchSeriesFilter',
+    'IDistroArchSeriesFilterSet',
+    'NoSuchDistroArchSeriesFilter',
+    ]
+
+from lazr.restful.fields import Reference
+from zope.interface import Interface
+from zope.schema import (
+    Choice,
+    Datetime,
+    Int,
+    )
+
+from lp import _
+from lp.app.errors import NameLookupFailed
+from lp.services.fields import PublicPersonChoice
+from lp.soyuz.enums import DistroArchSeriesFilterSense
+from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
+from lp.soyuz.interfaces.packageset import IPackageset
+
+
+class NoSuchDistroArchSeriesFilter(NameLookupFailed):
+    """Raised when we try to look up a nonexistent DistroArchSeriesFilter."""
+    _message_prefix = (
+        "The given distro arch series has no DistroArchSeriesFilter")
+
+
+class IDistroArchSeriesFilterView(Interface):
+    """`IDistroArchSeriesFilter` attributes that require launchpad.View."""
+
+    id = Int(title=_("ID"), readonly=True, required=True)
+
+    distroarchseries = Reference(
+        title=_("Distro arch series"), required=True, readonly=True,
+        schema=IDistroArchSeries,
+        description=_("The distro arch series that this filter is for."))
+
+    packageset = Reference(
+        title=_("Package set"), required=True, readonly=True,
+        schema=IPackageset,
+        description=_(
+            "The package set to be included in or excluded from this distro "
+            "arch series."))
+
+    sense = Choice(
+        title=_("Sense"),
+        vocabulary=DistroArchSeriesFilterSense, required=True, readonly=True,
+        description=_(
+            "Whether the filter represents packages to include or exclude "
+            "from the distro arch series."))
+
+    creator = PublicPersonChoice(
+        title=_("Creator"), required=True, readonly=True,
+        vocabulary="ValidPerson",
+        description=_("The user who created this filter."))
+
+    date_created = Datetime(
+        title=_("Date created"), required=True, readonly=True,
+        description=_("The time when this filter was created."))
+
+    date_last_modified = Datetime(
+        title=_("Date last modified"), required=True, readonly=True,
+        description=_("The time when this filter was last modified."))
+
+    def isSourceIncluded(sourcepackagename):
+        """Is this source package name included by this filter?
+
+        If the sense of the filter is INCLUDE, then this returns True iff
+        the source package name is included in the related package set;
+        otherwise, it returns True iff the source package name is not
+        included in the related package set.
+
+        :param sourcepackagename: an `ISourcePackageName`.
+        :return: True if the source is included by this filter, otherwise
+            False.
+        """
+
+
+class IDistroArchSeriesFilterEdit(Interface):
+    """`IDistroArchSeriesFilter` attributes that require launchpad.Edit."""
+
+    def destroySelf():
+        """Delete this filter."""
+
+
+class IDistroArchSeriesFilter(
+        IDistroArchSeriesFilterView, IDistroArchSeriesFilterEdit):
+    """A filter for packages to be included in or excluded from a DAS.
+
+    Since package sets can include other package sets, a single package set
+    is flexible enough for this.  However, one might reasonably want to
+    either include some packages ("this architecture is obsolescent or
+    experimental and we only want to build a few packages for it") or
+    exclude some packages ("this architecture can't handle some packages so
+    we want to make them go away centrally").
+    """
+
+
+class IDistroArchSeriesFilterSet(Interface):
+    """An interface for multiple distro arch series filters."""
+
+    def new(distroarchseries, packageset, sense, creator, date_created=None):
+        """Create an `IDistroArchSeriesFilter`."""
+
+    def getByDistroArchSeries(distroarchseries):
+        """Return the filter for this distro arch series, if any.
+
+        :param distroarchseries: The `IDistroArchSeries` to search for.
+        :return: An `IDistroArchSeriesFilter` instance.
+        :raises NoSuchDistroArchSeriesFilter: if no filter is found.
+        """
+
+    def findByPackageset(packageset):
+        """Return any filters using this package set.
+
+        :param packageset: The `IPackageset` to search for.
+        :return: A `ResultSet` of `IDistroArchSeriesFilter` instances.
+        """

=== modified file 'lib/lp/soyuz/model/distroarchseries.py'
--- lib/lp/soyuz/model/distroarchseries.py	2019-07-30 11:38:18 +0000
+++ lib/lp/soyuz/model/distroarchseries.py	2019-09-04 14:01:22 +0000
@@ -55,10 +55,14 @@
 from lp.soyuz.interfaces.buildrecords import IHasBuildRecords
 from lp.soyuz.interfaces.distroarchseries import (
     ChrootNotPublic,
+    FilterSeriesMismatch,
     IDistroArchSeries,
     InvalidChrootUploaded,
     IPocketChroot,
     )
+from lp.soyuz.interfaces.distroarchseriesfilter import (
+    IDistroArchSeriesFilterSet,
+    )
 from lp.soyuz.interfaces.publishing import active_publishing_status
 from lp.soyuz.model.binarypackagename import BinaryPackageName
 from lp.soyuz.model.binarypackagerelease import BinaryPackageRelease
@@ -341,6 +345,32 @@
     def main_archive(self):
         return self.distroseries.distribution.main_archive
 
+    def getFilter(self):
+        """See `IDistroArchSeries`."""
+        return getUtility(IDistroArchSeriesFilterSet).getByDistroArchSeries(
+            self)
+
+    def setFilter(self, packageset, sense, creator):
+        """See `IDistroArchSeries`."""
+        if self.distroseries != packageset.distroseries:
+            raise FilterSeriesMismatch(self, packageset)
+        self.removeFilter()
+        getUtility(IDistroArchSeriesFilterSet).new(
+            self, packageset, sense, creator)
+
+    def removeFilter(self):
+        """See `IDistroArchSeries`."""
+        dasf = self.getFilter()
+        if dasf is not None:
+            dasf.destroySelf()
+
+    def isSourceIncluded(self, sourcepackagename):
+        """See `IDistroArchSeries`."""
+        dasf = self.getFilter()
+        if dasf is None:
+            return True
+        return dasf.isSourceIncluded(sourcepackagename)
+
 
 @implementer(IPocketChroot)
 class PocketChroot(SQLBase):

=== added file 'lib/lp/soyuz/model/distroarchseriesfilter.py'
--- lib/lp/soyuz/model/distroarchseriesfilter.py	1970-01-01 00:00:00 +0000
+++ lib/lp/soyuz/model/distroarchseriesfilter.py	2019-09-04 14:01:22 +0000
@@ -0,0 +1,124 @@
+# Copyright 2019 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Distro arch series filters."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'DistroArchSeriesFilter',
+    ]
+
+import pytz
+from storm.locals import (
+    DateTime,
+    Int,
+    Reference,
+    Storm,
+    )
+from zope.interface import implementer
+from zope.security.proxy import removeSecurityProxy
+
+from lp.services.database.constants import (
+    DEFAULT,
+    UTC_NOW,
+    )
+from lp.services.database.enumcol import DBEnum
+from lp.services.database.interfaces import (
+    IMasterStore,
+    IStore,
+    )
+from lp.soyuz.enums import DistroArchSeriesFilterSense
+from lp.soyuz.interfaces.distroarchseriesfilter import (
+    IDistroArchSeriesFilter,
+    IDistroArchSeriesFilterSet,
+    )
+
+
+def distro_arch_series_filter_modified(pss, event):
+    """Update date_last_modified when a `DistroArchSeriesFilter` is modified.
+
+    This method is registered as a subscriber to `IObjectModifiedEvent`
+    events on `DistroArchSeriesFilter`s.
+    """
+    removeSecurityProxy(pss).date_last_modified = UTC_NOW
+
+
+@implementer(IDistroArchSeriesFilter)
+class DistroArchSeriesFilter(Storm):
+    """See `IDistroArchSeriesFilter`."""
+
+    __storm_table__ = "DistroArchSeriesFilter"
+
+    id = Int(primary=True)
+
+    distroarchseries_id = Int(name="distroarchseries", allow_none=False)
+    distroarchseries = Reference(distroarchseries_id, "DistroArchSeries.id")
+
+    packageset_id = Int(name="packageset", allow_none=False)
+    packageset = Reference(packageset_id, "Packageset.id")
+
+    sense = DBEnum(enum=DistroArchSeriesFilterSense, allow_none=False)
+
+    creator_id = Int(name="creator", allow_none=False)
+    creator = Reference(creator_id, "Person.id")
+
+    date_created = DateTime(
+        name="date_created", tzinfo=pytz.UTC, allow_none=False)
+    date_last_modified = DateTime(
+        name="date_last_modified", tzinfo=pytz.UTC, allow_none=False)
+
+    def __init__(self, distroarchseries, packageset, sense, creator,
+            date_created=DEFAULT):
+        """Construct a `DistroArchSeriesFilter`."""
+        super(DistroArchSeriesFilter, self).__init__()
+        self.distroarchseries = distroarchseries
+        self.packageset = packageset
+        self.sense = sense
+        self.creator = creator
+        self.date_created = date_created
+        self.date_last_modified = date_created
+
+    def __repr__(self):
+        return "<DistroArchSeriesFilter for %s>" % self.distroarchseries.title
+
+    def isSourceIncluded(self, sourcepackagename):
+        """See `IDistroArchSeriesFilter`."""
+        return (
+            (self.sense == DistroArchSeriesFilterSense.INCLUDE) ==
+            self.packageset.isSourceIncluded(sourcepackagename))
+
+    def destroySelf(self):
+        """See `IDistroArchSeriesFilter`."""
+        IStore(DistroArchSeriesFilter).remove(self)
+
+
+@implementer(IDistroArchSeriesFilterSet)
+class DistroArchSeriesFilterSet:
+    """See `IDistroArchSeriesFilterSet`."""
+
+    def new(self, distroarchseries, packageset, sense, creator,
+            date_created=DEFAULT):
+        """See `IDistroArchSeriesFilterSet`.
+
+        The caller must check that the creator has suitable permissions on
+        `distroarchseries`.
+        """
+        store = IMasterStore(DistroArchSeriesFilter)
+        dasf = DistroArchSeriesFilter(
+            distroarchseries, packageset, sense, creator,
+            date_created=date_created)
+        store.add(dasf)
+        return dasf
+
+    def getByDistroArchSeries(self, distroarchseries):
+        """See `IDistroArchSeriesFilterSet`."""
+        return IStore(DistroArchSeriesFilter).find(
+            DistroArchSeriesFilter,
+            DistroArchSeriesFilter.distroarchseries == distroarchseries).one()
+
+    def findByPackageset(self, packageset):
+        return IStore(DistroArchSeriesFilter).find(
+            DistroArchSeriesFilter,
+            DistroArchSeriesFilter.packageset == packageset)

=== added file 'lib/lp/soyuz/tests/test_distroarchseriesfilter.py'
--- lib/lp/soyuz/tests/test_distroarchseriesfilter.py	1970-01-01 00:00:00 +0000
+++ lib/lp/soyuz/tests/test_distroarchseriesfilter.py	2019-09-04 14:01:22 +0000
@@ -0,0 +1,128 @@
+# Copyright 2019 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test distro arch series filters."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+from testtools.matchers import MatchesStructure
+from zope.component import getUtility
+from zope.security.interfaces import Unauthorized
+
+from lp.services.database.interfaces import IStore
+from lp.services.database.sqlbase import get_transaction_timestamp
+from lp.soyuz.enums import DistroArchSeriesFilterSense
+from lp.soyuz.interfaces.distroarchseriesfilter import (
+    IDistroArchSeriesFilter,
+    IDistroArchSeriesFilterSet,
+    )
+from lp.testing import (
+    person_logged_in,
+    TestCaseWithFactory,
+    )
+from lp.testing.layers import (
+    DatabaseFunctionalLayer,
+    ZopelessDatabaseLayer,
+    )
+
+
+class TestDistroArchSeriesFilter(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_implements_interfaces(self):
+        # DistroArchSeriesFilter implements IDistroArchSeriesFilter.
+        dasf = self.factory.makeDistroArchSeriesFilter()
+        self.assertProvides(dasf, IDistroArchSeriesFilter)
+
+    def test___repr__(self):
+        # `DistroArchSeriesFilter` objects have an informative __repr__.
+        das = self.factory.makeDistroArchSeries()
+        dasf = self.factory.makeDistroArchSeriesFilter(distroarchseries=das)
+        self.assertEqual(
+            "<DistroArchSeriesFilter for %s>" % das.title, repr(dasf))
+
+    def test_isSourceIncluded_include(self):
+        # INCLUDE filters report that a source is included if it is in the
+        # packageset.
+        spns = [self.factory.makeSourcePackageName() for _ in range(3)]
+        dasf = self.factory.makeDistroArchSeriesFilter(
+            sense=DistroArchSeriesFilterSense.INCLUDE)
+        dasf.packageset.add(spns[:2])
+        self.assertTrue(dasf.isSourceIncluded(spns[0]))
+        self.assertTrue(dasf.isSourceIncluded(spns[1]))
+        self.assertFalse(dasf.isSourceIncluded(spns[2]))
+
+    def test_isSourceIncluded_exclude(self):
+        # EXCLUDE filters report that a source is included if it is not in
+        # the packageset.
+        spns = [self.factory.makeSourcePackageName() for _ in range(3)]
+        dasf = self.factory.makeDistroArchSeriesFilter(
+            sense=DistroArchSeriesFilterSense.EXCLUDE)
+        dasf.packageset.add(spns[:2])
+        self.assertFalse(dasf.isSourceIncluded(spns[0]))
+        self.assertFalse(dasf.isSourceIncluded(spns[1]))
+        self.assertTrue(dasf.isSourceIncluded(spns[2]))
+
+    def test_destroySelf_unauthorized(self):
+        # Ordinary users cannot delete a filter.
+        das = self.factory.makeDistroArchSeries()
+        self.factory.makeDistroArchSeriesFilter(distroarchseries=das)
+        dasf = das.getFilter()
+        with person_logged_in(self.factory.makePerson()):
+            self.assertRaises(Unauthorized, getattr, dasf, "destroySelf")
+
+    def test_destroySelf(self):
+        # Owners of the DAS's archive can delete a filter.
+        das = self.factory.makeDistroArchSeries()
+        self.factory.makeDistroArchSeriesFilter(distroarchseries=das)
+        dasf = das.getFilter()
+        with person_logged_in(das.main_archive.owner):
+            dasf.destroySelf()
+        self.assertIsNone(das.getFilter())
+
+
+class TestDistroArchSeriesFilterSet(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+
+    def test_class_implements_interface(self):
+        # The DistroArchSeriesFilterSet class implements
+        # IDistroArchSeriesFilterSet.
+        self.assertProvides(
+            getUtility(IDistroArchSeriesFilterSet), IDistroArchSeriesFilterSet)
+
+    def test_new(self):
+        # The arguments passed when creating a filter are present on the new
+        # object.
+        das = self.factory.makeDistroArchSeries()
+        packageset = self.factory.makePackageset(distroseries=das.distroseries)
+        sense = DistroArchSeriesFilterSense.EXCLUDE
+        creator = self.factory.makePerson()
+        dasf = getUtility(IDistroArchSeriesFilterSet).new(
+            distroarchseries=das, packageset=packageset, sense=sense,
+            creator=creator)
+        now = get_transaction_timestamp(IStore(dasf))
+        self.assertThat(dasf, MatchesStructure.byEquality(
+            distroarchseries=das, packageset=packageset, sense=sense,
+            creator=creator, date_created=now, date_last_modified=now))
+
+    def test_getByDistroArchSeries(self):
+        # getByDistroArchSeries returns the filter for a DAS, if any.
+        das = self.factory.makeDistroArchSeries()
+        dasf_set = getUtility(IDistroArchSeriesFilterSet)
+        self.assertIsNone(dasf_set.getByDistroArchSeries(das))
+        dasf = self.factory.makeDistroArchSeriesFilter(distroarchseries=das)
+        self.assertEqual(dasf, dasf_set.getByDistroArchSeries(das))
+
+    def test_findByPackageset(self):
+        # findByPackageset returns any filters using a package set.
+        packageset = self.factory.makePackageset()
+        dasf_set = getUtility(IDistroArchSeriesFilterSet)
+        self.assertContentEqual([], dasf_set.findByPackageset(packageset))
+        dasfs = [
+            self.factory.makeDistroArchSeriesFilter(packageset=packageset)
+            for _ in range(2)]
+        self.assertContentEqual(dasfs, dasf_set.findByPackageset(packageset))

=== modified file 'lib/lp/testing/factory.py'
--- lib/lp/testing/factory.py	2019-07-09 12:32:22 +0000
+++ lib/lp/testing/factory.py	2019-09-04 14:01:22 +0000
@@ -297,6 +297,7 @@
     ArchivePurpose,
     BinaryPackageFileType,
     BinaryPackageFormat,
+    DistroArchSeriesFilterSense,
     PackageDiffStatus,
     PackagePublishingPriority,
     PackagePublishingStatus,
@@ -326,6 +327,7 @@
 from lp.soyuz.model.distributionsourcepackagecache import (
     DistributionSourcePackageCache,
     )
+from lp.soyuz.model.distroarchseriesfilter import DistroArchSeriesFilter
 from lp.soyuz.model.files import BinaryPackageFile
 from lp.soyuz.model.livefsbuild import LiveFSFile
 from lp.soyuz.model.packagediff import PackageDiff
@@ -4198,6 +4200,27 @@
         run_with_login(owner, lambda: package_set.add(packages))
         return package_set
 
+    def makeDistroArchSeriesFilter(self, distroarchseries=None,
+                                   packageset=None,
+                                   sense=DistroArchSeriesFilterSense.INCLUDE,
+                                   creator=None, date_created=DEFAULT):
+        """Make a new `DistroArchSeriesFilter`."""
+        if distroarchseries is None:
+            if packageset is not None:
+                distroseries = packageset.distroseries
+            else:
+                distroseries = None
+            distroarchseries = self.makeDistroArchSeries(
+                distroseries=distroseries)
+        if packageset is None:
+            packageset = self.makePackageset(
+                distroseries=distroarchseries.distroseries)
+        if creator is None:
+            creator = self.makePerson()
+        return DistroArchSeriesFilter(
+            distroarchseries=distroarchseries, packageset=packageset,
+            sense=sense, creator=creator, date_created=date_created)
+
     def getAnyPocket(self):
         return PackagePublishingPocket.BACKPORTS
 


Follow ups