← Back to team overview

launchpad-reviewers team mailing list archive

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

 

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

Commit message:
UI workflow for users to subscribe to snaps

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~pappacena/launchpad/+git/launchpad/+merge/398319
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~pappacena/launchpad:snap-pillar-subscribe-ui into launchpad:master.
diff --git a/lib/lp/registry/tests/test_personmerge.py b/lib/lp/registry/tests/test_personmerge.py
index d080746..f1a86b6 100644
--- a/lib/lp/registry/tests/test_personmerge.py
+++ b/lib/lp/registry/tests/test_personmerge.py
@@ -680,22 +680,22 @@ class TestMergePeople(TestCaseWithFactory, KarmaTestMixin):
         login_admin()
         snap.subscribe(duplicate, snap.owner)
         self.assertTrue(snap.visibleByUser(duplicate))
-        self.assertThat(snap._getSubscription(duplicate), MatchesStructure(
+        self.assertThat(snap.getSubscription(duplicate), MatchesStructure(
             snap=Equals(snap),
             person=Equals(duplicate)
         ))
         self.assertFalse(snap.visibleByUser(mergee))
-        self.assertIsNone(snap._getSubscription(mergee))
+        self.assertIsNone(snap.getSubscription(mergee))
 
         duplicate, mergee = self._do_merge(duplicate, mergee)
 
         self.assertTrue(snap.visibleByUser(mergee))
-        self.assertThat(snap._getSubscription(mergee), MatchesStructure(
+        self.assertThat(snap.getSubscription(mergee), MatchesStructure(
             snap=Equals(snap),
             person=Equals(mergee)
         ))
         self.assertFalse(snap.visibleByUser(duplicate))
-        self.assertIsNone(snap._getSubscription(duplicate))
+        self.assertIsNone(snap.getSubscription(duplicate))
 
     def test_merge_moves_oci_recipes(self):
         # When person/teams are merged, oci recipes owned by the from
diff --git a/lib/lp/security.py b/lib/lp/security.py
index 35c8b8c..2e4739f 100644
--- a/lib/lp/security.py
+++ b/lib/lp/security.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).
 
 """Security policies for using content objects."""
@@ -214,6 +214,7 @@ from lp.snappy.interfaces.snappyseries import (
     ISnappySeries,
     ISnappySeriesSet,
     )
+from lp.snappy.interfaces.snapsubscription import ISnapSubscription
 from lp.soyuz.interfaces.archive import IArchive
 from lp.soyuz.interfaces.archiveauthtoken import IArchiveAuthToken
 from lp.soyuz.interfaces.archivepermission import IArchivePermissionSet
@@ -3298,17 +3299,13 @@ class ViewSnap(AuthorizationBase):
     permission = 'launchpad.View'
     usedfor = ISnap
 
-    def checkUnauthenticated(self):
-        return not self.obj.private
-
     def checkAuthenticated(self, user):
-        if not self.obj.private:
+        if user.isOwner(self.obj) or user.in_commercial_admin or user.in_admin:
             return True
+        return self.obj.visibleByUser(user.person)
 
-        return (
-            user.isOwner(self.obj) or
-            user.in_commercial_admin or
-            user.in_admin)
+    def checkUnauthenticated(self):
+        return self.obj.visibleByUser(None)
 
 
 class EditSnap(AuthorizationBase):
@@ -3339,6 +3336,30 @@ class AdminSnap(AuthorizationBase):
             and EditSnap(self.obj).checkAuthenticated(user))
 
 
+class SnapSubscriptionEdit(AuthorizationBase):
+    permission = 'launchpad.Edit'
+    usedfor = ISnapSubscription
+
+    def checkAuthenticated(self, user):
+        """Is the user able to edit a Snap recipe subscription?
+
+        Any team member can edit a Snap recipe subscription for their
+        team.
+        Launchpad Admins can also edit any Snap recipe subscription.
+        The owner of the subscribed Snap can edit the subscription. If
+        the Snap owner is a team, then members of the team can edit
+        the subscription.
+        """
+        return (user.inTeam(self.obj.snap.owner) or
+                user.inTeam(self.obj.person) or
+                user.inTeam(self.obj.subscribed_by) or
+                user.in_admin)
+
+
+class SnapSubscriptionView(SnapSubscriptionEdit):
+    permission = 'launchpad.View'
+
+
 class ViewSnapBuildRequest(DelegatedAuthorization):
     permission = 'launchpad.View'
     usedfor = ISnapBuildRequest
diff --git a/lib/lp/snappy/browser/configure.zcml b/lib/lp/snappy/browser/configure.zcml
index 9da248a..b11f797 100644
--- a/lib/lp/snappy/browser/configure.zcml
+++ b/lib/lp/snappy/browser/configure.zcml
@@ -1,4 +1,4 @@
-<!-- Copyright 2015-2020 Canonical Ltd.  This software is licensed under the
+<!-- Copyright 2015-2021 Canonical Ltd.  This software is licensed under the
      GNU Affero General Public License version 3 (see the file LICENSE).
 -->
 
@@ -37,6 +37,45 @@
             name="+portlet-privacy"
             template="../templates/snap-portlet-privacy.pt"/>
         <browser:page
+            for="lp.snappy.interfaces.snap.ISnap"
+            permission="launchpad.View"
+            name="+portlet-subscribers"
+            template="../templates/snap-portlet-subscribers.pt"/>
+        <browser:page
+            for="lp.snappy.interfaces.snap.ISnap"
+            class="lp.snappy.browser.snapsubscription.SnapPortletSubscribersContent"
+            permission="launchpad.View"
+            name="+snap-portlet-subscriber-content"
+            template="../templates/snap-portlet-subscribers-content.pt"/>
+
+        <browser:defaultView
+            for="lp.snappy.interfaces.snapsubscription.ISnapSubscription"
+            name="+index"/>
+        <browser:page
+            for="lp.snappy.interfaces.snapsubscription.ISnapSubscription"
+            class="lp.snappy.browser.snapsubscription.SnapSubscriptionEditView"
+            permission="launchpad.Edit"
+            name="+index"
+            template="../templates/snapsubscription-edit.pt"/>
+        <browser:page
+            for="lp.snappy.interfaces.snap.ISnap"
+            class="lp.snappy.browser.snapsubscription.SnapSubscriptionAddView"
+            permission="launchpad.AnyPerson"
+            name="+subscribe"
+            template="../../app/templates/generic-edit.pt"/>
+        <browser:page
+            for="lp.snappy.interfaces.snap.ISnap"
+            class="lp.snappy.browser.snapsubscription.SnapSubscriptionAddOtherView"
+            permission="launchpad.AnyPerson"
+            name="+addsubscriber"
+            template="../../app/templates/generic-edit.pt"/>
+        <browser:url
+            for="lp.snappy.interfaces.snapsubscription.ISnapSubscription"
+            path_expression="string:+subscription/${person/name}"
+            attribute_to_parent="snap"
+            rootsite="code"/>
+
+        <browser:page
             for="lp.code.interfaces.branch.IBranch"
             class="lp.snappy.browser.snap.SnapAddView"
             permission="launchpad.AnyPerson"
diff --git a/lib/lp/snappy/browser/snap.py b/lib/lp/snappy/browser/snap.py
index 7056519..5b388ee 100644
--- a/lib/lp/snappy/browser/snap.py
+++ b/lib/lp/snappy/browser/snap.py
@@ -64,6 +64,7 @@ from lp.buildmaster.interfaces.processor import IProcessorSet
 from lp.code.browser.widgets.gitref import GitRefWidget
 from lp.code.interfaces.gitref import IGitRef
 from lp.registry.enums import VCSType
+from lp.registry.interfaces.person import IPersonSet
 from lp.registry.interfaces.pocket import PackagePublishingPocket
 from lp.services.features import getFeatureFlag
 from lp.services.propertycache import cachedproperty
@@ -133,6 +134,13 @@ class SnapNavigation(WebhookTargetNavigationMixin, Navigation):
             return None
         return build
 
+    @stepthrough("+subscription")
+    def traverse_subscription(self, name):
+        """Traverses to an `ISnapSubscription`."""
+        person = getUtility(IPersonSet).getByName(name)
+        if person is not None:
+            return self.context.getSubscription(person)
+
 
 class SnapBreadcrumb(NameBreadcrumb):
 
@@ -187,12 +195,31 @@ class SnapContextMenu(ContextMenu):
 
     facet = 'overview'
 
-    links = ('request_builds',)
+    links = ('request_builds', 'add_subscriber', 'subscription')
 
     @enabled_with_permission('launchpad.Edit')
     def request_builds(self):
         return Link('+request-builds', 'Request builds', icon='add')
 
+    @enabled_with_permission("launchpad.AnyPerson")
+    def subscription(self):
+        if self.context.hasSubscription(self.user):
+            url = "+subscription/%s" % self.user.name
+            text = "Edit your subscription"
+            icon = "edit"
+        elif self.context.userCanBeSubscribed(self.user):
+            url = "+subscribe"
+            text = "Subscribe yourself"
+            icon = "add"
+        else:
+            return None
+        return Link(url, text, icon=icon)
+
+    @enabled_with_permission("launchpad.Edit")
+    def add_subscriber(self):
+        text = "Subscribe someone else"
+        return Link("+addsubscriber", text, icon="add")
+
 
 class SnapView(LaunchpadView):
     """Default view of a Snap."""
diff --git a/lib/lp/snappy/browser/snapsubscription.py b/lib/lp/snappy/browser/snapsubscription.py
new file mode 100644
index 0000000..84c1597
--- /dev/null
+++ b/lib/lp/snappy/browser/snapsubscription.py
@@ -0,0 +1,173 @@
+# Copyright 2020-2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Snap subscription views."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'SnapPortletSubscribersContent'
+]
+
+from zope.component._api import getUtility
+from zope.formlib.form import action
+
+from lp.app.browser.launchpadform import (
+    LaunchpadEditFormView,
+    LaunchpadFormView,
+    )
+from lp.registry.interfaces.person import IPersonSet
+from lp.services.webapp import (
+    canonical_url,
+    LaunchpadView,
+    )
+from lp.services.webapp.authorization import (
+    check_permission,
+    precache_permission_for_objects,
+    )
+from lp.snappy.interfaces.snapsubscription import ISnapSubscription
+
+
+class SnapPortletSubscribersContent(LaunchpadView):
+    """View for the contents for the subscribers portlet."""
+
+    def subscriptions(self):
+        """Return a decorated list of Snap recipe subscriptions."""
+
+        # Cache permissions so private subscribers can be rendered.
+        # The security adaptor will do the job also but we don't want or
+        # need the expense of running several complex SQL queries.
+        subscriptions = list(self.context.subscriptions)
+        person_ids = [sub.person.id for sub in subscriptions]
+        list(getUtility(IPersonSet).getPrecachedPersonsFromIDs(
+            person_ids, need_validity=True))
+        if self.user is not None:
+            subscribers = [
+                subscription.person for subscription in subscriptions]
+            precache_permission_for_objects(
+                self.request, "launchpad.LimitedView", subscribers)
+
+        visible_subscriptions = [
+            subscription for subscription in subscriptions
+            if check_permission("launchpad.LimitedView", subscription.person)]
+        return sorted(
+            visible_subscriptions,
+            key=lambda subscription: subscription.person.displayname)
+
+
+class RedirectToSnapMixin:
+    @property
+    def next_url(self):
+        url = canonical_url(self.snap)
+        # If the subscriber can no longer see the Snap recipe, redirect them
+        # away.
+        if not self.snap.visibleByUser(self.user):
+            url = canonical_url(self.snap.project)
+        return url
+
+    cancel_url = next_url
+
+
+class SnapSubscriptionEditView(RedirectToSnapMixin, LaunchpadEditFormView):
+    """The view for editing Snap recipe subscriptions."""
+    schema = ISnapSubscription
+    field_names = []
+
+    @property
+    def page_title(self):
+        return (
+            "Edit subscription to Snap recipe %s" %
+            self.snap.displayname)
+
+    @property
+    def label(self):
+        return (
+            "Edit subscription to Snap recipe for %s" %
+            self.person.displayname)
+
+    def initialize(self):
+        self.snap = self.context.snap
+        self.person = self.context.person
+        super(SnapSubscriptionEditView, self).initialize()
+
+    @action("Unsubscribe", name="unsubscribe")
+    def unsubscribe_action(self, action, data):
+        """Unsubscribe the team from the Snap recipe."""
+        self.snap.unsubscribe(self.person, self.user)
+        self.request.response.addNotification(
+            "%s has been unsubscribed from this Snap recipe."
+            % self.person.displayname)
+
+
+class _SnapSubscriptionCreationView(RedirectToSnapMixin, LaunchpadFormView):
+    """Contains the common functionality of the Add and Edit views."""
+
+    schema = ISnapSubscription
+    field_names = []
+
+    def initialize(self):
+        self.snap = self.context
+        super(_SnapSubscriptionCreationView, self).initialize()
+
+
+class SnapSubscriptionAddView(_SnapSubscriptionCreationView):
+
+    page_title = label = "Subscribe to Snap recipe"
+
+    @action("Subscribe")
+    def subscribe(self, action, data):
+        # To catch the stale post problem, check that the user is not
+        # subscribed before continuing.
+        if self.context.hasSubscription(self.user):
+            self.request.response.addNotification(
+                "You are already subscribed to this Snap recipe.")
+        else:
+            self.context.subscribe(self.user, self.user)
+
+            self.request.response.addNotification(
+                "You have subscribed to this Snap recipe.")
+
+
+class SnapSubscriptionAddOtherView(_SnapSubscriptionCreationView):
+    """View used to subscribe someone other than the current user."""
+
+    field_names = ["person"]
+    for_input = True
+
+    # Since we are subscribing other people, the current user
+    # is never considered subscribed.
+    user_is_subscribed = False
+
+    page_title = label = "Subscribe to Snap recipe"
+
+    def validate(self, data):
+        if "person" in data:
+            person = data["person"]
+            subscription = self.context.getSubscription(person)
+            if (subscription is None
+                    and not self.context.userCanBeSubscribed(person)):
+                self.setFieldError(
+                    "person",
+                    "Open and delegated teams cannot be subscribed to "
+                    "private Snap recipes.")
+
+    @action("Subscribe", name="subscribe_action")
+    def subscribe_action(self, action, data):
+        """Subscribe the specified user to the Snap recipe.
+
+        The user must be a member of a team in order to subscribe that team
+        to the Snap recipe. Launchpad Admins are special and they can
+        subscribe any team.
+        """
+        person = data["person"]
+        subscription = self.context.getSubscription(person)
+        if subscription is None:
+            self.context.subscribe(person, self.user)
+            self.request.response.addNotification(
+                "%s has been subscribed to this Snap recipe." %
+                person.displayname)
+        else:
+            self.request.response.addNotification(
+                "%s was already subscribed to this Snap recipe with." %
+                person.displayname)
diff --git a/lib/lp/snappy/configure.zcml b/lib/lp/snappy/configure.zcml
index a16c664..05529f8 100644
--- a/lib/lp/snappy/configure.zcml
+++ b/lib/lp/snappy/configure.zcml
@@ -1,4 +1,4 @@
-<!-- Copyright 2015-2019 Canonical Ltd.  This software is licensed under the
+<!-- Copyright 2015-2021 Canonical Ltd.  This software is licensed under the
      GNU Affero General Public License version 3 (see the file LICENSE).
 -->
 
@@ -42,6 +42,15 @@
         <allow interface="lp.snappy.interfaces.snap.ISnapSet" />
     </securedutility>
 
+    <!-- SnapSubscription -->
+
+    <class class="lp.snappy.model.snapsubscription.SnapSubscription">
+      <allow interface="lp.snappy.interfaces.snapsubscription.ISnapSubscription"/>
+      <require
+          permission="zope.Public"
+          set_schema="lp.snappy.interfaces.snapsubscription.ISnapSubscription"/>
+    </class>
+
     <!-- SnapBuildRequest -->
     <class class="lp.snappy.model.snap.SnapBuildRequest">
         <require
diff --git a/lib/lp/snappy/interfaces/snap.py b/lib/lp/snappy/interfaces/snap.py
index 687a605..9da7dc2 100644
--- a/lib/lp/snappy/interfaces/snap.py
+++ b/lib/lp/snappy/interfaces/snap.py
@@ -570,10 +570,25 @@ class ISnapView(Interface):
         # Really ISnapBuild, patched in lp.snappy.interfaces.webservice.
         value_type=Reference(schema=Interface), readonly=True)))
 
