launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #22996
[Merge] lp:~cjwatson/launchpad/git-grantee-widgets into lp:launchpad
Colin Watson has proposed merging lp:~cjwatson/launchpad/git-grantee-widgets into lp:launchpad.
Commit message:
Add widgets to display or select a Git access grantee.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
Related bugs:
Bug #1517559 in Launchpad itself: "git fine-grained permissions"
https://bugs.launchpad.net/launchpad/+bug/1517559
For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/git-grantee-widgets/+merge/357600
This isn't hugely elegant, but it gets the job done. You can see it in context (with an as-yet-unpushed UI branch) here:
https://people.canonical.com/~cjwatson/tmp/lp-git-ref-permissions.png
--
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/git-grantee-widgets into lp:launchpad.
=== added file 'lib/lp/code/browser/widgets/gitgrantee.py'
--- lib/lp/code/browser/widgets/gitgrantee.py 1970-01-01 00:00:00 +0000
+++ lib/lp/code/browser/widgets/gitgrantee.py 2018-10-21 23:56:34 +0000
@@ -0,0 +1,224 @@
+# Copyright 2018 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'GitGranteeDisplayWidget',
+ 'GitGranteeField',
+ 'GitGranteeWidget',
+ ]
+
+from lazr.enum import DBItem
+from z3c.ptcompat import ViewPageTemplateFile
+from zope.formlib.interfaces import (
+ ConversionError,
+ IDisplayWidget,
+ IInputWidget,
+ InputErrors,
+ MissingInputError,
+ WidgetInputError,
+ )
+from zope.formlib.utility import setUpWidget
+from zope.formlib.widget import (
+ BrowserWidget,
+ CustomWidgetFactory,
+ DisplayWidget,
+ InputWidget,
+ renderElement,
+ )
+from zope.interface import implementer
+from zope.schema import (
+ Choice,
+ Field,
+ )
+from zope.schema.interfaces import IField
+from zope.schema.vocabulary import getVocabularyRegistry
+from zope.security.proxy import isinstance as zope_isinstance
+
+from lp.app.errors import UnexpectedFormData
+from lp.app.validators import LaunchpadValidationError
+from lp.app.widgets.popup import PersonPickerWidget
+from lp.code.enums import GitGranteeType
+from lp.code.interfaces.gitref import IGitRef
+from lp.registry.interfaces.person import IPerson
+from lp.services.webapp.interfaces import (
+ IAlwaysSubmittedWidget,
+ IMultiLineWidgetLayout,
+ )
+
+
+class IGitGranteeField(IField):
+ """An interface for a Git access grantee field."""
+
+
+@implementer(IGitGranteeField)
+class GitGranteeField(Field):
+ """A field that holds a Git access grantee."""
+
+ def constraint(self, value):
+ """See `IField`."""
+ if zope_isinstance(value, DBItem) and value.enum == GitGranteeType:
+ return value != GitGranteeType.PERSON
+ else:
+ return value in getVocabularyRegistry().get(
+ None, "ValidPersonOrTeam")
+
+
+@implementer(IMultiLineWidgetLayout)
+class GitGranteeWidgetBase(BrowserWidget):
+
+ template = ViewPageTemplateFile("templates/gitgrantee.pt")
+ default_option = "person"
+ _widgets_set_up = False
+
+ def setUpSubWidgets(self):
+ if self._widgets_set_up:
+ return
+ fields = [
+ Choice(
+ __name__="person", title=u"Person",
+ required=False, vocabulary="ValidPersonOrTeam"),
+ ]
+ if not self._read_only:
+ self.person_widget = CustomWidgetFactory(
+ PersonPickerWidget,
+ # XXX cjwatson 2018-10-18: This is a little unfortunate, but
+ # otherwise there's no spacing at all between the
+ # (deliberately unlabelled) radio button and the text box.
+ style="margin-left: 4px;")
+ for field in fields:
+ setUpWidget(
+ self, field.__name__, field, self._sub_widget_interface,
+ prefix=self.name)
+ self._widgets_set_up = True
+
+ def setUpOptions(self):
+ """Set up options to be rendered."""
+ self.options = {}
+ for option in ("repository_owner", "person"):
+ attributes = {
+ "type": "radio", "name": self.name, "value": option,
+ "id": "%s.option.%s" % (self.name, option),
+ # XXX cjwatson 2018-10-18: Ugly, but it's worse without
+ # this, especially in a permissions table where this widget
+ # is normally used.
+ "style": "margin-left: 0;",
+ }
+ if self.request.form_ng.getOne(
+ self.name, self.default_option) == option:
+ attributes["checked"] = "checked"
+ if self._read_only:
+ attributes["disabled"] = "disabled"
+ self.options[option] = renderElement("input", **attributes)
+
+ @property
+ def show_options(self):
+ return {
+ option: not self._read_only or self.default_option == option
+ for option in ("repository_owner", "person")}
+
+ def setRenderedValue(self, value):
+ """See `IWidget`."""
+ self.setUpSubWidgets()
+ if value == GitGranteeType.REPOSITORY_OWNER:
+ self.default_option = "repository_owner"
+ return
+ elif value is None or IPerson.providedBy(value):
+ self.default_option = "person"
+ self.person_widget.setRenderedValue(value)
+ return
+ else:
+ raise AssertionError("Not a valid value: %r" % value)
+
+ def __call__(self):
+ """See `zope.formlib.interfaces.IBrowserWidget`."""
+ self.setUpSubWidgets()
+ self.setUpOptions()
+ return self.template()
+
+
+@implementer(IDisplayWidget)
+class GitGranteeDisplayWidget(GitGranteeWidgetBase, DisplayWidget):
+ """Widget for displaying a Git access grantee."""
+
+ _sub_widget_interface = IDisplayWidget
+ _read_only = True
+
+
+@implementer(IAlwaysSubmittedWidget, IInputWidget)
+class GitGranteeWidget(GitGranteeWidgetBase, InputWidget):
+ """Widget for selecting a Git access grantee."""
+
+ _sub_widget_interface = IInputWidget
+ _read_only = False
+ _widgets_set_up = False
+
+ @property
+ def show_options(self):
+ show_options = super(GitGranteeWidget, self).show_options
+ # Hide options that indicate unique grantee_types (e.g.
+ # repository_owner) if they already exist in the context.
+ # XXX untested
+ if IGitRef.providedBy(self.context.context):
+ repository = self.context.context.repository
+ ref_pattern = self.context.context.path
+ else:
+ repository = None
+ ref_pattern = None
+ if (show_options["repository_owner"] and
+ repository is not None and ref_pattern is not None and
+ not repository.findRuleGrantsForRepositoryOwner(
+ ref_pattern=ref_pattern).is_empty()):
+ show_options["repository_owner"] = False
+ return show_options
+
+ def hasInput(self):
+ self.setUpSubWidgets()
+ form_value = self.request.form_ng.getOne(self.name)
+ if form_value is None:
+ return False
+ return form_value != "person" or self.person_widget.hasInput()
+
+ def hasValidInput(self):
+ """See `zope.formlib.interfaces.IInputWidget`."""
+ try:
+ self.getInputValue()
+ return True
+ except (InputErrors, UnexpectedFormData):
+ return False
+
+ def getInputValue(self):
+ """See `zope.formlib.interfaces.IInputWidget`."""
+ self.setUpSubWidgets()
+ form_value = self.request.form_ng.getOne(self.name)
+ if form_value == "repository_owner":
+ return GitGranteeType.REPOSITORY_OWNER
+ elif form_value == "person":
+ try:
+ return self.person_widget.getInputValue()
+ except MissingInputError:
+ raise WidgetInputError(
+ self.name, self.label,
+ LaunchpadValidationError(
+ "Please enter a person or team name"))
+ except ConversionError:
+ entered_name = self.request.form_ng.getOne(
+ "%s.person" % self.name)
+ raise WidgetInputError(
+ self.name, self.label,
+ LaunchpadValidationError(
+ "There is no person or team named '%s' registered in "
+ "Launchpad" % entered_name))
+ else:
+ raise UnexpectedFormData("No valid option was selected.")
+
+ def error(self):
+ """See `zope.formlib.interfaces.IBrowserWidget`."""
+ try:
+ if self.hasInput():
+ self.getInputValue()
+ except InputErrors as error:
+ self._error = error
+ return super(GitGranteeWidget, self).error()
=== added file 'lib/lp/code/browser/widgets/templates/gitgrantee.pt'
--- lib/lp/code/browser/widgets/templates/gitgrantee.pt 1970-01-01 00:00:00 +0000
+++ lib/lp/code/browser/widgets/templates/gitgrantee.pt 2018-10-21 23:56:34 +0000
@@ -0,0 +1,27 @@
+<table>
+ <tr tal:condition="view/show_options/repository_owner">
+ <td colspan="2">
+ <label>
+ <input
+ type="radio" value="repository_owner"
+ tal:condition="not: context/readonly"
+ tal:replace="structure view/options/repository_owner" />
+ Repository owner
+ </label>
+ </td>
+ </tr>
+
+ <tr tal:condition="view/show_options/person">
+ <td>
+ <label>
+ <input
+ type="radio" value="person"
+ tal:condition="not: context/readonly"
+ tal:replace="structure view/options/person" />
+ </label>
+ </td>
+ <td>
+ <tal:person replace="structure view/person_widget" />
+ </td>
+ </tr>
+</table>
=== added file 'lib/lp/code/browser/widgets/tests/test_gitgrantee.py'
--- lib/lp/code/browser/widgets/tests/test_gitgrantee.py 1970-01-01 00:00:00 +0000
+++ lib/lp/code/browser/widgets/tests/test_gitgrantee.py 2018-10-21 23:56:34 +0000
@@ -0,0 +1,271 @@
+# Copyright 2018 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+import re
+
+from zope.formlib.interfaces import (
+ IBrowserWidget,
+ IDisplayWidget,
+ IInputWidget,
+ WidgetInputError,
+ )
+
+from lp.app.validators import LaunchpadValidationError
+from lp.code.browser.widgets.gitgrantee import (
+ GitGranteeDisplayWidget,
+ GitGranteeField,
+ GitGranteeWidget,
+ )
+from lp.code.enums import GitGranteeType
+from lp.registry.vocabularies import ValidPersonOrTeamVocabulary
+from lp.services.beautifulsoup import BeautifulSoup
+from lp.services.webapp.escaping import html_escape
+from lp.services.webapp.servers import LaunchpadTestRequest
+from lp.testing import (
+ TestCaseWithFactory,
+ verifyObject,
+ )
+from lp.testing.layers import DatabaseFunctionalLayer
+
+
+class TestGitGranteeWidgetBase:
+
+ layer = DatabaseFunctionalLayer
+
+ def setUp(self):
+ super(TestGitGranteeWidgetBase, self).setUp()
+ field = GitGranteeField(__name__="grantee")
+ request = LaunchpadTestRequest()
+ self.widget = self.widget_factory(field, request)
+
+ def test_implements(self):
+ self.assertTrue(verifyObject(IBrowserWidget, self.widget))
+ self.assertTrue(
+ verifyObject(self.expected_widget_interface, self.widget))
+
+ def test_template(self):
+ # The render template is setup.
+ self.assertTrue(
+ self.widget.template.filename.endswith("gitgrantee.pt"),
+ "Template was not set up.")
+
+ def test_default_option(self):
+ # The person field is the default option.
+ self.assertEqual("person", self.widget.default_option)
+
+ def test_setUpSubWidgets_first_call(self):
+ # The subwidget is set up and a flag is set.
+ self.widget.setUpSubWidgets()
+ self.assertTrue(self.widget._widgets_set_up)
+ self.assertIsInstance(
+ self.widget.person_widget.context.vocabulary,
+ ValidPersonOrTeamVocabulary)
+
+ def test_setUpSubWidgets_second_call(self):
+ # The setUpSubWidgets method exits early if a flag is set to
+ # indicate that the subwidget was set up.
+ self.widget._widgets_set_up = True
+ self.widget.setUpSubWidgets()
+ self.assertIsNone(getattr(self.widget, "person_widget", None))
+
+ def test_setUpOptions_default_person_checked(self):
+ # The radio button options are composed of the setup widgets with
+ # the person widget set as the default.
+ self.widget.setUpSubWidgets()
+ self.widget.setUpOptions()
+ self.assertEqual(
+ '<input class="radioType" style="margin-left: 0;" ' +
+ self.expected_disabled_attr +
+ 'id="field.grantee.option.repository_owner" name="field.grantee" '
+ 'type="radio" value="repository_owner" />',
+ self.widget.options["repository_owner"])
+ self.assertEqual(
+ '<input class="radioType" style="margin-left: 0;" ' +
+ 'checked="checked" ' + self.expected_disabled_attr +
+ 'id="field.grantee.option.person" name="field.grantee" '
+ 'type="radio" value="person" />',
+ self.widget.options["person"])
+
+ def test_setUpOptions_repository_owner_checked(self):
+ # The repository owner radio button is selected when the form is
+ # submitted when the grantee field's value is 'repository_owner'.
+ form = {"field.grantee": "repository_owner"}
+ self.widget.request = LaunchpadTestRequest(form=form)
+ self.widget.setUpSubWidgets()
+ self.widget.setUpOptions()
+ self.assertEqual(
+ '<input class="radioType" style="margin-left: 0;" '
+ 'checked="checked" ' + self.expected_disabled_attr +
+ 'id="field.grantee.option.repository_owner" name="field.grantee" '
+ 'type="radio" value="repository_owner" />',
+ self.widget.options["repository_owner"])
+ self.assertEqual(
+ '<input class="radioType" style="margin-left: 0;" ' +
+ self.expected_disabled_attr +
+ 'id="field.grantee.option.person" name="field.grantee" '
+ 'type="radio" value="person" />',
+ self.widget.options["person"])
+
+ def test_setUpOptions_person_checked(self):
+ # The person radio button is selected when the form is submitted
+ # when the grantee field's value is 'person'.
+ form = {"field.grantee": "person"}
+ self.widget.request = LaunchpadTestRequest(form=form)
+ self.widget.setUpSubWidgets()
+ self.widget.setUpOptions()
+ self.assertEqual(
+ '<input class="radioType" style="margin-left: 0;" ' +
+ self.expected_disabled_attr +
+ 'id="field.grantee.option.repository_owner" name="field.grantee" '
+ 'type="radio" value="repository_owner" />',
+ self.widget.options["repository_owner"])
+ self.assertEqual(
+ '<input class="radioType" style="margin-left: 0;" ' +
+ 'checked="checked" ' + self.expected_disabled_attr +
+ 'id="field.grantee.option.person" name="field.grantee" '
+ 'type="radio" value="person" />',
+ self.widget.options["person"])
+
+ def test_setRenderedValue_repository_owner(self):
+ # Passing GitGranteeType.REPOSITORY_OWNER will set the widget's
+ # render state to "repository_owner".
+ self.widget.setUpSubWidgets()
+ self.widget.setRenderedValue(GitGranteeType.REPOSITORY_OWNER)
+ self.assertEqual("repository_owner", self.widget.default_option)
+
+ def test_setRenderedValue_person(self):
+ # Passing a person will set the widget's render state to "person".
+ self.widget.setUpSubWidgets()
+ person = self.factory.makePerson()
+ self.widget.setRenderedValue(person)
+ self.assertEqual("person", self.widget.default_option)
+ self.assertEqual(person, self.widget.person_widget._getCurrentValue())
+
+ def test_call(self):
+ # The __call__ method sets up the widgets and the options.
+ markup = self.widget()
+ self.assertIsNotNone(self.widget.person_widget)
+ self.assertIn("repository_owner", self.widget.options)
+ self.assertIn("person", self.widget.options)
+ soup = BeautifulSoup(markup)
+ fields = soup.findAll(["input", "select"], {"id": re.compile(".*")})
+ ids = [field["id"] for field in fields]
+ self.assertContentEqual(self.expected_ids, ids)
+
+
+class TestGitGranteeDisplayWidget(
+ TestGitGranteeWidgetBase, TestCaseWithFactory):
+ """Test the GitGranteeDisplayWidget class."""
+
+ widget_factory = GitGranteeDisplayWidget
+ expected_widget_interface = IDisplayWidget
+ expected_disabled_attr = 'disabled="disabled" '
+ expected_ids = ["field.grantee.option.person"]
+
+
+class TestGitGranteeWidget(TestGitGranteeWidgetBase, TestCaseWithFactory):
+ """Test the GitGranteeWidget class."""
+
+ widget_factory = GitGranteeWidget
+ expected_widget_interface = IInputWidget
+ expected_disabled_attr = ""
+ expected_ids = [
+ "field.grantee.option.repository_owner",
+ "field.grantee.option.person",
+ "field.grantee.person",
+ ]
+
+ def setUp(self):
+ super(TestGitGranteeWidget, self).setUp()
+ self.person = self.factory.makePerson()
+
+ @property
+ def form(self):
+ return {
+ "field.grantee": "person",
+ "field.grantee.person": self.person.name,
+ }
+
+ def test_hasInput_not_in_form(self):
+ # hasInput is false when the widget's name is not in the form data.
+ self.widget.request = LaunchpadTestRequest(form={})
+ self.assertEqual("field.grantee", self.widget.name)
+ self.assertFalse(self.widget.hasInput())
+
+ def test_hasInput_no_person(self):
+ # hasInput is false when the person radio button is selected and the
+ # person widget's name is not in the form data.
+ self.widget.request = LaunchpadTestRequest(
+ form={"field.grantee": "person"})
+ self.assertEqual("field.grantee", self.widget.name)
+ self.assertFalse(self.widget.hasInput())
+
+ def test_hasInput_repository_owner(self):
+ # hasInput is true when the repository owner radio button is selected.
+ self.widget.request = LaunchpadTestRequest(
+ form={"field.grantee": "repository_owner"})
+ self.assertEqual("field.grantee", self.widget.name)
+ self.assertTrue(self.widget.hasInput())
+
+ def test_hasInput_person(self):
+ # hasInput is true when the person radio button is selected and the
+ # person widget's name is in the form data.
+ self.widget.request = LaunchpadTestRequest(form=self.form)
+ self.assertEqual("field.grantee", self.widget.name)
+ self.assertTrue(self.widget.hasInput())
+
+ def test_hasValidInput_true(self):
+ # The field input is valid when all submitted parts are valid.
+ self.widget.request = LaunchpadTestRequest(form=self.form)
+ self.assertTrue(self.widget.hasValidInput())
+
+ def test_hasValidInput_false(self):
+ # The field input is invalid if any of the submitted parts are invalid.
+ form = self.form
+ form["field.grantee.person"] = "non-existent"
+ self.widget.request = LaunchpadTestRequest(form=form)
+ self.assertFalse(self.widget.hasValidInput())
+
+ def test_getInputValue_repository_owner(self):
+ # The field value is GitGranteeType.REPOSITORY_OWNER when the
+ # repository owner radio button is selected.
+ form = self.form
+ form["field.grantee"] = "repository_owner"
+ self.widget.request = LaunchpadTestRequest(form=form)
+ self.assertEqual(
+ GitGranteeType.REPOSITORY_OWNER, self.widget.getInputValue())
+
+ def test_getInputValue_person(self):
+ # The field value is the person when the person radio button is
+ # selected and the person sub field is valid.
+ form = self.form
+ form["field.grantee"] = "person"
+ self.widget.request = LaunchpadTestRequest(form=form)
+ self.assertEqual(self.person, self.widget.getInputValue())
+
+ def test_getInputValue_person_missing(self):
+ # An error is raised when the person field is missing.
+ form = self.form
+ form["field.grantee"] = "person"
+ del form["field.grantee.person"]
+ self.widget.request = LaunchpadTestRequest(form=form)
+ message = "Please enter a person or team name"
+ e = self.assertRaises(WidgetInputError, self.widget.getInputValue)
+ self.assertEqual(LaunchpadValidationError(message), e.errors)
+
+ def test_getInputValue_person_invalid(self):
+ # An error is raised when the person is not valid.
+ form = self.form
+ form["field.grantee"] = "person"
+ form["field.grantee.person"] = "non-existent"
+ self.widget.request = LaunchpadTestRequest(form=form)
+ message = (
+ "There is no person or team named 'non-existent' registered in "
+ "Launchpad")
+ e = self.assertRaises(WidgetInputError, self.widget.getInputValue)
+ self.assertEqual(LaunchpadValidationError(message), e.errors)
+ self.assertEqual(html_escape(message), self.widget.error())
Follow ups