← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~pappacena/launchpad:ocirecipe-subscription-ui into launchpad:master

 

Thiago F. Pappacena has proposed merging ~pappacena/launchpad:ocirecipe-subscription-ui into launchpad:master with ~pappacena/launchpad:ocirecipe-subscription as a prerequisite.

Commit message:
OCI recipe subscription UI flow

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~pappacena/launchpad/+git/launchpad/+merge/399750
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~pappacena/launchpad:ocirecipe-subscription-ui into launchpad:master.
diff --git a/lib/lp/oci/browser/configure.zcml b/lib/lp/oci/browser/configure.zcml
index 6c254fb..53db0d2 100644
--- a/lib/lp/oci/browser/configure.zcml
+++ b/lib/lp/oci/browser/configure.zcml
@@ -1,4 +1,4 @@
-<!-- Copyright 2020 Canonical Ltd.  This software is licensed under the
+<!-- Copyright 2020-2021 Canonical Ltd.  This software is licensed under the
      GNU Affero General Public License version 3 (see the file LICENSE).
 -->
 
@@ -129,5 +129,44 @@
             for="lp.oci.interfaces.ocipushrule.IOCIPushRule"
             path_expression="string:+push-rule/${id}"
             attribute_to_parent="recipe" />
+
+        <browser:page
+            for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
+            permission="launchpad.View"
+            name="+portlet-subscribers"
+            template="../templates/ocirecipe-portlet-subscribers.pt"/>
+        <browser:page
+            for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
+            class="lp.oci.browser.ocirecipesubscription.OCIRecipePortletSubscribersContent"
+            permission="launchpad.View"
+            name="+ocirecipe-portlet-subscriber-content"
+            template="../templates/ocirecipe-portlet-subscribers-content.pt"/>
+
+        <browser:defaultView
+            for="lp.oci.interfaces.ocirecipesubscription.IOCIRecipeSubscription"
+            name="+index"/>
+        <browser:page
+            for="lp.oci.interfaces.ocirecipesubscription.IOCIRecipeSubscription"
+            class="lp.oci.browser.ocirecipesubscription.OCIRecipeSubscriptionEditView"
+            permission="launchpad.Edit"
+            name="+index"
+            template="../templates/ocirecipesubscription-edit.pt"/>
+        <browser:page
+            for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
+            class="lp.oci.browser.ocirecipesubscription.OCIRecipeSubscriptionAddView"
+            permission="launchpad.AnyPerson"
+            name="+subscribe"
+            template="../../app/templates/generic-edit.pt"/>
+        <browser:page
+            for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
+            class="lp.oci.browser.ocirecipesubscription.OCIRecipeSubscriptionAddOtherView"
+            permission="launchpad.AnyPerson"
+            name="+addsubscriber"
+            template="../../app/templates/generic-edit.pt"/>
+        <browser:url
+            for="lp.oci.interfaces.ocirecipesubscription.IOCIRecipeSubscription"
+            path_expression="string:+subscription/${person/name}"
+            attribute_to_parent="recipe"
+            rootsite="code"/>
     </facet>
 </configure>
diff --git a/lib/lp/oci/browser/ocirecipe.py b/lib/lp/oci/browser/ocirecipe.py
index 3b79cec..801b005 100644
--- a/lib/lp/oci/browser/ocirecipe.py
+++ b/lib/lp/oci/browser/ocirecipe.py
@@ -42,6 +42,7 @@ from zope.schema import (
     TextLine,
     ValidationError,
     )