+    subscriptions = CollectionField(
+        title=_("SnapSubscriptions associated with this repository."),
+        readonly=True,
+        # Really IGitSubscription, patched in _schema_circular_imports.py.
+        value_type=Reference(Interface))
+
     subscribers = CollectionField(
-        title=_("Persons subscribed to this repository."),
+        title=_("Persons subscribed to this snap recipe."),
         readonly=True, value_type=Reference(IPerson))
 
+    def getSubscription(person):
+        """Returns the person's snap subscription for this snap recipe."""
+
+    def hasSubscription(person):
+        """Is this person subscribed to the snap recipe?"""
+
+    def userCanBeSubscribed(person):
+        """Checks if the given person can be subscribed to this snap recipe."""
+
     def visibleByUser(user):
         """Can the specified user see this snap recipe?"""
 
diff --git a/lib/lp/snappy/model/snap.py b/lib/lp/snappy/model/snap.py
index a1000ea..abfb293 100644
--- a/lib/lp/snappy/model/snap.py
+++ b/lib/lp/snappy/model/snap.py
@@ -1098,6 +1098,18 @@ class Snap(Storm, WebhookTargetMixin):
         order_by = Desc(SnapBuild.id)
         return self._getBuilds(filter_term, order_by)
 
+    @property
+    def subscriptions(self):
+        return Store.of(self).find(
+            SnapSubscription, SnapSubscription.snap == self)
+
+    @property
+    def subscribers(self):
+        return Store.of(self).find(
+            Person,
+            SnapSubscription.person_id == Person.id,
+            SnapSubscription.snap == self)
+
     def visibleByUser(self, user):
         """See `IGitRepository`."""
         if not self.private:
