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

Proposed by Thiago F. Pappacena
Status: Merged
Approved by: Thiago F. Pappacena
Approved revision: 3f2e509121d0ff1843be9a63966e8f5d4620a420
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~pappacena/launchpad:ocirecipe-subscription-ui
Merge into: launchpad:master
Prerequisite: ~pappacena/launchpad:ocirecipe-subscription
Diff against target: 874 lines (+684/-8)
13 files modified
lib/lp/oci/browser/configure.zcml (+40/-1)
lib/lp/oci/browser/ocirecipe.py (+35/-1)
lib/lp/oci/browser/ocirecipesubscription.py (+176/-0)
lib/lp/oci/browser/tests/test_ocirecipe.py (+44/-0)
lib/lp/oci/browser/tests/test_ocirecipesubscription.py (+268/-0)
lib/lp/oci/interfaces/ocirecipe.py (+7/-0)
lib/lp/oci/model/ocirecipe.py (+6/-0)
lib/lp/oci/templates/ocirecipe-index.pt (+5/-1)
lib/lp/oci/templates/ocirecipe-portlet-privacy.pt (+13/-0)
lib/lp/oci/templates/ocirecipe-portlet-subscribers-content.pt (+31/-0)
lib/lp/oci/templates/ocirecipe-portlet-subscribers.pt (+29/-0)
lib/lp/oci/templates/ocirecipesubscription-edit.pt (+25/-0)
lib/lp/registry/interfaces/product.py (+5/-5)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+399750@code.launchpad.net

Commit message

OCI recipe subscription UI flow