+from zope.security.interfaces import Unauthorized
 
 from lp.app.browser.launchpadform import (
     action,
@@ -76,6 +77,7 @@ from lp.oci.interfaces.ociregistrycredentials import (
     OCIRegistryCredentialsAlreadyExist,
     user_can_edit_credentials_for_owner,
     )
+from lp.registry.interfaces.person import IPersonSet
 from lp.services.features import getFeatureFlag
 from lp.services.propertycache import cachedproperty
 from lp.services.webapp import (
@@ -121,6 +123,13 @@ class OCIRecipeNavigation(WebhookTargetNavigationMixin, Navigation):
         id = int(id)
         return getUtility(IOCIPushRuleSet).getByID(id)
 
+    @stepthrough("+subscription")
+    def traverse_subscription(self, name):
+        """Traverses to an `IOCIRecipeSubscription`."""
+        person = getUtility(IPersonSet).getByName(name)
+        if person is not None:
+            return self.context.getSubscription(person)
+
 
 class OCIRecipeBreadcrumb(NameBreadcrumb):
 
@@ -164,7 +173,8 @@ class OCIRecipeContextMenu(ContextMenu):
 
     facet = 'overview'
 
-    links = ('request_builds', 'edit_push_rules')
+    links = ('request_builds', 'edit_push_rules',
+             'add_subscriber', 'subscription')
 
     @enabled_with_permission('launchpad.Edit')
     def request_builds(self):
@@ -175,6 +185,23 @@ class OCIRecipeContextMenu(ContextMenu):
         return Link(
             '+edit-push-rules', 'Edit push rules', icon='edit')
 
+    @enabled_with_permission("launchpad.AnyPerson")
+    def subscription(self):
+        if self.context.getSubscription(self.user) is not None:
+            url = "+subscription/%s" % self.user.name
+            text = "Edit your subscription"
+            icon = "edit"
+        else:
+            url = "+subscribe"
+            text = "Subscribe yourself"
+            icon = "add"
+        return Link(url, text, icon=icon)
+
+    @enabled_with_permission("launchpad.AnyPerson")
+    def add_subscriber(self):
+        text = "Subscribe someone else"
+        return Link("+addsubscriber", text, icon="add")
+
 
 class OCIProjectRecipesView(LaunchpadView):
     """Default view for the list of OCI recipes of an OCI project."""
@@ -233,6 +260,13 @@ class OCIRecipeView(LaunchpadView):
         return len(self.push_rules) > 0
 
     @property
+    def user_can_see_source(self):
+        try:
+            return self.context.git_ref.repository.visibleByUser(self.user)
+        except Unauthorized:
+            return False
+
+    @property
     def person_picker(self):
         field = copy_field(
             IOCIRecipe["owner"],
diff --git a/lib/lp/oci/browser/ocirecipesubscription.py b/lib/lp/oci/browser/ocirecipesubscription.py
new file mode 100644
index 0000000..2b83696
--- /dev/null
+++ b/lib/lp/oci/browser/ocirecipesubscription.py
@@ -0,0 +1,176 @@
+# Copyright 2020-2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""OCI recipe subscription views."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'OCIRecipePortletSubscribersContent'
+]
+
+from lp.oci.interfaces.ocirecipesubscription import IOCIRecipeSubscription
+from zope.component import getUtility
+from zope.formlib.form import action
+from zope.security.interfaces import ForbiddenAttribute
+
+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,
+    )
+
+
+class OCIRecipePortletSubscribersContent(LaunchpadView):
+    """View for the contents for the subscribers portlet."""
+
+    def subscriptions(self):
+        """Return a decorated list of OCI 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 RedirectToOCIRecipeMixin:
+    @property
+    def next_url(self):
+        if self.ocirecipe.visibleByUser(self.user):
+            return canonical_url(self.ocirecipe)
+        # If the subscriber can no longer see the OCI recipe, tries to
+        # redirect to the pillar page.
+        try:
+            pillar = self.ocirecipe.pillar
+            if pillar is not None and pillar.userCanLimitedView(self.user):
+                return canonical_url(pillar)
+        except ForbiddenAttribute:
+            pass
+        # If not possible, redirect user back to its own page.
+        return canonical_url(self.user)
+
+    cancel_url = next_url
+
+
+class OCIRecipeSubscriptionEditView(RedirectToOCIRecipeMixin,
+                                    LaunchpadEditFormView):
+    """The view for editing OCI recipe subscriptions."""
+    schema = IOCIRecipeSubscription
+    field_names = []
+
+    @property
+    def page_title(self):
+        return (
+            "Edit subscription to OCI recipe %s" %
+            self.ocirecipe.displayname)
+
+    @property
+    def label(self):
+        return (
+            "Edit subscription to OCI recipe for %s" %
+            self.person.displayname)
+
+    def initialize(self):
+        self.ocirecipe = self.context.recipe
+        self.person = self.context.person
+        super(OCIRecipeSubscriptionEditView, self).initialize()
+
+    @action("Unsubscribe", name="unsubscribe")
+    def unsubscribe_action(self, action, data):
+        """Unsubscribe the team from the OCI recipe."""
+        self.ocirecipe.unsubscribe(self.person, self.user)
+        self.request.response.addNotification(
+            "%s has been unsubscribed from this OCI recipe."
+            % self.person.displayname)
+
+
+class _OCIRecipeSubscriptionCreationView(RedirectToOCIRecipeMixin,
+                                         LaunchpadFormView):
+    """Contains the common functionality of the Add and Edit views."""
+
+    schema = IOCIRecipeSubscription
+    field_names = []
+
+    def initialize(self):
+        self.ocirecipe = self.context
+        super(_OCIRecipeSubscriptionCreationView, self).initialize()
+
+
+class OCIRecipeSubscriptionAddView(_OCIRecipeSubscriptionCreationView):
+
+    page_title = label = "Subscribe to OCI 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.getSubscription(self.user) is not None:
+            self.request.response.addNotification(
+                "You are already subscribed to this OCI recipe.")
+        else:
+            self.context.subscribe(self.user, self.user)
+            self.request.response.addNotification(
+                "You have subscribed to this OCI recipe.")
+
+
+class OCIRecipeSubscriptionAddOtherView(_OCIRecipeSubscriptionCreationView):
+    """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 OCI 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 OCI recipes.")
+
+    @action("Subscribe", name="subscribe_action")
+    def subscribe_action(self, action, data):
+        """Subscribe the specified user to the OCI recipe."""
+        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 OCI recipe." %
+                person.displayname)
+        else:
+            self.request.response.addNotification(
+                "%s was already subscribed to this OCI recipe." %
+                person.displayname)
diff --git a/lib/lp/oci/browser/tests/test_ocirecipe.py b/lib/lp/oci/browser/tests/test_ocirecipe.py
index 490a0e4..4890679 100644
--- a/lib/lp/oci/browser/tests/test_ocirecipe.py
+++ b/lib/lp/oci/browser/tests/test_ocirecipe.py
@@ -34,6 +34,7 @@ from zope.security.proxy import removeSecurityProxy
 from zope.testbrowser.browser import LinkNotFoundError
 
 from lp.app.browser.tales import GitRepositoryFormatterAPI
