← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~pappacena/launchpad:snap-pillar-subscribe into launchpad:master

 

Thiago F. Pappacena has proposed merging ~pappacena/launchpad:snap-pillar-subscribe into launchpad:master with ~pappacena/launchpad:snap-pillar-reconcile-access as a prerequisite.

Commit message:
Adding SnapSubscription model

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~pappacena/launchpad/+git/launchpad/+merge/397755
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~pappacena/launchpad:snap-pillar-subscribe into launchpad:master.
diff --git a/database/schema/security.cfg b/database/schema/security.cfg
index 29990e3..599f486 100644
--- a/database/schema/security.cfg
+++ b/database/schema/security.cfg
@@ -1,4 +1,4 @@
-# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 #
 # Possible permissions: SELECT, INSERT, UPDATE, EXECUTE
@@ -302,6 +302,7 @@ public.snapbuild                        = SELECT, INSERT, UPDATE, DELETE
 public.snapbuildjob                     = SELECT, INSERT, UPDATE, DELETE
 public.snapfile                         = SELECT, INSERT, UPDATE, DELETE
 public.snapjob                          = SELECT, INSERT, UPDATE, DELETE
+public.snapsubscription                 = SELECT, INSERT, UPDATE, DELETE
 public.snappydistroseries               = SELECT, INSERT, UPDATE, DELETE
 public.snappyseries                     = SELECT, INSERT, UPDATE, DELETE
 public.sourcepackageformatselection     = SELECT
@@ -2245,6 +2246,7 @@ type=user
 
 [person-merge-job]
 groups=script
+public.accesspolicyartifact             = SELECT
 public.accessartifactgrant              = SELECT, UPDATE, DELETE
 public.accesspolicy                     = SELECT, UPDATE, DELETE
 public.accesspolicygrant                = SELECT, UPDATE, DELETE
@@ -2362,6 +2364,7 @@ public.signedcodeofconduct              = SELECT, UPDATE
 public.snap                             = SELECT, UPDATE
 public.snapbase                         = SELECT, UPDATE
 public.snapbuild                        = SELECT, UPDATE
+public.snapsubscription                 = SELECT, UPDATE, DELETE
 public.snappyseries                     = SELECT, UPDATE
 public.sourcepackagename                = SELECT
 public.sourcepackagepublishinghistory   = SELECT, UPDATE
diff --git a/lib/lp/registry/personmerge.py b/lib/lp/registry/personmerge.py
index 7873a61..d788e9f 100644
--- a/lib/lp/registry/personmerge.py
+++ b/lib/lp/registry/personmerge.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Person/team merger implementation."""
@@ -672,6 +672,24 @@ def _mergeSnap(cur, from_person, to_person):
         IStore(snaps[0]).flush()
 
 
+def _mergeSnapSubscription(cur, from_id, to_id):
+    # Update only the SnapSubscription that will not conflict.
+    cur.execute('''
+        UPDATE SnapSubscription
+        SET person=%(to_id)d
+        WHERE person=%(from_id)d AND snap NOT IN
+            (
+            SELECT snap
+            FROM SnapSubscription
+            WHERE person = %(to_id)d
+            )
+        ''' % vars())
+    # and delete those left over.
+    cur.execute('''
+        DELETE FROM SnapSubscription WHERE person=%(from_id)d
+        ''' % vars())
+
+
 def _mergeOCIRecipe(cur, from_person, to_person):
     # This shouldn't use removeSecurityProxy
     oci_recipes = getUtility(IOCIRecipeSet).findByOwner(from_person)
@@ -917,6 +935,9 @@ def merge_people(from_person, to_person, reviewer, delete=False):
     _mergeSnap(cur, from_person, to_person)
     skip.append(('snap', 'owner'))
 
+    _mergeSnapSubscription(cur, from_id, to_id)
+    skip.append(('snapsubscription', 'person'))
+
     _mergeOCIRecipe(cur, from_person, to_person)
     skip.append(('ocirecipe', 'owner'))
 
diff --git a/lib/lp/registry/tests/test_personmerge.py b/lib/lp/registry/tests/test_personmerge.py
index 02fefcc..d080746 100644
--- a/lib/lp/registry/tests/test_personmerge.py
+++ b/lib/lp/registry/tests/test_personmerge.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for merge_people."""
@@ -8,6 +8,7 @@ from operator import attrgetter
 
 import pytz
 from testtools.matchers import (
+    Equals,
     MatchesSetwise,
     MatchesStructure,
     )
