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

Proposed by Colin Watson on 2018-10-21
Status: Merged
Merged at revision: 18853
Proposed branch: lp:~cjwatson/launchpad/git-grantee-widgets
Merge into: lp:launchpad
Diff against target: 783 lines (+708/-3)
6 files modified
lib/lp/code/browser/widgets/gitgrantee.py (+245/-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 (+7/-1)
lib/lp/code/model/gitrepository.py (+12/-1)
lib/lp/code/model/tests/test_gitrepository.py (+112/-1)
To merge this branch: bzr merge lp:~cjwatson/launchpad/git-grantee-widgets
Reviewer Review Type Date Requested Status
William Grant code 2018-10-21 Approve on 2019-01-09
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.

William Grant (wgrant) :
review: Approve (code)
18812. By Colin Watson on 2019-01-09

Simplify GitGranteePersonDisplayWidget using PersonFormatterAPI.

18813. By Colin Watson on 2019-01-09

Rename exact_grantee to include_transitive and invert its sense.

Colin Watson (cjwatson) :

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