← Back to team overview

launchpad-reviewers team mailing list archive

[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>