← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~andrey-fedoseev/launchpad:bug-presense into launchpad:master

 

Andrey Fedoseev has proposed merging ~andrey-fedoseev/launchpad:bug-presense into launchpad:master.

Commit message:
Add `BugPresence` model

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~andrey-fedoseev/launchpad/+git/launchpad/+merge/431710

It represents a range of versions or git commits in which the bug was present.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~andrey-fedoseev/launchpad:bug-presense into launchpad:master.
diff --git a/lib/lp/bugs/configure.zcml b/lib/lp/bugs/configure.zcml
index 6eaa58c..89e0402 100644
--- a/lib/lp/bugs/configure.zcml
+++ b/lib/lp/bugs/configure.zcml
@@ -1113,4 +1113,29 @@
     >
     <allow interface="zope.schema.interfaces.IVocabularyFactory"/>
   </securedutility>
+
+    <!-- BugPresence   -->
+    <class
+        class="lp.bugs.model.bugpresence.BugPresence">
+        <require
+            permission="launchpad.View"
+            interface="lp.bugs.interfaces.bugpresence.IBugPresence"/>
+        <require
+            permission="launchpad.Edit"
+            set_schema="lp.bugs.interfaces.bugpresence.IBugPresence" />
+    </class>
+
+    <!-- BugPresenceSet -->
+    <class
+        class="lp.bugs.model.bugpresence.BugPresenceSet">
+        <allow
+            interface="lp.bugs.interfaces.bugpresence.IBugPresenceSet"/>
+    </class>
+    <securedutility
+        class="lp.bugs.model.bugpresence.BugPresenceSet"
+        provides="lp.bugs.interfaces.bugpresence.IBugPresenceSet">
+        <allow
+            interface="lp.bugs.interfaces.bugpresence.IBugPresenceSet"/>
+    </securedutility>
+
 </configure>
