← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~allenap/launchpad/sub-search-ui-bug-656823-5 into lp:launchpad

 

Gavin Panella has proposed merging lp:~allenap/launchpad/sub-search-ui-bug-656823-5 into lp:launchpad with lp:~allenap/launchpad/sub-search-ui-bug-656823-4-cleanup as a prerequisite.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  #656823 Subscribing to a search lacks a UI
  https://bugs.launchpad.net/bugs/656823

For more details, see:
https://code.launchpad.net/~allenap/launchpad/sub-search-ui-bug-656823-5/+merge/46179

This branch adds links to create new bug subscription filters, and the
forms necessary to populate filters initially.

- BugSubscriptionFilterCreateView is the form, and is registered as
  IStructuralSubscription:+new-filter. It is subclassed from the +edit
  view.

  For some reason the actions from the +edit view are not inherited. I
  am a bit suspicious about that. I have one more branch in this
  series and I suspect I'll refactor everything but the actions into a
  new base class just to make it unambiguous.

- I have fixed the "(edit)" links to only appear when the logged-in
  user actually has the ability to edit the bug filter.

- I have removed the template for +edit and used generic-edit.pt
  instead.

- I discovered that StructuralSubscription.newBugFilter() could be
  called by any logged-in user, which isn't right. I've split
  IStructuralSubscription up into *Public and *Restricted interfaces,
  changed the security declaration zcml, and added a new security
  adapter - EditStructuralSubscription - to take care of this.

  This security adapter is in a new lp.registry.security module, which
  I have also registered. This module is now ready and waiting for
  anyone to move other Registry security adapters over from
  canonical.launchpad.security.

- The new links to create bug filters have been added to the template
  and only appear when the user has permission to do so; i.e. to call
  newBugFilter().

-- 
https://code.launchpad.net/~allenap/launchpad/sub-search-ui-bug-656823-5/+merge/46179
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~allenap/launchpad/sub-search-ui-bug-656823-5 into lp:launchpad.
=== modified file 'lib/lp/bugs/browser/bugsubscriptionfilter.py'
--- lib/lp/bugs/browser/bugsubscriptionfilter.py	2011-01-10 15:23:31 +0000
+++ lib/lp/bugs/browser/bugsubscriptionfilter.py	2011-01-13 20:41:59 +0000
@@ -104,3 +104,18 @@
             self.user, view_name="+structural-subscriptions")
 
     cancel_url = next_url
+
+
+class BugSubscriptionFilterCreateView(BugSubscriptionFilterEditView):
+
+    page_title = u"Create new filter"
+
+    # The context does not correspond to the thing we're creating - which,
+    # somewhat obviously, doesn't exist yet - so don't even try to render it.
+    render_context = False
+
+    @action("Create", name="create")
+    def create_action(self, action, data):
+        """Create a new bug filter with the form data."""
+        bug_filter = self.context.newBugFilter()
+        self.updateContextFromData(data, context=bug_filter)

=== modified file 'lib/lp/bugs/browser/configure.zcml'
--- lib/lp/bugs/browser/configure.zcml	2011-01-10 14:20:13 +0000
+++ lib/lp/bugs/browser/configure.zcml	2011-01-13 20:41:59 +0000
@@ -1192,9 +1192,15 @@
       <browser:page
           for="lp.bugs.interfaces.bugsubscriptionfilter.IBugSubscriptionFilter"
           class=".bugsubscriptionfilter.BugSubscriptionFilterEditView"
-          template="../templates/bug-subscription-filter-edit.pt"
+          template="../../app/templates/generic-edit.pt"
           permission="launchpad.Edit"
           name="+edit" />
+      <browser:page
+          for="lp.registry.interfaces.structuralsubscription.IStructuralSubscription"
+          class=".bugsubscriptionfilter.BugSubscriptionFilterCreateView"
+          template="../../app/templates/generic-edit.pt"
+          permission="launchpad.View"
+          name="+new-filter" />
     </facet>
 
     <!-- Widgets -->

