Merge ~pappacena/launchpad:ui-create-ociproject-for-projects into launchpad:master

Proposed by Thiago F. Pappacena
Status: Merged
Approved by: Thiago F. Pappacena
Approved revision: c78a3c2b0236d70d760598effc20aa254e10b14b
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~pappacena/launchpad:ui-create-ociproject-for-projects
Merge into: launchpad:master
Prerequisite: ~pappacena/launchpad:gitrepo-ociproject-refactoring
Diff against target: 426 lines (+172/-26)
9 files modified
lib/lp/code/browser/tests/test_product.py (+14/-0)
lib/lp/registry/browser/configure.zcml (+7/-0)
lib/lp/registry/browser/ociproject.py (+31/-10)
lib/lp/registry/browser/product.py (+12/-1)
lib/lp/registry/browser/tests/test_ociproject.py (+72/-8)
lib/lp/registry/interfaces/product.py (+9/-1)
lib/lp/registry/model/product.py (+19/-1)
lib/lp/registry/templates/ociproject-index.pt (+7/-4)
lib/lp/security.py (+1/-1)
Reviewer Review Type Date Requested Status
Ioana Lasc (community) Approve
Tom Wardill (community) Approve
Review via email: mp+384506@code.launchpad.net

Commit message

UI to allow creating and editing OCI projects based on projects.

To post a comment you must log in.
Revision history for this message
Tom Wardill (twom) wrote :

One minor comment, one general comment, but LGTM

review: Approve
Revision history for this message
Ioana Lasc (ilasc) wrote :

In Unit tests I'd prob go for soupmatchers , but other than that LGTM!

review: Approve
8290700... by Thiago F. Pappacena

Displaying pillar type on error message

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

Pushed the requested change. Merging this MP now.

c78a3c2... by Thiago F. Pappacena

