launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #26049
[Merge] ~twom/launchpad:oci-policy-set-the-official-with-the-official-permissions into launchpad:master
Tom Wardill has proposed merging ~twom/launchpad:oci-policy-set-the-official-with-the-official-permissions into launchpad:master.
Commit message:
Set official status for an OCIRecipe via the UI
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~twom/launchpad/+git/launchpad/+merge/396584
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~twom/launchpad:oci-policy-set-the-official-with-the-official-permissions into launchpad:master.
diff --git a/lib/lp/oci/browser/ocirecipe.py b/lib/lp/oci/browser/ocirecipe.py
index a2b8119..d804268 100644
--- a/lib/lp/oci/browser/ocirecipe.py
+++ b/lib/lp/oci/browser/ocirecipe.py
@@ -742,6 +742,18 @@ class OCIRecipeFormMixin:
build_args[k] = v
data['build_args'] = build_args
+ def userIsRecipeAdmin(self):
+ if check_permission("launchpad.Admin", self.context):
+ return True
+ person = getattr(self.request.principal, 'person', None)
+ if not person:
+ return False
+ # Edit context = OCIRecipe, New context = OCIProject
+ project = getattr(self.context, "oci_project", self.context)
+ if project.pillar.canAdministerOCIProjects(person):
+ return True
+ return False
+
class OCIRecipeAddView(LaunchpadFormView, EnableProcessorsMixin,
OCIRecipeFormMixin):
@@ -775,6 +787,16 @@ class OCIRecipeAddView(LaunchpadFormView, EnableProcessorsMixin,
"The architectures that this OCI recipe builds for. Some "
"architectures are restricted and may only be enabled or "
"disabled by administrators.")
+ self.form_fields += FormFields(Bool(
+ __name__="official_recipe",
+ title="Official recipe",
+ description=(
+ "Mark this recipe as official for this OCI Project. "
+ "Allows use of distribution registry credentials "
+ "and the default git repository routing. "
+ "May only be enabled by the owner of the OCI Project."),
+ default=False,
+ required=False, readonly=False))
def setUpGitRefWidget(self):
"""Setup GitRef widget indicating the user to use the default
@@ -798,6 +820,11 @@ class OCIRecipeAddView(LaunchpadFormView, EnableProcessorsMixin,
super(OCIRecipeAddView, self).setUpWidgets()
self.widgets["processors"].widget_class = "processors"
self.setUpGitRefWidget()
+ # disable the official recipe button if the user doesn't have
+ # permissions to change it
+ widget = self.widgets['official_recipe']
+ if not self.userIsRecipeAdmin():
+ widget.extra = "disabled='disabled'"
@property
def cancel_url(self):
@@ -829,6 +856,12 @@ class OCIRecipeAddView(LaunchpadFormView, EnableProcessorsMixin,
"this name." % (
owner.display_name, self.context.display_name))
self.validateBuildArgs(data)
+ official = data.get("official_recipe", None)
+ if official and not self.userIsRecipeAdmin():
+ self.setFieldError(
+ "official_recipe",
+ "You do not have permission to set the official status "
+ "of this recipe.")
@action("Create OCI recipe", name="create")
def create_action(self, action, data):
@@ -837,7 +870,8 @@ class OCIRecipeAddView(LaunchpadFormView, EnableProcessorsMixin,
oci_project=self.context, git_ref=data["git_ref"],
build_file=data["build_file"], description=data["description"],
build_daily=data["build_daily"], build_args=data["build_args"],
- build_path=data["build_path"], processors=data["processors"])
+ build_path=data["build_path"], processors=data["processors"],
+ official=data.get('official_recipe', False))
self.next_url = canonical_url(recipe)
@@ -858,6 +892,10 @@ class BaseOCIRecipeEditView(LaunchpadEditFormView):
self.context.setProcessors(
new_processors, check_permissions=True, user=self.user)
del data["processors"]
+ official = data.pop('official_recipe', None)
+ if official is not None and self.userIsRecipeAdmin():
+ self.context.oci_project.setOfficialRecipe(self.context)
+
self.updateContextFromData(data)
self.next_url = canonical_url(self.context)
@@ -931,6 +969,11 @@ class OCIRecipeEditView(BaseOCIRecipeEditView, EnableProcessorsMixin,
"""See `LaunchpadFormView`."""
super(OCIRecipeEditView, self).setUpWidgets()
self.setUpGitRefWidget()
+ # disable the official recipe button if the user doesn't have
+ # permissions to change it
+ widget = self.widgets['official_recipe']
+ if not self.userIsRecipeAdmin():
+ widget.extra = "disabled='disabled'"
def setUpFields(self):
"""See `LaunchpadFormView`."""
@@ -941,6 +984,16 @@ class OCIRecipeEditView(BaseOCIRecipeEditView, EnableProcessorsMixin,
"The architectures that this OCI recipe builds for. Some "
"architectures are restricted and may only be enabled or "
"disabled by administrators.")
+ self.form_fields += FormFields(Bool(
+ __name__="official_recipe",
+ title="Official recipe",
+ description=(
+ "Mark this recipe as official for this OCI Project. "
+ "Allows use of distribution registry credentials "
+ "and the default git repository routing. "
+ "May only be enabled by the owner of the OCI Project."),
+ default=False,
+ required=False, readonly=False))
def validate(self, data):
"""See `LaunchpadFormView`."""
@@ -976,6 +1029,14 @@ class OCIRecipeEditView(BaseOCIRecipeEditView, EnableProcessorsMixin,
# enabled. Leave it untouched.
data["processors"].append(processor)
self.validateBuildArgs(data)
+ official = data.get('official_recipe')
+ official_change = not self.context.official == official
+ is_admin = self.userIsRecipeAdmin()
+ if official is not None and official_change and not is_admin:
+ self.setFieldError(
+ "official_recipe",
+ "You do not have permission to change the official status "
+ "of this recipe.")
class OCIRecipeDeleteView(BaseOCIRecipeEditView):
diff --git a/lib/lp/oci/browser/tests/test_ocirecipe.py b/lib/lp/oci/browser/tests/test_ocirecipe.py
index 3505fd3..1cae4d0 100644
--- a/lib/lp/oci/browser/tests/test_ocirecipe.py
+++ b/lib/lp/oci/browser/tests/test_ocirecipe.py
@@ -223,6 +223,9 @@ class TestOCIRecipeAddView(BaseTestOCIRecipeView):
self.assertThat(
"Build schedule:\nBuilt on request\nEdit OCI recipe\n",
MatchesTagText(content, "build-schedule"))
+ self.assertThat(
+ "Official recipe:\nNo",
+ MatchesTagText(content, "official-recipe"))
def test_create_new_recipe_with_build_args(self):
oci_project = self.factory.makeOCIProject()
@@ -335,6 +338,65 @@ class TestOCIRecipeAddView(BaseTestOCIRecipeView):
"id": "field.git_ref.repository",
"value": default_repo_path})))
+ def test_official_is_disabled(self):
+ oci_project = self.factory.makeOCIProject()
+ browser = self.getViewBrowser(
+ oci_project, view_name="+new-recipe", user=self.person)
+ official_control = browser.getControl("Official recipe")
+ self.assertTrue(official_control.disabled)
+
+ def test_official_is_enabled(self):
+ distribution = self.factory.makeDistribution(
+ oci_project_admin=self.person)
+ oci_project = self.factory.makeOCIProject(pillar=distribution)
+ browser = self.getViewBrowser(
+ oci_project, view_name="+new-recipe", user=self.person)
+ official_control = browser.getControl("Official recipe")
+ self.assertFalse(official_control.disabled)
+
+ def test_set_official(self):
+ distribution = self.factory.makeDistribution(
+ oci_project_admin=self.person)
+ oci_project = self.factory.makeOCIProject(pillar=distribution)
+ [git_ref] = self.factory.makeGitRefs()
+ browser = self.getViewBrowser(
+ oci_project, view_name="+new-recipe", user=self.person)
+ browser.getControl(name="field.name").value = "recipe-name"
+ browser.getControl("Description").value = "Recipe description"
+ browser.getControl(name="field.git_ref.repository").value = (
+ git_ref.repository.identity)
+ browser.getControl(name="field.git_ref.path").value = git_ref.path
+ official_control = browser.getControl("Official recipe")
+ official_control.selected = True
+ browser.getControl("Create OCI recipe").click()
+
+ content = find_main_content(browser.contents)
+ self.assertThat(
+ "Official recipe:\nYes",
+ MatchesTagText(content, "official-recipe"))
+
+ def test_set_official_no_permissions(self):
+ distro_owner = self.factory.makePerson()
+ distribution = self.factory.makeDistribution(
+ oci_project_admin=distro_owner)
+ oci_project = self.factory.makeOCIProject(pillar=distribution)
+ [git_ref] = self.factory.makeGitRefs()
+ browser = self.getViewBrowser(
+ oci_project, view_name="+new-recipe", user=self.person)
+ browser.getControl(name="field.name").value = "recipe-name"
+ browser.getControl("Description").value = "Recipe description"
+ browser.getControl(name="field.git_ref.repository").value = (
+ git_ref.repository.identity)
+ browser.getControl(name="field.git_ref.path").value = git_ref.path
+ official_control = browser.getControl("Official recipe")
+ official_control.selected = True
+ browser.getControl("Create OCI recipe").click()
+
+ error_message = (
+ "You do not have permission to set the official status "
+ "of this recipe.")
+ self.assertIn(error_message, browser.contents)
+
class TestOCIRecipeAdminView(BaseTestOCIRecipeView):
@@ -751,6 +813,69 @@ class TestOCIRecipeEditView(OCIConfigHelperMixin, BaseTestOCIRecipeView):
self.assertNotIn(wrong_namespace_msg, browser.contents)
self.assertIn(wrong_ref_path_msg, browser.contents)
+ def test_official_is_disabled(self):
+ oci_project = self.factory.makeOCIProject()
+ recipe = self.factory.makeOCIRecipe(
+ registrant=self.person, owner=self.person,
+ oci_project=oci_project)
+
+ browser = self.getViewBrowser(recipe, user=self.person)
+ browser.getLink("Edit OCI recipe").click()
+ official_control = browser.getControl("Official recipe")
+ self.assertTrue(official_control.disabled)
+
+ def test_official_is_enabled(self):
+ distribution = self.factory.makeDistribution(
+ oci_project_admin=self.person)
+ oci_project = self.factory.makeOCIProject(pillar=distribution)
+ recipe = self.factory.makeOCIRecipe(
+ registrant=self.person, owner=self.person,
+ oci_project=oci_project)
+
+ browser = self.getViewBrowser(recipe, user=self.person)
+ browser.getLink("Edit OCI recipe").click()
+ official_control = browser.getControl("Official recipe")
+ self.assertFalse(official_control.disabled)
+
+ def test_set_official(self):
+ distribution = self.factory.makeDistribution(
+ oci_project_admin=self.person)
+ oci_project = self.factory.makeOCIProject(pillar=distribution)
+ recipe = self.factory.makeOCIRecipe(
+ registrant=self.person, owner=self.person,
+ oci_project=oci_project)
+
+ browser = self.getViewBrowser(recipe, user=self.person)
+ browser.getLink("Edit OCI recipe").click()
+ official_control = browser.getControl("Official recipe")
+ official_control.selected = True
+ browser.getControl("Update OCI recipe").click()
+
+ content = find_main_content(browser.contents)
+ self.assertThat(
+ "Official recipe:\nYes",
+ MatchesTagText(content, "official-recipe"))
+
+ def test_set_official_no_permissions(self):
+ distro_owner = self.factory.makePerson()
+ distribution = self.factory.makeDistribution(
+ oci_project_admin=distro_owner)
+ oci_project = self.factory.makeOCIProject(pillar=distribution)
+ recipe = self.factory.makeOCIRecipe(
+ registrant=self.person, owner=self.person,
+ oci_project=oci_project)
+
+ browser = self.getViewBrowser(recipe, user=self.person)
+ browser.getLink("Edit OCI recipe").click()
+ official_control = browser.getControl("Official recipe")
+ official_control.selected = True
+ browser.getControl("Update OCI recipe").click()
+
+ error_message = (
+ "You do not have permission to change the official status "
+ "of this recipe.")
+ self.assertIn(error_message, browser.contents)
+
class TestOCIRecipeDeleteView(BaseTestOCIRecipeView):
@@ -901,6 +1026,8 @@ class TestOCIRecipeView(BaseTestOCIRecipeView):
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
@@ -934,6 +1061,8 @@ class TestOCIRecipeView(BaseTestOCIRecipeView):
Build context directory: %s
Build schedule: Built on request
Build-time\nARG variables: VAR1=123 VAR2=XXX
+ Official recipe:
+ No
Latest builds
Status When complete Architecture
Successfully built 30 minutes ago 386
diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py
index a27c5fd..88a4869 100644
--- a/lib/lp/oci/interfaces/ocirecipe.py
+++ b/lib/lp/oci/interfaces/ocirecipe.py
@@ -320,6 +320,13 @@ class IOCIRecipeView(Interface):
"""Get an OCIRecipeBuildRequest object for the given job_id.
"""
+ def userIsRecipeAdmin(user):
+ """Is a particular user allowed to edit certain attributes.
+
+ Defined as user is launchpad.Admin or is owner of the containing
+ OCI Project.
+ """
+
class IOCIRecipeEdit(IWebhookTarget):
"""`IOCIRecipe` methods that require launchpad.Edit permission."""
diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py
index fd14762..b4cdcf5 100644
--- a/lib/lp/oci/model/ocirecipe.py
+++ b/lib/lp/oci/model/ocirecipe.py
@@ -107,6 +107,7 @@ from lp.services.propertycache import (
cachedproperty,
get_property_cache,
)
+from lp.services.webapp.authorization import check_permission
from lp.services.webhooks.interfaces import IWebhookSet
from lp.services.webhooks.model import WebhookTargetMixin
from lp.soyuz.model.distroarchseries import DistroArchSeries
diff --git a/lib/lp/oci/templates/ocirecipe-index.pt b/lib/lp/oci/templates/ocirecipe-index.pt
index 9a9aefc..a3e67bb 100644
--- a/lib/lp/oci/templates/ocirecipe-index.pt
+++ b/lib/lp/oci/templates/ocirecipe-index.pt
@@ -81,6 +81,13 @@
<pre tal:content="build_args" />
</dd>
</dl>
+ <dl id="official-recipe">
+ <dt>Official recipe:</dt>
+ <dd>
+ <span tal:condition="context/official">Yes</span>
+ <span tal:condition="not: context/official">No</span>
+ </dd>
+ </dl>
</div>
<h2>Latest builds</h2>
diff --git a/lib/lp/oci/templates/ocirecipe-new.pt b/lib/lp/oci/templates/ocirecipe-new.pt
index 1cae71e..00ed4e2 100644
--- a/lib/lp/oci/templates/ocirecipe-new.pt
+++ b/lib/lp/oci/templates/ocirecipe-new.pt
@@ -41,6 +41,9 @@
<tal:widget define="widget nocall:view/widgets/processors">
<metal:block use-macro="context/@@launchpad_form/widget_row" />
</tal:widget>
+ <tal:widget define="widget nocall:view/widgets/official_recipe">
+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
+ </tal:widget>
</table>
</metal:formbody>
</div>