=== modified file 'lib/lp/bugs/browser/tests/test_bugsubscriptionfilter.py'
--- lib/lp/bugs/browser/tests/test_bugsubscriptionfilter.py	2011-01-13 20:41:58 +0000
+++ lib/lp/bugs/browser/tests/test_bugsubscriptionfilter.py	2011-01-13 20:41:59 +0000
@@ -25,11 +25,11 @@
     BugTaskImportance,
     BugTaskStatus,
     )
-from lp.bugs.publisher import BugsLayer
 from lp.registry.browser.structuralsubscription import (
     StructuralSubscriptionNavigation,
     )
 from lp.testing import (
+    anonymous_logged_in,
     login_person,
     normalize_whitespace,
     person_logged_in,
@@ -308,7 +308,7 @@
         # the absense of conditions.
         self.assertRender(
             u"This filter does not allow mail through.",
-            u"There are no filter conditions! (edit)")
+            u"There are no filter conditions!")
 
     def test_render_with_no_description_and_conditions(self):
         # If conditions are set but no description, the rendered description
@@ -327,7 +327,7 @@
             self.subscription_filter.tags = [u"foo", u"bar"]
         self.assertRender(
             u"This filter allows mail through when:",
-            u" and ".join(self.view.conditions) + u" (edit)")
+            u" and ".join(self.view.conditions))
 
     def test_render_with_description_and_no_conditions(self):
         # If a description is set it appears in the content of the dt tag,
@@ -336,7 +336,7 @@
             self.subscription_filter.description = u"The Wait"
         self.assertRender(
             u"\u201cThe Wait\u201d does not allow mail through.",
-            u"There are no filter conditions! (edit)")
+            u"There are no filter conditions!")
 
     def test_render_with_description_and_conditions(self):
         # If a description is set it appears in the content of the dt tag,
@@ -346,7 +346,34 @@
             self.subscription_filter.tags = [u"foo"]
         self.assertRender(
             u"\u201cThe Wait\u201d allows mail through when:",
-            u" and ".join(self.view.conditions) + u" (edit)")
+            u" and ".join(self.view.conditions))
+
+    def findEditLinks(self, view):
+        root = html.fromstring(view.render())
+        return [
+            node for node in root.findall("dd//a")
+            if node.get("href").endswith("/+edit")]
+
+    def test_edit_link_for_subscriber(self):
+        # A link to edit the filter is rendered for the subscriber.
+        with person_logged_in(self.subscription.subscriber):
+            subscriber_view = create_initialized_view(
+                self.subscription_filter, "+definition")
+            self.assertNotEqual([], self.findEditLinks(subscriber_view))
+
+    def test_edit_link_for_non_subscriber(self):
+        # A link to edit the filter is *not* rendered for anyone but the
+        # subscriber.
+        with person_logged_in(self.factory.makePerson()):
+            non_subscriber_view = create_initialized_view(
+                self.subscription_filter, "+definition")
+            self.assertEqual([], self.findEditLinks(non_subscriber_view))
+
+    def test_edit_link_for_anonymous(self):
+        # A link to edit the filter is *not* rendered for anyone but the
+        # subscriber.
+        with anonymous_logged_in():
+            self.assertEqual([], self.findEditLinks(self.view))
 
 
 class TestBugSubscriptionFilterEditView(
@@ -359,8 +386,7 @@
         # subscription overview page.
         login_person(self.owner)
         view = create_initialized_view(
-            self.subscription_filter, name="+edit",
-            layer=BugsLayer, principal=self.owner)
+            self.subscription_filter, name="+edit")
         self.assertEqual([], view.errors)
         path = "/~%s/+structural-subscriptions" % self.owner.name
         self.assertEqual(path, urlparse(view.cancel_url).path)
@@ -378,8 +404,7 @@
             }
         with person_logged_in(self.owner):
             view = create_initialized_view(
-                self.subscription_filter, name="+edit",
-                form=form, layer=BugsLayer, principal=self.owner)
+                self.subscription_filter, name="+edit", form=form)
             self.assertEqual([], view.errors)
         # The subscription filter has been updated.
         self.assertEqual(
@@ -396,3 +421,60 @@
             self.subscription_filter.tags)
         self.assertTrue(
             self.subscription_filter.find_all_tags)
