Merge lp:~cjwatson/launchpad/git-grantee-widgets into lp:launchpad

Proposed by Colin Watson on 2018-10-21
Status: Needs review
Proposed branch: lp:~cjwatson/launchpad/git-grantee-widgets
Merge into: lp:launchpad
Diff against target: 789 lines (+714/-3)
6 files modified
lib/lp/code/browser/widgets/gitgrantee.py (+253/-0)
lib/lp/code/browser/widgets/templates/gitgrantee.pt (+27/-0)
lib/lp/code/browser/widgets/tests/test_gitgrantee.py (+305/-0)
lib/lp/code/interfaces/gitrepository.py (+6/-1)
lib/lp/code/model/gitrepository.py (+12/-1)
lib/lp/code/model/tests/test_gitrepository.py (+111/-1)
To merge this branch: bzr merge lp:~cjwatson/launchpad/git-grantee-widgets
Reviewer Review Type Date Requested Status
Launchpad code reviewers 2018-10-21 Pending
Review via email: mp+357600@code.launchpad.net

Commit message

Add widgets to display or select a Git access grantee.

Description of the change

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

To post a comment you must log in.
18804. By Colin Watson on 2018-10-22

Add ref_pattern keyword argument to GitRepository.findRuleGrants*.

Forgotten in previous commit.

These two methods should really be refactored into one; I'll look into that
shortly.

18805. By Colin Watson on 2018-10-22

Test GitGranteeWidget.show_options.

18806. By Colin Watson on 2018-10-22

Test IGitRepository case of GitGranteeWidget.show_options.

18807. By Colin Watson on 2018-10-22

Merge devel.

18808. By Colin Watson on 2018-10-23

Pass rule explicitly to GitGranteeField.

Arranging to set its context appropriately in browser code in all cases is
too hard; this is much simpler.

18809. By Colin Watson on 2018-10-29

Merge devel.

18810. By Colin Watson on 2018-10-30

Render names of existing grantees in a more usual way.

18811. By Colin Watson on 2018-11-09

Merge devel.

Unmerged revisions

18811. By Colin Watson on 2018-11-09

Merge devel.

18810. By Colin Watson on 2018-10-30

Render names of existing grantees in a more usual way.

18809. By Colin Watson on 2018-10-29

Merge devel.

18808. By Colin Watson on 2018-10-23

Pass rule explicitly to GitGranteeField.

Arranging to set its context appropriately in browser code in all cases is
too hard; this is much simpler.

18807. By Colin Watson on 2018-10-22

Merge devel.

18806. By Colin Watson on 2018-10-22

Test IGitRepository case of GitGranteeWidget.show_options.

18805. By Colin Watson on 2018-10-22

Test GitGranteeWidget.show_options.

18804. By Colin Watson on 2018-10-22

Add ref_pattern keyword argument to GitRepository.findRuleGrants*.

Forgotten in previous commit.

These two methods should really be refactored into one; I'll look into that
shortly.

18803. By Colin Watson on 2018-10-21