Merge branch 'master' into ui-create-ociproject-for-projects

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/code/browser/tests/test_product.py b/lib/lp/code/browser/tests/test_product.py
2index 360745e..d33a85e 100644
3--- a/lib/lp/code/browser/tests/test_product.py
4+++ b/lib/lp/code/browser/tests/test_product.py
5@@ -426,3 +426,17 @@ class TestCanConfigureBranches(TestCaseWithFactory):
6 login_person(product.owner)
7 view = create_view(product, '+branches')
8 self.assertTrue(view.can_configure_branches())
9+
10+
11+class TestProductOverviewOCIProject(TestCaseWithFactory):
12+
13+ layer = DatabaseFunctionalLayer
14+
15+ def test_displays_create_oci_project_link(self):
16+ product = self.factory.makeProduct()
17+
18+ browser = self.getUserBrowser(
19+ canonical_url(product), user=product.owner)
20+ text = extract_text(find_tag_by_id(browser.contents, 'global-actions'))
21+
22+ self.assertIn("Create an OCI Project", text)
23diff --git a/lib/lp/registry/browser/configure.zcml b/lib/lp/registry/browser/configure.zcml
24index 5e1489b..7e97bf9 100644
25--- a/lib/lp/registry/browser/configure.zcml
26+++ b/lib/lp/registry/browser/configure.zcml
27@@ -2170,6 +2170,13 @@
28 template="../templates/ociproject-new.pt"
29 />
30 <browser:page
31+ name="+new-oci-project"
32+ for="lp.registry.interfaces.product.IProduct"
33+ class="lp.registry.browser.ociproject.OCIProjectAddView"
34+ permission="launchpad.AnyPerson"
35+ template="../templates/ociproject-new.pt"
36+ />
37+ <browser:page
38 name="+search-oci-project"
39 for="lp.registry.interfaces.distribution.IDistribution"
40 class="lp.registry.browser.distribution.DistributionOCIProjectSearchView"
41diff --git a/lib/lp/registry/browser/ociproject.py b/lib/lp/registry/browser/ociproject.py
42index df7ac85..99c5b43 100644
43--- a/lib/lp/registry/browser/ociproject.py
44+++ b/lib/lp/registry/browser/ociproject.py
45@@ -28,6 +28,7 @@ from lp.app.browser.tales import CustomizableFormatter
46 from lp.app.errors import NotFoundError
47 from lp.code.browser.vcslisting import TargetDefaultVCSNavigationMixin
48 from lp.oci.interfaces.ocirecipe import IOCIRecipeSet
49+from lp.registry.interfaces.distribution import IDistribution
50 from lp.registry.interfaces.ociproject import (
51 IOCIProject,
52 IOCIProjectSet,
53@@ -38,6 +39,7 @@ from lp.registry.interfaces.ociprojectname import (
54 IOCIProjectName,
55 IOCIProjectNameSet,
56 )
57+from lp.registry.interfaces.product import IProduct
58 from lp.services.features import getFeatureFlag
59 from lp.services.webapp import (
60 canonical_url,
61@@ -53,6 +55,15 @@ from lp.services.webapp.breadcrumb import Breadcrumb
62 from lp.services.webapp.interfaces import IMultiFacetedBreadcrumb
63
64
65+def getPillarFieldName(pillar):
66+ if IDistribution.providedBy(pillar):
67+ return 'distribution'
68+ elif IProduct.providedBy(pillar):
69+ return 'project'
70+ raise NotImplementedError("This view only supports distribution or "
71+ "project as pillars for OCIProject.")
72+
73+
74 class OCIProjectAddView(LaunchpadFormView):
75
76 schema = IOCIProjectName
77@@ -85,10 +96,10 @@ class OCIProjectAddView(LaunchpadFormView):
78 oci_project = getUtility(IOCIProjectSet).getByPillarAndName(
79 self.context, oci_project_name.name)
80 if oci_project:
81- self.setFieldError(
82- 'name',
83- 'There is already an OCI project in %s with this name.' % (
84- self.context.display_name))
85+ pillar_type = getPillarFieldName(self.context)
86+ msg = ('There is already an OCI project in %s %s with this name.'
87+ % (pillar_type, self.context.display_name))
88+ self.setFieldError('name', msg)
89
90
91 class OCIProjectFormatterAPI(CustomizableFormatter):
92@@ -170,11 +181,20 @@ class OCIProjectEditView(LaunchpadEditFormView):
93
94 schema = IOCIProject
95 field_names = [
96- 'distribution',
97 'name',
98 'official_recipe',
99 ]
100
101+ def setUpFields(self):
102+ pillar_key = getPillarFieldName(self.context.pillar)
103+ self.field_names = [pillar_key] + self.field_names
104+
105+ super(OCIProjectEditView, self).setUpFields()
106+
107+ # Set the correct pillar field as mandatory
108+ pillar_field = self.form_fields.get(pillar_key).field
109+ pillar_field.required = True
110+
111 def extendFields(self):
112 official_recipe = self.context.getOfficialRecipe()
113 self.form_fields += form.Fields(
114@@ -191,16 +211,17 @@ class OCIProjectEditView(LaunchpadEditFormView):
115
116 def validate(self, data):
117 super(OCIProjectEditView, self).validate(data)
118- distribution = data.get('distribution')
119+ pillar_type_field = getPillarFieldName(self.context.pillar)
120+ pillar = data.get(pillar_type_field)
121 name = data.get('name')
122- if distribution and name:
123+ if pillar and name:
124 oci_project = getUtility(IOCIProjectSet).getByPillarAndName(
125- distribution, name)
126+ pillar, name)
127 if oci_project is not None and oci_project != self.context:
128 self.setFieldError(
129 'name',
130- 'There is already an OCI project in %s with this name.' % (
131- distribution.display_name))
132+ 'There is already an OCI project in %s %s with this name.'
133+ % (pillar_type_field, pillar.display_name))
134
135 @action('Update OCI project', name='update')
136 def update_action(self, action, data):
137diff --git a/lib/lp/registry/browser/product.py b/lib/lp/registry/browser/product.py
138index 5c3ebce..f974d39 100644
139--- a/lib/lp/registry/browser/product.py
140+++ b/lib/lp/registry/browser/product.py
141@@ -275,6 +275,10 @@ class ProductNavigation(
142 def traverse_commercialsubscription(self, name):
143 return self.context.commercial_subscription
144
145+ @stepthrough('+oci')
146+ def traverse_oci(self, name):
147+ return self.context.getOCIProject(name)
148+
149 @stepthrough('+series')
150 def traverse_series(self, name):
151 series = self.context.getSeries(name)
152@@ -431,6 +435,7 @@ class ProductNavigationMenu(NavigationMenu):
153 facet = 'overview'
154 links = [
155 'details',
156+ 'new_oci_project',
157 'announcements',
158 'downloads',
159 ]
160@@ -504,6 +509,11 @@ class ProductEditLinksMixin(StructuralSubscriptionMenuMixin):
161 def sharing(self):
162 return Link('+sharing', 'Sharing', icon='edit')
163
164+ @enabled_with_permission('launchpad.Driver')
165+ def new_oci_project(self):
166+ text = 'Create an OCI Project'
167+ return Link('+new-oci-project', text, icon='add')
168+
169
170 class IProductEditMenu(Interface):
171 """A marker interface for the 'Change details' navigation menu."""
172@@ -522,7 +532,8 @@ class ProductActionNavigationMenu(NavigationMenu, ProductEditLinksMixin):
173
174 @cachedproperty
175 def links(self):
176- links = ['edit', 'review_license', 'administer', 'sharing']
177+ links = ['edit', 'review_license', 'administer', 'sharing',
178+ 'new_oci_project']
179 add_subscribe_link(links)
180 return links
181
182diff --git a/lib/lp/registry/browser/tests/test_ociproject.py b/lib/lp/registry/browser/tests/test_ociproject.py
183index 5187cd6..648f4d4 100644
184--- a/lib/lp/registry/browser/tests/test_ociproject.py
185+++ b/lib/lp/registry/browser/tests/test_ociproject.py
186@@ -79,7 +79,7 @@ class TestOCIProjectView(BrowserTestCase):
187
188 layer = DatabaseFunctionalLayer
189
190- def test_index(self):
191+ def test_index_distribution_pillar(self):
192 distribution = self.factory.makeDistribution(displayname="My Distro")
193 oci_project = self.factory.makeOCIProject(
194 pillar=distribution, ociprojectname="oci-name")
195@@ -91,6 +91,18 @@ class TestOCIProjectView(BrowserTestCase):
196 Name: oci-name
197 """, self.getMainText(oci_project))
198
199+ def test_index_project_pillar(self):
200+ product = self.factory.makeProduct(displayname="My Project")
201+ oci_project = self.factory.makeOCIProject(
202+ pillar=product, ociprojectname="oci-name")
203+ self.assertTextMatchesExpressionIgnoreWhitespace("""\
204+ OCI project oci-name for My Project
205+ .*
206+ OCI project information
207+ Project: My Project
208+ Name: oci-name
209+ """, self.getMainText(oci_project))
210+
211
212 class TestOCIProjectEditView(BrowserTestCase):
213
214@@ -123,11 +135,39 @@ class TestOCIProjectEditView(BrowserTestCase):
215 self.assertThat(
216 "Distribution:\n%s\nEdit OCI project" % (
217 new_distribution.display_name),
218- MatchesTagText(content, "distribution"))
219+ MatchesTagText(content, "pillar"))
220 self.assertThat(
221 "Name:\nnew-name\nEdit OCI project",
222 MatchesTagText(content, "name"))
223
224+ def test_edit_oci_project_change_project_pillar(self):
225+ with admin_logged_in():
226+ owner = self.factory.makePerson()
227+ project = self.factory.makeProduct(owner=owner)
228+ new_project = self.factory.makeProduct(owner=owner)
229+ oci_project = self.factory.makeOCIProject(pillar=project)
230+ new_project_name = new_project.name
231+
232+ browser = self.getViewBrowser(
233+ oci_project, user=oci_project.pillar.owner)
234+ browser.getLink("Edit OCI project").click()
235+ browser.getControl(name="field.project").value = [new_project_name]
236+ browser.getControl(name="field.name").value = "new-name"
237+ browser.getControl("Update OCI project").click()
238+
239+ content = find_main_content(browser.contents)
240+ with person_logged_in(owner):
241+ self.assertEqual(
242+ "OCI project new-name for %s" % new_project.display_name,
243+ extract_text(content.h1))
244+ self.assertThat(
245+ "Project:\n%s\nEdit OCI project" % (
246+ new_project.display_name),
247+ MatchesTagText(content, "pillar"))
248+ self.assertThat(
249+ "Name:\nnew-name\nEdit OCI project",
250+ MatchesTagText(content, "name"))
251+
252 def test_edit_oci_project_ad_oci_project_admin(self):
253 admin_person = self.factory.makePerson()
254 admin_team = self.factory.makeTeam(members=[admin_person])
255@@ -153,7 +193,7 @@ class TestOCIProjectEditView(BrowserTestCase):
256 self.assertThat(
257 "Distribution:\n%s\nEdit OCI project" % (
258 new_distribution.display_name),
259- MatchesTagText(content, "distribution"))
260+ MatchesTagText(content, "pillar"))
261 self.assertThat(
262 "Name:\nnew-name\nEdit OCI project",
263 MatchesTagText(content, "name"))
264@@ -180,8 +220,8 @@ class TestOCIProjectEditView(BrowserTestCase):
265 oci_project, user=oci_project.pillar.owner)
266 self.submitEditForm(browser, "two")
267 self.assertEqual(
268- "There is already an OCI project in %s with this name." % (
269- pillar_display_name),
270+ "There is already an OCI project in distribution %s with this "
271+ "name." % (pillar_display_name),
272 extract_text(find_tags_by_class(browser.contents, "message")[1]))
273
274 def test_edit_oci_project_invalid_name(self):
275@@ -280,11 +320,33 @@ class TestOCIProjectAddView(BrowserTestCase):
276 self.assertThat(
277 "Distribution:\n%s\nEdit OCI project" % (
278 new_distribution.display_name),
279- MatchesTagText(content, "distribution"))
280+ MatchesTagText(content, "pillar"))
281 self.assertThat(
282 "Name:\nnew-name\nEdit OCI project",
283 MatchesTagText(content, "name"))
284
285+ def test_create_oci_project_for_project(self):
286+ oci_project = self.factory.makeOCIProject()
287+ user = oci_project.pillar.owner
288+ project = self.factory.makeProduct(owner=user)
289+ browser = self.getViewBrowser(
290+ project, user=user, view_name='+new-oci-project')
291+ browser.getControl(name="field.name").value = "new-name"
292+ browser.getControl("Create OCI Project").click()
293+
294+ content = find_main_content(browser.contents)
295+ with person_logged_in(user):
296+ self.assertEqual(
297+ "OCI project new-name for %s" % project.display_name,
298+ extract_text(content.h1))
299+ self.assertThat(
300+ "Project:\n%s\nEdit OCI project" % (
301+ project.display_name),
302+ MatchesTagText(content, "pillar"))
303+ self.assertThat(
304+ "Name:\nnew-name\nEdit OCI project",
305+ MatchesTagText(content, "name"))
306+
307 def test_create_oci_project_already_exists(self):
308 person = self.factory.makePerson()
309 distribution = self.factory.makeDistribution(oci_project_admin=person)
310@@ -297,9 +359,11 @@ class TestOCIProjectAddView(BrowserTestCase):
311 browser.getControl(name="field.name").value = "new-name"
312 browser.getControl("Create OCI Project").click()
313
314+ expected_msg = (
315+ "There is already an OCI project in distribution %s with this "
316+ "name." % distribution.display_name)
317 self.assertEqual(
318- "There is already an OCI project in %s with this name." % (
319- distribution.display_name),
320+ expected_msg,
321 extract_text(find_tags_by_class(browser.contents, "message")[1]))
322
323 def test_create_oci_project_no_permission(self):
324diff --git a/lib/lp/registry/interfaces/product.py b/lib/lp/registry/interfaces/product.py
325index c15a8b3..b386fca 100644
326--- a/lib/lp/registry/interfaces/product.py
327+++ b/lib/lp/registry/interfaces/product.py
328@@ -1,4 +1,4 @@
329-# Copyright 2009-2016 Canonical Ltd. This software is licensed under the
330+# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
331 # GNU Affero General Public License version 3 (see the file LICENSE).
332
333 """Interfaces including and related to IProduct."""
334@@ -798,6 +798,14 @@ class IProductView(
335 filter_statuses.
336 """
337
338+ def canAdministerOCIProjects(person):
339+ """Checks if the given person can manage OCI projects for this
340+ Product."""
341+
342+ def getOCIProject(name):
343+ """Return a `OCIProject` with the given name for this product, or None.
344+ """
345+
346 def getPackage(distroseries):
347 """Return a package in that distroseries for this product."""
348
349diff --git a/lib/lp/registry/model/product.py b/lib/lp/registry/model/product.py
350index a99d846..8cdd446 100644
351--- a/lib/lp/registry/model/product.py
352+++ b/lib/lp/registry/model/product.py
353@@ -1,4 +1,4 @@
354-# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
355+# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
356 # GNU Affero General Public License version 3 (see the file LICENSE).
357
358 """Database classes including and related to Product."""
359@@ -142,6 +142,7 @@ from lp.registry.interfaces.accesspolicy import (
360 IAccessPolicyGrantSource,
361 IAccessPolicySource,
362 )
363+from lp.registry.interfaces.ociproject import IOCIProjectSet
364 from lp.registry.interfaces.oopsreferences import IHasOOPSReferences
365 from lp.registry.interfaces.person import (
366 IPersonSet,
367@@ -1140,6 +1141,23 @@ class Product(SQLBase, BugTargetBase, MakesAnnouncements,
368 """See `IBugTarget`."""
369 return self.name
370
371+ def getOCIProject(self, name):
372+ return getUtility(IOCIProjectSet).getByPillarAndName(
373+ self, name)
374+
375+ def canAdministerOCIProjects(self, person):
376+ if person is None:
377+ return False
378+ # XXX: pappacena 2020-05-25: Maybe we should have an attribute named
379+ # oci_project_admin on Product too, the same way we have on
380+ # Distribution.
381+ if person.inTeam(self.driver):
382+ return True
383+ person_roles = IPersonRoles(person)
384+ if person_roles.in_admin or person_roles.isOwner(self):
385+ return True
386+ return False
387+
388 def getPackage(self, distroseries):
389 """See `IProduct`."""
390 if isinstance(distroseries, Distribution):
391diff --git a/lib/lp/registry/templates/ociproject-index.pt b/lib/lp/registry/templates/ociproject-index.pt
392index 427b383..9a14cb7 100644
393--- a/lib/lp/registry/templates/ociproject-index.pt
394+++ b/lib/lp/registry/templates/ociproject-index.pt
395@@ -28,11 +28,14 @@
396 <div metal:fill-slot="main">
397 <h2>OCI project information</h2>
398 <div class="two-column-list">
399- <dl id="distribution" tal:define="distribution context/distribution">
400- <dt>Distribution:</dt>
401+ <dl id="pillar" tal:define="pillar context/pillar">
402+ <dt>
403+ <span tal:condition="context/distribution">Distribution:</span>
404+ <span tal:condition="context/project">Project:</span>
405+ </dt>
406 <dd>
407- <a tal:attributes="href distribution/fmt:url"
408- tal:content="distribution/display_name"/>
409+ <a tal:attributes="href pillar/fmt:url"
410+ tal:content="pillar/display_name"/>
411 <a tal:replace="structure context/menu:overview/edit/fmt:icon"/>
412 </dd>
413 </dl>
414diff --git a/lib/lp/security.py b/lib/lp/security.py
415index f7d7bf8..dfc06c2 100644
416--- a/lib/lp/security.py
417+++ b/lib/lp/security.py
418@@ -3464,7 +3464,7 @@ class EditOCIProject(AuthorizationBase):
419 """Maintainers, drivers, and admins can drive projects."""
420 return (user.in_admin or
421 user.isDriver(self.obj.pillar) or
422- user.inTeam(self.obj.pillar.oci_project_admin))
423+ self.obj.pillar.canAdministerOCIProjects(user))
424
425
426 class EditOCIProjectSeries(DelegatedAuthorization):