@@ -48,7 +49,10 @@ from lp.services.identity.interfaces.emailaddress import (
     EmailAddressStatus,
     IEmailAddressSet,
     )
-from lp.snappy.interfaces.snap import ISnapSet
+from lp.snappy.interfaces.snap import (
+    ISnapSet,
+    SNAP_TESTING_FLAGS,
+    )
 from lp.soyuz.enums import ArchiveStatus
 from lp.soyuz.interfaces.livefs import (
     ILiveFSSet,
@@ -56,6 +60,7 @@ from lp.soyuz.interfaces.livefs import (
     )
 from lp.testing import (
     celebrity_logged_in,
+    login_admin,
     login_person,
     person_logged_in,
     TestCaseWithFactory,
@@ -664,6 +669,34 @@ class TestMergePeople(TestCaseWithFactory, KarmaTestMixin):
         self.assertIsNone(snaps[1].git_path)
         self.assertEqual(u'foo-1', snaps[1].name)
 
+    def test_merge_snapsubscription(self):
+        # Checks that merging users moves subscriptions.
+        self.useFixture(FeatureFixture(SNAP_TESTING_FLAGS))
+        duplicate = self.factory.makePerson()
+        mergee = self.factory.makePerson()
+        snap = removeSecurityProxy(self.factory.makeSnap(
+            name=u'foo', private=True))
+        self._do_premerge(duplicate, mergee)
+        login_admin()
+        snap.subscribe(duplicate, snap.owner)
+        self.assertTrue(snap.visibleByUser(duplicate))
+        self.assertThat(snap._getSubscription(duplicate), MatchesStructure(
+            snap=Equals(snap),
+            person=Equals(duplicate)
+        ))
+        self.assertFalse(snap.visibleByUser(mergee))
+        self.assertIsNone(snap._getSubscription(mergee))
+
+        duplicate, mergee = self._do_merge(duplicate, mergee)
+
+        self.assertTrue(snap.visibleByUser(mergee))
+        self.assertThat(snap._getSubscription(mergee), MatchesStructure(
+            snap=Equals(snap),
+            person=Equals(mergee)
+        ))
+        self.assertFalse(snap.visibleByUser(duplicate))
+        self.assertIsNone(snap._getSubscription(duplicate))
+
     def test_merge_moves_oci_recipes(self):
         # When person/teams are merged, oci recipes owned by the from
         # person are moved.
diff --git a/lib/lp/snappy/interfaces/snapsubscription.py b/lib/lp/snappy/interfaces/snapsubscription.py
new file mode 100644
index 0000000..58206cc
--- /dev/null
+++ b/lib/lp/snappy/interfaces/snapsubscription.py
@@ -0,0 +1,42 @@
+# Copyright 2020-2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Snap subscription model."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'ISnapSubscription'
+]
+
+from lazr.restful.fields import Reference
+from zope.interface import Interface
+from zope.schema import (
+    Datetime,
+    Int,
+    )
+
+from lp import _
+from lp.services.fields import PersonChoice
+from lp.snappy.interfaces.snap import ISnap
+
+
+class ISnapSubscription(Interface):
+    """A person subscription to a specific Snap recipe."""
+
+    id = Int(title=_('ID'), readonly=True, required=True)
+    person = PersonChoice(
+        title=_('Person'), required=True, vocabulary='ValidPersonOrTeam',
+        readonly=True,
+        description=_("The person subscribed to the related snap recipe."))
+    snap = Reference(ISnap, title=_("Snap"), required=True, readonly=True)
+    subscribed_by = PersonChoice(
+        title=_('Subscribed by'), required=True,
+        vocabulary='ValidPersonOrTeam', readonly=True,
+        description=_("The person who created this subscription."))
+    date_created = Datetime(
+        title=_('Date subscribed'), required=True, readonly=True)
+
+    def canBeUnsubscribedByUser(user):
+        """Can the user unsubscribe the subscriber from the snap recipe?"""
diff --git a/lib/lp/snappy/model/snap.py b/lib/lp/snappy/model/snap.py
index ce73636..b4120c6 100644
--- a/lib/lp/snappy/model/snap.py
+++ b/lib/lp/snappy/model/snap.py
@@ -61,7 +61,10 @@ from lp.app.browser.tales import (
     DateTimeFormatterAPI,
     )
 from lp.app.enums import InformationType
-from lp.app.errors import IncompatibleArguments
+from lp.app.errors import (
+    IncompatibleArguments,
+    SubscriptionPrivacyViolation,
+    )
 from lp.app.interfaces.security import IAuthorization
 from lp.app.interfaces.services import IService
 from lp.buildmaster.enums import BuildStatus
@@ -193,6 +196,7 @@ from lp.snappy.interfaces.snappyseries import ISnappyDistroSeriesSet
 from lp.snappy.interfaces.snapstoreclient import ISnapStoreClient
 from lp.snappy.model.snapbuild import SnapBuild
 from lp.snappy.model.snapjob import SnapJob
+from lp.snappy.model.snapsubscription import SnapSubscription
 from lp.soyuz.interfaces.archive import ArchiveDisabled
 from lp.soyuz.model.archive import (
     Archive,
@@ -1081,9 +1085,35 @@ class Snap(Storm, WebhookTargetMixin):
             Snap.id == self.id,
             visibility_clause).is_empty()
 
+    def _getSubscription(self, person):
+        """Returns person's subscription to this snap recipe, or None if no
+        subscription is available.
+        """
+        if person is None:
+            return None
+        return Store.of(self).find(
+            SnapSubscription,
+            SnapSubscription.person == person,
+            SnapSubscription.snap == self).one()
+
+    def _userCanBeSubscribed(self, person):
+        """Checks if the given person can subscribe to this snap recipe."""
+        return not (
+            self.private and
+            person.is_team and
+            person.anyone_can_join())
+
     def subscribe(self, person, subscribed_by):
         """See `ISnap`."""
-        # XXX pappacena 2021-02-05: We may need a "SnapSubscription" here.
+        if not self._userCanBeSubscribed(person):
+            raise SubscriptionPrivacyViolation(
+                "Open and delegated teams cannot be subscribed to private "
+                "snap recipes.")
+        subscription = self._getSubscription(person)
+        if subscription is None:
+            subscription = SnapSubscription(
+                person=person, snap=self, subscribed_by=subscribed_by)
+            Store.of(subscription).flush()
         service = getUtility(IService, "sharing")
         service.ensureAccessGrants([person], subscribed_by, snaps=[self])
 
@@ -1092,6 +1122,12 @@ class Snap(Storm, WebhookTargetMixin):
         service = getUtility(IService, "sharing")
         service.revokeAccessGrants(
             self.pillar, person, unsubscribed_by, snaps=[self])
+        subscription = self._getSubscription(person)
+        # It should never be None, since we always create a SnapSubscription
+        # on Snap.subscribe. But just in case...
+        if subscription is not None:
+            store = Store.of(subscription)
+            store.remove(subscription)
         IStore(self).flush()
 
     def _reconcileAccess(self):
diff --git a/lib/lp/snappy/model/snapsubscription.py b/lib/lp/snappy/model/snapsubscription.py
new file mode 100644
index 0000000..95a0026
--- /dev/null
+++ b/lib/lp/snappy/model/snapsubscription.py
@@ -0,0 +1,61 @@
+# Copyright 2020-2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Snap subscription model."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'SnapSubscription'
+]
+
+import pytz
+from storm.properties import (
+    DateTime,
+    Int,
+    )
+from storm.references import Reference
+from zope.interface import implementer
+
+from lp.registry.interfaces.person import validate_person
+from lp.registry.interfaces.role import IPersonRoles
+from lp.services.database.constants import UTC_NOW
+from lp.services.database.stormbase import StormBase
+from lp.snappy.interfaces.snapsubscription import ISnapSubscription
+
+
+@implementer(ISnapSubscription)
+class SnapSubscription(StormBase):
+    """A relationship between a person and a snap recipe."""
+
+    __storm_table__ = 'SnapSubscription'
+
+    id = Int(primary=True)
+
+    person_id = Int(
+        "person", allow_none=False, validator=validate_person)
+    person = Reference(person_id, "Person.id")
+
+    snap_id = Int("snap", allow_none=False)
+    snap = Reference(snap_id, "Snap.id")
+
+    date_created = DateTime(allow_none=False, default=UTC_NOW, tzinfo=pytz.UTC)
+
+    subscribed_by_id = Int(
+        "subscribed_by", allow_none=False, validator=validate_person)
+    subscribed_by = Reference(subscribed_by_id, "Person.id")
+
+    def __init__(self, snap=None, person=None, subscribed_by=None):
+        super(SnapSubscription, self).__init__()
+        self.snap = snap
+        self.person = person
+        self.subscribed_by = subscribed_by
+
+    def canBeUnsubscribedByUser(self, user):
+        """See `ISnapSubscription`."""
+        if user is None:
+            return False
+        return (user.inTeam(self.person) or
+                user.inTeam(self.subscribed_by) or
+                IPersonRoles(user).in_admin)
diff --git a/lib/lp/snappy/tests/test_snap.py b/lib/lp/snappy/tests/test_snap.py
index c3b2fb5..c1dca50 100644
--- a/lib/lp/snappy/tests/test_snap.py
+++ b/lib/lp/snappy/tests/test_snap.py
@@ -34,6 +34,7 @@ from testtools.matchers import (
     Equals,
     GreaterThan,
     Is,
+    IsInstance,
     LessThan,
     MatchesAll,
     MatchesDict,
@@ -1347,6 +1348,9 @@ class TestSnapVisibility(TestCaseWithFactory):
             AccessArtifactGrant.abstract_artifact_id == AccessArtifact.id,
             *conditions)
 