+
+
+class TestBugSubscriptionFilterCreateView(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestBugSubscriptionFilterCreateView, self).setUp()
+        self.owner = self.factory.makePerson(name=u"foo")
+        self.structure = self.factory.makeProduct(
+            owner=self.owner, name=u"bar")
+        with person_logged_in(self.owner):
+            self.subscription = self.structure.addBugSubscription(
+                self.owner, self.owner)
+
+    def test_view_properties(self):
+        # The cancel url and next url will both point to the user's structural
+        # subscription overview page.
+        login_person(self.owner)
+        view = create_initialized_view(
+            self.subscription, name="+new-filter")
+        self.assertEqual([], view.errors)
+        path = "/~%s/+structural-subscriptions" % self.owner.name
+        self.assertEqual(path, urlparse(view.cancel_url).path)
+        self.assertEqual(path, urlparse(view.next_url).path)
+
+    def test_create(self):
+        # New filters can be created with +new-filter.
+        self.assertEqual([], list(self.subscription.bug_filters))
+        form = {
+            "field.description": "New description",
+            "field.statuses": ["NEW", "INCOMPLETE"],
+            "field.importances": ["LOW", "MEDIUM"],
+            "field.tags": u"foo bar",
+            "field.find_all_tags": "on",
+            "field.actions.create": "Create",
+            }
+        with person_logged_in(self.owner):
+            view = create_initialized_view(
+                self.subscription, name="+new-filter", form=form)
+            self.assertEqual([], view.errors)
+        # The subscription filter has been created.
+        subscription_filter = self.subscription.bug_filters.one()
+        self.assertEqual(
+            u"New description",
+            subscription_filter.description)
+        self.assertEqual(
+            frozenset([BugTaskStatus.NEW, BugTaskStatus.INCOMPLETE]),
+            subscription_filter.statuses)
+        self.assertEqual(
+            frozenset([BugTaskImportance.LOW, BugTaskImportance.MEDIUM]),
+            subscription_filter.importances)
+        self.assertEqual(
+            frozenset([u"foo", u"bar"]),
+            subscription_filter.tags)
+        self.assertTrue(
+            subscription_filter.find_all_tags)

=== removed file 'lib/lp/bugs/templates/bug-subscription-filter-edit.pt'
--- lib/lp/bugs/templates/bug-subscription-filter-edit.pt	2011-01-07 17:38:18 +0000
+++ lib/lp/bugs/templates/bug-subscription-filter-edit.pt	1970-01-01 00:00:00 +0000
@@ -1,14 +0,0 @@
-<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";
-    xml:lang="en" lang="en" dir="ltr"
-    metal:use-macro="view/macro:page/main_only"
-    i18n:domain="malone">
-  <body>
-    <div metal:fill-slot="main">
-      <div metal:use-macro="context/@@launchpad_form/form" />
-    </div>
-  </body>
-</html>

=== modified file 'lib/lp/bugs/templates/bug-subscription-filter.pt'
--- lib/lp/bugs/templates/bug-subscription-filter.pt	2011-01-11 09:45:13 +0000
+++ lib/lp/bugs/templates/bug-subscription-filter.pt	2011-01-13 20:41:59 +0000
@@ -27,10 +27,14 @@
         <b>and</b> <br />
       </tal:conjunction>
     </tal:conditions>
-    <br /><a tal:attributes="href context/fmt:url/+edit">(edit)</a>
+    <tal:editable condition="context/required:launchpad.Edit">
+      <br /><a tal:attributes="href context/fmt:url/+edit">(edit)</a>
+    </tal:editable>
   </dd>
   <dd tal:condition="not:conditions">
     There are no filter conditions!
-    <br /><a tal:attributes="href context/fmt:url/+edit">(edit)</a>
+    <tal:editable condition="context/required:launchpad.Edit">
+      <br /><a tal:attributes="href context/fmt:url/+edit">(edit)</a>
+    </tal:editable>
   </dd>
 </dl>