@@ -1115,7 +1127,11 @@ class Snap(Storm, WebhookTargetMixin):
             Snap.id == self.id,
             visibility_clause).is_empty()
 
-    def _getSubscription(self, person):
+    def hasSubscription(self, person):
+        """See `ISnap`."""
+        return self.getSubscription(person) is not None
+
+    def getSubscription(self, person):
         """Returns person's subscription to this snap recipe, or None if no
         subscription is available.
         """
@@ -1126,7 +1142,7 @@ class Snap(Storm, WebhookTargetMixin):
             SnapSubscription.person == person,
             SnapSubscription.snap == self).one()
 
-    def _userCanBeSubscribed(self, person):
+    def userCanBeSubscribed(self, person):
         """Checks if the given person can subscribe to this snap recipe."""
         return not (
             self.private and
@@ -1142,11 +1158,11 @@ class Snap(Storm, WebhookTargetMixin):
 
     def subscribe(self, person, subscribed_by):
         """See `ISnap`."""
-        if not self._userCanBeSubscribed(person):
+        if not self.userCanBeSubscribed(person):
             raise SubscriptionPrivacyViolation(
                 "Open and delegated teams cannot be subscribed to private "
                 "snap recipes.")
-        subscription = self._getSubscription(person)
+        subscription = self.getSubscription(person)
         if subscription is None:
             subscription = SnapSubscription(
                 person=person, snap=self, subscribed_by=subscribed_by)
@@ -1162,7 +1178,7 @@ class Snap(Storm, WebhookTargetMixin):
         service = getUtility(IService, "sharing")
         service.revokeAccessGrants(
             self.pillar, person, unsubscribed_by, snaps=[self])
-        subscription = self._getSubscription(person)
+        subscription = self.getSubscription(person)
         if (not ignore_permissions
                 and not subscription.canBeUnsubscribedByUser(unsubscribed_by)):
             raise UserCannotUnsubscribePerson(
diff --git a/lib/lp/snappy/templates/snap-index.pt b/lib/lp/snappy/templates/snap-index.pt
index 7d3083e..09eea5c 100644
--- a/lib/lp/snappy/templates/snap-index.pt
+++ b/lib/lp/snappy/templates/snap-index.pt
@@ -32,6 +32,7 @@
   <metal:side fill-slot="side">
     <div tal:replace="structure context/@@+portlet-privacy" />
     <div tal:replace="structure context/@@+global-actions"/>
+    <tal:subscribers replace="structure context/@@+portlet-subscribers" />
   </metal:side>
 
   <metal:heading fill-slot="heading">
diff --git a/lib/lp/snappy/templates/snap-portlet-subscribers-content.pt b/lib/lp/snappy/templates/snap-portlet-subscribers-content.pt
new file mode 100644
index 0000000..05495ff
--- /dev/null
+++ b/lib/lp/snappy/templates/snap-portlet-subscribers-content.pt
@@ -0,0 +1,31 @@
+<div
+  tal:omit-tag=""
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";>
+  <div class="section snap-subscribers">
+    <div
+      tal:condition="view/subscriptions"
+      tal:repeat="subscription view/subscriptions"
+      tal:attributes="id string:subscriber-${subscription/person/name}">
+        <a tal:condition="subscription/person/name|nothing"
+           tal:attributes="href subscription/person/fmt:url">
+
+          <tal:block replace="structure subscription/person/fmt:icon" />
+          <tal:block replace="subscription/person/fmt:displayname/fmt:shorten/20" />
+        </a>
+
+        <a tal:condition="subscription/required:launchpad.Edit"
+           tal:attributes="
+             href subscription/fmt:url;
+             title string:Edit subscription ${subscription/person/fmt:displayname};
+             id string:editsubscription-${subscription/person/name}">
+          <img class="editsub-icon" src="/@@/edit"
+            tal:attributes="id string:editsubscription-icon-${subscription/person/name}" />
+        </a>
+    </div>
+    <div id="none-subscribers" tal:condition="not:view/subscriptions">
+      No subscribers.
+    </div>
+  </div>
+</div>
diff --git a/lib/lp/snappy/templates/snap-portlet-subscribers.pt b/lib/lp/snappy/templates/snap-portlet-subscribers.pt
new file mode 100644
index 0000000..5f0dd60
--- /dev/null
+++ b/lib/lp/snappy/templates/snap-portlet-subscribers.pt
@@ -0,0 +1,29 @@
+<div
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  class="portlet" id="portlet-subscribers">
+  <div tal:define="context_menu view/context/menu:context">
+    <div>
+      <div class="section">
+        <div
+          tal:define="link context_menu/subscription"
+          tal:condition="link/enabled"
+          id="selfsubscriptioncontainer">
+          <a class="sprite add subscribe-self"
+             tal:attributes="href link/url"
+             tal:content="link/text" />
+        </div>
+        <div
+          tal:define="link context_menu/add_subscriber"
+          tal:condition="link/enabled"
+          tal:content="structure link/render" />
+      </div>
+    </div>
+
+    <h2>Subscribers</h2>
+    <div id="snap-subscribers-outer">
+      <div tal:replace="structure context/@@+snap-portlet-subscriber-content" />
+    </div>
+  </div>
+</div>
diff --git a/lib/lp/snappy/templates/snapsubscription-edit.pt b/lib/lp/snappy/templates/snapsubscription-edit.pt
new file mode 100644
index 0000000..f2d9d8c
--- /dev/null
+++ b/lib/lp/snappy/templates/snapsubscription-edit.pt
@@ -0,0 +1,25 @@
+<html
+  xmlns="http://www.w3.org/1999/xhtml";
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  metal:use-macro="view/macro:page/main_only"
+  i18n:domain="launchpad"
+>
+  <body>
+
+<div metal:fill-slot="main">
+
+  <div metal:use-macro="context/@@launchpad_form/form" >
+    <metal:extra fill-slot="extra_info">
+      <p class="documentDescription">
+        If you unsubscribe from a snap recipe it will no longer show up on
+        your personal pages.
+      </p>
+    </metal:extra>
+  </div>
+
+</div>
+
+</body>
+</html>
diff --git a/lib/lp/snappy/tests/test_snap.py b/lib/lp/snappy/tests/test_snap.py
index d089a46..0eb242f 100644
--- a/lib/lp/snappy/tests/test_snap.py
+++ b/lib/lp/snappy/tests/test_snap.py
@@ -1350,9 +1350,6 @@ 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)
@@ -1389,20 +1386,18 @@ 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)))
+            self.assertThat(snap.getSubscription(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))
+            self.assertIsNone(snap.getSubscription(person))
 
     def test_reconcile_set_public(self):
         owner = self.factory.makePerson()