+    def getSnapSubscription(self, snap, person):
+        return removeSecurityProxy(snap)._getSubscription(person)
+
     def test_only_owner_can_grant_access(self):
         owner = self.factory.makePerson()
         pillar = self.factory.makeProduct(owner=owner)
@@ -1383,11 +1387,20 @@ class TestSnapVisibility(TestCaseWithFactory):
         with person_logged_in(owner):
             self.assertFalse(snap.visibleByUser(person))
             snap.subscribe(person, snap.owner)
+            self.assertThat(
+                self.getSnapSubscription(snap, person),
+                MatchesStructure(
+                    person=Equals(person),
+                    snap=Equals(snap),
+                    subscribed_by=Equals(snap.owner),
+                    date_created=IsInstance(datetime)))
             # Calling again should be a no-op.
             snap.subscribe(person, snap.owner)
             self.assertTrue(snap.visibleByUser(person))
+
             snap.unsubscribe(person, snap.owner)
             self.assertFalse(snap.visibleByUser(person))
+            self.assertIsNone(self.getSnapSubscription(snap, person))
 
     def test_reconcile_set_public(self):
         owner = self.factory.makePerson()
@@ -1396,11 +1409,24 @@ class TestSnapVisibility(TestCaseWithFactory):
         another_user = self.factory.makePerson()
         with admin_logged_in():
             snap.subscribe(another_user, snap.owner)
