← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~wallyworld/launchpad/more-accesspolicy-service into lp:launchpad

 

Ian Booth has proposed merging lp:~wallyworld/launchpad/more-accesspolicy-service into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~wallyworld/launchpad/more-accesspolicy-service/+merge/94719

== Implementation ==

The branch provides a "working" end-end prototype of functionality using the new services architecture and rendering approach discussed on the list. It covers:
- model data added to the json cache by the lp view (but not rendered)
- no server side rendering (since no requirement for google to index anything)
- mustache templates used to render on the client
- proper yui widgets used for the view and associated ui components
- service api calls made to "do stuff" (the same api as would be used by launchpadlib)

Add methods to the AccessPolicyService (these are exported to the web service api):
- getProductObservers
- deleteProductObserver

The ProductSharingView uses the IAccessPolicyService getProductObservers() method to get data to put into the json cache. This data is the model for the view. The TAL for the view is essentially empty (just has sample data). The rendering is done in the browser using mustache and the model data just mentioned.

New YUI widgets have been written:
- ObserverTableWidget
- ProductSharingView

The above are used in place of the "current" most common approach used for yui in launchpad where methods are added to a namespace and called rather than using proper widgets.

The ProductSharingView does the work required to render and wire up the view, using the ObserverTableWidget and (previously written) DisclosurePersonPicker widgets.

The new service architecture being prototyped is used for the deletion of observers. It works nicely and has the advantage that a user of api via launchpadlib calls exactly the same api to do a deletion as is called here by the view. No extra form submission handling is required like is done for other views.

== Demo and QA ==

Goto the +sharing page for a product eg http://launchpad.dev/firefox/+sharing
The observer table will show data for 3 observers (hardwired to simply pick 3 people from the database).
The delete icon for each observer "works" (back end call made, but is a no-op)
The popup choice widgets for the access policy sharing permissions "work" (table updated but no server call made).

== Tests ==

YUI tests for:
  ObserverTableWidget  - test_observer_table_widget.js
  ProductSharingView   - test_product_sharing_view.js

Add test to TestProductSharingView test case

AccessPolicyService returns hard coded data, so no tests added yet.

== Lint ==

Linting changed files:
  lib/canonical/launchpad/icing/css/components/disclosure.css
  lib/lp/registry/browser/product.py
  lib/lp/registry/browser/tests/test_product.py
  lib/lp/registry/interfaces/accesspolicyservice.py
  lib/lp/registry/javascript/disclosure/observer_table_widget.js
  lib/lp/registry/javascript/disclosure/product_sharing.js
  lib/lp/registry/javascript/disclosure/tests/test_observer_table_widget.html
  lib/lp/registry/javascript/disclosure/tests/test_observer_table_widget.js
  lib/lp/registry/javascript/disclosure/tests/test_product_sharing_view.html
  lib/lp/registry/javascript/disclosure/tests/test_product_sharing_view.js
  lib/lp/registry/services/accesspolicyservice.py
  lib/lp/registry/services/tests/test_accesspolicyservice.py

-- 
https://code.launchpad.net/~wallyworld/launchpad/more-accesspolicy-service/+merge/94719
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~wallyworld/launchpad/more-accesspolicy-service into lp:launchpad.
=== added file 'lib/canonical/launchpad/icing/css/components/disclosure.css'
--- lib/canonical/launchpad/icing/css/components/disclosure.css	1970-01-01 00:00:00 +0000
+++ lib/canonical/launchpad/icing/css/components/disclosure.css	2012-02-28 02:17:18 +0000
@@ -0,0 +1,25 @@
+table.disclosure td {
+    padding: 0;
+    margin: 0;
+}
+table.disclosure ul {
+    margin-top: 3px
+}
+table.disclosure ul.horizontal li {
+  display: inline;
+  list-style-type: none;
+  padding-right: 10px;
+}
+table.disclosure ul.horizontal li div {
+  display: inline;
+    -moz-border-radius: 5px;
+    -o-border-radius: 5px;
+    -webkit-border-radius: 5px;
+    background-color: #dcdcdc;
+    padding: 3px;
+    width: auto;
+    overflow: hidden;
+}
+table.disclosure tr.spacebefore > td {
+  padding-top: 1em;
+}

=== modified file 'lib/lp/registry/browser/product.py'
--- lib/lp/registry/browser/product.py	2012-02-24 06:33:10 +0000
+++ lib/lp/registry/browser/product.py	2012-02-28 02:17:18 +0000
@@ -53,6 +53,7 @@
 
 from lazr.delegates import delegates
 from lazr.restful.interface import copy_field
+from lazr.restful.interfaces import IJSONRequestCache
 from lazr.restful import ResourceJSONEncoder
 import pytz
 import simplejson
@@ -85,6 +86,7 @@
 from zope.security.proxy import removeSecurityProxy
 
 from lp import _
