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