launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #26680
[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: <redacted>
+ 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"><redacted></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