← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:oci-project-basic-views into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:oci-project-basic-views into launchpad:master with ~cjwatson/launchpad:person-oci-project as a prerequisite.

Commit message:
Add basic index and edit views for OCIProject

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1847444 in Launchpad itself: "Support OCI image building"
  https://bugs.launchpad.net/launchpad/+bug/1847444

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/376106
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:oci-project-basic-views into launchpad:master.
diff --git a/lib/lp/registry/browser/configure.zcml b/lib/lp/registry/browser/configure.zcml
index 018a53c..e277e94 100644
--- a/lib/lp/registry/browser/configure.zcml
+++ b/lib/lp/registry/browser/configure.zcml
@@ -602,6 +602,45 @@
         provides="zope.traversing.interfaces.IPathAdapter"
         for="lp.registry.interfaces.sourcepackage.ISourcePackage"
         />
+    <browser:defaultView
+        name="+index"
+        for="lp.registry.interfaces.ociproject.IOCIProject"
+        />
+    <browser:url
+        for="lp.registry.interfaces.ociproject.IOCIProject"
+        path_expression="string:+oci/${name}"
+        attribute_to_parent="pillar"
+        />
+    <browser:navigation
+        module="lp.registry.browser.ociproject"
+        classes="OCIProjectNavigation"
+        />
+    <browser:page
+        name="+index"
+        for="lp.registry.interfaces.ociproject.IOCIProject"
+        class="lp.services.webapp.LaunchpadView"
+        permission="launchpad.View"
+        template="../templates/ociproject-index.pt"
+        />
+    <browser:page
+        name="+edit"
+        for="lp.registry.interfaces.ociproject.IOCIProject"
+        class="lp.registry.browser.ociproject.OCIProjectEditView"
+        permission="launchpad.Edit"
+        template="../../app/templates/generic-edit.pt"
+        />
+    <browser:menus
+        module="lp.registry.browser.ociproject"
+        classes="
+            OCIProjectFacets
+            OCIProjectNavigationMenu"
+        />
+    <adapter
+        name="fmt"
+        factory="lp.registry.browser.ociproject.OCIProjectFormatterAPI"
+        provides="zope.traversing.interfaces.IPathAdapter"
+        for="lp.registry.interfaces.ociproject.IOCIProject"
+        />
     <browser:url
         for="lp.registry.interfaces.commercialsubscription.ICommercialSubscription"
         path_expression="string:+commercialsubscription/${id}"