@@ -1413,7 +1408,7 @@ class TestSnapVisibility(TestCaseWithFactory):
             snap.subscribe(another_user, snap.owner)
             self.assertEqual(1, self.getSnapGrants(snap, another_user).count())
             self.assertThat(
-                self.getSnapSubscription(snap, another_user),
+                snap.getSubscription(another_user),
                 MatchesStructure(
                     person=Equals(another_user),
                     snap=Equals(snap),
@@ -1423,7 +1418,7 @@ class TestSnapVisibility(TestCaseWithFactory):
             snap.information_type = InformationType.PUBLIC
             self.assertEqual(0, self.getSnapGrants(snap, another_user).count())
             self.assertThat(
-                self.getSnapSubscription(snap, another_user),
+                snap.getSubscription(another_user),
                 MatchesStructure(
                     person=Equals(another_user),
                     snap=Equals(snap),
@@ -1448,7 +1443,7 @@ class TestSnapVisibility(TestCaseWithFactory):
             self.assertTrue(snap.visibleByUser(another_person))
             self.assertEqual(1, self.getSnapGrants(snap).count())
             self.assertThat(
-                self.getSnapSubscription(snap, another_person),
+                snap.getSubscription(another_person),
                 MatchesStructure(
                     person=Equals(another_person),
                     snap=Equals(snap),
@@ -1459,7 +1454,7 @@ class TestSnapVisibility(TestCaseWithFactory):
             self.assertTrue(snap.visibleByUser(another_person))
             self.assertEqual(1, self.getSnapGrants(snap).count())
             self.assertThat(
-                self.getSnapSubscription(snap, another_person),
+                snap.getSubscription(another_person),
                 MatchesStructure(
                     person=Equals(another_person),
                     snap=Equals(snap),

Follow ups