← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~lgp171188/launchpad:add-distribution-code-admin-model-and-ui into launchpad:master

 

Guruprasad has proposed merging ~lgp171188/launchpad:add-distribution-code-admin-model-and-ui into launchpad:master.

Commit message:
Add IDistribution.code_admin

And let the distribution code admin call setDefaultRepository()
on the distribution source packages

LP: #1992500

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1992500 in Launchpad itself: "Permission requirement for setDefaultRepository() is too broad"
  https://bugs.launchpad.net/launchpad/+bug/1992500

For more details, see:
https://code.launchpad.net/~lgp171188/launchpad/+git/launchpad/+merge/437481
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~lgp171188/launchpad:add-distribution-code-admin-model-and-ui into launchpad:master.
diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py
index f78c20c..cab022e 100644
--- a/lib/lp/code/model/gitrepository.py
+++ b/lib/lp/code/model/gitrepository.py
@@ -1,4 +1,4 @@
-# Copyright 2015-2022 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2023 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __all__ = [
@@ -2347,7 +2347,15 @@ class GitRepositorySet:
                 "Cannot set a default Git repository for a person, only "
                 "for a project or a package."
             )
-        if not check_permission("launchpad.Edit", target):
+        if not (
+            check_permission("launchpad.Edit", target)
+            or (
+                IDistributionSourcePackage.providedBy(target)
+                and getUtility(ILaunchBag).user.inTeam(
+                    target.distribution.code_admin
+                )
+            )
+        ):
             raise Unauthorized(
                 "You cannot set the default Git repository for %s."
                 % target.display_name
diff --git a/lib/lp/code/model/tests/test_gitrepository.py b/lib/lp/code/model/tests/test_gitrepository.py
index b21e131..48c1cd0 100644
--- a/lib/lp/code/model/tests/test_gitrepository.py
+++ b/lib/lp/code/model/tests/test_gitrepository.py
@@ -1,4 +1,4 @@
-# Copyright 2015-2022 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2023 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for Git repositories."""
@@ -5059,6 +5059,26 @@ class TestGitRepositorySet(TestCaseWithFactory):
                 ),
             )
 
+    def test_distribution_code_admin_calls_setDefaultRepository_for_dsp(self):
+        person = self.factory.makePerson()
+        distribution = self.factory.makeDistribution()
+        dsp = self.factory.makeDistributionSourcePackage(
+            distribution=distribution
+        )
+        repo1 = self.factory.makeGitRepository(target=dsp)
+        repo2 = self.factory.makeGitRepository(target=dsp)
+
+        with person_logged_in(distribution.owner):
+            distribution.code_admin = person
+            self.repository_set.setDefaultRepository(dsp, repo1)
+
+        self.assertEqual(repo1, self.repository_set.getDefaultRepository(dsp))
+
+        with person_logged_in(person):
+            self.repository_set.setDefaultRepository(dsp, repo2)
+
+        self.assertEqual(repo2, self.repository_set.getDefaultRepository(dsp))
+
     def test_setDefaultRepositoryForOwner_replaces_old(self):
         # If another repository is already the target owner default,
         # setting it overwrites.
diff --git a/lib/lp/registry/browser/configure.zcml b/lib/lp/registry/browser/configure.zcml
index fc7b805..6fd29fb 100644
--- a/lib/lp/registry/browser/configure.zcml
+++ b/lib/lp/registry/browser/configure.zcml
@@ -1,4 +1,4 @@
-<!-- Copyright 2009-2022 Canonical Ltd.  This software is licensed under the
+<!-- Copyright 2009-2023 Canonical Ltd.  This software is licensed under the
      GNU Affero General Public License version 3 (see the file LICENSE).
 -->
 
@@ -2342,6 +2342,13 @@
         template="../../app/templates/generic-edit.pt"
         />
     <browser:page
+        name="+select-code-admins"
+        for="lp.registry.interfaces.distribution.IDistribution"
+        class="lp.registry.browser.distribution.DistributionChangeCodeAdminView"
+        permission="launchpad.Edit"
+        template="../../app/templates/generic-edit.pt"
+        />
+    <browser:page
         name="+pubconf"
         for="lp.registry.interfaces.distribution.IDistribution"
         class="lp.registry.browser.distribution.DistributionPublisherConfigView"
