Merge lp:~cjwatson/launchpad/git-grantee-widgets into lp:launchpad
- git-grantee-widgets
- Merge into devel
Proposed by
Colin Watson
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 | ||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
William Grant | code | Approve | |
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:/
To post a comment you must log in.
Revision history for this message
William Grant (wgrant) : | # |
review:
Approve
(code)
Revision history for this message
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.""" |