+from lp.app.interfaces.services import IService
 from lp.answers.browser.faqtarget import FAQTargetNavigationMixin
 from lp.answers.browser.questiontarget import (
     QuestionTargetFacetMixin,
@@ -2424,18 +2426,16 @@
     page_title = "Sharing"
     label = "Sharing information"
 
+    def _getAccessPolicyService(self):
+        return getUtility(IService, 'accesspolicy')
+
     @property
     def access_policies(self):
-        result = []
-        for x, policy in enumerate(AccessPolicyType):
-            item = dict(
-                index=x,
-                value=policy.token,
-                title=policy.title,
-                description=policy.value.description
-            )
-            result.append(item)
-        return result
+        return self._getAccessPolicyService().getAccessPolicies()
+
+    @property
+    def sharing_permissions(self):
+        return self._getAccessPolicyService().getSharingPermissions()
 
     @cachedproperty
     def sharing_vocabulary(self):
@@ -2450,7 +2450,6 @@
     @property
     def sharing_picker_config(self):
         return dict(
-            access_policies=self.access_policies,
             vocabulary='ValidPillarOwner',
             vocabulary_filters=self.sharing_vocabulary_filters,
             header='Grant access to %s'
@@ -2460,3 +2459,14 @@
     def json_sharing_picker_config(self):
         return simplejson.dumps(
             self.sharing_picker_config, cls=ResourceJSONEncoder)
+
+    @property
+    def observer_data(self):
+        return self._getAccessPolicyService().getProductObservers(self.context)
+
+    def initialize(self):
+        super(ProductSharingView, self).initialize()
+        cache = IJSONRequestCache(self.request)
+        cache.objects['access_policies'] = self.access_policies
+        cache.objects['sharing_permissions'] = self.sharing_permissions
+        cache.objects['observer_data'] = self.observer_data

=== modified file 'lib/lp/registry/browser/tests/test_product.py'
--- lib/lp/registry/browser/tests/test_product.py	2012-02-22 05:22:13 +0000
+++ lib/lp/registry/browser/tests/test_product.py	2012-02-28 02:17:18 +0000
@@ -2,6 +2,7 @@
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for product views."""
+from lp.app.interfaces.services import IService
 
 __metaclass__ = type
 
@@ -9,6 +10,7 @@
 
 import pytz
 import simplejson
+from lazr.restful.interfaces import IJSONRequestCache
 from zope.component import getUtility
 from zope.security.proxy import removeSecurityProxy
 
@@ -416,10 +418,20 @@
         product = self.factory.makeProduct()
         view = create_view(product, name='+sharing')
         picker_config = simplejson.loads(view.json_sharing_picker_config)
-        self.assertTrue('access_policies' in picker_config)
         self.assertTrue('vocabulary_filters' in picker_config)
         self.assertEqual(
             'Grant access to %s' % product.displayname,
             picker_config['header'])
         self.assertEqual(
             'ValidPillarOwner', picker_config['vocabulary'])
+
+    def test_view_data_model(self):
+        # Test that the json request cache contains the view data model.
+        product = self.factory.makeProduct()
+        view = create_initialized_view(product, name='+sharing')
+        cache = IJSONRequestCache(view.request)
+        self.assertIsNotNone(cache.objects.get('access_policies'))
+        self.assertIsNotNone(cache.objects.get('sharing_permissions'))
+        aps = getUtility(IService, 'accesspolicy')
+        observers = aps.getProductObservers(product)
+        self.assertTrue(observers, cache.objects.get('observer_data'))

=== modified file 'lib/lp/registry/interfaces/accesspolicyservice.py'
--- lib/lp/registry/interfaces/accesspolicyservice.py	2012-02-23 23:34:39 +0000
+++ lib/lp/registry/interfaces/accesspolicyservice.py	2012-02-28 02:17:18 +0000
@@ -13,10 +13,16 @@
 from lazr.restful.declarations import (
     export_as_webservice_entry,
     export_read_operation,
+    export_write_operation,
     operation_for_version,
+    operation_parameters,
     )
+from lazr.restful.fields import Reference
 
+from lp import _
 from lp.app.interfaces.services import IService
+from lp.registry.interfaces.person import IPerson
+from lp.registry.interfaces.product import IProduct
 
 
 class IAccessPolicyService(IService):
@@ -26,7 +32,23 @@
     # version 'devel'
     export_as_webservice_entry(publish_web_link=False, as_of='beta')
 
-    @export_read_operation()
-    @operation_for_version('devel')
     def getAccessPolicies():
         """Return the access policy types."""
+
+    def getSharingPermissions():
+        """Return the access policy sharing permissions."""
+
+    @export_read_operation()
+    @operation_parameters(
+        product=Reference(IProduct, title=_('Product'), required=True))
+    @operation_for_version('devel')
+    def getProductObservers(product):
+        """Return people/teams who can see product artifacts."""
+
+    @export_write_operation()
+    @operation_parameters(
+        product=Reference(IProduct, title=_('Product'), required=True),
+        observer=Reference(IPerson, title=_('Observer'), required=True))
+    @operation_for_version('devel')
+    def deleteProductObserver(product, observer):
+        """Remove an observer from a product."""

=== added file 'lib/lp/registry/javascript/disclosure/observer_table_widget.js'
--- lib/lp/registry/javascript/disclosure/observer_table_widget.js	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/javascript/disclosure/observer_table_widget.js	2012-02-28 02:17:18 +0000
@@ -0,0 +1,257 @@
+/* Copyright 2012 Canonical Ltd.  This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE).
+ *
+ * Observer table widget.
+ *
+ * @module lp.registry.disclosure.observertable
+ */
+
+YUI.add('lp.registry.disclosure.observertable', function(Y) {
+
+var namespace = Y.namespace('lp.registry.disclosure.observertable');
+
+var
+    NAME = "observerTableWidget",
+    // Events
+    REMOVE_OBSERVER = 'removeObserver';
+
+
+/*
+ * Observer table widget.
+ * This widget displays the observers and their level of access to a product.
+ */
+function ObserverTableWidget(config) {
+    ObserverTableWidget.superclass.constructor.apply(this, arguments);
+}
+
+ObserverTableWidget.ATTRS = {
+    // The duration for various animations eg row deletion.
+    animation_duration: {
+        value: 1
+    },
+    // The list of observers to display.
+    observers: {
+        value: []
+    },
+    // The access policy types: public, publicsecurity, userdata etc.
+    access_policy_types: {
+        value: {}
+    },
+    // The sharing permission choices: all, some, nothing etc.
+    sharing_permissions: {
+        value: {}
+    },
+    // The node holding the observer table.
+    observer_table: {
+        getter: function() {
+            return Y.one('#observer-table');
+        }
+    },
+    // The handlebars template for the observer table.
+    observer_table_template: {
+        value: null
+    },
+    // The handlebars template for the each access policy item.
+    observer_policy_template: {
+        value: null
+    }
+};
+
+Y.extend(ObserverTableWidget, Y.Widget, {
+
+    initializer: function(config) {
+        this.set(
+            'observer_table_template', this._observer_table_template());
+        this.set(
+            'observer_policy_template', this._observer_policy_template());
+        this.publish(REMOVE_OBSERVER);
+    },
+
+    destructor: function() { },
+
+    _observer_table_template: function() {
+        return [
+            '<table class="disclosure listing" id="observer-table">',
+            '    <thead>',
+            '        <tr><th style="width: 33%" ' +
+            '                      colspan="2">User or Team</th>',
+            '            <th colspan="2">',
+            '                Sharing',
+            '                <span class="help">',
+            '                    (<a class="js-action help" target="help"',
+            '                        href="permissions_help.html">help</a>)',
+            '                </span>',
+            '            </th>',
+            '            <th style="width: " colspan="1">Shared items</th>',
+            '        </tr>',
+            '    </thead>',
+            '    <tbody id="observer-table-body">',
+            '        {{#observers}}',
+            '        <tr id="permission-{{name}}"><td>',
+            '            <a href="{{web_link}}" class="sprite person">' +
+            '                                  {{display_name}}',
+            '            <span class="formHelp">{{role}}</span></a>',
+            '        </td>',
+            '        <td id="remove-{{name}}">',
+            '            <a title="Share nothing with this user"',
+            '               href="#" class="sprite remove">',
+            '            </a>',
+            '        </td>',
+            '        <td id="td-permission-{{name}}">',
+            '            <span class="sortkey">1</span>',
+            '            <ul class="horizontal">',
+            '               {{>observer_access_policies}}',
+            '            </ul>',
+            '        </td>',
+            '        <td></td>',
+            '        <td><span class="formHelp">No items shared</span>',
+            '        </td>',
+            '        </tr>',
+            '        {{/observers}}',
+            '    </tbody>',
+            '</table>'].join(' ');
+    },
+
+    _observer_policy_template: function() {
+        return [
+           '{{#access_policies}}',
+           '<li><span id="{{policy}}-permission">',
+           '  <span class="value"></span>',
+           '  <a href="#">',
+           '    <img class="editicon sprite edit"/>',
+           '  </a>',
+           '</span></li>',
+           '{{/access_policies}}'].join(' ');
+    },
+
+    // Render the popup widget to pick the sharing permission for an
+    // access policy.
+    renderObserverPolicy: function(
+            table_node, observer, policy, current_value) {
+        var access_policy_types = this.get('access_policy_types');
+        var sharing_permissions = this.get('sharing_permissions');
+        var choice_items = [];
+        Y.Array.forEach(sharing_permissions, function(permission) {
+            var source_name =
+                '<strong>{policy_name}:</strong> {permission_name}';
+            choice_items.push({
+                value: permission.value,
+                name: permission.name,
+                source_name: Y.Lang.substitute(source_name,
+                    {policy_name: access_policy_types[policy],
+                     permission_name: permission.name})
+            });
+        });
+
+        var id = 'permission-'+observer.name;
+        var observer_row = table_node.one('#' + id);
+        var permission_node = observer_row.one('#td-' + id);
+        var contentBox = permission_node.one('#' + policy + '-permission');
+        var value_location = contentBox.one('.value');
+        var editicon = permission_node.one('img.editicon');
+
+        var permission_edit = new Y.ChoiceSource({
+            contentBox: contentBox,
+            value_location: value_location,
+            editicon: editicon,
+            value: current_value,
+            title: "Share " + access_policy_types[policy] + " with "
+                + observer.display_name,
+            items: choice_items,
+            elementToFlash: contentBox,
+            backgroundColor: '#FFFF99'
+        });
+        permission_edit.render();
+    },
+
+    // Render the access policy values for the observers.
+    renderSharingInfo: function(table_node) {
+        var observers = this.get('observers');
+        var self = this;
+        Y.Array.forEach(observers, function(observer) {
+            var observer_policies = observer.permissions;
+            var policy;
+            for (policy in observer_policies) {
+                if (observer_policies.hasOwnProperty(policy)) {
+                    self.renderObserverPolicy(
+                        table_node, observer, policy,
+                        observer_policies[policy]);
+                }
+            }
+        });
+    },
+
+    renderUI: function() {
+        var partials = {
+            observer_access_policies:
+                this.get('observer_policy_template')
+        };
+        var observers = this.get('observers');
+        Y.Array.forEach(observers, function(observer) {
+            var observer_policies = observer.permissions;
+            var policy_values = [];
+            var policy;
+            for (policy in observer_policies) {
+                if (observer_policies.hasOwnProperty(policy)) {
+                    var policy_value = {policy: policy};
+                    policy_values.push(policy_value);
+                }
+            }
+            observer.access_policies = policy_values;
+        });
+
+        var html = Y.lp.mustache.to_html(
+            this.get('observer_table_template'),
+            {observers: observers}, partials);
+        var table_node = Y.Node.create(html);
+        this.get('observer_table').replace(table_node);
+        this.renderSharingInfo(table_node);
+    },
+
+    bindUI: function() {
+        var observers = this.get('observers');
+
+        var self = this;
+        // Bind the delete observer link.
+        Y.Array.forEach(observers, function(observer) {
+            var link_id = 'remove-' + observer.name;
+            var delete_link = self.get('observer_table')
+                .one('td#' + link_id + ' a');
+            delete_link.on('click', function(e) {
+                e.preventDefault();
+                self.fire(REMOVE_OBSERVER, delete_link, observer.self_link);
+            });
+        });
+    },
+
+    syncUI: function() { },
+
+    // Delete the specified observer from the table.
+    deleteObserver: function(observer) {
+        var table_row = this.get('observer_table')
+            .one('tr[id=permission-' + observer.name + ']');
+        if (!Y.Lang.isValue(table_row)) {
+            return;
+        }
+        var anim_duration = this.get('animation_duration');
+        if (anim_duration === 0 ) {
+            table_row.remove(true);
+            return;
+        }
+        var anim = Y.lp.anim.red_flash(
+            {node: table_row, duration:anim_duration});
+        anim.on('end', function() {
+            table_row.remove(true);
+        });
+        anim.run();
+    }
+});
+
+ObserverTableWidget.NAME = NAME;
+ObserverTableWidget.REMOVE_OBSERVER = REMOVE_OBSERVER;
+namespace.ObserverTableWidget = ObserverTableWidget;
+
+}, "0.1", { "requires": [
+    'node', 'collection', 'lazr.choiceedit',
+    'lp.mustache', 'lp.registry.disclosure'] });
+

=== modified file 'lib/lp/registry/javascript/disclosure/product_sharing.js'
--- lib/lp/registry/javascript/disclosure/product_sharing.js	2012-02-22 05:22:13 +0000
+++ lib/lp/registry/javascript/disclosure/product_sharing.js	2012-02-28 02:17:18 +0000
@@ -10,22 +10,36 @@
 
 var namespace = Y.namespace('lp.registry.disclosure.sharing');
 
-var disclosure_picker = null;
-
-var save_sharing_selection = function(result) {
-    Y.log(result.access_policy);
-    Y.log(result.api_uri);
+function ProductSharingView(config) {
+    ProductSharingView.superclass.constructor.apply(this, arguments);
+}
+
+ProductSharingView.ATTRS = {
+    lp_client: {
+        value: new Y.lp.client.Launchpad()
+    },
+
+    disclosure_picker: {
+        value: null
+    },
+
+    observer_table: {
+        value: null
+    }
 };
 
-var setup_product_sharing = function(config) {
+Y.extend(ProductSharingView, Y.Widget, {
 
-    if (disclosure_picker === null) {
+    initializer: function(config) {
         var vocab = 'ValidPillarOwner';
         var header = 'Grant access to project artifacts.';
         if (config !== undefined) {
             if (config.header !== undefined) {
                 header = config.header;
             }
+            if (config.vocabulary !== undefined) {
+                vocab = config.vocabulary;
+            }
         } else {
             config = {};
         }
@@ -39,24 +53,124 @@
             headerContent: Y.Node.create("<h2></h2>").set('text', header),
             zIndex: 1000,
             visible: false,
-            save: save_sharing_selection
+            access_policies: LP.cache.access_policies,
+            save: this.saveSharingSelection
         });
-        disclosure_picker =
+        var disclosure_picker =
             new Y.lp.registry.disclosure.DisclosurePicker(new_config);
         Y.lp.app.picker.setup_vocab_picker(
             disclosure_picker, vocab, new_config);
+        this.set('disclosure_picker', disclosure_picker);
+    },
+
+    destructor: function() { },
+
+    renderUI: function() {
+        var access_policy_types = {};
+        Y.Array.each(LP.cache.access_policies, function(policy) {
+            access_policy_types[policy.value] = policy.title;
+        });
+        var sharing_permissions = LP.cache.sharing_permissions;
+        var observer_data = LP.cache.observer_data;
+        var otns = Y.lp.registry.disclosure.observertable;
+        var observer_table = new otns.ObserverTableWidget({
+            observers: observer_data,
+            sharing_permissions: sharing_permissions,
+            access_policy_types: access_policy_types
+        });
+        this.set('observer_table', observer_table);
+        observer_table.render();
+    },
+
+    bindUI: function() {
+        var self = this;
+        var share_link = Y.one('#add-observer-link');
+        share_link.on('click', function(e) {
+            e.preventDefault();
+            self.get('disclosure_picker').show();
+        });
+        var observer_table = this.get('observer_table');
+        var otns = Y.lp.registry.disclosure.observertable;
+        observer_table.subscribe(
+            otns.ObserverTableWidget.REMOVE_OBSERVER, function(e) {
+                self.performRemoveObserver(
+                    observer_table, e.details[0], e.details[1]);
+        });
+    },
+
+    syncUI: function() {
+    },
+
+
+    /**
+     * Show a spinner next to the delete icon.
+     *
+     * @method _showDeleteSpinner
+     */
+    _showDeleteSpinner: function(delete_link) {
+        var spinner_node = Y.Node.create(
+        '<img class="spinner" src="/@@/spinner" alt="Removing..." />');
+        delete_link.insertBefore(spinner_node, delete_link);
+        delete_link.addClass('unseen');
+    },
+
+    /**
+     * Hide the delete spinner.
+     *
+     * @method _hideDeleteSpinner
+     */
+    _hideDeleteSpinner: function(delete_link) {
+        delete_link.removeClass('unseen');
+        var spinner = delete_link.get('parentNode').one('.spinner');
+        if (spinner !== null) {
+            spinner.remove();
+        }
+    },
+
+    removeObserverSuccess: function(observer_table, person_uri) {
+        var observer_data = LP.cache.observer_data;
+        Y.Array.some(observer_data, function(observer, index) {
+            if (observer.self_link === person_uri) {
+                observer_data.splice(index, 1);
+                observer_table.deleteObserver(observer);
+                return true;
+            }
+        });
+    },
+
+    performRemoveObserver: function(observer_table, delete_link, person_uri) {
+        var error_handler = new Y.lp.client.ErrorHandler();
+        var product_uri = LP.cache.context.self_link;
+        var self = this;
+        var y_config =  {
+            on: {
+                start: Y.bind(self._showDeleteSpinner, namespace, delete_link),
+                end: Y.bind(self._hideDeleteSpinner, namespace, delete_link),
+                success: function() {
+                    self.removeObserverSuccess(observer_table, person_uri);
+                },
+                failure: error_handler.getFailureHandler()
+            },
+            parameters: {
+                product: product_uri,
+                observer: person_uri
+            }
+        };
+        this.get('lp_client').named_post(
+            '/+services/accesspolicy', 'deleteProductObserver', y_config);
+    },
+
+    saveSharingSelection: function(result) {
+        Y.log(result.access_policy);
+        Y.log(result.api_uri);
     }
-
-    var share_link = Y.one('#add-observer-link');
-    share_link.on('click', function(e) {
-        e.preventDefault();
-        disclosure_picker.show();
-    });
-};
-
-namespace.setup_product_sharing = setup_product_sharing;
+});
+
+ProductSharingView.NAME = 'productSharingView';
+namespace.ProductSharingView = ProductSharingView;
 
 }, "0.1", { "requires": [
-    'node', 'lp.mustache', 'lazr.picker', 'lp.app.picker',
-    'lp.registry.disclosure'] });
+    'node', 'lp.client', 'lp.mustache', 'lazr.picker', 'lp.app.picker',
+    'lp.mustache', 'lp.registry.disclosure',
+    'lp.registry.disclosure.observertable'] });
 

=== added file 'lib/lp/registry/javascript/disclosure/tests/test_observer_table_widget.html'
--- lib/lp/registry/javascript/disclosure/tests/test_observer_table_widget.html	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/javascript/disclosure/tests/test_observer_table_widget.html	2012-02-28 02:17:18 +0000
@@ -0,0 +1,72 @@
+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
+  "http://www.w3.org/TR/html4/strict.dtd";>
+<!--
+Copyright 2012 Canonical Ltd.  This software is licensed under the
+GNU Affero General Public License version 3 (see the file LICENSE).
+-->
+
+<html>
+  <head>
+      <title>Observer Table Widget Tests</title>
+
+      <!-- YUI and test setup -->
+      <script type="text/javascript"
+              src="../../../../../../build/js/yui/yui/yui.js">
+      </script>
+      <link rel="stylesheet"
+      href="../../../../../../build/js/yui/console/assets/console-core.css" />
+      <link rel="stylesheet"
+      href="../../../../../../build/js/yui/console/assets/skins/sam/console.css" />
+      <link rel="stylesheet"
+      href="../../../../../../build/js/yui/test/assets/skins/sam/test.css" />
+
+      <script type="text/javascript"
+              src="../../../../../../build/js/lp/app/testing/testrunner.js"></script>
+
+      <link rel="stylesheet" href="../../../../app/javascript/testing/test.css" />
+
+      <!-- Dependencies -->
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/client.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/lp.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/mustache.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/activator/activator.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/choiceedit/choiceedit.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/overlay/overlay.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/picker/picker.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/picker/person_picker.js"></script>
+       <script type="text/javascript"
+               src="../../../../../../build/js/lp/app/picker/picker_patcher.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/expander.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/anim/anim.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/effects/effects.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/lazr/lazr.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/extras/extras.js"></script>
+
+      <!-- The module under test. -->
+      <script type="text/javascript" src="../observer_table_widget.js"></script>
+
+      <!-- The test suite -->
+      <script type="text/javascript" src="test_observer_table_widget.js"></script>
+
+    </head>
+    <body class="yui3-skin-sam">
+        <ul id="suites">
+            <li>lp.registry.disclosure.observertable.test</li>
+        </ul>
+        <table id='observer-table'>
+        </table>
+    </body>
+</html>

=== added file 'lib/lp/registry/javascript/disclosure/tests/test_observer_table_widget.js'
--- lib/lp/registry/javascript/disclosure/tests/test_observer_table_widget.js	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/javascript/disclosure/tests/test_observer_table_widget.js	2012-02-28 02:17:18 +0000
@@ -0,0 +1,126 @@
+/* Copyright (c) 2012, Canonical Ltd. All rights reserved. */
+
+YUI.add('lp.registry.disclosure.observertable.test', function (Y) {
+
+    var tests = Y.namespace('lp.registry.disclosure.observertable.test');
+    tests.suite = new Y.Test.Suite(
+        'lp.registry.disclosure.observertable Tests');
+
+    tests.suite.add(new Y.Test.Case({
+        name: 'lp.registry.disclosure.observertable_tests',
+
+        setUp: function () {
+            this.observer_data = [
+            {'name': 'fred', 'display_name': 'Fred Bloggs',
+             'role': '(Maintainer)', web_link: '~fred',
+             'self_link': '~fred',
+             'permissions': {'P1': 's1', 'P2': 's2'}},
+            {'name': 'john', 'display_name': 'John Smith',
+             'role': '', web_link: '~smith', 'self_link': '~smith',
+            'permissions': {'P1': 's1', 'P3': 's3'}}
+            ];
+            this.sharing_permissions = [
+                {'value': 's1', 'name': 'S1',
+                 'title': 'Sharing 1'},
+                {'value': 's2', 'name': 'S2',
+                 'title': 'Sharing 2'}
+            ];
+            this.access_policies = {
+                'P1': 'Policy 1',
+                'P2': 'Policy 2',
+                'P3': 'Policy 3'
+            };
+        },
+
+        tearDown: function () {
+            Y.one('#observer-table').empty(true);
+        },
+
+        _create_Widget: function() {
+            var ns = Y.lp.registry.disclosure.observertable;
+            return new ns.ObserverTableWidget({
+                animation_duration: 0.001,
+                observers: this.observer_data,
+                sharing_permissions: this.sharing_permissions,
+                access_policy_types: this.access_policies
+            });
+        },
+
+        test_library_exists: function () {
+            Y.Assert.isObject(Y.lp.registry.disclosure.observertable,
+                "We should be able to locate the " +
+                "lp.registry.disclosure.observertable module");
+        },
+
+        test_widget_can_be_instantiated: function() {
+            this.observer_table = this._create_Widget();
+            Y.Assert.isInstanceOf(
+                Y.lp.registry.disclosure.observertable.ObserverTableWidget,
+                this.observer_table,
+                "Observer table failed to be instantiated");
+        },
+
+        // The observer table is correctly rendered.
+        test_render: function() {
+            this.observer_table = this._create_Widget();
+            this.observer_table.render();
+            Y.Array.each(this.observer_data, function(observer) {
+                // The observer row
+                Y.Assert.isNotNull(
+                    Y.one('#observer-table tr[id=permission-'
+                          + observer.name + ']'));
+                // The delete link
+                Y.Assert.isNotNull(
+                    Y.one('#observer-table td[id=remove-'
+                          + observer.name + '] a'));
+                // The access policy sharing permissions
+                var permission;
+                for (permission in observer.permissions) {
+                    if (observer.permissions.hasOwnProperty(permission)) {
+                        Y.Assert.isNotNull(
+                            Y.one('#observer-table td[id=td-permission-'
+                                  + observer.name + '] ul li '
+                                  + 'span[id='+permission+'-permission] '
+                                  + 'span.value'));
+                    }
+                }
+            });
+        },
+
+        // When the delete link is clicked, the correct event is published.
+        test_observer_delete_click: function() {
+            this.observer_table = this._create_Widget();
+            this.observer_table.render();
+            var event_fired = false;
+            var ns = Y.lp.registry.disclosure.observertable;
+            this.observer_table.subscribe(
+                ns.ObserverTableWidget.REMOVE_OBSERVER, function(e) {
+                    var delete_link = e.details[0];
+                    var observer_uri = e.details[1];
+                    Y.Assert.areEqual('~fred', observer_uri);
+                    Y.Assert.areEqual(delete_link_to_click, delete_link);
+                    event_fired = true;
+                }
+            );
+            var delete_link_to_click =
+                Y.one('#observer-table td[id=remove-fred] a');
+            delete_link_to_click.simulate('click');
+            Y.Assert.isTrue(event_fired);
+        },
+
+        // The deleteObserver call removes the observer from the table.
+        test_observer_delete: function() {
+            this.observer_table = this._create_Widget();
+            this.observer_table.render();
+            var row_selector = '#observer-table tr[id=permission-fred]';
+            Y.Assert.isNotNull(Y.one(row_selector));
+            this.observer_table.deleteObserver(this.observer_data[0]);
+            this.wait(function() {
+                    Y.Assert.isNull(Y.one(row_selector));
+                }, 60
+            );
+        }
+    }));
+
+}, '0.1', {'requires': ['test', 'console', 'event', 'node-event-simulate',
+        'lp.registry.disclosure.observertable']});

=== added file 'lib/lp/registry/javascript/disclosure/tests/test_product_sharing_view.html'
--- lib/lp/registry/javascript/disclosure/tests/test_product_sharing_view.html	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/javascript/disclosure/tests/test_product_sharing_view.html	2012-02-28 02:17:18 +0000
@@ -0,0 +1,82 @@
+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
+  "http://www.w3.org/TR/html4/strict.dtd";>
+<!--
+Copyright 2012 Canonical Ltd.  This software is licensed under the
+GNU Affero General Public License version 3 (see the file LICENSE).
+-->
+
+<html>
+  <head>
+      <title>Product Sharing View Tests</title>
+
+      <!-- YUI and test setup -->
+      <script type="text/javascript"
+              src="../../../../../../build/js/yui/yui/yui.js">
+      </script>
+      <link rel="stylesheet"
+      href="../../../../../../build/js/yui/console/assets/console-core.css" />
+      <link rel="stylesheet"
+      href="../../../../../../build/js/yui/console/assets/skins/sam/console.css" />
+      <link rel="stylesheet"
+      href="../../../../../../build/js/yui/test/assets/skins/sam/test.css" />
+
+      <script type="text/javascript"
+              src="../../../../../../build/js/lp/app/testing/testrunner.js"></script>
+
+      <link rel="stylesheet" href="../../../../app/javascript/testing/test.css" />
+
+      <!-- Dependencies -->
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/testing/mockio.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/client.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/lp.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/mustache.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/activator/activator.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/choiceedit/choiceedit.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/overlay/overlay.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/picker/picker.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/picker/person_picker.js"></script>
+       <script type="text/javascript"
+               src="../../../../../../build/js/lp/app/picker/picker_patcher.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/expander.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/anim/anim.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/effects/effects.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/lazr/lazr.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/extras/extras.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/registry/disclosure/observer_table_widget.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/registry/disclosure/disclosure_person_picker.js"></script>
+
+      <!-- The module under test. -->
+      <script type="text/javascript" src="../product_sharing.js"></script>
+
+      <!-- The test suite -->
+      <script type="text/javascript" src="test_product_sharing_view.js"></script>
+
+      <script id="test-fixture" type="text/x-template">
+          <table id='observer-table'></table>
+          <a id='add-observer-link' class='sprite add js-action' href="#">Share</a>
+      </script>
+    </head>
+    <body class="yui3-skin-sam">
+        <ul id="suites">
+            <li>lp.registry.disclosure.sharing.test</li>
+        </ul>
+        <div id='fixture'>
+        </div>
+    </body>
+</html>

=== added file 'lib/lp/registry/javascript/disclosure/tests/test_product_sharing_view.js'
--- lib/lp/registry/javascript/disclosure/tests/test_product_sharing_view.js	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/javascript/disclosure/tests/test_product_sharing_view.js	2012-02-28 02:17:18 +0000
@@ -0,0 +1,168 @@
+/* Copyright (c) 2012, Canonical Ltd. All rights reserved. */
+
+YUI.add('lp.registry.disclosure.sharing.test', function (Y) {
+
+    var tests = Y.namespace('lp.registry.disclosure.sharing.test');
+    tests.suite = new Y.Test.Suite(
+        'lp.registry.disclosure.sharing Tests');
+
+    tests.suite.add(new Y.Test.Case({
+        name: 'lp.registry.disclosure.sharing_tests',
+
+        setUp: function () {
+            Y.one('#fixture').appendChild(
+                Y.Node.create(Y.one('#test-fixture').getContent()));
+            window.LP = {
+                links: {},
+                cache: {
+                    context: {self_link: "~product" },
+                    observer_data: [
+                    {'name': 'fred', 'display_name': 'Fred Bloggs',
+                     'role': '(Maintainer)', web_link: '~fred',
+                     'self_link': '~fred',
+                     'permissions': {'P1': 's1', 'P2': 's2'}},
+                    {'name': 'john', 'display_name': 'John Smith',
+                     'role': '', web_link: '~smith', 'self_link': '~smith',
+                    'permissions': {'P1': 's1', 'P3': 's3'}}
+                    ],
+                    sharing_permissions: [
+                        {'value': 's1', 'name': 'S1',
+                         'title': 'Sharing 1'},
+                        {'value': 's2', 'name': 'S2',
+                         'title': 'Sharing 2'}
+                    ],
+                    access_policies: {
+                        'P1': 'Policy 1',
+                        'P2': 'Policy 2',
+                        'P3': 'Policy 3'
+                    }
+                }
+            };
+        },
+
+        tearDown: function () {
+            Y.one('#fixture').empty(true);
+            delete window.LP;
+        },
+
+        _create_Widget: function(cfg) {
+            var ns = Y.lp.registry.disclosure.sharing;
+            return new ns.ProductSharingView(cfg);
+        },
+
+        test_library_exists: function () {
+            Y.Assert.isObject(Y.lp.registry.disclosure.sharing,
+                "We should be able to locate the " +
+                "lp.registry.disclosure.sharing module");
+        },
+
+        test_widget_can_be_instantiated: function() {
+            this.view = this._create_Widget();
+            Y.Assert.isInstanceOf(
+                Y.lp.registry.disclosure.sharing.ProductSharingView,
+                this.view,
+                "Observer table failed to be instantiated");
+        },
+
+        // The view is correctly rendered.
+        test_render: function() {
+            this.view = this._create_Widget();
+            this.view.render();
+            // The observer table - we'll just check one row
+            Y.Assert.isNotNull(
+                Y.one('#observer-table tr[id=permission-fred]'));
+            // The disclosure picker
+            Y.Assert.isNotNull(Y.one('.yui3-disclosure_picker'));
+        },
+
+        // Clicking the sharing link opens the disclosure picker
+        test_sharing_link_click: function() {
+            this.view = this._create_Widget();
+            this.view.render();
+            Y.one('#add-observer-link').simulate('click');
+            Y.Assert.isFalse(
+                Y.one('.yui3-disclosure_picker')
+                    .hasClass('yui3-disclosure_picker-hidden'));
+        },
+
+        // Clicking a delete observer link calls the performRemoveObserver
+        // method with the correct parameters.
+        test_delete_observer_click: function() {
+            this.view = this._create_Widget();
+            this.view.render();
+            var performRemove_called = false;
+            var self = this;
+            this.view.performRemoveObserver = function(
+                observer_table, delete_link, person_uri) {
+                Y.Assert.areEqual(
+                    self.view.get('observer_table'), observer_table);
+                Y.Assert.areEqual('~fred', person_uri);
+                Y.Assert.areEqual(delete_link_to_click, delete_link);
+                performRemove_called = true;
+
+            };
+            var delete_link_to_click =
+                Y.one('#observer-table td[id=remove-fred] a');
+            delete_link_to_click.simulate('click');
+            Y.Assert.isTrue(performRemove_called);
+        },
+
+        // The performRemoveObserver method makes the expected XHR calls.
+        test_performRemoveObserver: function() {
+            var mockio = new Y.lp.testing.mockio.MockIo();
+            var lp_client = new Y.lp.client.Launchpad({
+                io_provider: mockio
+            });
+            this.view = this._create_Widget({
+                lp_client: lp_client
+            });
+            this.view.render();
+            var removeObserverSuccess_called = false;
+            var self = this;
+            this.view.removeObserverSuccess = function(
+                    observer_table, person_uri) {
+                Y.Assert.areEqual(
+                    self.view.get('observer_table'), observer_table);
+                Y.Assert.areEqual('~fred', person_uri);
+                removeObserverSuccess_called = true;
+            };
+            var delete_link =
+                Y.one('#observer-table td[id=remove-fred] a');
+            this.view.performRemoveObserver(
+                this.view.get('observer_table'),
+                delete_link, '~fred');
+            Y.Assert.areEqual(
+                '/api/devel/+services/accesspolicy',
+                mockio.last_request.url);
+            Y.Assert.areEqual(
+                'ws.op=deleteProductObserver&product=~product' +
+                    '&observer=~fred',
+                mockio.last_request.config.data);
+            mockio.last_request.successJSON('');
+            Y.Assert.isTrue(removeObserverSuccess_called);
+        },
+
+        // The removeObserver callback updates the model and table.
+        test_removeObserverSuccess: function() {
+            this.view = this._create_Widget({animation_duration: 0.001});
+            this.view.render();
+            var deleteObserver_called = false;
+            var expected_observer = window.LP.cache.observer_data[0];
+            var observer_table = this.view.get('observer_table');
+            observer_table.deleteObserver = function(observer) {
+                Y.Assert.areEqual(expected_observer, observer);
+                deleteObserver_called = true;
+            };
+            this.view.removeObserverSuccess(observer_table, '~fred');
+            Y.Assert.isTrue(deleteObserver_called);
+            Y.Array.each(window.LP.cache.observer_data,
+                function(observer) {
+                    Y.Assert.areNotEqual('fred', observer.name);
+            });
+        }
+    }));
+
+}, '0.1', {'requires': ['test', 'console', 'event', 'node-event-simulate',
+        'lp.testing.mockio', 'lp.registry.disclosure',
+        'lp.registry.disclosure.sharing',
+        'lp.registry.disclosure.observertable']});

=== modified file 'lib/lp/registry/services/accesspolicyservice.py'
--- lib/lp/registry/services/accesspolicyservice.py	2012-02-24 06:33:10 +0000
+++ lib/lp/registry/services/accesspolicyservice.py	2012-02-28 02:17:18 +0000
@@ -9,13 +9,20 @@
     ]
 
 import simplejson
-from lazr.restful import ResourceJSONEncoder
+from lazr.restful import (
+    EntryResource,
+    ResourceJSONEncoder,
+    )
+from lazr.restful.utils import get_current_web_service_request
+
+from zope.component import getUtility
 from zope.interface import implements
 
 from lp.registry.enums import AccessPolicyType
 from lp.registry.interfaces.accesspolicyservice import (
     IAccessPolicyService,
     )
+from lp.registry.interfaces.person import IPersonSet
 
 
 class AccessPolicyService:
@@ -33,6 +40,7 @@
         return 'accesspolicy'
 
     def getAccessPolicies(self):
+        """See `IAccessPolicyService`."""
         policies = []
         for x, policy in enumerate(AccessPolicyType):
             item = dict(
@@ -42,4 +50,42 @@
                 description=policy.value.description
             )
             policies.append(item)
-        return simplejson.dumps(policies, cls=ResourceJSONEncoder)
+        return policies
+
+    def getSharingPermissions(self):
+        """See `IAccessPolicyService`."""
+        # TODO - use proper model class
+        sharing_permissions = [
+            {'value': 'all', 'name': 'All',
+             'title': 'share bug and branch subscriptions'},
+            {'value': 'some', 'name': 'Some',
+             'title': 'share bug and branch subscriptions'},
+            {'value': 'nothing', 'name': 'Nothing',
+             'title': 'revoke all bug and branch subscriptions'}
+        ]
+        return sharing_permissions
+
+    def getProductObservers(self, product):
+        """See `IAccessPolicyService`."""
+        # TODO - replace this sample data with something real
+        result = []
+        request = get_current_web_service_request()
+        personset = getUtility(IPersonSet)
+        for id in range(1, 4):
+            person = personset.get(id)
+            resource = EntryResource(person, request)
+            person_data = resource.toDataForJSON()
+            permissions = {
+                'PUBLICSECURITY': 'some',
+                'EMBARGOEDSECURITY': 'all'
+            }
+            if id > 2:
+                permissions['USERDATA'] = 'some'
+            person_data['permissions'] = permissions
+            result.append(person_data)
+        return result
+
+    def deleteProductObserver(self, product, observer):
+        """See `IAccessPolicyService`."""
+        # TODO - implement this
+        pass

=== modified file 'lib/lp/registry/templates/product-sharing.pt'
--- lib/lp/registry/templates/product-sharing.pt	2012-02-22 05:22:13 +0000
+++ lib/lp/registry/templates/product-sharing.pt	2012-02-28 02:17:18 +0000
@@ -15,7 +15,8 @@
                 function(Y) {
             Y.on('domready', function() {
                 var config = ${view/json_sharing_picker_config}
-                Y.lp.registry.disclosure.sharing.setup_product_sharing(config);
+                var view_widget = new Y.lp.registry.disclosure.sharing.ProductSharingView(config);
+                view_widget.render();
             });
           });
     "/>


Follow ups