diff --git a/lib/lp/registry/browser/distribution.py b/lib/lp/registry/browser/distribution.py
index 7180941..c51d677 100644
--- a/lib/lp/registry/browser/distribution.py
+++ b/lib/lp/registry/browser/distribution.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2022 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2023 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Browser views for distributions."""
@@ -9,6 +9,7 @@ __all__ = [
     "DistributionArchiveMirrorsRSSView",
     "DistributionArchiveMirrorsView",
     "DistributionArchivesView",
+    "DistributionChangeCodeAdminView",
     "DistributionChangeMembersView",
     "DistributionChangeMirrorAdminView",
     "DistributionChangeOCIProjectAdminView",
@@ -442,6 +443,7 @@ class DistributionOverviewMenu(ApplicationMenu, DistributionLinksMixin):
         "mirror_admin",
         "oci_project_admin",
         "security_admin",
+        "code_admin",
         "reassign",
         "addseries",
         "series",
@@ -543,6 +545,11 @@ class DistributionOverviewMenu(ApplicationMenu, DistributionLinksMixin):
         text = "Change security admins"
         return Link("+select-security-admins", text, icon="edit")
 
+    @enabled_with_permission("launchpad.Edit")
+    def code_admin(self):
+        text = "Change code admins"
+        return Link("+select-code-admins", text, icon="edit")
+
     def search(self):
         text = "Search packages"
         return Link("+search", text, icon="search")
@@ -869,6 +876,26 @@ class DistributionView(PillarViewMixin, HasAnnouncementsView, FeedsMixin):
             step_title="Select a new security administrator",
         )
 
+    @property
+    def code_admin_widget(self):
+        if canWrite(self.context, "code_admin"):
+            empty_value = " Specify a code admininstrator"
+        else:
+            empty_value = "None"
+
+        return InlinePersonEditPickerWidget(
+            self.context,
+            IDistribution["code_admin"],
+            format_link(
+                self.context.code_admin,
+                empty_value=empty_value,
+            ),
+            header="Change the code administrator",
+            edit_view="+select-code-admins",
+            null_display_value=empty_value,
+            step_title="Select a new code administrator",
+        )
+
     def linkedMilestonesForSeries(self, series):
         """Return a string of linkified milestones in the series."""
         # Listify to remove repeated queries.
@@ -1350,6 +1377,18 @@ class DistributionChangeSecurityAdminView(RegistryEditFormView):
         )
 
 
+class DistributionChangeCodeAdminView(RegistryEditFormView):
+    """A view to change the code administrator."""
+
+    schema = IDistribution
+    field_names = ["code_admin"]
+
+    @property
+    def label(self):
+        """See `LaunchpadFormView`."""
+        return "Change the %s code administrator" % (self.context.displayname)
+
+
 class DistributionChangeMembersView(RegistryEditFormView):
     """A view to change the members team."""
 
diff --git a/lib/lp/registry/browser/tests/distribution-views.rst b/lib/lp/registry/browser/tests/distribution-views.rst
index c4d47a8..2edfb50 100644
--- a/lib/lp/registry/browser/tests/distribution-views.rst
+++ b/lib/lp/registry/browser/tests/distribution-views.rst
@@ -316,6 +316,56 @@ Only admins and owners can access the view.
     False
 
 
+Changing a distribution code administrator
+------------------------------------------
+
+The +select-code-admins view allows the owner or admin to change the
+code administrator.
+
+    >>> login("admin@xxxxxxxxxxxxx")
+    >>> view = create_view(distribution, "+select-code-admins")
+    >>> print(view.label)
+    Change the YoUbuntu code administrator
+
+    >>> print(view.page_title)
+    Change the YoUbuntu code administrator
+
+    >>> print(view.cancel_url)
+    http://launchpad.test/youbuntu
+
+The view accepts the code_admin field.
+
+    >>> print(distribution.code_admin)
+    None
+
+    >>> view.field_names
+    ['code_admin']
+
+    >>> form = {
+    ...     "field.code_admin": "no-priv",
+    ...     "field.actions.change": "Change",
+    ... }
+    >>> view = create_initialized_view(
+    ...     distribution, "+select-code-admins", form=form
+    ... )
+    >>> view.errors
+    []
+    >>> print(view.next_url)
+    http://launchpad.test/youbuntu
+
+    >>> print(distribution.code_admin.name)
+    no-priv
+
+Only admins and owners can access the view.
+
+    >>> check_permission("launchpad.Edit", view)
+    True
+
+    >>> login("no-priv@xxxxxxxxxxxxx")
+    >>> check_permission("launchpad.Edit", view)
+    False
+
+
 Changing a distribution members team
 ------------------------------------
 
diff --git a/lib/lp/registry/configure.zcml b/lib/lp/registry/configure.zcml
index 9da6880..22178f0 100644
--- a/lib/lp/registry/configure.zcml
+++ b/lib/lp/registry/configure.zcml
@@ -1,4 +1,4 @@
-<!-- Copyright 2009-2021 Canonical Ltd.  This software is licensed under the
+<!-- Copyright 2009-2023 Canonical Ltd.  This software is licensed under the
      GNU Affero General Public License version 3 (see the file LICENSE).
 -->
 
@@ -1850,6 +1850,7 @@
             set_attributes="
                 bug_reported_acknowledgement
                 bug_reporting_guidelines
+                code_admin
                 default_traversal_policy
                 description
                 development_series_alias
diff --git a/lib/lp/registry/interfaces/distribution.py b/lib/lp/registry/interfaces/distribution.py
index 2547403..2d3fcef 100644
--- a/lib/lp/registry/interfaces/distribution.py
+++ b/lib/lp/registry/interfaces/distribution.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2022 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2023 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Interfaces including and related to IDistribution."""
@@ -688,6 +688,16 @@ class IDistributionView(
         )
     )
 
