Merge ~pappacena/launchpad:ocirecipe-subscription-ui into launchpad:master
- Git
- lp:~pappacena/launchpad
- ocirecipe-subscription-ui
- Merge into 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) |
Related bugs: |
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
Description of the change
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
1 | diff --git a/lib/lp/oci/browser/configure.zcml b/lib/lp/oci/browser/configure.zcml |
2 | index 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> |
56 | diff --git a/lib/lp/oci/browser/ocirecipe.py b/lib/lp/oci/browser/ocirecipe.py |
57 | index 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"], |
138 | diff --git a/lib/lp/oci/browser/ocirecipesubscription.py b/lib/lp/oci/browser/ocirecipesubscription.py |
139 | new file mode 100644 |
140 | index 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) |
320 | diff --git a/lib/lp/oci/browser/tests/test_ocirecipe.py b/lib/lp/oci/browser/tests/test_ocirecipe.py |
321 | index 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: <redacted> |
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( |
382 | diff --git a/lib/lp/oci/browser/tests/test_ocirecipesubscription.py b/lib/lp/oci/browser/tests/test_ocirecipesubscription.py |
383 | new file mode 100644 |
384 | index 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)) |
656 | diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py |
657 | index 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 | |
681 | diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py |
682 | index 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, |
698 | diff --git a/lib/lp/oci/templates/ocirecipe-index.pt b/lib/lp/oci/templates/ocirecipe-index.pt |
699 | index 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"><redacted></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> |
722 | diff --git a/lib/lp/oci/templates/ocirecipe-portlet-privacy.pt b/lib/lp/oci/templates/ocirecipe-portlet-privacy.pt |
723 | new file mode 100644 |
724 | index 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 | + |
741 | diff --git a/lib/lp/oci/templates/ocirecipe-portlet-subscribers-content.pt b/lib/lp/oci/templates/ocirecipe-portlet-subscribers-content.pt |
742 | new file mode 100644 |
743 | index 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> |
778 | diff --git a/lib/lp/oci/templates/ocirecipe-portlet-subscribers.pt b/lib/lp/oci/templates/ocirecipe-portlet-subscribers.pt |
779 | new file mode 100644 |
780 | index 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> |
813 | diff --git a/lib/lp/oci/templates/ocirecipesubscription-edit.pt b/lib/lp/oci/templates/ocirecipesubscription-edit.pt |
814 | new file mode 100644 |
815 | index 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> |
844 | diff --git a/lib/lp/registry/interfaces/product.py b/lib/lp/registry/interfaces/product.py |
845 | index 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 |