+from lp.app.enums import InformationType
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.buildmaster.enums import BuildStatus
 from lp.buildmaster.interfaces.processor import IProcessorSet
@@ -1240,6 +1241,49 @@ class TestOCIRecipeView(BaseTestOCIRecipeView):
             """ % (oci_project_name, oci_project_display, build_path),
             self.getMainText(build.recipe))
 
+    def test_index_for_subscriber_without_git_repo_access(self):
+        oci_project = self.factory.makeOCIProject(
+            pillar=self.distroseries.distribution)
+        oci_project_name = oci_project.name
+        oci_project_display = oci_project.display_name
+        [ref] = self.factory.makeGitRefs(
+            owner=self.person, target=self.person, name="recipe-repository",
+            paths=["refs/heads/master"],
+            information_type=InformationType.PRIVATESECURITY)
+        recipe = self.makeOCIRecipe(
+            oci_project=oci_project, git_ref=ref, build_file="Dockerfile",
+            information_type=InformationType.PRIVATESECURITY)
+        with admin_logged_in():
+            build_path = recipe.build_path
+            build = self.makeBuild(
+                recipe=recipe, status=BuildStatus.FULLYBUILT,
+                duration=timedelta(minutes=30))
+
+        # Subscribe a user.
+        subscriber = self.factory.makePerson()
+        with person_logged_in(self.person):
+            recipe.subscribe(subscriber, self.person)
+
+        main_text = self.getMainText(build.recipe, user=subscriber)
+        self.assertTextMatchesExpressionIgnoreWhitespace("""\
+            %s OCI project
+            recipe-name
+            .*
+            OCI recipe information
+            Owner: Test Person
+            OCI project: %s
+            Source: &lt;redacted&gt;
+            Build file path: Dockerfile
+            Build context directory: %s
+            Build schedule: Built on request
+            Official recipe:
+            No
+            Latest builds
+            Status When complete Architecture
+            Successfully built 30 minutes ago 386
+            """ % (oci_project_name, oci_project_display, build_path),
+            main_text)
+
     def test_index_success_with_buildlog(self):
         # The build log is shown if it is there.
         build = self.makeBuild(
diff --git a/lib/lp/oci/browser/tests/test_ocirecipesubscription.py b/lib/lp/oci/browser/tests/test_ocirecipesubscription.py
new file mode 100644
index 0000000..468b735
--- /dev/null
+++ b/lib/lp/oci/browser/tests/test_ocirecipesubscription.py
@@ -0,0 +1,268 @@
+# Copyright 2015-2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test OCI recipe subscription views."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+
+from fixtures import FakeLogger
+from zope.security.interfaces import Unauthorized
+
+from lp.app.enums import InformationType
+from lp.oci.tests.helpers import OCIConfigHelperMixin
+from lp.registry.enums import BranchSharingPolicy
+from lp.services.webapp import canonical_url
+from lp.testing import (
+    admin_logged_in,
+    BrowserTestCase,
+    person_logged_in,
+    )
+from lp.testing.layers import DatabaseFunctionalLayer
+from lp.testing.pages import (
+    extract_text,
+    find_main_content,
+    find_tag_by_id,
+    find_tags_by_class,
+    )
+
+
+class BaseTestOCIRecipeView(OCIConfigHelperMixin, BrowserTestCase):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(BaseTestOCIRecipeView, self).setUp()
+        self.setConfig()
+        self.useFixture(FakeLogger())
+        self.person = self.factory.makePerson(name='recipe-owner')
+
+    def makeOCIRecipe(self, oci_project=None, **kwargs):
+        [ref] = self.factory.makeGitRefs(
+            owner=self.person, target=self.person, name="recipe-repository",
+            paths=["refs/heads/master"])
+        if oci_project is None:
+            project = self.factory.makeProduct(
+                owner=self.person, registrant=self.person)
+            oci_project = self.factory.makeOCIProject(
+                registrant=self.person, pillar=project,
+                ociprojectname='my-oci-project')
+        return self.factory.makeOCIRecipe(
+            registrant=self.person, owner=self.person, name="recipe-name",
+            git_ref=ref, oci_project=oci_project, **kwargs)
+
+    def getSubscriptionPortletText(self, browser):
+        return extract_text(
+            find_tag_by_id(browser.contents, 'portlet-subscribers'))
+
+    def extractMainText(self, browser):
+        return extract_text(find_main_content(browser.contents))
+
+    def extractInfoMessageContent(self, browser):
+        return extract_text(
+            find_tags_by_class(browser.contents, 'informational message')[0])
+
+
+class TestPublicOCIRecipeSubscriptionViews(BaseTestOCIRecipeView):
+
+    def test_subscribe_self(self):
+        recipe = self.makeOCIRecipe()
+        another_user = self.factory.makePerson(name="another-user")
+        browser = self.getViewBrowser(recipe, user=another_user)
+        self.assertTextMatchesExpressionIgnoreWhitespace(r"""
+            Subscribe yourself
+            Subscribe someone else
+            Subscribers
+            Recipe-owner
+            """, self.getSubscriptionPortletText(browser))
+
+        # Go to "subscribe myself" page, and click the button.
+        browser = self.getViewBrowser(
+            recipe, view_name="+subscribe", user=another_user)
+        self.assertTextMatchesExpressionIgnoreWhitespace(r"""
+            Subscribe to OCI recipe
+            my-oci-project OCI project
+            recipe-name
+            Subscribe to OCI recipe or Cancel
+            """, self.extractMainText(browser))
+        browser.getControl("Subscribe").click()
+
+        # We should be redirected back to OCI page.
+        with admin_logged_in():
+            self.assertEqual(canonical_url(recipe), browser.url)
+
+        # And the new user should be listed in the subscribers list.
+        self.assertTextMatchesExpressionIgnoreWhitespace(r"""
+            Edit your subscription
+            Subscribe someone else
+            Subscribers
+            Another-user
+            Recipe-owner
+            """, self.getSubscriptionPortletText(browser))
+
+    def test_unsubscribe_self(self):
+        recipe = self.makeOCIRecipe()
+        another_user = self.factory.makePerson(name="another-user")
+        with person_logged_in(recipe.owner):
+            recipe.subscribe(another_user, recipe.owner)
+        subscription = recipe.getSubscription(another_user)
+        browser = self.getViewBrowser(subscription, user=another_user)
+        self.assertTextMatchesExpressionIgnoreWhitespace(r"""
+            Edit subscription to OCI recipe for Another-user
+            my-oci-project OCI project
+            recipe-name
+            If you unsubscribe from an OCI recipe it will no longer show up on
+            your personal pages. or Cancel
+            """, self.extractMainText(browser))
+        browser.getControl("Unsubscribe").click()
+        self.assertTextMatchesExpressionIgnoreWhitespace(r"""
+            Another-user has been unsubscribed from this OCI recipe.
+            """, self.extractInfoMessageContent(browser))
+        with person_logged_in(self.person):
+            self.assertIsNone(recipe.getSubscription(another_user))
+
+    def test_subscribe_someone_else(self):
+        recipe = self.makeOCIRecipe()
+        another_user = self.factory.makePerson(name="another-user")
+        browser = self.getViewBrowser(recipe, user=recipe.owner)
+        self.assertTextMatchesExpressionIgnoreWhitespace(r"""
+            Edit your subscription
+            Subscribe someone else
+            Subscribers
+            Recipe-owner
+            """, self.getSubscriptionPortletText(browser))
+
+        # Go to "subscribe" page, and click the button.
+        browser = self.getViewBrowser(
+            recipe, view_name="+addsubscriber", user=another_user)
+        self.assertTextMatchesExpressionIgnoreWhitespace(r"""
+            Subscribe to OCI recipe
+            my-oci-project OCI project
+            recipe-name
+            Subscribe to OCI recipe
+            Person:
+            .*
+            The person subscribed to the related OCI recipe.
+            or
+            Cancel
+            """, self.extractMainText(browser))
+        browser.getControl(name="field.person").value = 'another-user'
+        browser.getControl("Subscribe").click()
+
+        # We should be redirected back to OCI recipe page.
+        with admin_logged_in():
+            self.assertEqual(canonical_url(recipe), browser.url)
+
+        # And the new user should be listed in the subscribers list.
+        self.assertTextMatchesExpressionIgnoreWhitespace(r"""
+            Edit your subscription
+            Subscribe someone else
+            Subscribers
+            Another-user
+            Recipe-owner
+            """, self.getSubscriptionPortletText(browser))
+
+    def test_unsubscribe_someone_else(self):
+        recipe = self.makeOCIRecipe()
+        another_user = self.factory.makePerson(name="another-user")
+        with person_logged_in(recipe.owner):
+            recipe.subscribe(another_user, recipe.owner)
+
+        subscription = recipe.getSubscription(another_user)
+        browser = self.getViewBrowser(subscription, user=recipe.owner)
+        self.assertTextMatchesExpressionIgnoreWhitespace(r"""
+            Edit subscription to OCI recipe for Another-user
+            my-oci-project OCI project
+            recipe-name
+            If you unsubscribe from an OCI recipe it will no longer show up on
+            your personal pages. or Cancel
+            """, self.extractMainText(browser))
+        browser.getControl("Unsubscribe").click()
+        self.assertTextMatchesExpressionIgnoreWhitespace(r"""
+            Another-user has been unsubscribed from this OCI recipe.
+            """, self.extractInfoMessageContent(browser))
+        with person_logged_in(self.person):
+            self.assertIsNone(recipe.getSubscription(another_user))
+
+
+class TestPrivateOCIRecipeSubscriptionViews(BaseTestOCIRecipeView):
+
+    def makePrivateOCIRecipe(self, **kwargs):
+        project = self.factory.makeProduct(
+            owner=self.person, registrant=self.person,
+            information_type=InformationType.PROPRIETARY,
+            branch_sharing_policy=BranchSharingPolicy.PROPRIETARY)
+        oci_project = self.factory.makeOCIProject(
+            ociprojectname='my-oci-project', pillar=project)
+        return self.makeOCIRecipe(
+            information_type=InformationType.PROPRIETARY,
+            oci_project=oci_project)
+
+    def test_cannot_subscribe_to_private_snap(self):
+        recipe = self.makePrivateOCIRecipe()
+        another_user = self.factory.makePerson(name="another-user")
+        # Unsubscribed user should not see the OCI recipe page.
+        self.assertRaises(
+            Unauthorized, self.getViewBrowser, recipe, user=another_user)
+        # Nor the subscribe pages.
+        self.assertRaises(
+            Unauthorized, self.getViewBrowser,
+            recipe, view_name="+subscribe", user=another_user)
+        self.assertRaises(
+            Unauthorized, self.getViewBrowser,
+            recipe, view_name="+addsubscriber", user=another_user)
+
+    def test_recipe_owner_can_subscribe_someone_to_private_recipe(self):
+        recipe = self.makePrivateOCIRecipe()
+        another_user = self.factory.makePerson(name="another-user")
+
+        # Go to "subscribe" page, and click the button.
+        browser = self.getViewBrowser(
+            recipe, view_name="+addsubscriber", user=self.person)
+        self.assertTextMatchesExpressionIgnoreWhitespace(r"""
+            Subscribe to OCI recipe
+            my-oci-project OCI project
+            recipe-name
+            Subscribe to OCI recipe
+            Person:
+            .*
+            The person subscribed to the related OCI recipe.
+            or
+            Cancel
+            """, self.extractMainText(browser))
+        browser.getControl(name="field.person").value = 'another-user'
+        browser.getControl("Subscribe").click()
+
+        # Now the new user should be listed in the subscribers list,
+        # and have access to the recipe page.
+        browser = self.getViewBrowser(recipe, user=another_user)
+        self.assertTextMatchesExpressionIgnoreWhitespace(r"""
+            Edit your subscription
+            Subscribe someone else
+            Subscribers
+            Another-user
+            Recipe-owner
+            """, self.getSubscriptionPortletText(browser))
+
+    def test_unsubscribe_self(self):
+        recipe = self.makePrivateOCIRecipe()
+        another_user = self.factory.makePerson(name="another-user")
+        with person_logged_in(self.person):
+            recipe.subscribe(another_user, self.person)
+            subscription = recipe.getSubscription(another_user)
+        browser = self.getViewBrowser(subscription, user=another_user)
+        self.assertTextMatchesExpressionIgnoreWhitespace(r"""
+            Edit subscription to OCI recipe for Another-user
+            my-oci-project OCI project
+            recipe-name
+            If you unsubscribe from an OCI  recipe it will no longer show up on
+            your personal pages. or Cancel
+            """, self.extractMainText(browser))
+        browser.getControl("Unsubscribe").click()
+        self.assertTextMatchesExpressionIgnoreWhitespace(r"""
+            Another-user has been unsubscribed from this OCI recipe.
+            """, self.extractInfoMessageContent(browser))
+        with person_logged_in(self.person):
+            self.assertIsNone(recipe.getSubscription(another_user))
diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py
index b87e904..7ab9bcb 100644
--- a/lib/lp/oci/interfaces/ocirecipe.py
+++ b/lib/lp/oci/interfaces/ocirecipe.py
@@ -293,6 +293,10 @@ class IOCIRecipeView(Interface):
         description=_("Use the credentials on a Distribution for "
                       "registry upload"))
 
+    subscriptions = CollectionField(
+        title=_("OCIRecipeSubscriptions associated with this snap recipe."),
+        readonly=True, value_type=Reference(Interface))
+
     subscribers = CollectionField(
         title=_("Persons subscribed to this snap recipe."),
         readonly=True, value_type=Reference(IPerson))
@@ -332,6 +336,9 @@ class IOCIRecipeView(Interface):
         """Get an OCIRecipeBuildRequest object for the given job_id.
         """
 
+    def userCanBeSubscribed(user):
+        """Checks if a user can be subscribed to the current OCI recipe."""
+
     def visibleByUser(user):
         """Can the specified user see this snap recipe?"""
 
diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py
index 38dd476..774e9f0 100644
--- a/lib/lp/oci/model/ocirecipe.py
+++ b/lib/lp/oci/model/ocirecipe.py
@@ -313,6 +313,12 @@ class OCIRecipe(Storm, WebhookTargetMixin):
             person.anyone_can_join())
 
     @property
+    def subscriptions(self):
+        return Store.of(self).find(
+            OCIRecipeSubscription,
+            OCIRecipeSubscription.recipe == self)
+
+    @property
     def subscribers(self):
         return Store.of(self).find(
             Person,
diff --git a/lib/lp/oci/templates/ocirecipe-index.pt b/lib/lp/oci/templates/ocirecipe-index.pt
index b575488..315973c 100644
--- a/lib/lp/oci/templates/ocirecipe-index.pt
+++ b/lib/lp/oci/templates/ocirecipe-index.pt
@@ -19,6 +19,7 @@
 
   <metal:side fill-slot="side">
     <div tal:replace="structure context/@@+global-actions"/>
+    <tal:subscribers replace="structure context/@@+portlet-subscribers" />
   </metal:side>
 
   <metal:heading fill-slot="heading">
@@ -45,7 +46,10 @@
       </dl>
       <dl id="source" tal:define="source context/git_ref">
         <dt>Source:</dt>
-        <dd>
+        <dd tal:condition="not: view/user_can_see_source">
+            <span class="sprite private">&lt;redacted&gt;</span>
+        </dd>
+        <dd tal:condition="view/user_can_see_source">
           <a tal:replace="structure source/fmt:link"/>
           <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
         </dd>
diff --git a/lib/lp/oci/templates/ocirecipe-portlet-privacy.pt b/lib/lp/oci/templates/ocirecipe-portlet-privacy.pt
new file mode 100644
index 0000000..a8d3dd5
--- /dev/null
+++ b/lib/lp/oci/templates/ocirecipe-portlet-privacy.pt
@@ -0,0 +1,16 @@
+<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";
+  id="privacy"
+  tal:attributes="
+    class python: path('context/private') and 'portlet private' or 'portlet public'
+  "
+>
+    <span tal:attributes="class python: path('context/private') and 'sprite private' or 'sprite public'"
+      >This OCI recipe contains <strong
+        tal:content="python: path('context/private') and 'Private' or 'Public'"
+      ></strong> information</span>
+</div>
+
+
diff --git a/lib/lp/oci/templates/ocirecipe-portlet-subscribers-content.pt b/lib/lp/oci/templates/ocirecipe-portlet-subscribers-content.pt
new file mode 100644
index 0000000..b1b8cf6
--- /dev/null
+++ b/lib/lp/oci/templates/ocirecipe-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 ocirecipe-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/oci/templates/ocirecipe-portlet-subscribers.pt b/lib/lp/oci/templates/ocirecipe-portlet-subscribers.pt
new file mode 100644
index 0000000..8288778
--- /dev/null
+++ b/lib/lp/oci/templates/ocirecipe-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="ocirecipe-subscribers-outer">
+      <div tal:replace="structure context/@@+ocirecipe-portlet-subscriber-content" />
+    </div>
+  </div>
+</div>
diff --git a/lib/lp/oci/templates/ocirecipesubscription-edit.pt b/lib/lp/oci/templates/ocirecipesubscription-edit.pt
new file mode 100644
index 0000000..810a24d
--- /dev/null
+++ b/lib/lp/oci/templates/ocirecipesubscription-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 an OCI recipe it will no longer show up on
+        your personal pages.
+      </p>
+    </metal:extra>
+  </div>
+
+</div>
+
+</body>
+</html>
diff --git a/lib/lp/oci/tests/test_ocirecipe.py b/lib/lp/oci/tests/test_ocirecipe.py
index 91bf20f..0e7e56b 100644
--- a/lib/lp/oci/tests/test_ocirecipe.py
+++ b/lib/lp/oci/tests/test_ocirecipe.py
@@ -902,11 +902,10 @@ class TestOCIRecipeAccessControl(TestCaseWithFactory, OCIConfigHelperMixin):
             registrant=owner, owner=owner,
             information_type=InformationType.USERDATA)
         other_person = self.factory.makePerson()
+        with person_logged_in(other_person):
+            self.assertRaises(Unauthorized, getattr, recipe, 'subscribe')
         with person_logged_in(owner):
             recipe.subscribe(other_person, owner)
-        with person_logged_in(other_person):
-            self.assertRaises(
-                Unauthorized, recipe.subscribe, other_person, other_person)
 
     def test_private_is_invisible_by_default(self):
         owner = self.factory.makePerson()
diff --git a/lib/lp/registry/interfaces/product.py b/lib/lp/registry/interfaces/product.py
index 9eb614e..e5ac436 100644
--- a/lib/lp/registry/interfaces/product.py
+++ b/lib/lp/registry/interfaces/product.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).
 
 """Interfaces including and related to IProduct."""
@@ -476,6 +476,10 @@ class IProductLimitedView(IHasIcon, IHasLogo, IHasOwner, ILaunchpadUsage):
             description=_("The project title. Should be just a few words."),
             readonly=True))
 
+    def getOCIProject(name):
+        """Return a `OCIProject` with the given name for this product, or None.
+        """
+
 
 class IProductView(
     ICanGetMilestonesDirectly, IHasAppointedDriver, IHasBranches,
@@ -802,10 +806,6 @@ class IProductView(
         """Checks if the given person can manage OCI projects for this
         Product."""
 
-    def getOCIProject(name):
-        """Return a `OCIProject` with the given name for this product, or None.
-        """
-
     def getPackage(distroseries):
         """Return a package in that distroseries for this product."""
 

Follow ups