+    code_admin = exported(
+        ReferenceChoice(
+            title=_("Code Administrator"),
+            description=_("The distribution source code administrator."),
+            required=False,
+            vocabulary="ValidPersonOrTeam",
+            schema=IPerson,
+        ),
+    )
+
     def getVulnerabilitiesVisibleToUser(user):
         """Return the vulnerabilities visible to the given user."""
 
diff --git a/lib/lp/registry/model/distribution.py b/lib/lp/registry/model/distribution.py
index 80433cc..f8816c4 100644
--- a/lib/lp/registry/model/distribution.py
+++ b/lib/lp/registry/model/distribution.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2021 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2023 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Database classes for implementing distribution items."""
@@ -773,6 +773,13 @@ class Distribution(
         notNull=False,
         default=None,
     )
+    code_admin = ForeignKey(
+        dbName="code_admin",
+        foreignKey="Person",
+        storm_validator=validate_public_person,
+        notNull=False,
+        default=None,
+    )
 
     @cachedproperty
     def main_archive(self):
diff --git a/lib/lp/registry/stories/webservice/xx-distribution.rst b/lib/lp/registry/stories/webservice/xx-distribution.rst
index 79a1450..de764b4 100644
--- a/lib/lp/registry/stories/webservice/xx-distribution.rst
+++ b/lib/lp/registry/stories/webservice/xx-distribution.rst
@@ -29,6 +29,7 @@ And for every distribution we publish most of its attributes.
     bug_reporting_guidelines: None
     bug_supervisor_link: None
     cdimage_mirrors_collection_link: 'http://.../ubuntu/cdimage_mirrors'
+    code_admin_link: None
     commercial_subscription_is_due: False
     commercial_subscription_link: None
     current_series_link: 'http://.../ubuntu/hoary'
diff --git a/lib/lp/registry/templates/distribution-details.pt b/lib/lp/registry/templates/distribution-details.pt
index 434989b..cc4a419 100644
--- a/lib/lp/registry/templates/distribution-details.pt
+++ b/lib/lp/registry/templates/distribution-details.pt
@@ -36,6 +36,11 @@
       <dt>Security admins:</dt>
       <dd tal:content="structure view/security_admin_widget" />
     </dl>
+
+    <dl id="code-admins">
+      <dt>Code admins:</dt>
+      <dd tal:content="structure view/code_admin_widget" />
+    </dl>
   </div>
 
   <dl id="uploaders" style="clear:left">
diff --git a/lib/lp/registry/tests/test_distribution.py b/lib/lp/registry/tests/test_distribution.py
index 96f9558..d9c3b1e 100644
--- a/lib/lp/registry/tests/test_distribution.py
+++ b/lib/lp/registry/tests/test_distribution.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2022 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2023 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for Distribution."""
@@ -2146,6 +2146,100 @@ class TestDistributionWebservice(OCIConfigHelperMixin, TestCaseWithFactory):
         )
         self.assertEqual(401, response.status)
 