diff --git a/lib/lp/bugs/interfaces/bugpresence.py b/lib/lp/bugs/interfaces/bugpresence.py
new file mode 100644
index 0000000..426a2d7
--- /dev/null
+++ b/lib/lp/bugs/interfaces/bugpresence.py
@@ -0,0 +1,85 @@
+from lazr.restful.fields import Reference
+from zope.interface import Attribute, Interface
+from zope.schema import Choice, Int, TextLine
+
+from lp import _
+from lp.services.fields import BugField
+
+__all__ = ["IBugPresence", "IBugPresenceSet"]
+
+
+class IBugPresence(Interface):
+    """
+    Represents a range of versions or git commits in which the bug was present.
+    """
+
+    id = Int()
+    bug = BugField(title=_("Bug"), readonly=True)
+    bug_id = Int()
+
+    project = Choice(title=_("Project"), required=False, vocabulary="Product")
+    project_id = Attribute("The product ID")
+    sourcepackagename = Choice(
+        title=_("Package"), required=False, vocabulary="SourcePackageName"
+    )
+    sourcepackagename_id = Attribute("The sourcepackagename ID")
+    distribution = Choice(
+        title=_("Distribution"), required=False, vocabulary="Distribution"
+    )
+    distribution_id = Attribute("The distribution ID")
+
+    broken_version = TextLine(
+        required=False, title=_("Version that introduced the bug")
+    )
+    fixed_version = TextLine(
+        required=False, title=_("Version that fixed the bug")
+    )
+
+    git_repository = Choice(
+        title=_("Git repository"), required=False, vocabulary="GitRepository"
+    )
+    git_repository_id = Attribute("The git repository ID")
+    broken_git_commit_sha1 = TextLine(
+        required=False,
+        title=_("Git commit that introduced the bug"),
+        max_length=40,
+    )
+    fixed_git_commit_sha1 = TextLine(
+        required=False, title=_("Git commit that fixed the bug"), max_length=40
+    )
+
+    target = Reference(
+        title=_("Target"),
+        required=True,
+        schema=Interface,  # IBugTarget|IGitRepository
+    )
+
+
+class IBugPresenceSet(Interface):
+    def new(
+        bug,
+        target,
+        broken_version,
+        fixed_version,
+        broken_git_commit_sha1,
+        fixed_git_commit_sha1,
+    ):
+        """Create new BugPresence instance.
+        :param bug: a bug to create a bug presence for
+        :param target: a project, a distribution, a distribution package or
+            a git repository
+        :param broken_version: version in which the bug was introduced
+        :param fixed_version: version in which the bug was fixed
+        :param broken_git_commit_sha1: git commit in which the bug
+            was introduced (for git repository)
+        :param fixed_git_commit_sha1: git commit in which the bug
+            was fixed (for git repository)
+        """
+        pass
+
+    def getByBug(bug):
+        """Get all BugPresence instances for the given bug.
+        :param bug: a bug to get the bug presence instances from
+        :return: a collection of BugPresence instances
+        """
+        pass
diff --git a/lib/lp/bugs/model/bugpresence.py b/lib/lp/bugs/model/bugpresence.py
new file mode 100644
index 0000000..d4f718b
--- /dev/null
+++ b/lib/lp/bugs/model/bugpresence.py
@@ -0,0 +1,138 @@
+from storm.properties import Int, Unicode
+from storm.references import Reference
+from zope.interface import implementer
+
+from lp.bugs.interfaces.bugpresence import IBugPresence, IBugPresenceSet
+from lp.code.interfaces.gitrepository import IGitRepository
+from lp.registry.interfaces.distribution import IDistribution
+from lp.registry.interfaces.distributionsourcepackage import (
+    IDistributionSourcePackage,
+)
+from lp.registry.interfaces.product import IProduct
+from lp.services.database.interfaces import IStore
+from lp.services.database.stormbase import StormBase
+
+__all__ = ["BugPresence", "BugPresenceSet"]
+
+
+@implementer(IBugPresence)
+class BugPresence(StormBase):
+    """See `IBugPresence`."""
+
+    __storm_table__ = "BugPresence"
+
+    id = Int(primary=True)
+
+    bug_id = Int(name="bug", allow_none=False)
+    bug = Reference(bug_id, "Bug.id")
+
+    project_id = Int(name="project", allow_none=True)
+    project = Reference(project_id, "Product.id")
+
+    sourcepackagename_id = Int(name="sourcepackagename", allow_none=True)
+    sourcepackagename = Reference(sourcepackagename_id, "SourcePackageName.id")
+
+    distribution_id = Int(name="distribution", allow_none=True)
+    distribution = Reference(distribution_id, "Distribution.id")
+
+    broken_version = Unicode(name="broken_version", allow_none=True)
+    fixed_version = Unicode(name="fixed_version", allow_none=True)
+
+    git_repository_id = Int(name="git_repository", allow_none=True)
+    git_repository = Reference(git_repository_id, "GitRepository.id")
+
+    broken_git_commit_sha1 = Unicode(
+        name="broken_git_commit_sha1", allow_none=True
+    )
+    fixed_git_commit_sha1 = Unicode(
+        name="fixed_git_commit_sha1", allow_none=True
+    )
+
+    def __init__(
+        self,
+        bug,
+        target,
+        broken_version=None,
+        fixed_version=None,
+        broken_git_commit_sha1=None,
+        fixed_git_commit_sha1=None,
+    ):
+        self.bug = bug
+        self.target = target
+        self.broken_version = broken_version
+        self.fixed_version = fixed_version
+        self.broken_git_commit_sha1 = broken_git_commit_sha1
+        self.fixed_git_commit_sha1 = fixed_git_commit_sha1
+
+    @property
+    def target(self):
+        if self.project:
+            return self.project
+        elif self.distribution:
+            if self.sourcepackagename:
+                return self.distribution.getSourcePackage(
+                    self.sourcepackagename
+                )
+            else:
+                return self.distribution
+        elif self.git_repository:
+            return self.git_repository
+        else:
+            raise AssertionError("Could not determine BugPresence target")
+
+    @target.setter
+    def target(self, target):
+        if IProduct.providedBy(target):
+            self.project = target
+        elif IDistribution.providedBy(target):
+            self.distribution = target
+        elif IDistributionSourcePackage.providedBy(target):
+            self.distribution = target.distribution
+            self.sourcepackagename = target.sourcepackagename
+        elif IGitRepository.providedBy(target):
+            self.git_repository = target
+        else:
+            raise AssertionError("Invalid BugPresence target.")
+
+
+@implementer(IBugPresenceSet)
+class BugPresenceSet:
+    """See `IBugPresenceSet`."""
+
+    def new(
+        self,
+        bug,
+        target,
+        broken_version=None,
+        fixed_version=None,
+        broken_git_commit_sha1=None,
+        fixed_git_commit_sha1=None,
+    ):
+
+        is_version = bool(broken_version or fixed_version)
+        is_git_range = bool(broken_git_commit_sha1 or fixed_git_commit_sha1)
+
+        if is_version == is_git_range:
+            raise ValueError(
+                "Either broken_version/fixed_version or "
+                "broken_git_commit_sha1/fixed_git_commit_sha1 "
+                "must be specified"
+            )
+
+        if target is None:
+            raise ValueError("target must be specified")
+
+        if is_git_range and not IGitRepository.providedBy(target):
+            raise ValueError("target must be a git repository")
+
+        return BugPresence(
+            bug=bug,
+            target=target,
+            broken_version=broken_version,
+            fixed_version=fixed_version,
+            broken_git_commit_sha1=broken_git_commit_sha1,
+            fixed_git_commit_sha1=fixed_git_commit_sha1,
+        )
+
+    def getByBug(self, bug):
+        return IStore(BugPresence).find(BugPresence, bug=bug)
diff --git a/lib/lp/bugs/security.py b/lib/lp/bugs/security.py
index 11d74e9..15ae315 100644
--- a/lib/lp/bugs/security.py
+++ b/lib/lp/bugs/security.py
@@ -17,6 +17,7 @@ from lp.bugs.interfaces.bug import IBug
 from lp.bugs.interfaces.bugactivity import IBugActivity
 from lp.bugs.interfaces.bugattachment import IBugAttachment
 from lp.bugs.interfaces.bugnomination import IBugNomination