Add widgets to display or select a Git access grantee.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'lib/lp/code/browser/widgets/gitgrantee.py'
2--- lib/lp/code/browser/widgets/gitgrantee.py 1970-01-01 00:00:00 +0000
3+++ lib/lp/code/browser/widgets/gitgrantee.py 2018-11-09 22:44:37 +0000
4@@ -0,0 +1,253 @@
5+# Copyright 2018 Canonical Ltd. This software is licensed under the
6+# GNU Affero General Public License version 3 (see the file LICENSE).
7+
8+from __future__ import absolute_import, print_function, unicode_literals
9+
10+__metaclass__ = type
11+__all__ = [
12+ 'GitGranteeDisplayWidget',
13+ 'GitGranteeField',
14+ 'GitGranteeWidget',
15+ ]
16+
17+from lazr.enum import DBItem
18+from lazr.restful.fields import Reference
19+from z3c.ptcompat import ViewPageTemplateFile
20+from zope.formlib.interfaces import (
21+ ConversionError,
22+ IDisplayWidget,
23+ IInputWidget,
24+ InputErrors,
25+ MissingInputError,
26+ WidgetInputError,
27+ )
28+from zope.formlib.utility import setUpWidget
29+from zope.formlib.widget import (
30+ BrowserWidget,
31+ CustomWidgetFactory,
32+ DisplayWidget,
33+ InputWidget,
34+ renderElement,
35+ )
36+from zope.interface import implementer
37+from zope.schema import (
38+ Choice,
39+ Field,
40+ )
41+from zope.schema.interfaces import IField
42+from zope.schema.vocabulary import getVocabularyRegistry
43+from zope.security.proxy import isinstance as zope_isinstance
44+
45+from lp import _
46+from lp.app.errors import UnexpectedFormData
47+from lp.app.validators import LaunchpadValidationError
48+from lp.app.widgets.popup import PersonPickerWidget
49+from lp.code.enums import GitGranteeType
50+from lp.code.interfaces.gitrule import IGitRule
51+from lp.registry.interfaces.person import IPerson
52+from lp.services.webapp.escaping import structured
53+from lp.services.webapp.interfaces import (
54+ IAlwaysSubmittedWidget,
55+ IMultiLineWidgetLayout,
56+ )
57+from lp.services.webapp.publisher import canonical_url
58+
59+
60+class IGitGranteeField(IField):
61+ """An interface for a Git access grantee field."""
62+
63+ rule = Reference(
64+ title=_("Rule"), required=True, readonly=True, schema=IGitRule,
65+ description=_("The rule that this grantee is for."))
66+
67+
68+@implementer(IGitGranteeField)
69+class GitGranteeField(Field):
70+ """A field that holds a Git access grantee."""
71+
72+ def __init__(self, rule, *args, **kwargs):
73+ super(GitGranteeField, self).__init__(*args, **kwargs)
74+ self.rule = rule
75+
76+ def constraint(self, value):
77+ """See `IField`."""
78+ if zope_isinstance(value, DBItem) and value.enum == GitGranteeType:
79+ return value != GitGranteeType.PERSON
80+ else:
81+ return value in getVocabularyRegistry().get(
82+ None, "ValidPersonOrTeam")
83+
84+
85+@implementer(IDisplayWidget)
86+class GitGranteePersonDisplayWidget(BrowserWidget):
87+
88+ def __init__(self, context, vocabulary, request):
89+ super(GitGranteePersonDisplayWidget, self).__init__(context, request)
90+
91+ def __call__(self):
92+ if self._renderedValueSet():
93+ grantee = self._data
94+ person_img = renderElement(
95+ "img", style="padding-bottom: 2px", src="/@@/person", alt="")
96+ return renderElement(
97+ "a", href=canonical_url(grantee),
98+ contents="%s %s" % (
99+ person_img,
100+ structured("%s", grantee.display_name).escapedtext))
101+ else:
102+ return ""
103+
104+
105+@implementer(IMultiLineWidgetLayout)
106+class GitGranteeWidgetBase(BrowserWidget):
107+
108+ template = ViewPageTemplateFile("templates/gitgrantee.pt")
109+ default_option = "person"
110+ _widgets_set_up = False
111+
112+ def setUpSubWidgets(self):
113+ if self._widgets_set_up:
114+ return
115+ fields = [
116+ Choice(
117+ __name__="person", title=u"Person",
118+ required=False, vocabulary="ValidPersonOrTeam"),
119+ ]
120+ if self._read_only:
121+ self.person_widget = CustomWidgetFactory(
122+ GitGranteePersonDisplayWidget)
123+ else:
124+ self.person_widget = CustomWidgetFactory(
125+ PersonPickerWidget,
126+ # XXX cjwatson 2018-10-18: This is a little unfortunate, but
127+ # otherwise there's no spacing at all between the
128+ # (deliberately unlabelled) radio button and the text box.
129+ style="margin-left: 4px;")
130+ for field in fields:
131+ setUpWidget(
132+ self, field.__name__, field, self._sub_widget_interface,
133+ prefix=self.name)
134+ self._widgets_set_up = True
135+
136+ def setUpOptions(self):
137+ """Set up options to be rendered."""
138+ self.options = {}
139+ for option in ("repository_owner", "person"):
140+ attributes = {
141+ "type": "radio", "name": self.name, "value": option,
142+ "id": "%s.option.%s" % (self.name, option),
143+ # XXX cjwatson 2018-10-18: Ugly, but it's worse without
144+ # this, especially in a permissions table where this widget
145+ # is normally used.
146+ "style": "margin-left: 0;",
147+ }
148+ if self.request.form_ng.getOne(
149+ self.name, self.default_option) == option:
150+ attributes["checked"] = "checked"
151+ if self._read_only:
152+ attributes["disabled"] = "disabled"
153+ self.options[option] = renderElement("input", **attributes)
154+
155+ @property
156+ def show_options(self):
157+ return {
158+ option: not self._read_only or self.default_option == option
159+ for option in ("repository_owner", "person")}
160+
161+ def setRenderedValue(self, value):
162+ """See `IWidget`."""
163+ self.setUpSubWidgets()
164+ if value == GitGranteeType.REPOSITORY_OWNER:
165+ self.default_option = "repository_owner"
166+ return
167+ elif value is None or IPerson.providedBy(value):
168+ self.default_option = "person"
169+ self.person_widget.setRenderedValue(value)
170+ return
171+ else:
172+ raise AssertionError("Not a valid value: %r" % value)
173+
174+ def __call__(self):
175+ """See `zope.formlib.interfaces.IBrowserWidget`."""
176+ self.setUpSubWidgets()
177+ self.setUpOptions()
178+ return self.template()
179+
180+
181+@implementer(IDisplayWidget)
182+class GitGranteeDisplayWidget(GitGranteeWidgetBase, DisplayWidget):
183+ """Widget for displaying a Git access grantee."""
184+
185+ _sub_widget_interface = IDisplayWidget
186+ _read_only = True
187+
188+
189+@implementer(IAlwaysSubmittedWidget, IInputWidget)
190+class GitGranteeWidget(GitGranteeWidgetBase, InputWidget):
191+ """Widget for selecting a Git access grantee."""
192+
193+ _sub_widget_interface = IInputWidget
194+ _read_only = False
195+ _widgets_set_up = False
196+
197+ @property
198+ def show_options(self):
199+ show_options = super(GitGranteeWidget, self).show_options
200+ # Hide options that indicate unique grantee_types (e.g.
201+ # repository_owner) if they already exist for the context rule.
202+ if (show_options["repository_owner"] and
203+ not self.context.rule.repository.findRuleGrantsByGrantee(
204+ GitGranteeType.REPOSITORY_OWNER,
205+ ref_pattern=self.context.rule.ref_pattern,
206+ exact_grantee=True).is_empty()):
207+ show_options["repository_owner"] = False
208+ return show_options
209+
210+ def hasInput(self):
211+ self.setUpSubWidgets()
212+ form_value = self.request.form_ng.getOne(self.name)
213+ if form_value is None:
214+ return False
215+ return form_value != "person" or self.person_widget.hasInput()
216+
217+ def hasValidInput(self):
218+ """See `zope.formlib.interfaces.IInputWidget`."""
219+ try:
220+ self.getInputValue()
221+ return True
222+ except (InputErrors, UnexpectedFormData):
223+ return False
224+
225+ def getInputValue(self):
226+ """See `zope.formlib.interfaces.IInputWidget`."""
227+ self.setUpSubWidgets()
228+ form_value = self.request.form_ng.getOne(self.name)
229+ if form_value == "repository_owner":
230+ return GitGranteeType.REPOSITORY_OWNER
231+ elif form_value == "person":
232+ try:
233+ return self.person_widget.getInputValue()
234+ except MissingInputError:
235+ raise WidgetInputError(
236+ self.name, self.label,
237+ LaunchpadValidationError(
238+ "Please enter a person or team name"))
239+ except ConversionError:
240+ entered_name = self.request.form_ng.getOne(
241+ "%s.person" % self.name)
242+ raise WidgetInputError(
243+ self.name, self.label,
244+ LaunchpadValidationError(
245+ "There is no person or team named '%s' registered in "
246+ "Launchpad" % entered_name))
247+ else:
248+ raise UnexpectedFormData("No valid option was selected.")
249+
250+ def error(self):
251+ """See `zope.formlib.interfaces.IBrowserWidget`."""
252+ try:
253+ if self.hasInput():
254+ self.getInputValue()
255+ except InputErrors as error:
256+ self._error = error
257+ return super(GitGranteeWidget, self).error()
258
259=== added file 'lib/lp/code/browser/widgets/templates/gitgrantee.pt'
260--- lib/lp/code/browser/widgets/templates/gitgrantee.pt 1970-01-01 00:00:00 +0000
261+++ lib/lp/code/browser/widgets/templates/gitgrantee.pt 2018-11-09 22:44:37 +0000
262@@ -0,0 +1,27 @@
263+<table>
264+ <tr tal:condition="view/show_options/repository_owner">
265+ <td colspan="2">
266+ <label>
267+ <input
268+ type="radio" value="repository_owner"
269+ tal:condition="not: context/readonly"
270+ tal:replace="structure view/options/repository_owner" />
271+ Repository owner
272+ </label>
273+ </td>
274+ </tr>
275+
276+ <tr tal:condition="view/show_options/person">
277+ <td>
278+ <label>
279+ <input
280+ type="radio" value="person"
281+ tal:condition="not: context/readonly"
282+ tal:replace="structure view/options/person" />
283+ </label>
284+ </td>
285+ <td>
286+ <tal:person replace="structure view/person_widget" />
287+ </td>
288+ </tr>
289+</table>
290
291=== added file 'lib/lp/code/browser/widgets/tests/test_gitgrantee.py'
292--- lib/lp/code/browser/widgets/tests/test_gitgrantee.py 1970-01-01 00:00:00 +0000
293+++ lib/lp/code/browser/widgets/tests/test_gitgrantee.py 2018-11-09 22:44:37 +0000
294@@ -0,0 +1,305 @@
295+# Copyright 2018 Canonical Ltd. This software is licensed under the
296+# GNU Affero General Public License version 3 (see the file LICENSE).
297+
298+from __future__ import absolute_import, print_function, unicode_literals
299+
300+__metaclass__ = type
301+
302+import re
303+
304+from zope.formlib.interfaces import (
305+ IBrowserWidget,
306+ IDisplayWidget,
307+ IInputWidget,
308+ WidgetInputError,
309+ )
310+
311+from lp.app.validators import LaunchpadValidationError
312+from lp.code.browser.widgets.gitgrantee import (
313+ GitGranteeDisplayWidget,
314+ GitGranteeField,
315+ GitGranteeWidget,
316+ )
317+from lp.code.enums import GitGranteeType
318+from lp.registry.vocabularies import ValidPersonOrTeamVocabulary
319+from lp.services.beautifulsoup import BeautifulSoup
320+from lp.services.webapp.escaping import html_escape
321+from lp.services.webapp.publisher import canonical_url
322+from lp.services.webapp.servers import LaunchpadTestRequest
323+from lp.testing import (
324+ TestCaseWithFactory,
325+ verifyObject,
326+ )
327+from lp.testing.layers import DatabaseFunctionalLayer
328+
329+
330+class TestGitGranteeWidgetBase:
331+
332+ layer = DatabaseFunctionalLayer
333+
334+ def setUp(self):
335+ super(TestGitGranteeWidgetBase, self).setUp()
336+ [self.ref] = self.factory.makeGitRefs()
337+ self.rule = self.factory.makeGitRule(
338+ repository=self.ref.repository, ref_pattern=self.ref.path)
339+ self.field = GitGranteeField(__name__="grantee", rule=self.rule)
340+ self.request = LaunchpadTestRequest()
341+ self.widget = self.widget_factory(self.field, self.request)
342+
343+ def test_implements(self):
344+ self.assertTrue(verifyObject(IBrowserWidget, self.widget))
345+ self.assertTrue(
346+ verifyObject(self.expected_widget_interface, self.widget))
347+
348+ def test_template(self):
349+ # The render template is setup.
350+ self.assertTrue(
351+ self.widget.template.filename.endswith("gitgrantee.pt"),
352+ "Template was not set up.")
353+
354+ def test_default_option(self):
355+ # The person field is the default option.
356+ self.assertEqual("person", self.widget.default_option)
357+
358+ def test_setUpSubWidgets_first_call(self):
359+ # The subwidget is set up and a flag is set.
360+ self.widget.setUpSubWidgets()
361+ self.assertTrue(self.widget._widgets_set_up)
362+ self.assertIsInstance(
363+ self.widget.person_widget.context.vocabulary,
364+ ValidPersonOrTeamVocabulary)
365+
366+ def test_setUpSubWidgets_second_call(self):
367+ # The setUpSubWidgets method exits early if a flag is set to
368+ # indicate that the subwidget was set up.
369+ self.widget._widgets_set_up = True
370+ self.widget.setUpSubWidgets()
371+ self.assertIsNone(getattr(self.widget, "person_widget", None))
372+
373+ def test_setUpOptions_default_person_checked(self):
374+ # The radio button options are composed of the setup widgets with
375+ # the person widget set as the default.
376+ self.widget.setUpSubWidgets()
377+ self.widget.setUpOptions()
378+ self.assertEqual(
379+ '<input class="radioType" style="margin-left: 0;" ' +
380+ self.expected_disabled_attr +
381+ 'id="field.grantee.option.repository_owner" name="field.grantee" '
382+ 'type="radio" value="repository_owner" />',
383+ self.widget.options["repository_owner"])
384+ self.assertEqual(
385+ '<input class="radioType" style="margin-left: 0;" ' +
386+ 'checked="checked" ' + self.expected_disabled_attr +
387+ 'id="field.grantee.option.person" name="field.grantee" '
388+ 'type="radio" value="person" />',
389+ self.widget.options["person"])
390+
391+ def test_setUpOptions_repository_owner_checked(self):
392+ # The repository owner radio button is selected when the form is
393+ # submitted when the grantee field's value is 'repository_owner'.
394+ form = {"field.grantee": "repository_owner"}
395+ self.widget.request = LaunchpadTestRequest(form=form)
396+ self.widget.setUpSubWidgets()
397+ self.widget.setUpOptions()
398+ self.assertEqual(
399+ '<input class="radioType" style="margin-left: 0;" '
400+ 'checked="checked" ' + self.expected_disabled_attr +
401+ 'id="field.grantee.option.repository_owner" name="field.grantee" '
402+ 'type="radio" value="repository_owner" />',
403+ self.widget.options["repository_owner"])
404+ self.assertEqual(
405+ '<input class="radioType" style="margin-left: 0;" ' +
406+ self.expected_disabled_attr +
407+ 'id="field.grantee.option.person" name="field.grantee" '
408+ 'type="radio" value="person" />',
409+ self.widget.options["person"])
410+
411+ def test_setUpOptions_person_checked(self):
412+ # The person radio button is selected when the form is submitted
413+ # when the grantee field's value is 'person'.
414+ form = {"field.grantee": "person"}
415+ self.widget.request = LaunchpadTestRequest(form=form)
416+ self.widget.setUpSubWidgets()
417+ self.widget.setUpOptions()
418+ self.assertEqual(
419+ '<input class="radioType" style="margin-left: 0;" ' +
420+ self.expected_disabled_attr +
421+ 'id="field.grantee.option.repository_owner" name="field.grantee" '
422+ 'type="radio" value="repository_owner" />',
423+ self.widget.options["repository_owner"])
424+ self.assertEqual(
425+ '<input class="radioType" style="margin-left: 0;" ' +
426+ 'checked="checked" ' + self.expected_disabled_attr +
427+ 'id="field.grantee.option.person" name="field.grantee" '
428+ 'type="radio" value="person" />',
429+ self.widget.options["person"])
430+
431+ def test_setRenderedValue_repository_owner(self):
432+ # Passing GitGranteeType.REPOSITORY_OWNER will set the widget's
433+ # render state to "repository_owner".
434+ self.widget.setUpSubWidgets()
435+ self.widget.setRenderedValue(GitGranteeType.REPOSITORY_OWNER)
436+ self.assertEqual("repository_owner", self.widget.default_option)
437+
438+ def test_setRenderedValue_person(self):
439+ # Passing a person will set the widget's render state to "person".
440+ self.widget.setUpSubWidgets()
441+ person = self.factory.makePerson()
442+ self.widget.setRenderedValue(person)
443+ self.assertEqual("person", self.widget.default_option)
444+ self.assertEqual(person, self.widget.person_widget._data)
445+
446+ def test_call(self):
447+ # The __call__ method sets up the widgets and the options.
448+ markup = self.widget()
449+ self.assertIsNotNone(self.widget.person_widget)
450+ self.assertIn("repository_owner", self.widget.options)
451+ self.assertIn("person", self.widget.options)
452+ soup = BeautifulSoup(markup)
453+ fields = soup.findAll(["input", "select"], {"id": re.compile(".*")})
454+ ids = [field["id"] for field in fields]
455+ self.assertContentEqual(self.expected_ids, ids)
456+
457+
458+class TestGitGranteeDisplayWidget(
459+ TestGitGranteeWidgetBase, TestCaseWithFactory):
460+ """Test the GitGranteeDisplayWidget class."""
461+
462+ widget_factory = GitGranteeDisplayWidget
463+ expected_widget_interface = IDisplayWidget
464+ expected_disabled_attr = 'disabled="disabled" '
465+ expected_ids = ["field.grantee.option.person"]
466+
467+ def test_setRenderedValue_person_display_widget(self):
468+ # If the widget's render state is "person", a customised display
469+ # widget is used.
470+ self.widget.setUpSubWidgets()
471+ person = self.factory.makePerson()
472+ self.widget.setRenderedValue(person)
473+ person_url = canonical_url(person)
474+ self.assertEqual(
475+ '<a href="%s">'
476+ '<img style="padding-bottom: 2px" alt="" src="/@@/person" /> '
477+ '%s</a>' % (person_url, html_escape(person.display_name)),
478+ self.widget.person_widget())
479+
480+
481+class TestGitGranteeWidget(TestGitGranteeWidgetBase, TestCaseWithFactory):
482+ """Test the GitGranteeWidget class."""
483+
484+ widget_factory = GitGranteeWidget
485+ expected_widget_interface = IInputWidget
486+ expected_disabled_attr = ""
487+ expected_ids = [
488+ "field.grantee.option.repository_owner",
489+ "field.grantee.option.person",
490+ "field.grantee.person",
491+ ]
492+
493+ def setUp(self):
494+ super(TestGitGranteeWidget, self).setUp()
495+ self.person = self.factory.makePerson()
496+
497+ def test_show_options_repository_owner_grant_already_exists(self):
498+ # If the rule already has a repository owner grant, the input widget
499+ # doesn't offer that option.
500+ self.factory.makeGitRuleGrant(
501+ rule=self.rule, grantee=GitGranteeType.REPOSITORY_OWNER)
502+ self.assertEqual(
503+ {"repository_owner": False, "person": True},
504+ self.widget.show_options)
505+
506+ def test_show_options_repository_owner_grant_does_not_exist(self):
507+ # If the rule doesn't have a repository owner grant, the input
508+ # widget offers that option.
509+ self.factory.makeGitRuleGrant(rule=self.rule)
510+ self.assertEqual(
511+ {"repository_owner": True, "person": True},
512+ self.widget.show_options)
513+
514+ @property
515+ def form(self):
516+ return {
517+ "field.grantee": "person",
518+ "field.grantee.person": self.person.name,
519+ }
520+
521+ def test_hasInput_not_in_form(self):
522+ # hasInput is false when the widget's name is not in the form data.
523+ self.widget.request = LaunchpadTestRequest(form={})
524+ self.assertEqual("field.grantee", self.widget.name)
525+ self.assertFalse(self.widget.hasInput())
526+
527+ def test_hasInput_no_person(self):
528+ # hasInput is false when the person radio button is selected and the
529+ # person widget's name is not in the form data.
530+ self.widget.request = LaunchpadTestRequest(
531+ form={"field.grantee": "person"})
532+ self.assertEqual("field.grantee", self.widget.name)
533+ self.assertFalse(self.widget.hasInput())
534+
535+ def test_hasInput_repository_owner(self):
536+ # hasInput is true when the repository owner radio button is selected.
537+ self.widget.request = LaunchpadTestRequest(
538+ form={"field.grantee": "repository_owner"})
539+ self.assertEqual("field.grantee", self.widget.name)
540+ self.assertTrue(self.widget.hasInput())
541+
542+ def test_hasInput_person(self):
543+ # hasInput is true when the person radio button is selected and the
544+ # person widget's name is in the form data.
545+ self.widget.request = LaunchpadTestRequest(form=self.form)
546+ self.assertEqual("field.grantee", self.widget.name)
547+ self.assertTrue(self.widget.hasInput())
548+
549+ def test_hasValidInput_true(self):
550+ # The field input is valid when all submitted parts are valid.
551+ self.widget.request = LaunchpadTestRequest(form=self.form)
552+ self.assertTrue(self.widget.hasValidInput())
553+
554+ def test_hasValidInput_false(self):
555+ # The field input is invalid if any of the submitted parts are invalid.
556+ form = self.form
557+ form["field.grantee.person"] = "non-existent"
558+ self.widget.request = LaunchpadTestRequest(form=form)
559+ self.assertFalse(self.widget.hasValidInput())
560+
561+ def test_getInputValue_repository_owner(self):
562+ # The field value is GitGranteeType.REPOSITORY_OWNER when the
563+ # repository owner radio button is selected.
564+ form = self.form
565+ form["field.grantee"] = "repository_owner"
566+ self.widget.request = LaunchpadTestRequest(form=form)
567+ self.assertEqual(
568+ GitGranteeType.REPOSITORY_OWNER, self.widget.getInputValue())
569+
570+ def test_getInputValue_person(self):
571+ # The field value is the person when the person radio button is
572+ # selected and the person sub field is valid.
573+ form = self.form
574+ form["field.grantee"] = "person"
575+ self.widget.request = LaunchpadTestRequest(form=form)
576+ self.assertEqual(self.person, self.widget.getInputValue())
577+
578+ def test_getInputValue_person_missing(self):
579+ # An error is raised when the person field is missing.
580+ form = self.form
581+ form["field.grantee"] = "person"
582+ del form["field.grantee.person"]
583+ self.widget.request = LaunchpadTestRequest(form=form)
584+ message = "Please enter a person or team name"
585+ e = self.assertRaises(WidgetInputError, self.widget.getInputValue)
586+ self.assertEqual(LaunchpadValidationError(message), e.errors)
587+
588+ def test_getInputValue_person_invalid(self):
589+ # An error is raised when the person is not valid.
590+ form = self.form
591+ form["field.grantee"] = "person"
592+ form["field.grantee.person"] = "non-existent"
593+ self.widget.request = LaunchpadTestRequest(form=form)
594+ message = (
595+ "There is no person or team named 'non-existent' registered in "
596+ "Launchpad")
597+ e = self.assertRaises(WidgetInputError, self.widget.getInputValue)
598+ self.assertEqual(LaunchpadValidationError(message), e.errors)
599+ self.assertEqual(html_escape(message), self.widget.error())
600
601=== modified file 'lib/lp/code/interfaces/gitrepository.py'
602--- lib/lp/code/interfaces/gitrepository.py 2018-11-09 22:06:43 +0000
603+++ lib/lp/code/interfaces/gitrepository.py 2018-11-09 22:44:37 +0000
604@@ -766,12 +766,17 @@
605 :param user: The `IPerson` who is moving the rule.
606 """
607
608- def findRuleGrantsByGrantee(grantee):
609+ def findRuleGrantsByGrantee(grantee, exact_grantee=False,
610+ ref_pattern=None):
611 """Find the grants for a grantee applied to this repository.
612
613 :param grantee: The `IPerson` to search for, or an item of
614 `GitGranteeType` other than `GitGranteeType.PERSON` to search
615 for some other kind of entity.
616+ :param exact_grantee: If True, match `grantee` exactly; if False
617+ (the default), also accept teams of which `grantee` is a member.
618+ :param ref_pattern: If not None, only return grants for rules with
619+ this ref_pattern.
620 """
621
622 @export_read_operation()
623
624=== modified file 'lib/lp/code/model/gitrepository.py'
625--- lib/lp/code/model/gitrepository.py 2018-11-09 22:06:43 +0000
626+++ lib/lp/code/model/gitrepository.py 2018-11-09 22:44:37 +0000
627@@ -1216,7 +1216,8 @@
628 return Store.of(self).find(
629 GitRuleGrant, GitRuleGrant.repository_id == self.id)
630
631- def findRuleGrantsByGrantee(self, grantee):
632+ def findRuleGrantsByGrantee(self, grantee, exact_grantee=False,
633+ ref_pattern=None):
634 """See `IGitRepository`."""
635 if isinstance(grantee, DBItem) and grantee.enum == GitGranteeType:
636 if grantee == GitGranteeType.PERSON:
637@@ -1224,12 +1225,22 @@
638 "grantee may not be GitGranteeType.PERSON; pass a person "
639 "object instead")
640 clauses = [GitRuleGrant.grantee_type == grantee]
641+ elif exact_grantee:
642+ clauses = [
643+ GitRuleGrant.grantee_type == GitGranteeType.PERSON,
644+ GitRuleGrant.grantee == grantee,
645+ ]
646 else:
647 clauses = [
648 GitRuleGrant.grantee_type == GitGranteeType.PERSON,
649 TeamParticipation.person == grantee,
650 GitRuleGrant.grantee == TeamParticipation.teamID
651 ]
652+ if ref_pattern is not None:
653+ clauses.extend([
654+ GitRuleGrant.rule_id == GitRule.id,
655+ GitRule.ref_pattern == ref_pattern,
656+ ])
657 return self.grants.find(*clauses).config(distinct=True)
658
659 def getRules(self):
660
661=== modified file 'lib/lp/code/model/tests/test_gitrepository.py'
662--- lib/lp/code/model/tests/test_gitrepository.py 2018-11-09 22:06:43 +0000
663+++ lib/lp/code/model/tests/test_gitrepository.py 2018-11-09 22:44:37 +0000
664@@ -265,7 +265,7 @@
665 grant = self.factory.makeGitRuleGrant(
666 rule=rule, grantee=requester, can_push=True, can_create=True)
667
668- results = repository.findRuleGrantsByGrantee(requester)
669+ results = repository.findRuleGrantsByGrantee(member)
670 self.assertEqual([grant], list(results))
671
672 def test_findRuleGrantsByGrantee_team_in_team(self):
673@@ -357,6 +357,116 @@
674 results = repository.findRuleGrantsByGrantee(requester)
675 self.assertEqual([owner_grant], list(results))
676
677+ def test_findRuleGrantsByGrantee_ref_pattern(self):
678+ requester = self.factory.makePerson()
679+ repository = removeSecurityProxy(
680+ self.factory.makeGitRepository(owner=requester))
681+ [ref] = self.factory.makeGitRefs(repository=repository)
682+
683+ exact_grant = self.factory.makeGitRuleGrant(
684+ repository=repository, ref_pattern=ref.path, grantee=requester)
685+ self.factory.makeGitRuleGrant(
686+ repository=repository, ref_pattern="refs/heads/*",
687+ grantee=requester)
688+
689+ results = repository.findRuleGrantsByGrantee(
690+ requester, ref_pattern=ref.path)
691+ self.assertEqual([exact_grant], list(results))
692+
693+ def test_findRuleGrantsByGrantee_exact_grantee_person(self):
694+ requester = self.factory.makePerson()
695+ repository = removeSecurityProxy(
696+ self.factory.makeGitRepository(owner=requester))
697+
698+ rule = self.factory.makeGitRule(repository)
699+ grant = self.factory.makeGitRuleGrant(rule=rule, grantee=requester)
700+
701+ results = repository.findRuleGrantsByGrantee(
702+ requester, exact_grantee=True)
703+ self.assertEqual([grant], list(results))
704+
705+ def test_findRuleGrantsByGrantee_exact_grantee_team(self):
706+ team = self.factory.makeTeam()
707+ repository = removeSecurityProxy(
708+ self.factory.makeGitRepository(owner=team))
709+
710+ rule = self.factory.makeGitRule(repository)
711+ grant = self.factory.makeGitRuleGrant(rule=rule, grantee=team)
712+
713+ results = repository.findRuleGrantsByGrantee(team, exact_grantee=True)
714+ self.assertEqual([grant], list(results))
715+
716+ def test_findRuleGrantsByGrantee_exact_grantee_member_of_team(self):
717+ member = self.factory.makePerson()
718+ team = self.factory.makeTeam(members=[member])
719+ repository = removeSecurityProxy(
720+ self.factory.makeGitRepository(owner=team))
721+
722+ rule = self.factory.makeGitRule(repository)
723+ self.factory.makeGitRuleGrant(rule=rule, grantee=team)
724+
725+ results = repository.findRuleGrantsByGrantee(
726+ member, exact_grantee=True)
727+ self.assertEqual([], list(results))
728+
729+ def test_findRuleGrantsByGrantee_no_owner_grant(self):
730+ repository = removeSecurityProxy(self.factory.makeGitRepository())
731+
732+ rule = self.factory.makeGitRule(repository=repository)
733+ self.factory.makeGitRuleGrant(rule=rule)
734+
735+ results = repository.findRuleGrantsByGrantee(
736+ GitGranteeType.REPOSITORY_OWNER)
737+ self.assertEqual([], list(results))
738+
739+ def test_findRuleGrantsByGrantee_owner_grant(self):
740+ repository = removeSecurityProxy(self.factory.makeGitRepository())
741+
742+ rule = self.factory.makeGitRule(repository=repository)
743+ grant = self.factory.makeGitRuleGrant(
744+ rule=rule, grantee=GitGranteeType.REPOSITORY_OWNER)
745+ self.factory.makeGitRuleGrant(rule=rule)
746+
747+ results = repository.findRuleGrantsByGrantee(
748+ GitGranteeType.REPOSITORY_OWNER)
749+ self.assertEqual([grant], list(results))
750+
751+ def test_findRuleGrantsByGrantee_owner_ref_pattern(self):
752+ repository = removeSecurityProxy(self.factory.makeGitRepository())
753+ [ref] = self.factory.makeGitRefs(repository=repository)
754+
755+ exact_grant = self.factory.makeGitRuleGrant(
756+ repository=repository, ref_pattern=ref.path,
757+ grantee=GitGranteeType.REPOSITORY_OWNER)
758+ self.factory.makeGitRuleGrant(
759+ repository=repository, ref_pattern="refs/heads/*",
760+ grantee=GitGranteeType.REPOSITORY_OWNER)
761+
762+ results = ref.repository.findRuleGrantsByGrantee(
763+ GitGranteeType.REPOSITORY_OWNER, ref_pattern=ref.path)
764+ self.assertEqual([exact_grant], list(results))
765+
766+ def test_findRuleGrantsByGrantee_owner_exact_grantee(self):
767+ repository = removeSecurityProxy(self.factory.makeGitRepository())
768+ [ref] = self.factory.makeGitRefs(repository=repository)
769+
770+ exact_grant = self.factory.makeGitRuleGrant(
771+ repository=repository, ref_pattern=ref.path,
772+ grantee=GitGranteeType.REPOSITORY_OWNER)
773+ self.factory.makeGitRuleGrant(
774+ rule=exact_grant.rule, grantee=repository.owner)
775+ wildcard_grant = self.factory.makeGitRuleGrant(
776+ repository=repository, ref_pattern="refs/heads/*",
777+ grantee=GitGranteeType.REPOSITORY_OWNER)
778+
779+ results = ref.repository.findRuleGrantsByGrantee(
780+ GitGranteeType.REPOSITORY_OWNER, exact_grantee=True)
781+ self.assertItemsEqual([exact_grant, wildcard_grant], list(results))
782+ results = ref.repository.findRuleGrantsByGrantee(
783+ GitGranteeType.REPOSITORY_OWNER, ref_pattern=ref.path,
784+ exact_grantee=True)
785+ self.assertEqual([exact_grant], list(results))
786+
787
788 class TestGitIdentityMixin(TestCaseWithFactory):
789 """Test the defaults and identities provided by GitIdentityMixin."""