diff --git a/lib/lp/registry/browser/distribution.py b/lib/lp/registry/browser/distribution.py
index 453823c..a17ff8d 100644
--- a/lib/lp/registry/browser/distribution.py
+++ b/lib/lp/registry/browser/distribution.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Browser views for distributions."""
@@ -155,6 +155,10 @@ class DistributionNavigation(
     def traverse_sources(self, name):
         return self.context.getSourcePackage(name)
 
+    @stepthrough('+oci')
+    def traverse_oci(self, name):
+        return self.context.getOCIProject(name)
+
     @stepthrough('+milestone')
     def traverse_milestone(self, name):
         return self.context.getMilestone(name)
diff --git a/lib/lp/registry/browser/ociproject.py b/lib/lp/registry/browser/ociproject.py
new file mode 100644
index 0000000..4d760a4
--- /dev/null
+++ b/lib/lp/registry/browser/ociproject.py
@@ -0,0 +1,124 @@
+# Copyright 2019 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Views, menus, and traversal related to `OCIProject`s."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'OCIProjectBreadcrumb',
+    'OCIProjectFacets',
+    'OCIProjectNavigation',
+    ]
+
+from zope.component import getUtility
+from zope.interface import implementer
+
+from lp.app.browser.launchpadform import (
+    action,
+    LaunchpadEditFormView,
+    )
+from lp.app.browser.tales import CustomizableFormatter
+from lp.app.interfaces.headings import IHeadingBreadcrumb
+from lp.code.browser.vcslisting import TargetDefaultVCSNavigationMixin
+from lp.registry.interfaces.ociproject import (
+    IOCIProject,
+    IOCIProjectSet,
+    )
+from lp.services.webapp import (
+    canonical_url,
+    enabled_with_permission,
+    Link,
+    Navigation,
+    NavigationMenu,
+    StandardLaunchpadFacets,
+    )
+from lp.services.webapp.breadcrumb import Breadcrumb
+from lp.services.webapp.interfaces import IMultiFacetedBreadcrumb
+
+
+class OCIProjectFormatterAPI(CustomizableFormatter):
+    """Adapt `IOCIProject` objects to a formatted string."""
+
+    _link_summary_template = '%(displayname)s'
+
+    def _link_summary_values(self):
+        displayname = self._context.display_name
+        return {'displayname': displayname}
+
+
+class OCIProjectNavigation(TargetDefaultVCSNavigationMixin, Navigation):
+
+    usedfor = IOCIProject
+
+
+@implementer(IHeadingBreadcrumb, IMultiFacetedBreadcrumb)
+class OCIProjectBreadcrumb(Breadcrumb):
+    """Builds a breadcrumb for an `IOCIProject`."""
+
+    @property
+    def text(self):
+        return '%s OCI project' % self.context.name
+
+
+class OCIProjectFacets(StandardLaunchpadFacets):
+
+    usedfor = IOCIProject
+    enable_only = [
+        'overview',
+        'branches',
+        ]
+
+
+class OCIProjectNavigationMenu(NavigationMenu):
+    """Navigation menu for OCI projects."""
+
+    usedfor = IOCIProject
+
+    facet = 'overview'
+
+    links = ('edit',)
+
+    @enabled_with_permission('launchpad.Edit')
+    def edit(self):
+        return Link('+edit', 'Edit OCI project', icon='edit')
+
+
+class OCIProjectEditView(LaunchpadEditFormView):
+    """Edit an OCI project."""
+
+    schema = IOCIProject
+    field_names = [
+        'distribution',
+        'name',
+        ]
+
+    @property
+    def label(self):
+        return 'Edit %s OCI project' % self.context.name
+
+    page_title = 'Edit'
+
+    def validate(self, data):
+        super(OCIProjectEditView, self).validate(data)
+        distribution = data.get('distribution')
+        name = data.get('name')
+        if distribution and name:
+            oci_project = getUtility(IOCIProjectSet).getByDistributionAndName(
+                distribution, name)
+            if oci_project is not None and oci_project != self.context:
+                self.setFieldError(
+                    'name',
+                    'There is already an OCI project in %s with this name.' % (
+                        distribution.display_name))
+
+    @action('Update OCI project', name='update')
+    def update_action(self, action, data):
+        self.updateContextFromData(data)
+
+    @property
+    def next_url(self):
+        return canonical_url(self.context)
+
+    cancel_url = next_url
diff --git a/lib/lp/registry/browser/tests/test_ociproject.py b/lib/lp/registry/browser/tests/test_ociproject.py
new file mode 100644
index 0000000..f868d0f
--- /dev/null
+++ b/lib/lp/registry/browser/tests/test_ociproject.py
@@ -0,0 +1,141 @@
+# Copyright 2019 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test OCI project views."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = []
+
+from datetime import datetime
+
+import pytz
+
+from lp.services.database.constants import UTC_NOW
+from lp.services.webapp import canonical_url
+from lp.services.webapp.escaping import structured
+from lp.testing import (
+    BrowserTestCase,
+    person_logged_in,
+    test_tales,
+    TestCaseWithFactory,
+    )
+from lp.testing.layers import DatabaseFunctionalLayer
+from lp.testing.matchers import MatchesTagText
+from lp.testing.pages import (
+    extract_text,
+    find_main_content,
+    find_tags_by_class,
+    )
+from lp.testing.publication import test_traverse
+from lp.testing.views import create_initialized_view
+
+
+class TestOCIProjectFormatterAPI(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_link(self):
+        oci_project = self.factory.makeOCIProject()
+        markup = structured(
+            '<a href="/%s/+oci/%s">%s</a>',
+            oci_project.pillar.name, oci_project.name,
+            oci_project.display_name).escapedtext
+        self.assertEqual(
+            markup,
+            test_tales('oci_project/fmt:link', oci_project=oci_project))
+
+
+class TestOCIProjectNavigation(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_canonical_url(self):
+        distribution = self.factory.makeDistribution(name="mydistro")
+        oci_project = self.factory.makeOCIProject(
+            pillar=distribution, ociprojectname="myociproject")
+        self.assertEqual(
+            "http://launchpad.test/mydistro/+oci/myociproject";,
+            canonical_url(oci_project))
+
+    def test_traversal(self):
+        oci_project = self.factory.makeOCIProject()
+        obj, _, _ = test_traverse(
+            "http://launchpad.test/%s/+oci/%s"; %
+            (oci_project.pillar.name, oci_project.name))
+        self.assertEqual(oci_project, obj)
+
+
+class TestOCIProjectView(BrowserTestCase):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_index(self):
+        distribution = self.factory.makeDistribution(displayname="My Distro")
+        oci_project = self.factory.makeOCIProject(
+            pillar=distribution, ociprojectname="oci-name")
+        self.assertTextMatchesExpressionIgnoreWhitespace("""\
+            OCI project oci-name for My Distro
+            .*
+            OCI project information
+            Distribution: My Distro
+            Name: oci-name
+            """, self.getMainText(oci_project))
+
+
+class TestOCIProjectEditView(BrowserTestCase):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_edit_oci_project(self):
+        oci_project = self.factory.makeOCIProject()
+        new_distribution = self.factory.makeDistribution(
+            owner=oci_project.pillar.owner)
+
+        browser = self.getViewBrowser(
+            oci_project, user=oci_project.pillar.owner)
+        browser.getLink("Edit OCI project").click()
+        browser.getControl(name="field.distribution").value = [
+            new_distribution.name]
+        browser.getControl(name="field.name").value = "new-name"
+        browser.getControl("Update OCI project").click()
+
+        content = find_main_content(browser.contents)
+        self.assertEqual(
+            "OCI project new-name for %s" % new_distribution.display_name,
+            extract_text(content.h1))
+        self.assertThat(
+            "Distribution:\n%s\nEdit OCI project" % (
+                new_distribution.display_name),
+            MatchesTagText(content, "distribution"))
+        self.assertThat(
+            "Name:\nnew-name\nEdit OCI project",
+            MatchesTagText(content, "name"))
+
+    def test_edit_oci_project_sets_date_last_modified(self):
+        # Editing an OCI project sets the date_last_modified property.
+        date_created = datetime(2000, 1, 1, tzinfo=pytz.UTC)
+        oci_project = self.factory.makeOCIProject(date_created=date_created)
+        self.assertEqual(date_created, oci_project.date_last_modified)
+        with person_logged_in(oci_project.pillar.owner):
+            view = create_initialized_view(
+                oci_project, name="+edit", principal=oci_project.pillar.owner)
+            view.update_action.success({"name": "changed"})
+        self.assertSqlAttributeEqualsDate(
+            oci_project, "date_last_modified", UTC_NOW)
+
+    def test_edit_oci_project_already_exists(self):
+        oci_project = self.factory.makeOCIProject(ociprojectname="one")
+        self.factory.makeOCIProject(
+            pillar=oci_project.pillar, ociprojectname="two")
+        pillar_display_name = oci_project.pillar.display_name
+        browser = self.getViewBrowser(
+            oci_project, user=oci_project.pillar.owner)
+        browser.getLink("Edit OCI project").click()
+        browser.getControl(name="field.name").value = "two"
+        browser.getControl("Update OCI project").click()
+        self.assertEqual(
+            "There is already an OCI project in %s with this name." % (
+                pillar_display_name),
+            extract_text(find_tags_by_class(browser.contents, "message")[1]))
diff --git a/lib/lp/registry/configure.zcml b/lib/lp/registry/configure.zcml
index c95a7b2..2f01072 100644
--- a/lib/lp/registry/configure.zcml
+++ b/lib/lp/registry/configure.zcml
@@ -746,18 +746,19 @@
             interface="lp.registry.interfaces.ociproject.IOCIProjectEdit"
             set_schema="lp.registry.interfaces.ociproject.IOCIProjectEditableAttributes" />
     </class>
-    <securedutility
-        class="lp.registry.model.ociproject.OCIProject"
-        provides="lp.registry.interfaces.ociproject.IOCIProject">
-        <allow
-            interface="lp.registry.interfaces.ociproject.IOCIProject"/>
-    </securedutility>
+    <subscriber
+        for="lp.registry.interfaces.ociproject.IOCIProject zope.lifecycleevent.interfaces.IObjectModifiedEvent"
+        handler="lp.registry.model.ociproject.oci_project_modified" />
     <securedutility
         class="lp.registry.model.ociproject.OCIProjectSet"
         provides="lp.registry.interfaces.ociproject.IOCIProjectSet">
         <allow
             interface="lp.registry.interfaces.ociproject.IOCIProjectSet"/>
     </securedutility>
+    <adapter
+        for="lp.registry.interfaces.ociproject.IOCIProject"
+        provides="lp.services.webapp.interfaces.IBreadcrumb"
+        factory="lp.registry.browser.ociproject.OCIProjectBreadcrumb"/>
 
     <!-- OCIProjectSeries -->
     <class
diff --git a/lib/lp/registry/interfaces/ociproject.py b/lib/lp/registry/interfaces/ociproject.py
index 0750767..8991082 100644
--- a/lib/lp/registry/interfaces/ociproject.py
+++ b/lib/lp/registry/interfaces/ociproject.py
@@ -14,6 +14,7 @@ __all__ = [
 from lazr.restful.fields import (
     CollectionField,
     Reference,
+    ReferenceChoice,
     )
 from zope.interface import (
     Attribute,
@@ -23,6 +24,7 @@ from zope.schema import (
     Datetime,
     Int,
     Text,
+    TextLine,
     )
 
 from lp import _
@@ -54,7 +56,6 @@ class IOCIProjectView(IHasGitRepositories, Interface):
         # Really IOCIProjectSeries
         value_type=Reference(schema=Interface))
 
-    name = Attribute(_("Name"))
     display_name = Attribute(_("Display name for this OCI project."))
 
 
@@ -64,12 +65,14 @@ class IOCIProjectEditableAttributes(IBugTarget):
     These attributes need launchpad.View to see, and launchpad.Edit to change.
     """
 