=== modified file 'lib/lp/registry/configure.zcml'
--- lib/lp/registry/configure.zcml	2011-01-05 08:10:10 +0000
+++ lib/lp/registry/configure.zcml	2011-01-13 20:41:59 +0000
@@ -10,6 +10,7 @@
     xmlns:lp="http://namespaces.canonical.com/lp";
     xmlns:webservice="http://namespaces.canonical.com/webservice";
     i18n_domain="launchpad">
+    <authorizations module=".security" />
     <include
         file="vocabularies.zcml"/>
     <include
@@ -1862,10 +1863,13 @@
     <class
         class="lp.registry.model.structuralsubscription.StructuralSubscription">
         <allow
-            interface="lp.registry.interfaces.structuralsubscription.IStructuralSubscription"/>
-        <require
-            permission="zope.Public"
-            set_schema="lp.registry.interfaces.structuralsubscription.IStructuralSubscription"/>
+            interface=".interfaces.structuralsubscription.IStructuralSubscriptionPublic" />
+        <require
+            set_schema=".interfaces.structuralsubscription.IStructuralSubscriptionPublic"
+            permission="zope.Public" />
+        <require
+            interface=".interfaces.structuralsubscription.IStructuralSubscriptionRestricted"
+            permission="launchpad.Edit" />
     </class>
 
     <!-- PersonNotification -->

=== modified file 'lib/lp/registry/interfaces/structuralsubscription.py'
--- lib/lp/registry/interfaces/structuralsubscription.py	2010-12-21 09:42:47 +0000
+++ lib/lp/registry/interfaces/structuralsubscription.py	2011-01-13 20:41:59 +0000
@@ -83,9 +83,8 @@
         """)
 
 
-class IStructuralSubscription(Interface):
-    """A subscription to a Launchpad structure."""
-    export_as_webservice_entry()
+class IStructuralSubscriptionPublic(Interface):
+    """The public parts of a subscription to a Launchpad structure."""
 
     id = Int(title=_('ID'), readonly=True, required=True)
     product = Int(title=_('Product'), required=False, readonly=True)
@@ -134,11 +133,22 @@
         readonly=True, required=False,
         value_type=Reference(schema=Interface)))
 
+
+class IStructuralSubscriptionRestricted(Interface):
+    """The restricted parts of a subscription to a Launchpad structure."""
+
     @export_factory_operation(Interface, [])
     def newBugFilter():
         """Returns a new `BugSubscriptionFilter` for this subscription."""
 
 
+class IStructuralSubscription(
+    IStructuralSubscriptionPublic, IStructuralSubscriptionRestricted):
+    """A subscription to a Launchpad structure."""
+
+    export_as_webservice_entry()
+
+
 class IStructuralSubscriptionTargetRead(Interface):
     """A Launchpad Structure allowing users to subscribe to it.
 

=== added file 'lib/lp/registry/security.py'
--- lib/lp/registry/security.py	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/security.py	2011-01-13 20:41:59 +0000
@@ -0,0 +1,23 @@
+# Copyright 2011 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Security adapters for Registry."""
+
+__metaclass__ = type
+__all__ = []
+
+from canonical.launchpad.security import AuthorizationBase
+from lp.registry.interfaces.structuralsubscription import (
+    IStructuralSubscription,
+    )
+
+
+class EditStructuralSubscription(AuthorizationBase):
+    """Edit permissions for `IStructuralSubscription`."""
+
+    usedfor = IStructuralSubscription
+    permission = "launchpad.Edit"
+
+    def checkAuthenticated(self, user):
+        """Subscribers can edit their own structural subscriptions."""
+        return user.inTeam(self.obj.subscriber)

=== modified file 'lib/lp/registry/stories/person/xx-person-subscriptions.txt'
--- lib/lp/registry/stories/person/xx-person-subscriptions.txt	2011-01-11 21:14:36 +0000
+++ lib/lp/registry/stories/person/xx-person-subscriptions.txt	2011-01-13 20:41:59 +0000
@@ -171,7 +171,7 @@
     >>> subscriptions = find_tag_by_id(
     ...     admin_browser.contents, 'structural-subscriptions')
     >>> for subscription in subscriptions.findAll("li"):