To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) :
review: Approve
Revision history for this message
Thiago F. Pappacena (pappacena) :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/oci/browser/configure.zcml b/lib/lp/oci/browser/configure.zcml
2index 6c254fb..53db0d2 100644
3--- a/lib/lp/oci/browser/configure.zcml
4+++ b/lib/lp/oci/browser/configure.zcml
5@@ -1,4 +1,4 @@
6-<!-- Copyright 2020 Canonical Ltd. This software is licensed under the
7+<!-- Copyright 2020-2021 Canonical Ltd. This software is licensed under the
8 GNU Affero General Public License version 3 (see the file LICENSE).
9 -->
10
11@@ -129,5 +129,44 @@
12 for="lp.oci.interfaces.ocipushrule.IOCIPushRule"
13 path_expression="string:+push-rule/${id}"
14 attribute_to_parent="recipe" />
15+
16+ <browser:page
17+ for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
18+ permission="launchpad.View"
19+ name="+portlet-subscribers"
20+ template="../templates/ocirecipe-portlet-subscribers.pt"/>
21+ <browser:page
22+ for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
23+ class="lp.oci.browser.ocirecipesubscription.OCIRecipePortletSubscribersContent"
24+ permission="launchpad.View"
25+ name="+ocirecipe-portlet-subscriber-content"
26+ template="../templates/ocirecipe-portlet-subscribers-content.pt"/>
27+
28+ <browser:defaultView
29+ for="lp.oci.interfaces.ocirecipesubscription.IOCIRecipeSubscription"
30+ name="+index"/>
31+ <browser:page
32+ for="lp.oci.interfaces.ocirecipesubscription.IOCIRecipeSubscription"
33+ class="lp.oci.browser.ocirecipesubscription.OCIRecipeSubscriptionEditView"
34+ permission="launchpad.Edit"
35+ name="+index"
36+ template="../templates/ocirecipesubscription-edit.pt"/>
37+ <browser:page
38+ for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
39+ class="lp.oci.browser.ocirecipesubscription.OCIRecipeSubscriptionAddView"
40+ permission="launchpad.AnyPerson"
41+ name="+subscribe"
42+ template="../../app/templates/generic-edit.pt"/>
43+ <browser:page
44+ for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
45+ class="lp.oci.browser.ocirecipesubscription.OCIRecipeSubscriptionAddOtherView"
46+ permission="launchpad.AnyPerson"
47+ name="+addsubscriber"
48+ template="../../app/templates/generic-edit.pt"/>
49+ <browser:url
50+ for="lp.oci.interfaces.ocirecipesubscription.IOCIRecipeSubscription"
51+ path_expression="string:+subscription/${person/name}"
52+ attribute_to_parent="recipe"
53+ rootsite="code"/>
54 </facet>
55 </configure>
56diff --git a/lib/lp/oci/browser/ocirecipe.py b/lib/lp/oci/browser/ocirecipe.py
57index 3b79cec..801b005 100644
58--- a/lib/lp/oci/browser/ocirecipe.py
59+++ b/lib/lp/oci/browser/ocirecipe.py
60@@ -42,6 +42,7 @@ from zope.schema import (
61 TextLine,
62 ValidationError,
63 )
64+from zope.security.interfaces import Unauthorized
65
66 from lp.app.browser.launchpadform import (
67 action,
68@@ -76,6 +77,7 @@ from lp.oci.interfaces.ociregistrycredentials import (
69 OCIRegistryCredentialsAlreadyExist,
70 user_can_edit_credentials_for_owner,
71 )
72+from lp.registry.interfaces.person import IPersonSet
73 from lp.services.features import getFeatureFlag
74 from lp.services.propertycache import cachedproperty
75 from lp.services.webapp import (
76@@ -121,6 +123,13 @@ class OCIRecipeNavigation(WebhookTargetNavigationMixin, Navigation):
77 id = int(id)
78 return getUtility(IOCIPushRuleSet).getByID(id)
79
80+ @stepthrough("+subscription")
81+ def traverse_subscription(self, name):
82+ """Traverses to an `IOCIRecipeSubscription`."""
83+ person = getUtility(IPersonSet).getByName(name)
84+ if person is not None:
85+ return self.context.getSubscription(person)
86+
87
88 class OCIRecipeBreadcrumb(NameBreadcrumb):
89
90@@ -164,7 +173,8 @@ class OCIRecipeContextMenu(ContextMenu):
91
92 facet = 'overview'
93
94- links = ('request_builds', 'edit_push_rules')
95+ links = ('request_builds', 'edit_push_rules',
96+ 'add_subscriber', 'subscription')
97
98 @enabled_with_permission('launchpad.Edit')
99 def request_builds(self):
100@@ -175,6 +185,23 @@ class OCIRecipeContextMenu(ContextMenu):
101 return Link(
102 '+edit-push-rules', 'Edit push rules', icon='edit')
103
104+ @enabled_with_permission("launchpad.AnyPerson")
105+ def subscription(self):
106+ if self.context.getSubscription(self.user) is not None:
107+ url = "+subscription/%s" % self.user.name
108+ text = "Edit your subscription"
109+ icon = "edit"
110+ else:
111+ url = "+subscribe"
112+ text = "Subscribe yourself"
113+ icon = "add"
114+ return Link(url, text, icon=icon)
115+
116+ @enabled_with_permission("launchpad.AnyPerson")
117+ def add_subscriber(self):
118+ text = "Subscribe someone else"
119+ return Link("+addsubscriber", text, icon="add")
120+
121
122 class OCIProjectRecipesView(LaunchpadView):
123 """Default view for the list of OCI recipes of an OCI project."""
124@@ -233,6 +260,13 @@ class OCIRecipeView(LaunchpadView):
125 return len(self.push_rules) > 0
126
127 @property
128+ def user_can_see_source(self):
129+ try:
130+ return self.context.git_ref.repository.visibleByUser(self.user)
131+ except Unauthorized:
132+ return False
133+
134+ @property
135 def person_picker(self):
136 field = copy_field(
137 IOCIRecipe["owner"],
138diff --git a/lib/lp/oci/browser/ocirecipesubscription.py b/lib/lp/oci/browser/ocirecipesubscription.py
139new file mode 100644
140index 0000000..c51d303
141--- /dev/null
142+++ b/lib/lp/oci/browser/ocirecipesubscription.py
143@@ -0,0 +1,176 @@
144+# Copyright 2020-2021 Canonical Ltd. This software is licensed under the
145+# GNU Affero General Public License version 3 (see the file LICENSE).
146+
147+"""OCI recipe subscription views."""
148+
149+from __future__ import absolute_import, print_function, unicode_literals
150+
151+__metaclass__ = type
152+__all__ = [
153+ 'OCIRecipePortletSubscribersContent'
154+]
155+
156+from zope.component import getUtility
157+from zope.formlib.form import action
158+from zope.security.interfaces import ForbiddenAttribute
159+
160+from lp.app.browser.launchpadform import (
161+ LaunchpadEditFormView,
162+ LaunchpadFormView,
163+ )
164+from lp.oci.interfaces.ocirecipesubscription import IOCIRecipeSubscription
165+from lp.registry.interfaces.person import IPersonSet
166+from lp.services.webapp import (
167+ canonical_url,
168+ LaunchpadView,
169+ )
170+from lp.services.webapp.authorization import (
171+ check_permission,
172+ precache_permission_for_objects,
173+ )
174+
175+
176+class OCIRecipePortletSubscribersContent(LaunchpadView):
177+ """View for the contents for the subscribers portlet."""
178+
179+ def subscriptions(self):
180+ """Return a decorated list of OCI recipe subscriptions."""
181+
182+ # Cache permissions so private subscribers can be rendered.
183+ # The security adaptor will do the job also but we don't want or
184+ # need the expense of running several complex SQL queries.
185+ subscriptions = list(self.context.subscriptions)
186+ person_ids = [sub.person.id for sub in subscriptions]
187+ list(getUtility(IPersonSet).getPrecachedPersonsFromIDs(
188+ person_ids, need_validity=True))
189+ if self.user is not None:
190+ subscribers = [
191+ subscription.person for subscription in subscriptions]
192+ precache_permission_for_objects(
193+ self.request, "launchpad.LimitedView", subscribers)
194+
195+ visible_subscriptions = [
196+ subscription for subscription in subscriptions
197+ if check_permission("launchpad.LimitedView", subscription.person)]
198+ return sorted(
199+ visible_subscriptions,
200+ key=lambda subscription: subscription.person.displayname)
201+
202+
203+class RedirectToOCIRecipeMixin:
204+ @property
205+ def next_url(self):
206+ if self.ocirecipe.visibleByUser(self.user):
207+ return canonical_url(self.ocirecipe)
208+ # If the subscriber can no longer see the OCI recipe, tries to
209+ # redirect to the pillar page.
210+ try:
211+ pillar = self.ocirecipe.pillar
212+ if pillar is not None and pillar.userCanLimitedView(self.user):
213+ return canonical_url(pillar)
214+ except ForbiddenAttribute:
215+ pass
216+ # If not possible, redirect user back to its own page.
217+ return canonical_url(self.user)
218+
219+ cancel_url = next_url
220+
221+
222+class OCIRecipeSubscriptionEditView(RedirectToOCIRecipeMixin,
223+ LaunchpadEditFormView):
224+ """The view for editing OCI recipe subscriptions."""
225+ schema = IOCIRecipeSubscription
226+ field_names = []
227+
228+ @property
229+ def page_title(self):
230+ return (
231+ "Edit subscription to OCI recipe %s" %
232+ self.ocirecipe.displayname)
233+
234+ @property
235+ def label(self):
236+ return (
237+ "Edit subscription to OCI recipe for %s" %
238+ self.person.displayname)
239+
240+ def initialize(self):
241+ self.ocirecipe = self.context.recipe
242+ self.person = self.context.person
243+ super(OCIRecipeSubscriptionEditView, self).initialize()
244+
245+ @action("Unsubscribe", name="unsubscribe")
246+ def unsubscribe_action(self, action, data):
247+ """Unsubscribe the team from the OCI recipe."""
248+ self.ocirecipe.unsubscribe(self.person, self.user)
249+ self.request.response.addNotification(
250+ "%s has been unsubscribed from this OCI recipe."
251+ % self.person.displayname)
252+
253+
254+class _OCIRecipeSubscriptionCreationView(RedirectToOCIRecipeMixin,
255+ LaunchpadFormView):
256+ """Contains the common functionality of the Add and Edit views."""
257+
258+ schema = IOCIRecipeSubscription
259+ field_names = []
260+
261+ def initialize(self):
262+ self.ocirecipe = self.context
263+ super(_OCIRecipeSubscriptionCreationView, self).initialize()
264+
265+
266+class OCIRecipeSubscriptionAddView(_OCIRecipeSubscriptionCreationView):
267+
268+ page_title = label = "Subscribe to OCI recipe"
269+
270+ @action("Subscribe")
271+ def subscribe(self, action, data):
272+ # To catch the stale post problem, check that the user is not
273+ # subscribed before continuing.
274+ if self.context.getSubscription(self.user) is not None:
275+ self.request.response.addNotification(
276+ "You are already subscribed to this OCI recipe.")
277+ else:
278+ self.context.subscribe(self.user, self.user)
279+ self.request.response.addNotification(
280+ "You have subscribed to this OCI recipe.")
281+
282+
283+class OCIRecipeSubscriptionAddOtherView(_OCIRecipeSubscriptionCreationView):
284+ """View used to subscribe someone other than the current user."""
285+
286+ field_names = ["person"]
287+ for_input = True
288+
289+ # Since we are subscribing other people, the current user
290+ # is never considered subscribed.
291+ user_is_subscribed = False
292+
293+ page_title = label = "Subscribe to OCI recipe"
294+
295+ def validate(self, data):
296+ if "person" in data:
297+ person = data["person"]
298+ subscription = self.context.getSubscription(person)
299+ if (subscription is None
300+ and not self.context.userCanBeSubscribed(person)):
301+ self.setFieldError(
302+ "person",
303+ "Open and delegated teams cannot be subscribed to "
304+ "private OCI recipes.")
305+
306+ @action("Subscribe", name="subscribe_action")
307+ def subscribe_action(self, action, data):
308+ """Subscribe the specified user to the OCI recipe."""
309+ person = data["person"]
310+ subscription = self.context.getSubscription(person)
311+ if subscription is None:
312+ self.context.subscribe(person, self.user)
313+ self.request.response.addNotification(
314+ "%s has been subscribed to this OCI recipe." %
315+ person.displayname)
316+ else:
317+ self.request.response.addNotification(
318+ "%s was already subscribed to this OCI recipe." %
319+ person.displayname)
320diff --git a/lib/lp/oci/browser/tests/test_ocirecipe.py b/lib/lp/oci/browser/tests/test_ocirecipe.py
321index b06af3f..0fc6b1d 100644
322--- a/lib/lp/oci/browser/tests/test_ocirecipe.py
323+++ b/lib/lp/oci/browser/tests/test_ocirecipe.py
324@@ -34,6 +34,7 @@ from zope.security.proxy import removeSecurityProxy
325 from zope.testbrowser.browser import LinkNotFoundError
326
327 from lp.app.browser.tales import GitRepositoryFormatterAPI
328+from lp.app.enums import InformationType
329 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
330 from lp.buildmaster.enums import BuildStatus
331 from lp.buildmaster.interfaces.processor import IProcessorSet
332@@ -1240,6 +1241,49 @@ class TestOCIRecipeView(BaseTestOCIRecipeView):
333 """ % (oci_project_name, oci_project_display, build_path),
334 self.getMainText(build.recipe))
335
336+ def test_index_for_subscriber_without_git_repo_access(self):
337+ oci_project = self.factory.makeOCIProject(
338+ pillar=self.distroseries.distribution)
339+ oci_project_name = oci_project.name
340+ oci_project_display = oci_project.display_name
341+ [ref] = self.factory.makeGitRefs(
342+ owner=self.person, target=self.person, name="recipe-repository",
343+ paths=["refs/heads/master"],
344+ information_type=InformationType.PRIVATESECURITY)
345+ recipe = self.makeOCIRecipe(
346+ oci_project=oci_project, git_ref=ref, build_file="Dockerfile",
347+ information_type=InformationType.PRIVATESECURITY)
348+ with admin_logged_in():
349+ build_path = recipe.build_path
350+ build = self.makeBuild(
351+ recipe=recipe, status=BuildStatus.FULLYBUILT,
352+ duration=timedelta(minutes=30))
353+
354+ # Subscribe a user.
355+ subscriber = self.factory.makePerson()
356+ with person_logged_in(self.person):
357+ recipe.subscribe(subscriber, self.person)
358+
359+ main_text = self.getMainText(build.recipe, user=subscriber)
360+ self.assertTextMatchesExpressionIgnoreWhitespace("""\
361+ %s OCI project
362+ recipe-name
363+ .*
364+ OCI recipe information
365+ Owner: Test Person
366+ OCI project: %s
367+ Source: &lt;redacted&gt;
368+ Build file path: Dockerfile
369+ Build context directory: %s
370+ Build schedule: Built on request
371+ Official recipe:
372+ No
373+ Latest builds
374+ Status When complete Architecture
375+ Successfully built 30 minutes ago 386
376+ """ % (oci_project_name, oci_project_display, build_path),
377+ main_text)
378+
379 def test_index_success_with_buildlog(self):
380 # The build log is shown if it is there.
381 build = self.makeBuild(
382diff --git a/lib/lp/oci/browser/tests/test_ocirecipesubscription.py b/lib/lp/oci/browser/tests/test_ocirecipesubscription.py
383new file mode 100644
384index 0000000..468b735
385--- /dev/null
386+++ b/lib/lp/oci/browser/tests/test_ocirecipesubscription.py
387@@ -0,0 +1,268 @@
388+# Copyright 2015-2021 Canonical Ltd. This software is licensed under the
389+# GNU Affero General Public License version 3 (see the file LICENSE).
390+
391+"""Test OCI recipe subscription views."""
392+
393+from __future__ import absolute_import, print_function, unicode_literals
394+
395+__metaclass__ = type
396+
397+
398+from fixtures import FakeLogger
399+from zope.security.interfaces import Unauthorized
400+
401+from lp.app.enums import InformationType
402+from lp.oci.tests.helpers import OCIConfigHelperMixin
403+from lp.registry.enums import BranchSharingPolicy
404+from lp.services.webapp import canonical_url
405+from lp.testing import (
406+ admin_logged_in,
407+ BrowserTestCase,
408+ person_logged_in,
409+ )
410+from lp.testing.layers import DatabaseFunctionalLayer
411+from lp.testing.pages import (
412+ extract_text,
413+ find_main_content,
414+ find_tag_by_id,
415+ find_tags_by_class,
416+ )
417+
418+
419+class BaseTestOCIRecipeView(OCIConfigHelperMixin, BrowserTestCase):
420+
421+ layer = DatabaseFunctionalLayer
422+
423+ def setUp(self):
424+ super(BaseTestOCIRecipeView, self).setUp()
425+ self.setConfig()
426+ self.useFixture(FakeLogger())
427+ self.person = self.factory.makePerson(name='recipe-owner')
428+
429+ def makeOCIRecipe(self, oci_project=None, **kwargs):
430+ [ref] = self.factory.makeGitRefs(
431+ owner=self.person, target=self.person, name="recipe-repository",
432+ paths=["refs/heads/master"])
433+ if oci_project is None:
434+ project = self.factory.makeProduct(
435+ owner=self.person, registrant=self.person)
436+ oci_project = self.factory.makeOCIProject(
437+ registrant=self.person, pillar=project,
438+ ociprojectname='my-oci-project')
439+ return self.factory.makeOCIRecipe(
440+ registrant=self.person, owner=self.person, name="recipe-name",
441+ git_ref=ref, oci_project=oci_project, **kwargs)
442+
443+ def getSubscriptionPortletText(self, browser):
444+ return extract_text(
445+ find_tag_by_id(browser.contents, 'portlet-subscribers'))
446+
447+ def extractMainText(self, browser):
448+ return extract_text(find_main_content(browser.contents))
449+
450+ def extractInfoMessageContent(self, browser):
451+ return extract_text(
452+ find_tags_by_class(browser.contents, 'informational message')[0])
453+
454+
455+class TestPublicOCIRecipeSubscriptionViews(BaseTestOCIRecipeView):
456+
457+ def test_subscribe_self(self):
458+ recipe = self.makeOCIRecipe()
459+ another_user = self.factory.makePerson(name="another-user")
460+ browser = self.getViewBrowser(recipe, user=another_user)
461+ self.assertTextMatchesExpressionIgnoreWhitespace(r"""
462+ Subscribe yourself
463+ Subscribe someone else
464+ Subscribers
465+ Recipe-owner
466+ """, self.getSubscriptionPortletText(browser))
467+
468+ # Go to "subscribe myself" page, and click the button.
469+ browser = self.getViewBrowser(
470+ recipe, view_name="+subscribe", user=another_user)
471+ self.assertTextMatchesExpressionIgnoreWhitespace(r"""
472+ Subscribe to OCI recipe
473+ my-oci-project OCI project
474+ recipe-name
475+ Subscribe to OCI recipe or Cancel
476+ """, self.extractMainText(browser))
477+ browser.getControl("Subscribe").click()
478+
479+ # We should be redirected back to OCI page.
480+ with admin_logged_in():
481+ self.assertEqual(canonical_url(recipe), browser.url)
482+
483+ # And the new user should be listed in the subscribers list.
484+ self.assertTextMatchesExpressionIgnoreWhitespace(r"""
485+ Edit your subscription
486+ Subscribe someone else
487+ Subscribers
488+ Another-user
489+ Recipe-owner
490+ """, self.getSubscriptionPortletText(browser))
491+
492+ def test_unsubscribe_self(self):
493+ recipe = self.makeOCIRecipe()
494+ another_user = self.factory.makePerson(name="another-user")
495+ with person_logged_in(recipe.owner):
496+ recipe.subscribe(another_user, recipe.owner)
497+ subscription = recipe.getSubscription(another_user)
498+ browser = self.getViewBrowser(subscription, user=another_user)
499+ self.assertTextMatchesExpressionIgnoreWhitespace(r"""
500+ Edit subscription to OCI recipe for Another-user
501+ my-oci-project OCI project
502+ recipe-name
503+ If you unsubscribe from an OCI recipe it will no longer show up on
504+ your personal pages. or Cancel
505+ """, self.extractMainText(browser))
506+ browser.getControl("Unsubscribe").click()
507+ self.assertTextMatchesExpressionIgnoreWhitespace(r"""
508+ Another-user has been unsubscribed from this OCI recipe.
509+ """, self.extractInfoMessageContent(browser))
510+ with person_logged_in(self.person):
511+ self.assertIsNone(recipe.getSubscription(another_user))
512+
513+ def test_subscribe_someone_else(self):
514+ recipe = self.makeOCIRecipe()
515+ another_user = self.factory.makePerson(name="another-user")
516+ browser = self.getViewBrowser(recipe, user=recipe.owner)
517+ self.assertTextMatchesExpressionIgnoreWhitespace(r"""
518+ Edit your subscription
519+ Subscribe someone else
520+ Subscribers
521+ Recipe-owner
522+ """, self.getSubscriptionPortletText(browser))
523+
524+ # Go to "subscribe" page, and click the button.
525+ browser = self.getViewBrowser(
526+ recipe, view_name="+addsubscriber", user=another_user)
527+ self.assertTextMatchesExpressionIgnoreWhitespace(r"""
528+ Subscribe to OCI recipe
529+ my-oci-project OCI project
530+ recipe-name
531+ Subscribe to OCI recipe
532+ Person:
533+ .*
534+ The person subscribed to the related OCI recipe.
535+ or
536+ Cancel
537+ """, self.extractMainText(browser))
538+ browser.getControl(name="field.person").value = 'another-user'
539+ browser.getControl("Subscribe").click()
540+
541+ # We should be redirected back to OCI recipe page.
542+ with admin_logged_in():
543+ self.assertEqual(canonical_url(recipe), browser.url)
544+
545+ # And the new user should be listed in the subscribers list.
546+ self.assertTextMatchesExpressionIgnoreWhitespace(r"""
547+ Edit your subscription
548+ Subscribe someone else
549+ Subscribers
550+ Another-user
551+ Recipe-owner
552+ """, self.getSubscriptionPortletText(browser))
553+
554+ def test_unsubscribe_someone_else(self):
555+ recipe = self.makeOCIRecipe()
556+ another_user = self.factory.makePerson(name="another-user")
557+ with person_logged_in(recipe.owner):
558+ recipe.subscribe(another_user, recipe.owner)
559+
560+ subscription = recipe.getSubscription(another_user)
561+ browser = self.getViewBrowser(subscription, user=recipe.owner)
562+ self.assertTextMatchesExpressionIgnoreWhitespace(r"""
563+ Edit subscription to OCI recipe for Another-user
564+ my-oci-project OCI project
565+ recipe-name
566+ If you unsubscribe from an OCI recipe it will no longer show up on
567+ your personal pages. or Cancel
568+ """, self.extractMainText(browser))
569+ browser.getControl("Unsubscribe").click()
570+ self.assertTextMatchesExpressionIgnoreWhitespace(r"""
571+ Another-user has been unsubscribed from this OCI recipe.
572+ """, self.extractInfoMessageContent(browser))
573+ with person_logged_in(self.person):
574+ self.assertIsNone(recipe.getSubscription(another_user))
575+
576+
577+class TestPrivateOCIRecipeSubscriptionViews(BaseTestOCIRecipeView):
578+
579+ def makePrivateOCIRecipe(self, **kwargs):
580+ project = self.factory.makeProduct(
581+ owner=self.person, registrant=self.person,
582+ information_type=InformationType.PROPRIETARY,
583+ branch_sharing_policy=BranchSharingPolicy.PROPRIETARY)
584+ oci_project = self.factory.makeOCIProject(
585+ ociprojectname='my-oci-project', pillar=project)
586+ return self.makeOCIRecipe(
587+ information_type=InformationType.PROPRIETARY,
588+ oci_project=oci_project)
589+
590+ def test_cannot_subscribe_to_private_snap(self):
591+ recipe = self.makePrivateOCIRecipe()
592+ another_user = self.factory.makePerson(name="another-user")
593+ # Unsubscribed user should not see the OCI recipe page.
594+ self.assertRaises(
595+ Unauthorized, self.getViewBrowser, recipe, user=another_user)
596+ # Nor the subscribe pages.
597+ self.assertRaises(
598+ Unauthorized, self.getViewBrowser,
599+ recipe, view_name="+subscribe", user=another_user)
600+ self.assertRaises(
601+ Unauthorized, self.getViewBrowser,
602+ recipe, view_name="+addsubscriber", user=another_user)
603+
604+ def test_recipe_owner_can_subscribe_someone_to_private_recipe(self):
605+ recipe = self.makePrivateOCIRecipe()
606+ another_user = self.factory.makePerson(name="another-user")
607+
608+ # Go to "subscribe" page, and click the button.
609+ browser = self.getViewBrowser(
610+ recipe, view_name="+addsubscriber", user=self.person)
611+ self.assertTextMatchesExpressionIgnoreWhitespace(r"""
612+ Subscribe to OCI recipe
613+ my-oci-project OCI project
614+ recipe-name
615+ Subscribe to OCI recipe
616+ Person:
617+ .*
618+ The person subscribed to the related OCI recipe.
619+ or
620+ Cancel
621+ """, self.extractMainText(browser))
622+ browser.getControl(name="field.person").value = 'another-user'
623+ browser.getControl("Subscribe").click()
624+
625+ # Now the new user should be listed in the subscribers list,
626+ # and have access to the recipe page.
627+ browser = self.getViewBrowser(recipe, user=another_user)
628+ self.assertTextMatchesExpressionIgnoreWhitespace(r"""
629+ Edit your subscription
630+ Subscribe someone else
631+ Subscribers
632+ Another-user
633+ Recipe-owner
634+ """, self.getSubscriptionPortletText(browser))
635+
636+ def test_unsubscribe_self(self):
637+ recipe = self.makePrivateOCIRecipe()
638+ another_user = self.factory.makePerson(name="another-user")
639+ with person_logged_in(self.person):
640+ recipe.subscribe(another_user, self.person)
641+ subscription = recipe.getSubscription(another_user)
642+ browser = self.getViewBrowser(subscription, user=another_user)
643+ self.assertTextMatchesExpressionIgnoreWhitespace(r"""
644+ Edit subscription to OCI recipe for Another-user
645+ my-oci-project OCI project
646+ recipe-name
647+ If you unsubscribe from an OCI recipe it will no longer show up on
648+ your personal pages. or Cancel
649+ """, self.extractMainText(browser))
650+ browser.getControl("Unsubscribe").click()
651+ self.assertTextMatchesExpressionIgnoreWhitespace(r"""
652+ Another-user has been unsubscribed from this OCI recipe.
653+ """, self.extractInfoMessageContent(browser))
654+ with person_logged_in(self.person):
655+ self.assertIsNone(recipe.getSubscription(another_user))
656diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py
657index 0b006d2..4575a34 100644
658--- a/lib/lp/oci/interfaces/ocirecipe.py
659+++ b/lib/lp/oci/interfaces/ocirecipe.py
660@@ -306,6 +306,10 @@ class IOCIRecipeView(Interface):
661 description=_("Use the credentials on a Distribution for "
662 "registry upload"))
663
664+ subscriptions = CollectionField(
665+ title=_("OCIRecipeSubscriptions associated with this OCI recipe."),
666+ readonly=True, value_type=Reference(Interface))
667+
668 subscribers = CollectionField(
669 title=_("Persons subscribed to this snap recipe."),
670 readonly=True, value_type=Reference(IPerson))
671@@ -345,6 +349,9 @@ class IOCIRecipeView(Interface):
672 """Get an OCIRecipeBuildRequest object for the given job_id.
673 """
674
675+ def userCanBeSubscribed(user):
676+ """Checks if a user can be subscribed to the current OCI recipe."""
677+
678 def visibleByUser(user):
679 """Can the specified user see this snap recipe?"""
680
681diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py
682index 622e26e..ac668f4 100644
683--- a/lib/lp/oci/model/ocirecipe.py
684+++ b/lib/lp/oci/model/ocirecipe.py
685@@ -347,6 +347,12 @@ class OCIRecipe(Storm, WebhookTargetMixin):
686 person.anyone_can_join())
687
688 @property
689+ def subscriptions(self):
690+ return Store.of(self).find(
691+ OCIRecipeSubscription,
692+ OCIRecipeSubscription.recipe == self)
693+
694+ @property
695 def subscribers(self):
696 return Store.of(self).find(
697 Person,
698diff --git a/lib/lp/oci/templates/ocirecipe-index.pt b/lib/lp/oci/templates/ocirecipe-index.pt
699index b575488..315973c 100644
700--- a/lib/lp/oci/templates/ocirecipe-index.pt
701+++ b/lib/lp/oci/templates/ocirecipe-index.pt
702@@ -19,6 +19,7 @@
703
704 <metal:side fill-slot="side">
705 <div tal:replace="structure context/@@+global-actions"/>
706+ <tal:subscribers replace="structure context/@@+portlet-subscribers" />
707 </metal:side>
708
709 <metal:heading fill-slot="heading">
710@@ -45,7 +46,10 @@
711 </dl>
712 <dl id="source" tal:define="source context/git_ref">
713 <dt>Source:</dt>
714- <dd>
715+ <dd tal:condition="not: view/user_can_see_source">
716+ <span class="sprite private">&lt;redacted&gt;</span>
717+ </dd>
718+ <dd tal:condition="view/user_can_see_source">
719 <a tal:replace="structure source/fmt:link"/>
720 <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
721 </dd>
722diff --git a/lib/lp/oci/templates/ocirecipe-portlet-privacy.pt b/lib/lp/oci/templates/ocirecipe-portlet-privacy.pt
723new file mode 100644
724index 0000000..ecb5cba
725--- /dev/null
726+++ b/lib/lp/oci/templates/ocirecipe-portlet-privacy.pt
727@@ -0,0 +1,13 @@
728+<div
729+ xmlns:tal="http://xml.zope.org/namespaces/tal"
730+ xmlns:metal="http://xml.zope.org/namespaces/metal"
731+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
732+ id="privacy"
733+ tal:attributes="class python: 'portlet private' if context.private else 'portlet public'">
734+ <span tal:attributes="class python: 'sprite private' if context.private else 'sprite public'">
735+ This OCI recipe contains
736+ <strong tal:content="python: 'Private' if context.private else 'Public'" />
737+ information</span>
738+</div>
739+
740+
741diff --git a/lib/lp/oci/templates/ocirecipe-portlet-subscribers-content.pt b/lib/lp/oci/templates/ocirecipe-portlet-subscribers-content.pt
742new file mode 100644
743index 0000000..b1b8cf6
744--- /dev/null
745+++ b/lib/lp/oci/templates/ocirecipe-portlet-subscribers-content.pt
746@@ -0,0 +1,31 @@
747+<div
748+ tal:omit-tag=""
749+ xmlns:tal="http://xml.zope.org/namespaces/tal"
750+ xmlns:metal="http://xml.zope.org/namespaces/metal"
751+ xmlns:i18n="http://xml.zope.org/namespaces/i18n">
752+ <div class="section ocirecipe-subscribers">
753+ <div
754+ tal:condition="view/subscriptions"
755+ tal:repeat="subscription view/subscriptions"
756+ tal:attributes="id string:subscriber-${subscription/person/name}">
757+ <a tal:condition="subscription/person/name|nothing"
758+ tal:attributes="href subscription/person/fmt:url">
759+
760+ <tal:block replace="structure subscription/person/fmt:icon" />
761+ <tal:block replace="subscription/person/fmt:displayname/fmt:shorten/20" />
762+ </a>
763+
764+ <a tal:condition="subscription/required:launchpad.Edit"
765+ tal:attributes="
766+ href subscription/fmt:url;
767+ title string:Edit subscription ${subscription/person/fmt:displayname};
768+ id string:editsubscription-${subscription/person/name}">
769+ <img class="editsub-icon" src="/@@/edit"
770+ tal:attributes="id string:editsubscription-icon-${subscription/person/name}" />
771+ </a>
772+ </div>
773+ <div id="none-subscribers" tal:condition="not:view/subscriptions">
774+ No subscribers.
775+ </div>
776+ </div>
777+</div>
778diff --git a/lib/lp/oci/templates/ocirecipe-portlet-subscribers.pt b/lib/lp/oci/templates/ocirecipe-portlet-subscribers.pt
779new file mode 100644
780index 0000000..8288778
781--- /dev/null
782+++ b/lib/lp/oci/templates/ocirecipe-portlet-subscribers.pt
783@@ -0,0 +1,29 @@
784+<div
785+ xmlns:tal="http://xml.zope.org/namespaces/tal"
786+ xmlns:metal="http://xml.zope.org/namespaces/metal"
787+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
788+ class="portlet" id="portlet-subscribers">
789+ <div tal:define="context_menu view/context/menu:context">
790+ <div>
791+ <div class="section">
792+ <div
793+ tal:define="link context_menu/subscription"
794+ tal:condition="link/enabled"
795+ id="selfsubscriptioncontainer">
796+ <a class="sprite add subscribe-self"
797+ tal:attributes="href link/url"
798+ tal:content="link/text" />
799+ </div>
800+ <div
801+ tal:define="link context_menu/add_subscriber"
802+ tal:condition="link/enabled"
803+ tal:content="structure link/render" />
804+ </div>
805+ </div>
806+
807+ <h2>Subscribers</h2>
808+ <div id="ocirecipe-subscribers-outer">
809+ <div tal:replace="structure context/@@+ocirecipe-portlet-subscriber-content" />
810+ </div>
811+ </div>
812+</div>
813diff --git a/lib/lp/oci/templates/ocirecipesubscription-edit.pt b/lib/lp/oci/templates/ocirecipesubscription-edit.pt
814new file mode 100644
815index 0000000..810a24d
816--- /dev/null
817+++ b/lib/lp/oci/templates/ocirecipesubscription-edit.pt
818@@ -0,0 +1,25 @@
819+<html
820+ xmlns="http://www.w3.org/1999/xhtml"
821+ xmlns:tal="http://xml.zope.org/namespaces/tal"
822+ xmlns:metal="http://xml.zope.org/namespaces/metal"
823+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
824+ metal:use-macro="view/macro:page/main_only"
825+ i18n:domain="launchpad"
826+>
827+ <body>
828+
829+<div metal:fill-slot="main">
830+
831+ <div metal:use-macro="context/@@launchpad_form/form" >
832+ <metal:extra fill-slot="extra_info">
833+ <p class="documentDescription">
834+ If you unsubscribe from an OCI recipe it will no longer show up on
835+ your personal pages.
836+ </p>
837+ </metal:extra>
838+ </div>
839+
840+</div>
841+
842+</body>
843+</html>
844diff --git a/lib/lp/registry/interfaces/product.py b/lib/lp/registry/interfaces/product.py
845index 9eb614e..e5ac436 100644
846--- a/lib/lp/registry/interfaces/product.py
847+++ b/lib/lp/registry/interfaces/product.py
848@@ -1,4 +1,4 @@
849-# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
850+# Copyright 2009-2021 Canonical Ltd. This software is licensed under the
851 # GNU Affero General Public License version 3 (see the file LICENSE).
852
853 """Interfaces including and related to IProduct."""
854@@ -476,6 +476,10 @@ class IProductLimitedView(IHasIcon, IHasLogo, IHasOwner, ILaunchpadUsage):
855 description=_("The project title. Should be just a few words."),
856 readonly=True))
857
858+ def getOCIProject(name):
859+ """Return a `OCIProject` with the given name for this product, or None.
860+ """
861+
862
863 class IProductView(
864 ICanGetMilestonesDirectly, IHasAppointedDriver, IHasBranches,
865@@ -802,10 +806,6 @@ class IProductView(
866 """Checks if the given person can manage OCI projects for this
867 Product."""
868
869- def getOCIProject(name):
870- """Return a `OCIProject` with the given name for this product, or None.
871- """
872-
873 def getPackage(distroseries):
874 """Return a package in that distroseries for this product."""
875