← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~pappacena/launchpad:ui-oci-project-search into launchpad:master

 

Thiago F. Pappacena has proposed merging ~pappacena/launchpad:ui-oci-project-search into launchpad:master.

Commit message:
Adding page to see the (paginated) list of OCI projects of a distribution, including the possibility to search.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~pappacena/launchpad/+git/launchpad/+merge/383657
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~pappacena/launchpad:ui-oci-project-search into launchpad:master.
diff --git a/lib/lp/registry/browser/configure.zcml b/lib/lp/registry/browser/configure.zcml
index 2fdc177..96eb8a8 100644
--- a/lib/lp/registry/browser/configure.zcml
+++ b/lib/lp/registry/browser/configure.zcml
@@ -2156,6 +2156,13 @@
         template="../templates/ociproject-new.pt"
         />
     <browser:page
+        name="+oci-project-search"
+        for="lp.registry.interfaces.distribution.IDistribution"
+        class="lp.registry.browser.ociproject.OCIProjectSearchView"
+        permission="zope.Public"
+        template="../templates/ociproject-search.pt"
+        />
+    <browser:page
         name="+search"
         for="lp.registry.interfaces.distribution.IDistribution"
         class="lp.registry.browser.distribution.DistributionPackageSearchView"
diff --git a/lib/lp/registry/browser/distribution.py b/lib/lp/registry/browser/distribution.py
index 4b3b18c..e5f3e46 100644
--- a/lib/lp/registry/browser/distribution.py
+++ b/lib/lp/registry/browser/distribution.py
@@ -311,11 +311,16 @@ class DistributionNavigationMenu(NavigationMenu, DistributionLinksMixin):
         text = 'Create an OCI Project'
         return Link('+new-oci-project', text, icon='add')
 
+    def search_oci_project(self):
+        text = 'Search for OCI Project'
+        return Link('+oci-project-search', text, icon='info')
+
     @cachedproperty
     def links(self):
         return [
             'edit', 'admin', 'pubconf', 'subscribe_to_bug_mail',
-            'edit_bug_mail', 'sharing', 'new_oci_project']
+            'edit_bug_mail', 'sharing', 'new_oci_project',
+            'search_oci_project']
 
 
 class DistributionOverviewMenu(ApplicationMenu, DistributionLinksMixin):
diff --git a/lib/lp/registry/browser/ociproject.py b/lib/lp/registry/browser/ociproject.py
index 700af68..cba86f4 100644
--- a/lib/lp/registry/browser/ociproject.py
+++ b/lib/lp/registry/browser/ociproject.py
@@ -37,16 +37,19 @@ from lp.registry.interfaces.ociprojectname import (
     IOCIProjectNameSet,
     )
 from lp.services.features import getFeatureFlag
+from lp.services.propertycache import cachedproperty
 from lp.services.webapp import (
     canonical_url,
     ContextMenu,
     enabled_with_permission,
+    LaunchpadView,
     Link,
     Navigation,
     NavigationMenu,
     StandardLaunchpadFacets,
     stepthrough,
     )
+from lp.services.webapp.batching import BatchNavigator
 from lp.services.webapp.breadcrumb import Breadcrumb
 from lp.services.webapp.interfaces import IMultiFacetedBreadcrumb
 
@@ -200,3 +203,44 @@ class OCIProjectEditView(LaunchpadEditFormView):
         return canonical_url(self.context)
 
     cancel_url = next_url
+
+
+class OCIProjectSearchView(LaunchpadView):
+    """Page to search for OCI projects of a given distribution."""
+    page_title = ''
+
+    @property
+    def text(self):
+        text = self.request.get("text", None)
+        if isinstance(text, list):
+            text = text[-1]
+        return text
+
+    @property
+    def search_requested(self):
+        return self.text is not None
+
+    @property
+    def title(self):
+        return self.context.name
+
+    @cachedproperty
+    def count(self):
+        """Return the number of matched search results."""
+        return self.batchnav.batch.total()
+
+    @cachedproperty
+    def batchnav(self):
+        """Return the batch navigator for the search results."""
+        return BatchNavigator(self.search_results, self.request)
+
+    @cachedproperty
+    def preloaded_batch(self):
+        projects = self.batchnav.batch
+        getUtility(IOCIProjectSet).preloadDataForOCIProjects(projects)
+        return projects
+
+    @property
+    def search_results(self):
+        return getUtility(IOCIProjectSet).findByDistributionAndName(
+            self.context, self.text or '')
diff --git a/lib/lp/registry/browser/tests/test_ociproject.py b/lib/lp/registry/browser/tests/test_ociproject.py
index 13adfbf..c73603f 100644
--- a/lib/lp/registry/browser/tests/test_ociproject.py
+++ b/lib/lp/registry/browser/tests/test_ociproject.py
@@ -1,3 +1,4 @@
+# -*- coding: utf-8 -*-
 # Copyright 2019-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