-    ...     structure_link, modify_link = subscription.findAll("a")
+    ...     structure_link, modify_link = subscription.findAll("a")[:2]
     ...     print "%s <%s>" % (
     ...         extract_text(structure_link), structure_link.get("href"))
     ...     print "--> %s" % modify_link.get("href")
@@ -188,7 +188,7 @@
     >>> subscriptions = find_tag_by_id(
     ...     subscriber_browser.contents, 'structural-subscriptions')
     >>> for subscription in subscriptions.findAll("li"):
-    ...     [structure_link] = subscription.findAll("a")
+    ...     structure_link = subscription.find("a")
     ...     print "%s <%s>" % (
     ...         extract_text(structure_link), structure_link.get("href"))
     mozilla-firefox in ubuntu </ubuntu/+source/mozilla-firefox>
@@ -210,6 +210,48 @@
     Webster does not have any structural subscriptions.
 
 
+Creating Bug Filters
+~~~~~~~~~~~~~~~~~~~~
+
+Every structural subscription also has a link to create a new bug
+subscription filter.
+
+    >>> import re
+
+    >>> def show_create_links(browser):
+    ...     subscriptions = find_tag_by_id(
+    ...         browser.contents, 'structural-subscriptions')
+    ...     for subscription in subscriptions.findAll("li"):
+    ...         structure_link = subscription.find("a")
+    ...         print extract_text(structure_link)
+    ...         create_text = subscription.find(text=re.compile("Create"))
+    ...         if create_text is None:
+    ...             print "* No create link."
+    ...         else:
+    ...             print "* %s --> %s" % (
+    ...                 create_text.strip(),
+    ...                 create_text.parent.get("href"))
+
+    >>> admin_browser.open(
+    ...     "http://launchpad.dev/people/+me/+structural-subscriptions";)
+    >>> show_create_links(admin_browser)
+    mozilla-firefox in ubuntu
+    * Create a new filter --> /ubuntu/.../name16/+new-filter
+    pmount in ubuntu
+    * Create a new filter --> /ubuntu/.../name16/+new-filter
+
+If the user does not have the necessary rights to create new bug
+filters the "Create" link is not shown.
+
+    >>> subscriber_browser.open(
+    ...     "http://launchpad.dev/~name16/+structural-subscriptions";)
+    >>> show_create_links(subscriber_browser)
+    mozilla-firefox in ubuntu
+    * No create link.
+    pmount in ubuntu
+    * No create link.
+
+
 Subscriptions with Bug Filters
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
@@ -234,7 +276,9 @@
     ...     subscriptions = find_tag_by_id(
     ...         nigel_browser.contents, 'structural-subscriptions')
     ...     for subscription in subscriptions.findAll("li"):
-    ...         print extract_text(subscription.div)
+    ...         print extract_text(subscription.p)
+    ...         if subscription.dl is not None:
+    ...             print extract_text(subscription.dl)
 
     >>> show_nigels_subscriptions()
     Bug mail is not filtered; mail for Nigel about Scofflaw will

=== modified file 'lib/lp/registry/templates/person-structural-subscriptions.pt'
--- lib/lp/registry/templates/person-structural-subscriptions.pt	2011-01-07 16:01:55 +0000
+++ lib/lp/registry/templates/person-structural-subscriptions.pt	2011-01-13 20:41:59 +0000
@@ -55,12 +55,21 @@
                   </dl>
                 </tal:filtered>
                 <tal:unfiltered condition="not:bug_filters">
-                  Bug mail is <b>not</b> filtered; mail for <span
-                  tal:content="subscription/subscriber/fmt:displayname" />
-                  about
-                  <span tal:content="subscription/target/displayname" />
-                  will always be sent.
+                  <p>
+                    Bug mail is <b>not</b> filtered; mail for <span
+                    tal:content="subscription/subscriber/fmt:displayname" />
+                    about
+                    <span tal:content="subscription/target/displayname" />
+                    will always be sent.
+                  </p>
                 </tal:unfiltered>