+    def test_distribution_code_admin_unset(self):
+        with person_logged_in(self.person):
+            distro = self.factory.makeDistribution(owner=self.person)
+            distro_url = api_url(distro)
+
+        response = self.webservice.get(distro_url)
+        json_body = response.jsonBody()
+        self.assertEqual(200, response.status)
+        self.assertIsNone(json_body["code_admin_link"])
+
+    def test_distribution_code_admin_set(self):
+        with person_logged_in(self.person):
+            distro = self.factory.makeDistribution(owner=self.person)
+            distro_url = api_url(distro)
+            person = self.factory.makePerson()
+            distro.code_admin = person
+            person_url = "http://api.launchpad.test/devel"; + api_url(person)
+
+        response = self.webservice.get(distro_url)
+        json_body = response.jsonBody()
+        self.assertEqual(200, response.status)
+        self.assertEqual(
+            person_url,
+            json_body["code_admin_link"],
+        )
+
+    def test_admin_can_set_distribution_code_admin(self):
+        with admin_logged_in():
+            distro = self.factory.makeDistribution()
+            person = self.factory.makePerson()
+            person_url = "http://api.launchpad.test/devel"; + api_url(person)
+            self.assertIsNone(distro.code_admin)
+            admin_user = getUtility(IPersonSet).getByEmail(
+                "admin@xxxxxxxxxxxxx"
+            )
+            distro_url = api_url(distro)
+
+        webservice = webservice_for_person(
+            admin_user,
+            permission=OAuthPermission.WRITE_PUBLIC,
+            default_api_version="devel",
+        )
+
+        response = webservice.patch(
+            distro_url,
+            "application/json",
+            json.dumps({"code_admin_link": person_url}),
+        )
+        self.assertEqual(209, response.status)
+        with admin_logged_in():
+            self.assertEqual(person, distro.code_admin)
+
+    def test_distribution_owner_can_set_code_admin(self):
+        with person_logged_in(self.person):
+            distro = self.factory.makeDistribution(owner=self.person)
+            self.assertIsNone(distro.code_admin)
+            distro_url = api_url(distro)
+            person_url = "http://api.launchpad.test/devel"; + api_url(
+                self.person
+            )
+
+        response = self.webservice.patch(
+            distro_url,
+            "application/json",
+            json.dumps(
+                {
+                    "code_admin_link": person_url,
+                }
+            ),
+        )
+        self.assertEqual(209, response.status)
+
+        with person_logged_in(self.person):
+            self.assertEqual(self.person, distro.code_admin)
+
+    def test_others_cannot_set_distribution_code_admin(self):
+        with person_logged_in(self.person):
+            distro = self.factory.makeDistribution()
+            distro_url = api_url(distro)
+            person_url = "http://api.launchpad.test/devel"; + api_url(
+                self.person
+            )
+
+        response = self.webservice.patch(
+            distro_url,
+            "application/json",
+            json.dumps(
+                {
+                    "code_admin_link": person_url,
+                }
+            ),
+        )
+        self.assertEqual(401, response.status)
+
 
 class TestDistributionVulnerabilities(TestCaseWithFactory):
 
@@ -2346,6 +2440,26 @@ class TestDistributionVulnerabilities(TestCaseWithFactory):
             ),
         )
 
+    def test_set_code_admin_permissions(self):
+        distribution = self.factory.makeDistribution()
+        person = self.factory.makePerson()
+        person2 = self.factory.makePerson()
+        code_admin_team = self.factory.makeTeam(members=[person])
+
+        with person_logged_in(distribution.owner):
+            distribution.code_admin = code_admin_team
+            self.assertEqual(code_admin_team, distribution.code_admin)
+
+        with admin_logged_in():
+            distribution.code_admin = None
+            self.assertIsNone(distribution.code_admin)
+
+        with person_logged_in(person2), ExpectedException(Unauthorized):
+            distribution.code_admin = code_admin_team
+
+        with anonymous_logged_in(), ExpectedException(Unauthorized):
+            distribution.code_admin = code_admin_team
+
 
 class TestDistributionVulnerabilitiesWebService(TestCaseWithFactory):
 

Follow ups