@@ -21,8 +22,11 @@ from lp.services.features.testing import FeatureFixture
 from lp.services.webapp import canonical_url
 from lp.services.webapp.escaping import structured
 from lp.testing import (
+    admin_logged_in,
     BrowserTestCase,
+    login_person,
     person_logged_in,
+    record_two_runs,
     test_tales,
     TestCaseWithFactory,
     )
@@ -31,6 +35,7 @@ from lp.testing.matchers import MatchesTagText
 from lp.testing.pages import (
     extract_text,
     find_main_content,
+    find_tag_by_id,
     find_tags_by_class,
     )
 from lp.testing.publication import test_traverse
@@ -161,9 +166,6 @@ class TestOCIProjectAddView(BrowserTestCase):
 
     layer = DatabaseFunctionalLayer
 
-    def setUp(self):
-        super(TestOCIProjectAddView, self).setUp()
-
     def test_create_oci_project(self):
         oci_project = self.factory.makeOCIProject()
         user = oci_project.pillar.owner
@@ -213,3 +215,132 @@ class TestOCIProjectAddView(BrowserTestCase):
             new_distribution,
             user=another_person,
             view_name='+new-oci-project')
+
+
+class TestOCIProjectSearchView(BrowserTestCase):
+
+    layer = DatabaseFunctionalLayer
+
+    def assertPaginationIsPresent(
+            self, browser, results_in_page, total_result):
+        """Checks that pagination is shown at the browser."""
+        nav_index = find_tags_by_class(
+            browser.contents, "batch-navigation-index")[0]
+        nav_index_text = extract_text(nav_index).replace('\n', ' ')
+        self.assertIn(
+            "1 → %s of %s results" % (results_in_page, total_result),
+            nav_index_text)
+
+        nav_links = find_tags_by_class(
+            browser.contents, "batch-navigation-links")[0]
+        nav_links_text = extract_text(nav_links).replace('\n', ' ')
+        self.assertIn("First • Previous • Next • Last", nav_links_text)
+
+    def assertOCIProjectsArePresent(self, browser, oci_projects):
+        table = find_tag_by_id(browser.contents, "projects_list")
+        with admin_logged_in():
+            for oci_project in oci_projects:
+                url = canonical_url(oci_project, force_local_path=True)
+                self.assertIn(url, str(table))
+                self.assertIn(oci_project.name, str(table))
+
+    def assertOCIProjectsAreNotPresent(self, browser, oci_projects):
+        table = find_tag_by_id(browser.contents, "projects_list")
+        with admin_logged_in():
+            for oci_project in oci_projects:
+                url = canonical_url(oci_project, force_local_path=True)
+                self.assertNotIn(url, str(table))
+                self.assertNotIn(oci_project.name, str(table))
+
+    def test_search_no_oci_projects(self):
+        person = self.factory.makePerson()
+        distribution = self.factory.makeDistribution()
+        browser = self.getViewBrowser(
+            distribution, user=person, view_name='+oci-project-search')
+
+        main_portlet = find_tags_by_class(browser.contents, "main-portlet")[0]
+        self.assertIn(
+            "There are no OCI projects registered for %s" % distribution.name,
+            extract_text(main_portlet).replace("\n", " "))
+
+    def test_oci_projects_no_search_keyword(self):
+        person = self.factory.makePerson()
+        distro = self.factory.makeDistribution(owner=person)
+
+        # Creates 3 OCI Projects
+        oci_projects = [
+            self.factory.makeOCIProject(
+                ociprojectname="test-project-%s" % i,
+                registrant=person, pillar=distro) for i in range(3)]
+
+        browser = self.getViewBrowser(
+            distro, user=person, view_name='+oci-project-search')
+
+        # Check top message.
+        main_portlet = find_tags_by_class(browser.contents, "main-portlet")[0]
+        self.assertIn(
+            "There are 3 OCI projects registered for %s" % distro.name,
+            extract_text(main_portlet).replace("\n", " "))
+
+        self.assertOCIProjectsArePresent(browser, oci_projects)
+        self.assertPaginationIsPresent(browser, 3, 3)
+
+    def test_oci_projects_with_search_keyword(self):
+        person = self.factory.makePerson()
+        distro = self.factory.makeDistribution(owner=person)
+
+        # And 2 OCI projects that will match the name
+        oci_projects = [
+            self.factory.makeOCIProject(
+                ociprojectname="find-me-%s" % i,
+                registrant=person, pillar=distro) for i in range(2)]
+
+        # Creates 2 OCI Projects that will not match search
+        other_oci_projects = [
+            self.factory.makeOCIProject(
+                ociprojectname="something-%s" % i,
+                registrant=person, pillar=distro) for i in range(2)]
+
+        browser = self.getViewBrowser(
+            distro, user=person, view_name='+oci-project-search')
+        browser.getControl(name="text").value = "find-me"
+        browser.getControl("Search").click()
+
+        # Check top message.
+        main_portlet = find_tags_by_class(browser.contents, "main-portlet")[0]
+        self.assertIn(
+            'There are 2 OCI projects registered for %s matching "%s"' %
+            (distro.name, "find-me"),
+            extract_text(main_portlet).replace("\n", " "))
+
+        self.assertOCIProjectsArePresent(browser, oci_projects)
+        self.assertOCIProjectsAreNotPresent(browser, other_oci_projects)
+        self.assertPaginationIsPresent(browser, 2, 2)
+
+    def test_query_count_is_constant(self):
+        batch_size = 3
+        self.pushConfig("launchpad", default_batch_size=batch_size)
+
+        person = self.factory.makePerson()
+        distro = self.factory.makeDistribution(owner=person)
+        name_pattern = "find-me-"
+
+        def createOCIProject():
+            self.factory.makeOCIProject(
+                ociprojectname=self.factory.getUniqueString(name_pattern),
+                pillar=distro)
+
+        viewer = self.factory.makePerson()
+        def getView():
+            browser = self.getViewBrowser(
+                distro, user=viewer, view_name='+oci-project-search')
+            browser.getControl(name="text").value = name_pattern
+            browser.getControl("Search").click()
+            return browser
+
+        def do_login():
+            login_person(person)
+
+        recorder1, recorder2 = record_two_runs(
+            getView, createOCIProject, 1, 10, login_method=do_login)
+        self.assertEqual(recorder1.count, recorder2.count)
diff --git a/lib/lp/registry/interfaces/ociproject.py b/lib/lp/registry/interfaces/ociproject.py
index 7394079..99a8496 100644
--- a/lib/lp/registry/interfaces/ociproject.py
+++ b/lib/lp/registry/interfaces/ociproject.py
@@ -54,7 +54,6 @@ from lp.services.fields import (
     PublicPersonChoice,
     )
 