+                <div style="margin-top: 1em"
+                     tal:condition="subscription/required:launchpad.Edit">
+                  <a class="sprite add new-filter"
+                     tal:attributes="href subscription/fmt:url/+new-filter">
+                    Create a new filter
+                  </a>
+                </div>
               </div>
             </li>
           </ul>

=== modified file 'lib/lp/registry/tests/test_structuralsubscription.py'
--- lib/lp/registry/tests/test_structuralsubscription.py	2010-10-01 16:22:14 +0000
+++ lib/lp/registry/tests/test_structuralsubscription.py	2011-01-13 20:41:59 +0000
@@ -5,37 +5,61 @@
 
 __metaclass__ = type
 
-from canonical.testing.layers import LaunchpadZopelessLayer
+from zope.security.interfaces import Unauthorized
+
+from canonical.testing.layers import DatabaseFunctionalLayer
 from lp.bugs.model.bugsubscriptionfilter import BugSubscriptionFilter
-from lp.testing import TestCaseWithFactory
+from lp.testing import (
+    anonymous_logged_in,
+    person_logged_in,
+    TestCaseWithFactory,
+    )
 
 
 class TestStructuralSubscription(TestCaseWithFactory):
 
-    layer = LaunchpadZopelessLayer
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestStructuralSubscription, self).setUp()
+        self.product = self.factory.makeProduct()
+        with person_logged_in(self.product.owner):
+            self.subscription = self.product.addSubscription(
+                self.product.owner, self.product.owner)
 
     def test_bug_filters_empty(self):
-        # StructuralSubscription.filters returns the BugSubscriptionFilter
-        # records associated with this subscription. It's empty to begin with.
-        product = self.factory.makeProduct()
-        subscription = product.addSubscription(product.owner, product.owner)
-        self.assertEqual([], list(subscription.bug_filters))
+        # The bug_filters attribute is empty to begin with.
+        self.assertEqual([], list(self.subscription.bug_filters))
 
     def test_bug_filters(self):
-        # StructuralSubscription.filters returns the BugSubscriptionFilter
-        # records associated with this subscription.
-        product = self.factory.makeProduct()
-        subscription = product.addSubscription(product.owner, product.owner)
+        # The bug_filters attribute returns the BugSubscriptionFilter records
+        # associated with this subscription.
         subscription_filter = BugSubscriptionFilter()
-        subscription_filter.structural_subscription = subscription
-        self.assertEqual([subscription_filter], list(subscription.bug_filters))
+        subscription_filter.structural_subscription = self.subscription
+        self.assertEqual(
+            [subscription_filter],
+            list(self.subscription.bug_filters))
 
     def test_newBugFilter(self):
-        # Structural_Subscription.newBugFilter() creates a new subscription
-        # filter linked to the subscription.
-        product = self.factory.makeProduct()
-        subscription = product.addSubscription(product.owner, product.owner)
-        subscription_filter = subscription.newBugFilter()
-        self.assertEqual(
-            subscription, subscription_filter.structural_subscription)
-        self.assertEqual([subscription_filter], list(subscription.bug_filters))
+        # newBugFilter() creates a new subscription filter linked to the
+        # subscription.
+        with person_logged_in(self.product.owner):
+            subscription_filter = self.subscription.newBugFilter()
+        self.assertEqual(
+            self.subscription,
+            subscription_filter.structural_subscription)
+        self.assertEqual(
+            [subscription_filter],
+            list(self.subscription.bug_filters))
+
+    def test_newBugFilter_by_anonymous(self):
+        # newBugFilter() is not available to anonymous users.
+        with anonymous_logged_in():
+            self.assertRaises(
+                Unauthorized, lambda: self.subscription.newBugFilter)
+
+    def test_newBugFilter_by_other_user(self):
+        # newBugFilter() is only available to the subscriber.
+        with person_logged_in(self.factory.makePerson()):
+            self.assertRaises(
+                Unauthorized, lambda: self.subscription.newBugFilter)