+from lp.bugs.interfaces.bugpresence import IBugPresence
 from lp.bugs.interfaces.bugsubscription import IBugSubscription
 from lp.bugs.interfaces.bugsubscriptionfilter import IBugSubscriptionFilter
 from lp.bugs.interfaces.bugsupervisor import IHasBugSupervisor
@@ -455,3 +456,35 @@ class BugTargetOwnerOrBugSupervisorOrAdmins(AuthorizationBase):
             or user.inTeam(self.obj.owner)
             or user.in_admin
         )
+
+
+class ViewBugPresence(DelegatedAuthorization):
+    """
+    A person that can view a Bug can also view a related BugPresence.
+    """
+
+    permission = "launchpad.View"
+    usedfor = IBugPresence
+
+    def __init__(self, obj):
+        super().__init__(obj, obj.bug, "launchpad.View")
+
+    def checkAuthenticated(self, user):
+        r = super().checkAuthenticated(user)
+        return r
+
+    def checkUnauthenticated(self):
+        r = super().checkUnauthenticated()
+        return r
+
+
+class EditBugPresence(DelegatedAuthorization):
+    """
+    A person that can edit a Bug can also edit a related BugPresence.
+    """
+
+    permission = "launchpad.Edit"
+    usedfor = IBugPresence
+
+    def __init__(self, obj):
+        super().__init__(obj, obj.bug, "launchpad.Edit")
diff --git a/lib/lp/bugs/tests/test_bugpresence.py b/lib/lp/bugs/tests/test_bugpresence.py
new file mode 100644
index 0000000..ce03db9
--- /dev/null
+++ b/lib/lp/bugs/tests/test_bugpresence.py
@@ -0,0 +1,309 @@
+from psycopg2.errors import CheckViolation
+from zope.component import getUtility
+from zope.security import checkPermission
+
+from lp.app.enums import InformationType
+from lp.bugs.interfaces.bugpresence import IBugPresenceSet
+from lp.bugs.model.bugpresence import BugPresence
+from lp.registry.model.distributionsourcepackage import (
+    DistributionSourcePackage,
+)
+from lp.services.database.interfaces import IStore
+from lp.testing import (
+    TestCaseWithFactory,
+    anonymous_logged_in,
+    person_logged_in,
+)
+from lp.testing.layers import DatabaseFunctionalLayer, ZopelessDatabaseLayer
+
+
+class TestBugPresence(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+
+    def test_target(self):
+        project = self.factory.makeProduct()
+        distribution = self.factory.makeDistribution()
+        spn = self.factory.makeSourcePackageName()
+
+        for target, attrs in (
+            (project, {"project": project}),
+            (distribution, {"distribution": distribution}),
+            (
+                DistributionSourcePackage(distribution, spn),
+                {"distribution": distribution, "sourcepackagename": spn},
+            ),
+        ):
+            bug_presence = BugPresence(
+                bug=self.factory.makeBug(),
+                target=target,
+                broken_version="1",
+            )
+            self.assertEqual(target, bug_presence.target)
+            for attr_name, value in attrs.items():
+                self.assertEqual(value, getattr(bug_presence, attr_name))
+
+        git_repository = self.factory.makeGitRepository()
+        bug_presence = BugPresence(
+            bug=self.factory.makeBug(),
+            target=git_repository,
+            broken_git_commit_sha1="1",
+        )
+        self.assertEqual(git_repository, bug_presence.target)
+        self.assertEqual(git_repository, bug_presence.git_repository)
+
+    def test_constraints(self):
+        project = self.factory.makeProduct()
+        distribution = self.factory.makeDistribution()
+        distro_package = self.factory.makeDistributionSourcePackage(
+            distribution=distribution
+        )
+        git_repository = self.factory.makeGitRepository()
+
+        store = IStore(BugPresence)
+        store.commit()
+
+        # no version range, nor git commit range
+        for target in project, distribution, distro_package, git_repository:
+            BugPresence(bug=self.factory.makeBug(), target=target)
+            self.assertRaises(CheckViolation, store.flush)
+            store.rollback()
+
+        # valid version range
+        for broken_version, fixed_version in (
+            ("1", None),
+            (None, "2"),
+            ("1", "2"),
+        ):
+            for target in project, distribution, distro_package:
+                bp = BugPresence(
+                    bug=self.factory.makeBug(),
+                    target=target,
+                    broken_version=broken_version,
+                    fixed_version=fixed_version,
+                )
+                store.commit()
+                bp = store.get(BugPresence, bp.id)
+                self.assertEqual(broken_version, bp.broken_version)
+                self.assertEqual(fixed_version, bp.fixed_version)
+
+        # valid git commit range
+        for broken_git_commit_sha1, fixed_git_commit_sha1 in (
+            ("1", None),
+            (None, "2"),
+            ("1", "2"),
+        ):
+            bp = BugPresence(
+                bug=self.factory.makeBug(),
+                target=git_repository,
+                broken_git_commit_sha1=broken_git_commit_sha1,
+                fixed_git_commit_sha1=fixed_git_commit_sha1,
+            )
+            store.commit()
+            bp = store.get(BugPresence, bp.id)
+            self.assertEqual(broken_git_commit_sha1, bp.broken_git_commit_sha1)
+            self.assertEqual(fixed_git_commit_sha1, bp.fixed_git_commit_sha1)
+
+
+class TestBugPresenceSecurity(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_view_permission(self):
+        project = self.factory.makeProduct()
+
+        # Anyone can view a public bug presence
+        public_bug = self.factory.makeBug(
+            target=project, information_type=InformationType.PUBLIC
+        )
+        bp = BugPresence(bug=public_bug, target=project, broken_version="1")
+        with anonymous_logged_in():
+            self.assertTrue(checkPermission("launchpad.View", bp))
+
+        # Anonymous and random users can't see a private bug presence
+        owner = self.factory.makePerson()
+        project = self.factory.makeProduct(
+            information_type=InformationType.PROPRIETARY, owner=owner
+        )
+        with person_logged_in(owner):
+            private_bug = self.factory.makeBug(
+                target=project,
+                information_type=InformationType.PROPRIETARY,
+                owner=owner,
+            )
+        bp = BugPresence(bug=private_bug, target=project, broken_version="1")
+        with anonymous_logged_in():
+            self.assertFalse(checkPermission("launchpad.View", bp))
+        with person_logged_in(self.factory.makePerson()):
+            self.assertFalse(checkPermission("launchpad.View", bp))
+
+        # Private bug owners can see a private bug presence
+        with person_logged_in(owner):
+            self.assertTrue(checkPermission("launchpad.View", bp))
+
+    def test_edit_permission(self):
+        project = self.factory.makeProduct()
+
+        public_bug = self.factory.makeBug(
+            target=project, information_type=InformationType.PUBLIC
+        )
+        bp = BugPresence(bug=public_bug, target=project, broken_version="1")
+        # Anonymous can't edit a public bug presence
+        with anonymous_logged_in():
+            self.assertFalse(checkPermission("launchpad.Edit", bp))
+
+        # Any authenticated user can edit a public bug presence
+        with person_logged_in(self.factory.makePerson()):
+            self.assertTrue(checkPermission("launchpad.Edit", bp))
+
+        # Anonymous and random users can't edit a private bug presence
+        owner = self.factory.makePerson()
+        project = self.factory.makeProduct(
+            information_type=InformationType.PROPRIETARY, owner=owner
+        )
+        with person_logged_in(owner):
+            private_bug = self.factory.makeBug(
+                target=project,
+                information_type=InformationType.PROPRIETARY,
+                owner=owner,
+            )
+        bp = BugPresence(bug=private_bug, target=project, broken_version="1")
+        with anonymous_logged_in():
+            self.assertFalse(checkPermission("launchpad.Edit", bp))
+        with person_logged_in(self.factory.makePerson()):
+            self.assertFalse(checkPermission("launchpad.Edit", bp))
+
+        # Private bug owners can edit a private bug presence
+        with person_logged_in(owner):
+            self.assertTrue(checkPermission("launchpad.Edit", bp))
+
+
+class TestBugPresenceSet(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_new__version_range(self):
+        project = self.factory.makeProduct()
+        distribution = self.factory.makeDistribution()
+        distro_package = self.factory.makeDistributionSourcePackage(
+            distribution=distribution
+        )
+
+        bug_presence_set = getUtility(IBugPresenceSet)
+
+        # version range
+        for broken_version, fixed_version in (
+            ("1", None),
+            (None, "2"),
+            ("1", "2"),
+        ):
+            for target in project, distribution, distro_package:
+                bug = self.factory.makeBug()
+                bp = bug_presence_set.new(
+                    bug=bug,
+                    target=target,
+                    broken_version=broken_version,
+                    fixed_version=fixed_version,
+                )
+                self.assertEqual(bug, bp.bug)
+                self.assertEqual(target, bp.target)
+                self.assertEqual(broken_version, bp.broken_version)
+                self.assertEqual(fixed_version, bp.fixed_version)
+
+    def test_new__git_commit_range(self):
+        git_repository = self.factory.makeGitRepository()
+
+        bug_presence_set = getUtility(IBugPresenceSet)
+
+        # version range
+        for broken_git_commit_sha1, fixed_git_commit_sha1 in (
+            ("1", None),
+            (None, "2"),
+            ("1", "2"),
+        ):
+            bug = self.factory.makeBug()
+            bp = bug_presence_set.new(
+                bug=bug,
+                target=git_repository,
+                broken_git_commit_sha1=broken_git_commit_sha1,
+                fixed_git_commit_sha1=fixed_git_commit_sha1,
+            )
+            self.assertEqual(bug, bp.bug)
+            self.assertEqual(git_repository, bp.target)
+            self.assertEqual(broken_git_commit_sha1, bp.broken_git_commit_sha1)
+            self.assertEqual(fixed_git_commit_sha1, bp.fixed_git_commit_sha1)
+
+    def test_new__invalid_arguments(self):
+        project = self.factory.makeProduct()
+        bug = self.factory.makeBug()
+        bug_presence_set = getUtility(IBugPresenceSet)
+
+        # no version range nor git commit range is specified
+        self.assertRaises(
+            ValueError, bug_presence_set.new, bug=bug, target=project
+        )
+
+        # both version range and commit range is specified
+        self.assertRaises(
+            ValueError,
+            bug_presence_set.new,
+            bug=bug,
+            target=project,
+            broken_version="1",
+            fixed_git_commit_sha1="2",
+        )
+
+        # target is not a git repository for git commit range
+        self.assertRaises(
+            ValueError,
+            bug_presence_set.new,
+            bug=bug,
+            target=project,
+            fixed_git_commit_sha1="2",
+        )
+
+        # target is missing
+        self.assertRaises(
+            ValueError,
+            bug_presence_set.new,
+            bug=bug,
+            target=None,
+            broken_version="1",
+        )
+
+    def test_getByBug(self):
+        project = self.factory.makeProduct()
+        bug = self.factory.makeBug()
+        another_bug = self.factory.makeBug()
+        bug_presence_set = getUtility(IBugPresenceSet)
+
+        self.assertEqual([], list(bug_presence_set.getByBug(bug)))
+        self.assertEqual([], list(bug_presence_set.getByBug(another_bug)))
+
+        bp_1 = bug_presence_set.new(
+            bug=bug, target=project, broken_version="1"
+        )
+        bp_2 = bug_presence_set.new(bug=bug, target=project, fixed_version="2")
+        bp_3 = bug_presence_set.new(
+            bug=another_bug, target=project, broken_version="3"
+        )
+        self.assertEqual({bp_1, bp_2}, set(bug_presence_set.getByBug(bug)))
+        self.assertEqual({bp_3}, set(bug_presence_set.getByBug(another_bug)))
+
+
+class TestBugPresenceFactory(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+
+    def test_makeBugPresence__no_arguments(self):
+        bp = self.factory.makeBugPresence()
+        self.assertIsNotNone(bp.target)
+        self.assertIsNotNone(bp.broken_version)
+        self.assertIsNotNone(bp.fixed_version)
+
+    def test_makeBugPresence__git_repository(self):
+        git_repository = self.factory.makeGitRepository()
+        bp = self.factory.makeBugPresence(target=git_repository)
+        self.assertEqual(git_repository, bp.git_repository)
+        self.assertIsNotNone(bp.broken_git_commit_sha1)
+        self.assertIsNotNone(bp.fixed_git_commit_sha1)
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index 7ecb210..7ec64e3 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -65,6 +65,7 @@ from lp.blueprints.interfaces.specification import ISpecificationSet
 from lp.blueprints.interfaces.sprint import ISprintSet
 from lp.bugs.interfaces.apportjob import IProcessApportBlobJobSource
 from lp.bugs.interfaces.bug import CreateBugParams, IBugSet
+from lp.bugs.interfaces.bugpresence import IBugPresenceSet
 from lp.bugs.interfaces.bugtask import (
     BugTaskImportance,
     BugTaskStatus,
@@ -2636,6 +2637,38 @@ class LaunchpadObjectFactory(ObjectFactory):
             subscriber, subscribed_by
         )
 
+    def makeBugPresence(
+        self,
+        bug=None,
+        target=None,
+        broken_version=None,
+        fixed_version=None,
+        broken_git_commit_sha1=None,
+        fixed_git_commit_sha1=None,
+    ):
+        if bug is None:
+            bug = self.makeBug()
+        if target is None:
+            target = self.makeProduct()
+
+        if IGitRepository.providedBy(target):
+            if not broken_git_commit_sha1 and not fixed_git_commit_sha1:
+                broken_git_commit_sha1 = self.getUniqueHexString(40)
+                fixed_git_commit_sha1 = self.getUniqueHexString(40)
+
+        elif not broken_version and not fixed_version:
+            broken_version = self.getUniqueString("version")
+            fixed_version = self.getUniqueString("version")
+
+        return getUtility(IBugPresenceSet).new(
+            bug=bug,
+            target=target,
+            broken_version=broken_version,
+            fixed_version=fixed_version,
+            broken_git_commit_sha1=broken_git_commit_sha1,
+            fixed_git_commit_sha1=fixed_git_commit_sha1,
+        )
+
     def makeSignedMessage(
         self,
         msgid=None,