-
 # XXX: pappacena 2020-04-20: It is ok to remove the feature flag since we
 # already have in place the correct permission check for this feature.
 OCI_PROJECT_ALLOW_CREATE = 'oci.project.create.enabled'
@@ -177,6 +176,13 @@ class IOCIProjectSet(Interface):
     def getByDistributionAndName(distribution, name):
         """Get the OCIProjects for a given distribution."""
 
+    def findByDistributionAndName(distribution, name):
+        """Find OCIProjects for a given distribution that contains the
+        provided name."""
+
+    def preloadDataForOCIProjects(oci_projects):
+        """Preload data for the given list of OCIProject objects."""
+
 
 @error_status(http_client.UNAUTHORIZED)
 class OCIProjectCreateFeatureDisabled(Unauthorized):
diff --git a/lib/lp/registry/model/ociproject.py b/lib/lp/registry/model/ociproject.py
index 2967007..de4ea46 100644
--- a/lib/lp/registry/model/ociproject.py
+++ b/lib/lp/registry/model/ociproject.py
@@ -12,6 +12,7 @@ __all__ = [
     ]
 
 import pytz
+from lp.registry.interfaces.person import IPersonSet
 from six import text_type
 from storm.locals import (
     Bool,
@@ -35,6 +36,7 @@ from lp.registry.interfaces.ociprojectname import IOCIProjectNameSet
 from lp.registry.interfaces.series import SeriesStatus
 from lp.registry.model.ociprojectname import OCIProjectName
 from lp.registry.model.ociprojectseries import OCIProjectSeries
+from lp.services.database.bulk import load_related
 from lp.services.database.constants import (
     DEFAULT,
     UTC_NOW,
@@ -191,3 +193,21 @@ class OCIProjectSet:
             OCIProject.ociprojectname == OCIProjectName.id,
             OCIProjectName.name == name).one()
         return target
+
+    def findByDistributionAndName(self, distribution, name):
+        """See `IOCIProjectSet`."""
+        return IStore(OCIProject).find(
+            OCIProject,
+            OCIProject.distribution == distribution,
+            OCIProject.ociprojectname == OCIProjectName.id,
+            OCIProjectName.name.contains_string(name))
+
+    def preloadDataForOCIProjects(self, oci_projects):
+        """See `IOCIProjectSet`."""
+        oci_projects = [removeSecurityProxy(i) for i in oci_projects]
+
+        person_ids = [i.registrant_id for i in oci_projects]
+        list(getUtility(IPersonSet).getPrecachedPersonsFromIDs(
+            person_ids, need_validity=True))
+
+        load_related(OCIProjectName, oci_projects, ["ociprojectname_id"])
diff --git a/lib/lp/registry/templates/ociproject-search.pt b/lib/lp/registry/templates/ociproject-search.pt
new file mode 100644
index 0000000..b30ade7
--- /dev/null
+++ b/lib/lp/registry/templates/ociproject-search.pt
@@ -0,0 +1,92 @@
+<html
+  xmlns="http://www.w3.org/1999/xhtml";
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  metal:use-macro="view/macro:page/searchless"
+  i18n:domain="launchpad"
+>
+<body>
+
+<div metal:fill-slot="heading">
+  <h2>Search OCI projects in <tal:title replace="context/title" /></h2>
+</div>
+
+<metal:side fill-slot="side">
+  <div tal:replace="structure context/@@+global-actions"/>
+</metal:side>
+
+<div metal:fill-slot="main">
+  <div class="top-portlet">
+    <form name="search" method="GET">
+      <div>
+        <label for="text">
+          Show <span tal:replace="context/title" /> OCI projects containing:
+        </label>
+      </div>
+
+      <input type="text" name="text" size="35"
+        tal:attributes="value request/text|nothing" />
+
+      <input type="submit" value="Search"
+          tal:condition="not: view/search_requested" />
+
+      <input type="submit" value="Search Again"
+          tal:condition="view/search_requested" />
+    </form>
+
+
+    <tal:search_result>
+      <div class="main-portlet">
+        <p tal:define="count view/count"
+           tal:condition="count">
+          <span tal:condition="python: count == 1">
+            There is <strong>1</strong> OCI project</span>
+          <span tal:condition="python: count != 1">
+            There are <strong tal:content="count" /> OCI projects
+          </span>
+          registered for <tal:context replace="view/title" />
+          <span tal:condition= "python: view.search_requested and view.text">
+            matching "<strong tal:content="view/text" />".
+          </span>
+        </p>
+        <p tal:define="count view/count"
+           tal:condition="not: count">
+          There are no OCI projects registered for
+          <tal:context replace="view/title" />
+          <span tal:condition= "python: view.search_requested and view.text">
+            matching "<strong tal:content="view/text" />".
+          </span>
+        </p>
+
+        <table class="listing" id="projects_list">
+          <tbody>
+            <tr class="head">
+              <th>Name</th>
+              <th>Description</th>
+              <th>Registrant</th>
+              <th>Date created</th>
+            </tr>
+
+            <tr tal:repeat="item view/preloaded_batch">
+              <td>
+                <a tal:content="item/name"
+                   tal:attributes="href item/fmt:url" />
+              </td>
+              <td tal:content="item/description" />
+              <td tal:content="structure item/registrant/fmt:link" />
+              <td tal:content="item/date_created/fmt:displaydate" />
+            </tr>
+          </tbody>
+        </table>
+
+        <tal:navigation
+          replace="structure view/batchnav/@@+navigation-links-lower" />
+      </div>
+    </tal:search_result>
+  </div>
+</div>
+
+</body>
+
+</html>