+            self.assertEqual(1, self.getSnapGrants(snap, another_user).count())
+            self.assertThat(
+                self.getSnapSubscription(snap, another_user),
+                MatchesStructure(
+                    person=Equals(another_user),
+                    snap=Equals(snap),
+                    subscribed_by=Equals(snap.owner),
+                    date_created=IsInstance(datetime)))
 
-        self.assertEqual(1, self.getSnapGrants(snap, another_user).count())
-        with admin_logged_in():
             snap.setPrivate(False)
-        self.assertEqual(0, self.getSnapGrants(snap, another_user).count())
+            self.assertEqual(0, self.getSnapGrants(snap, another_user).count())
+            self.assertThat(
+                self.getSnapSubscription(snap, another_user),
+                MatchesStructure(
+                    person=Equals(another_user),
+                    snap=Equals(snap),
+                    subscribed_by=Equals(snap.owner),
+                    date_created=IsInstance(datetime)))
 
     def test_reconcile_permissions_setting_project(self):
         owner = self.factory.makePerson()
@@ -1419,10 +1445,24 @@ class TestSnapVisibility(TestCaseWithFactory):
             snap.subscribe(another_person, owner)
             self.assertTrue(snap.visibleByUser(another_person))
             self.assertEqual(1, self.getSnapGrants(snap).count())
+            self.assertThat(
+                self.getSnapSubscription(snap, another_person),
+                MatchesStructure(
+                    person=Equals(another_person),
+                    snap=Equals(snap),
+                    subscribed_by=Equals(snap.owner),
+                    date_created=IsInstance(datetime)))
 
             snap.setProject(new_project)
             self.assertTrue(snap.visibleByUser(another_person))
             self.assertEqual(1, self.getSnapGrants(snap).count())
+            self.assertThat(
+                self.getSnapSubscription(snap, another_person),
+                MatchesStructure(
+                    person=Equals(another_person),
+                    snap=Equals(snap),
+                    subscribed_by=Equals(snap.owner),
+                    date_created=IsInstance(datetime)))
 
 
 class TestSnapSet(TestCaseWithFactory):

Follow ups