-    distribution = Reference(
-        IDistribution,
-        title=_("The distribution that this OCI project is associated with."))
+    distribution = ReferenceChoice(
+        title=_("The distribution that this OCI project is associated with."),
+        schema=IDistribution, vocabulary="Distribution",
+        required=True, readonly=False)
+    name = TextLine(title=_("The name of this OCI project."))
     ociprojectname = Reference(
         IOCIProjectName,
-        title=_("The name of this OCI project."),
+        title=_("The name of this OCI project, as an `IOCIProjectName`."),
         required=True,
         readonly=True)
     description = Text(title=_("The description for this OCI project."))
diff --git a/lib/lp/registry/interfaces/ociprojectname.py b/lib/lp/registry/interfaces/ociprojectname.py
index 3c34ffc..e2ed738 100644
--- a/lib/lp/registry/interfaces/ociprojectname.py
+++ b/lib/lp/registry/interfaces/ociprojectname.py
@@ -39,13 +39,16 @@ class IOCIProjectNameSet(Interface):
     """A set of `OCIProjectName`."""
 
     def __getitem__(name):
-        """Retrieve a `OCIProjectName` by name."""
+        """Retrieve an `OCIProjectName` by name."""
 
     def getByName(name):
-        """Return a `OCIProjectName` by its name.
+        """Return an `OCIProjectName` by its name.
 
         :raises NoSuchOCIProjectName: if the `OCIProjectName` can't be found.
         """
 
     def new(name):
         """Create a new `OCIProjectName`."""
+
+    def getOrCreateByName(name):
+        """Return an `OCIProjectName` by its name, creating it if necessary."""
diff --git a/lib/lp/registry/model/ociproject.py b/lib/lp/registry/model/ociproject.py
index f0975b3..7dcfdea 100644
--- a/lib/lp/registry/model/ociproject.py
+++ b/lib/lp/registry/model/ociproject.py
@@ -19,7 +19,9 @@ from storm.locals import (
     Reference,
     Unicode,
     )
+from zope.component import getUtility
 from zope.interface import implementer
+from zope.security.proxy import removeSecurityProxy
 
 from lp.bugs.model.bugtarget import BugTargetBase
 from lp.registry.interfaces.distribution import IDistribution
@@ -27,10 +29,14 @@ from lp.registry.interfaces.ociproject import (
     IOCIProject,
     IOCIProjectSet,
     )
+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.constants import DEFAULT
+from lp.services.database.constants import (
+    DEFAULT,
+    UTC_NOW,
+    )
 from lp.services.database.interfaces import (
     IMasterStore,
     IStore,
@@ -38,6 +44,17 @@ from lp.services.database.interfaces import (
 from lp.services.database.stormbase import StormBase
 
 
+def oci_project_modified(oci_project, event):
+    """Update the date_last_modified property when an OCIProject is modified.
+
+    This method is registered as a subscriber to `IObjectModifiedEvent`
+    events on OCI projects.
+    """
+    # This attribute is normally read-only; bypass the security proxy to
+    # avoid that.
+    removeSecurityProxy(oci_project).date_last_modified = UTC_NOW
+
+
 @implementer(IOCIProject)
 class OCIProject(BugTargetBase, StormBase):
     """See `IOCIProject` and `IOCIProjectSet`."""
@@ -70,6 +87,11 @@ class OCIProject(BugTargetBase, StormBase):
     def name(self):
         return self.ociprojectname.name
 
+    @name.setter
+    def name(self, value):
+        self.ociprojectname = getUtility(IOCIProjectNameSet).getOrCreateByName(
+            value)
+
     @property
     def pillar(self):
         """See `IBugTarget`."""
@@ -79,7 +101,7 @@ class OCIProject(BugTargetBase, StormBase):
     def display_name(self):
         """See `IOCIProject`."""
         return "OCI project %s for %s" % (
-            self.ociprojectname.name, self.pillar.name)
+            self.ociprojectname.name, self.pillar.display_name)
 
     bugtargetname = display_name
     bugtargetdisplayname = display_name
diff --git a/lib/lp/registry/model/ociprojectname.py b/lib/lp/registry/model/ociprojectname.py
index 9085916..5591bd8 100644
--- a/lib/lp/registry/model/ociprojectname.py
+++ b/lib/lp/registry/model/ociprojectname.py
@@ -70,3 +70,10 @@ class OCIProjectNameSet:
         project_name = OCIProjectName(name=name)
         store.add(project_name)
         return project_name
+
+    def getOrCreateByName(self, name):
+        """See `IOCIProjectNameSet`."""
+        try:
+            return self.getByName(name)
+        except NoSuchOCIProjectName:
+            return self.new(name)
diff --git a/lib/lp/registry/templates/ociproject-index.pt b/lib/lp/registry/templates/ociproject-index.pt
new file mode 100644
index 0000000..8a9a6a1
--- /dev/null
+++ b/lib/lp/registry/templates/ociproject-index.pt
@@ -0,0 +1,49 @@
+<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/main_side"
+  i18n:domain="launchpad"
+>
+
+<body>
+  <metal:registering fill-slot="registering">
+    Created by
+      <tal:registrant replace="structure context/registrant/fmt:link"/>
+    on
+      <tal:created-on replace="structure context/date_created/fmt:date"/>
+    and last modified on
+      <tal:last-modified replace="structure context/date_last_modified/fmt:date"/>
+  </metal:registering>
+
+  <metal:side fill-slot="side">
+    <div tal:replace="structure context/@@+global-actions"/>
+  </metal:side>
+
+  <metal:heading fill-slot="heading">
+    <h1 tal:content="context/display_name"/>
+  </metal:heading>
+
+  <div metal:fill-slot="main">
+    <h2>OCI project information</h2>
+    <div class="two-column-list">
+      <dl id="distribution" tal:define="distribution context/distribution">
+        <dt>Distribution:</dt>
+        <dd>
+          <a tal:attributes="href distribution/fmt:url"
+             tal:content="distribution/display_name"/>
+          <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
+        </dd>
+      </dl>
+      <dl id="name">
+        <dt>Name:</dt>
+        <dd>
+          <span tal:content="context/name"/>
+          <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
+        </dd>
+      </dl>
+    </div>
+  </div>
+</body>
+</html>
diff --git a/lib/lp/registry/tests/test_ociproject.py b/lib/lp/registry/tests/test_ociproject.py
index 174b12b..42c5d7d 100644
--- a/lib/lp/registry/tests/test_ociproject.py
+++ b/lib/lp/registry/tests/test_ociproject.py
@@ -71,7 +71,15 @@ class TestOCIProject(TestCaseWithFactory):
         oci_project_name = self.factory.makeOCIProjectName(name='test-name')
         oci_project = self.factory.makeOCIProject(
             ociprojectname=oci_project_name)
-        self.assertEqual(oci_project.name, 'test-name')
+        self.assertEqual('test-name', oci_project.name)
+
+    def test_display_name(self):
+        oci_project_name = self.factory.makeOCIProjectName(name='test-name')
+        oci_project = self.factory.makeOCIProject(
+            ociprojectname=oci_project_name)
+        self.assertEqual(
+            'OCI project test-name for %s' % oci_project.pillar.display_name,
+            oci_project.display_name)
 
 
 class TestOCIProjectSet(TestCaseWithFactory):