Merge lp:~cjwatson/launchpad/git-permissions-ui-edit into lp:launchpad
- git-permissions-ui-edit
- Merge into devel
Status: | Superseded | ||||
---|---|---|---|---|---|
Proposed branch: | lp:~cjwatson/launchpad/git-permissions-ui-edit | ||||
Merge into: | lp:launchpad | ||||
Prerequisite: | lp:~cjwatson/launchpad/snapshot-modifying-helper | ||||
Diff against target: |
2295 lines (+2023/-8) 13 files modified
lib/lp/code/browser/configure.zcml (+6/-0) lib/lp/code/browser/gitrepository.py (+486/-4) lib/lp/code/browser/tests/test_gitrepository.py (+612/-1) lib/lp/code/browser/widgets/gitgrantee.py (+253/-0) lib/lp/code/browser/widgets/templates/gitgrantee.pt (+27/-0) lib/lp/code/browser/widgets/tests/test_gitgrantee.py (+305/-0) lib/lp/code/interfaces/gitrepository.py (+6/-1) lib/lp/code/interfaces/gitrule.py (+4/-0) lib/lp/code/model/gitrepository.py (+12/-1) lib/lp/code/model/gitrule.py (+7/-0) lib/lp/code/model/tests/test_gitrepository.py (+111/-1) lib/lp/code/model/tests/test_gitrule.py (+2/-0) lib/lp/code/templates/gitrepository-permissions.pt (+192/-0) |
||||
To merge this branch: | bzr merge lp:~cjwatson/launchpad/git-permissions-ui-edit | ||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Launchpad code reviewers | Pending | ||
Review via email: mp+358099@code.launchpad.net |
This proposal has been superseded by a proposal from 2018-11-09.
Commit message
Add a Git repository permissions view.
Description of the change
This was very substantially complicated by not wanting to invest the time in a JS-based interface just yet (and wanting to have a non-JS fallback, in any case). Given that, we need to be able to cram everything into a single form which can be submitted in one go and specify the new state of the entire permissions structure. The UI-level separation between protected branches and protected tags also makes things hard, particularly when it comes to lining up columns consistently. This is the best I was able to do given those constraints; some bits are quite ugly (especially rule positions), but I think it's tolerable, and it should allow for future JS-based enhancement.
Example screenshot: https:/
This isn't ready for review yet since it has multiple prerequisites (https:/
Preview Diff
1 | === modified file 'lib/lp/code/browser/configure.zcml' |
2 | --- lib/lp/code/browser/configure.zcml 2018-11-08 11:40:25 +0000 |
3 | +++ lib/lp/code/browser/configure.zcml 2018-11-09 22:50:10 +0000 |
4 | @@ -871,6 +871,12 @@ |
5 | </class> |
6 | <browser:page |
7 | for="lp.code.interfaces.gitrepository.IGitRepository" |
8 | + class="lp.code.browser.gitrepository.GitRepositoryPermissionsView" |
9 | + name="+permissions" |
10 | + permission="launchpad.Edit" |
11 | + template="../templates/gitrepository-permissions.pt"/> |
12 | + <browser:page |
13 | + for="lp.code.interfaces.gitrepository.IGitRepository" |
14 | class="lp.code.browser.gitrepository.GitRepositoryDeletionView" |
15 | permission="launchpad.Edit" |
16 | name="+delete" |
17 | |
18 | === modified file 'lib/lp/code/browser/gitrepository.py' |
19 | --- lib/lp/code/browser/gitrepository.py 2018-11-08 15:53:56 +0000 |
20 | +++ lib/lp/code/browser/gitrepository.py 2018-11-09 22:50:10 +0000 |
21 | @@ -17,10 +17,13 @@ |
22 | 'GitRepositoryEditReviewerView', |
23 | 'GitRepositoryEditView', |
24 | 'GitRepositoryNavigation', |
25 | + 'GitRepositoryPermissionsView', |
26 | 'GitRepositoryURL', |
27 | 'GitRepositoryView', |
28 | ] |
29 | |
30 | +import base64 |
31 | + |
32 | from lazr.lifecycle.event import ObjectModifiedEvent |
33 | from lazr.lifecycle.snapshot import Snapshot |
34 | from lazr.restful.interface import ( |
35 | @@ -34,14 +37,21 @@ |
36 | from zope.component import getUtility |
37 | from zope.event import notify |
38 | from zope.formlib import form |
39 | +from zope.formlib.textwidgets import IntWidget |
40 | +from zope.formlib.widget import CustomWidgetFactory |
41 | from zope.interface import ( |
42 | implementer, |
43 | Interface, |
44 | providedBy, |
45 | ) |
46 | from zope.publisher.interfaces.browser import IBrowserPublisher |
47 | -from zope.schema import Choice |
48 | +from zope.schema import ( |
49 | + Bool, |
50 | + Choice, |
51 | + Int, |
52 | + ) |
53 | from zope.schema.vocabulary import ( |
54 | + getVocabularyRegistry, |
55 | SimpleTerm, |
56 | SimpleVocabulary, |
57 | ) |
58 | @@ -53,7 +63,10 @@ |
59 | LaunchpadEditFormView, |
60 | LaunchpadFormView, |
61 | ) |
62 | -from lp.app.errors import NotFoundError |
63 | +from lp.app.errors import ( |
64 | + NotFoundError, |
65 | + UnexpectedFormData, |
66 | + ) |
67 | from lp.app.vocabularies import InformationTypeVocabulary |
68 | from lp.app.widgets.itemswidgets import LaunchpadRadioWidgetWithDescription |
69 | from lp.code.browser.branch import CodeEditOwnerMixin |
70 | @@ -62,11 +75,19 @@ |
71 | ) |
72 | from lp.code.browser.codeimport import CodeImportTargetMixin |
73 | from lp.code.browser.sourcepackagerecipelisting import HasRecipesMenuMixin |
74 | +from lp.code.browser.widgets.gitgrantee import ( |
75 | + GitGranteeDisplayWidget, |
76 | + GitGranteeField, |
77 | + GitGranteeWidget, |
78 | + ) |
79 | from lp.code.browser.widgets.gitrepositorytarget import ( |
80 | GitRepositoryTargetDisplayWidget, |
81 | GitRepositoryTargetWidget, |
82 | ) |
83 | -from lp.code.enums import GitRepositoryType |
84 | +from lp.code.enums import ( |
85 | + GitGranteeType, |
86 | + GitRepositoryType, |
87 | + ) |
88 | from lp.code.errors import ( |
89 | GitDefaultConflict, |
90 | GitRepositoryCreationForbidden, |
91 | @@ -76,6 +97,7 @@ |
92 | from lp.code.interfaces.gitnamespace import get_git_namespace |
93 | from lp.code.interfaces.gitref import IGitRefBatchNavigator |
94 | from lp.code.interfaces.gitrepository import IGitRepository |
95 | +from lp.code.vocabularies.gitrule import GitPermissionsVocabulary |
96 | from lp.registry.interfaces.person import ( |
97 | IPerson, |
98 | IPersonSet, |
99 | @@ -84,6 +106,7 @@ |
100 | from lp.services.config import config |
101 | from lp.services.database.constants import UTC_NOW |
102 | from lp.services.features import getFeatureFlag |
103 | +from lp.services.fields import UniqueField |
104 | from lp.services.propertycache import cachedproperty |
105 | from lp.services.webapp import ( |
106 | canonical_url, |
107 | @@ -105,6 +128,7 @@ |
108 | from lp.services.webapp.escaping import structured |
109 | from lp.services.webapp.interfaces import ICanonicalUrlData |
110 | from lp.services.webapp.publisher import DataDownloadView |
111 | +from lp.services.webapp.snapshot import notify_modified |
112 | from lp.services.webhooks.browser import WebhookTargetNavigationMixin |
113 | from lp.snappy.browser.hassnaps import HasSnapsViewMixin |
114 | |
115 | @@ -211,7 +235,14 @@ |
116 | usedfor = IGitRepository |
117 | facet = "branches" |
118 | title = "Edit Git repository" |
119 | - links = ["edit", "reviewer", "webhooks", "activity", "delete"] |
120 | + links = [ |
121 | + "edit", |
122 | + "reviewer", |
123 | + "permissions", |
124 | + "activity", |
125 | + "webhooks", |
126 | + "delete", |
127 | + ] |
128 | |
129 | @enabled_with_permission("launchpad.Edit") |
130 | def edit(self): |
131 | @@ -224,6 +255,11 @@ |
132 | return Link("+reviewer", text, icon="edit") |
133 | |
134 | @enabled_with_permission("launchpad.Edit") |
135 | + def permissions(self): |
136 | + text = "Manage permissions" |
137 | + return Link("+permissions", text, icon="edit") |
138 | + |
139 | + @enabled_with_permission("launchpad.Edit") |
140 | def webhooks(self): |
141 | text = "Manage webhooks" |
142 | return Link( |
143 | @@ -709,6 +745,452 @@ |
144 | return self, () |
145 | |
146 | |
147 | +def encode_form_field_id(value): |
148 | + """Encode text for use in form field names. |
149 | + |
150 | + We use a modified version of base32 which fits into CSS identifiers and |
151 | + so doesn't cause FormattersAPI.zope_css_id to do unhelpful things. |
152 | + """ |
153 | + return base64.b32encode( |
154 | + value.encode("UTF-8")).decode("UTF-8").replace("=", "_") |
155 | + |
156 | + |
157 | +def decode_form_field_id(encoded): |
158 | + """Inverse of `encode_form_field_id`.""" |
159 | + return base64.b32decode( |
160 | + encoded.replace("_", "=").encode("UTF-8")).decode("UTF-8") |
161 | + |
162 | + |
163 | +class GitRulePatternField(UniqueField): |
164 | + |
165 | + errormessage = _("%s is already in use by another rule") |
166 | + attribute = "ref_pattern" |
167 | + _content_iface = IGitRepository |
168 | + |
169 | + def __init__(self, ref_prefix, rule=None, *args, **kwargs): |
170 | + self.ref_prefix = ref_prefix |
171 | + self.rule = rule |
172 | + super(GitRulePatternField, self).__init__(*args, **kwargs) |
173 | + |
174 | + def _getByAttribute(self, ref_pattern): |
175 | + """See `UniqueField`.""" |
176 | + if self._content_iface.providedBy(self.context): |
177 | + return self.context.getRule(self.ref_prefix + ref_pattern) |
178 | + else: |
179 | + return None |
180 | + |
181 | + def unchanged(self, input): |
182 | + """See `UniqueField`.""" |
183 | + return ( |
184 | + self.rule is not None and |
185 | + self.ref_prefix + input == self.rule.ref_pattern) |
186 | + |
187 | + def set(self, object, value): |
188 | + """See `IField`.""" |
189 | + if value is not None: |
190 | + value = value.strip() |
191 | + super(GitRulePatternField, self).set(object, value) |
192 | + |
193 | + |
194 | +class GitRepositoryPermissionsView(LaunchpadFormView): |
195 | + """A view to manage repository permissions.""" |
196 | + |
197 | + @property |
198 | + def label(self): |
199 | + return "Manage permissions for %s" % self.context.identity |
200 | + |
201 | + page_title = "Manage permissions" |
202 | + |
203 | + @cachedproperty |
204 | + def repository(self): |
205 | + return self.context |
206 | + |
207 | + @cachedproperty |
208 | + def rules(self): |
209 | + return self.repository.getRules() |
210 | + |
211 | + @cachedproperty |
212 | + def branch_rules(self): |
213 | + return [ |
214 | + rule for rule in self.rules |
215 | + if rule.ref_pattern.startswith(u"refs/heads/")] |
216 | + |
217 | + @cachedproperty |
218 | + def tag_rules(self): |
219 | + return [ |
220 | + rule for rule in self.rules |
221 | + if rule.ref_pattern.startswith(u"refs/tags/")] |
222 | + |
223 | + @cachedproperty |
224 | + def other_rules(self): |
225 | + return [ |
226 | + rule for rule in self.rules |
227 | + if not rule.ref_pattern.startswith(u"refs/heads/") and |
228 | + not rule.ref_pattern.startswith(u"refs/tags/")] |
229 | + |
230 | + def _getRuleGrants(self, rule): |
231 | + def grantee_key(grant): |
232 | + if grant.grantee is not None: |
233 | + return grant.grantee_type, grant.grantee.name |
234 | + else: |
235 | + return (grant.grantee_type,) |
236 | + |
237 | + return sorted(rule.grants, key=grantee_key) |
238 | + |
239 | + def _parseRefPattern(self, ref_pattern): |
240 | + """Parse a pattern into a prefix and the displayed portion.""" |
241 | + for prefix in (u"refs/heads/", u"refs/tags/"): |
242 | + if ref_pattern.startswith(prefix): |
243 | + return prefix, ref_pattern[len(prefix):] |
244 | + return u"", ref_pattern |
245 | + |
246 | + def _getFieldName(self, name, ref_pattern, grantee=None): |
247 | + """Get the combined field name for a ref pattern and optional grantee. |
248 | + |
249 | + In order to be able to render a permissions table, we encode the ref |
250 | + pattern and the grantee in the form field name. |
251 | + """ |
252 | + suffix = "." + encode_form_field_id(ref_pattern) |
253 | + if grantee is not None: |
254 | + if IPerson.providedBy(grantee): |
255 | + suffix += "." + str(grantee.id) |
256 | + else: |
257 | + suffix += "._" + grantee.name.lower() |
258 | + return name + suffix |
259 | + |
260 | + def _parseFieldName(self, field_name): |
261 | + """Parse a combined field name as described in `_getFieldName`. |
262 | + |
263 | + :raises UnexpectedFormData: if the field name cannot be parsed or |
264 | + the grantee cannot be found. |
265 | + """ |
266 | + field_bits = field_name.split(".") |
267 | + if len(field_bits) < 2: |
268 | + raise UnexpectedFormData( |
269 | + "Cannot parse field name: %s" % field_name) |
270 | + field_type = field_bits[0] |
271 | + try: |
272 | + ref_pattern = decode_form_field_id(field_bits[1]) |
273 | + except TypeError: |
274 | + raise UnexpectedFormData( |
275 | + "Cannot parse field name: %s" % field_name) |
276 | + if len(field_bits) > 2: |
277 | + grantee_id = field_bits[2] |
278 | + if grantee_id.startswith("_"): |
279 | + grantee_id = grantee_id[1:] |
280 | + try: |
281 | + grantee = GitGranteeType.getTermByToken(grantee_id).value |
282 | + except LookupError: |
283 | + grantee = None |
284 | + else: |
285 | + try: |
286 | + grantee_id = int(grantee_id) |
287 | + except ValueError: |
288 | + grantee = None |
289 | + else: |
290 | + grantee = getUtility(IPersonSet).get(grantee_id) |
291 | + if grantee is None or grantee == GitGranteeType.PERSON: |
292 | + raise UnexpectedFormData("No such grantee: %s" % grantee_id) |
293 | + else: |
294 | + grantee = None |
295 | + return field_type, ref_pattern, grantee |
296 | + |
297 | + def _getPermissionsTerm(self, grant): |
298 | + """Return a term from `GitPermissionsVocabulary` for this grant.""" |
299 | + vocabulary = getVocabularyRegistry().get(grant, "GitPermissions") |
300 | + try: |
301 | + return vocabulary.getTerm(grant.permissions) |
302 | + except LookupError: |
303 | + # This should never happen, because GitPermissionsVocabulary |
304 | + # adds a custom term for the context grant if necessary. |
305 | + raise AssertionError( |
306 | + "Could not find GitPermissions term for %r" % grant) |
307 | + |
308 | + def setUpFields(self): |
309 | + """See `LaunchpadFormView`.""" |
310 | + position_fields = [] |
311 | + pattern_fields = [] |
312 | + delete_fields = [] |
313 | + readonly_grantee_fields = [] |
314 | + grantee_fields = [] |
315 | + permissions_fields = [] |
316 | + |
317 | + default_permissions_by_prefix = { |
318 | + "refs/heads/": "can_push", |
319 | + "refs/tags/": "can_create", |
320 | + "": "can_push", |
321 | + } |
322 | + |
323 | + for rule_index, rule in enumerate(self.rules): |
324 | + # Remove the usual branch/tag prefixes from patterns. The full |
325 | + # pattern goes into form field names, so no data is lost here. |
326 | + ref_pattern = rule.ref_pattern |
327 | + ref_prefix, short_pattern = self._parseRefPattern(ref_pattern) |
328 | + position_fields.append( |
329 | + Int( |
330 | + __name__=self._getFieldName("position", ref_pattern), |
331 | + required=True, readonly=False, default=rule_index + 1)) |
332 | + pattern_fields.append( |
333 | + GitRulePatternField( |
334 | + __name__=self._getFieldName("pattern", ref_pattern), |
335 | + required=True, readonly=False, ref_prefix=ref_prefix, |
336 | + rule=rule, default=short_pattern)) |
337 | + delete_fields.append( |
338 | + Bool( |
339 | + __name__=self._getFieldName("delete", ref_pattern), |
340 | + readonly=False, default=False)) |
341 | + for grant in self._getRuleGrants(rule): |
342 | + grantee = grant.combined_grantee |
343 | + readonly_grantee_fields.append( |
344 | + GitGranteeField( |
345 | + __name__=self._getFieldName( |
346 | + "grantee", ref_pattern, grantee), |
347 | + required=False, readonly=True, default=grantee, |
348 | + rule=rule)) |
349 | + permissions_fields.append( |
350 | + Choice( |
351 | + __name__=self._getFieldName( |
352 | + "permissions", ref_pattern, grantee), |
353 | + source=GitPermissionsVocabulary(grant), |
354 | + readonly=False, |
355 | + default=self._getPermissionsTerm(grant).value)) |
356 | + delete_fields.append( |
357 | + Bool( |
358 | + __name__=self._getFieldName( |
359 | + "delete", ref_pattern, grantee), |
360 | + readonly=False, default=False)) |
361 | + grantee_fields.append( |
362 | + GitGranteeField( |
363 | + __name__=self._getFieldName("grantee", ref_pattern), |
364 | + required=False, readonly=False, rule=rule)) |
365 | + permissions_vocabulary = GitPermissionsVocabulary(rule) |
366 | + permissions_fields.append( |
367 | + Choice( |
368 | + __name__=self._getFieldName( |
369 | + "permissions", ref_pattern), |
370 | + source=permissions_vocabulary, readonly=False, |
371 | + default=permissions_vocabulary.getTermByToken( |
372 | + default_permissions_by_prefix[ref_prefix]).value)) |
373 | + for ref_prefix in ("refs/heads/", "refs/tags/"): |
374 | + position_fields.append( |
375 | + Int( |
376 | + __name__=self._getFieldName("new-position", ref_prefix), |
377 | + required=False, readonly=True)) |
378 | + pattern_fields.append( |
379 | + GitRulePatternField( |
380 | + __name__=self._getFieldName("new-pattern", ref_prefix), |
381 | + required=False, readonly=False, ref_prefix=ref_prefix)) |
382 | + |
383 | + self.form_fields = ( |
384 | + form.FormFields( |
385 | + *position_fields, |
386 | + custom_widget=CustomWidgetFactory(IntWidget, displayWidth=2)) + |
387 | + form.FormFields(*pattern_fields) + |
388 | + form.FormFields(*delete_fields) + |
389 | + form.FormFields( |
390 | + *readonly_grantee_fields, |
391 | + custom_widget=CustomWidgetFactory(GitGranteeDisplayWidget)) + |
392 | + form.FormFields( |
393 | + *grantee_fields, |
394 | + custom_widget=CustomWidgetFactory(GitGranteeWidget)) + |
395 | + form.FormFields(*permissions_fields)) |
396 | + |
397 | + def setUpWidgets(self, context=None): |
398 | + """See `LaunchpadFormView`.""" |
399 | + super(GitRepositoryPermissionsView, self).setUpWidgets( |
400 | + context=context) |
401 | + for widget in self.widgets: |
402 | + widget.display_label = False |
403 | + widget.hint = None |
404 | + |
405 | + @property |
406 | + def cancel_url(self): |
407 | + return canonical_url(self.context) |
408 | + |
409 | + def getRuleWidgets(self, rule): |
410 | + widgets_by_name = {widget.name: widget for widget in self.widgets} |
411 | + ref_pattern = rule.ref_pattern |
412 | + position_field_name = ( |
413 | + "field." + self._getFieldName("position", ref_pattern)) |
414 | + pattern_field_name = ( |
415 | + "field." + self._getFieldName("pattern", ref_pattern)) |
416 | + delete_field_name = ( |
417 | + "field." + self._getFieldName("delete", ref_pattern)) |
418 | + grant_widgets = [] |
419 | + for grant in self._getRuleGrants(rule): |
420 | + grantee = grant.combined_grantee |
421 | + grantee_field_name = ( |
422 | + "field." + self._getFieldName("grantee", ref_pattern, grantee)) |
423 | + permissions_field_name = ( |
424 | + "field." + |
425 | + self._getFieldName("permissions", ref_pattern, grantee)) |
426 | + delete_grant_field_name = ( |
427 | + "field." + self._getFieldName("delete", ref_pattern, grantee)) |
428 | + grant_widgets.append({ |
429 | + "grantee": widgets_by_name[grantee_field_name], |
430 | + "permissions": widgets_by_name[permissions_field_name], |
431 | + "delete": widgets_by_name[delete_grant_field_name], |
432 | + }) |
433 | + new_grantee_field_name = ( |
434 | + "field." + self._getFieldName("grantee", ref_pattern)) |
435 | + new_permissions_field_name = ( |
436 | + "field." + self._getFieldName("permissions", ref_pattern)) |
437 | + new_grant_widgets = { |
438 | + "grantee": widgets_by_name[new_grantee_field_name], |
439 | + "permissions": widgets_by_name[new_permissions_field_name], |
440 | + } |
441 | + return { |
442 | + "position": widgets_by_name[position_field_name], |
443 | + "pattern": widgets_by_name[pattern_field_name], |
444 | + "delete": widgets_by_name.get(delete_field_name), |
445 | + "grants": grant_widgets, |
446 | + "new_grant": new_grant_widgets, |
447 | + } |
448 | + |
449 | + def getNewRuleWidgets(self, ref_prefix): |
450 | + widgets_by_name = {widget.name: widget for widget in self.widgets} |
451 | + new_position_field_name = ( |
452 | + "field." + self._getFieldName("new-position", ref_prefix)) |
453 | + new_pattern_field_name = ( |
454 | + "field." + self._getFieldName("new-pattern", ref_prefix)) |
455 | + return { |
456 | + "position": widgets_by_name[new_position_field_name], |
457 | + "pattern": widgets_by_name[new_pattern_field_name], |
458 | + } |
459 | + |
460 | + def updateRepositoryFromData(self, repository, data): |
461 | + pattern_field_names = sorted( |
462 | + name for name in data if name.split(".")[0] == "pattern") |
463 | + new_pattern_field_names = sorted( |
464 | + name for name in data if name.split(".")[0] == "new-pattern") |
465 | + permissions_field_names = sorted( |
466 | + name for name in data if name.split(".")[0] == "permissions") |
467 | + |
468 | + # Fetch rules before making any changes, since their ref_patterns |
469 | + # may change as a result of this update. |
470 | + rule_map = {rule.ref_pattern: rule for rule in self.repository.rules} |
471 | + grant_map = { |
472 | + (grant.rule.ref_pattern, grant.combined_grantee): grant |
473 | + for grant in self.repository.grants} |
474 | + |
475 | + # Patterns must be processed in rule order so that position changes |
476 | + # work in a reasonably natural way. |
477 | + ordered_patterns = [] |
478 | + for pattern_field_name in pattern_field_names: |
479 | + _, ref_pattern, _ = self._parseFieldName(pattern_field_name) |
480 | + if ref_pattern is not None: |
481 | + rule = rule_map.get(ref_pattern) |
482 | + ordered_patterns.append( |
483 | + (pattern_field_name, ref_pattern, rule)) |
484 | + ordered_patterns.sort(key=lambda item: item[2].position) |
485 | + |
486 | + for pattern_field_name, ref_pattern, rule in ordered_patterns: |
487 | + prefix, _ = self._parseRefPattern(ref_pattern) |
488 | + rule = rule_map.get(ref_pattern) |
489 | + delete_field_name = self._getFieldName("delete", ref_pattern) |
490 | + # If the rule was already deleted by somebody else, then we |
491 | + # have nothing to do. |
492 | + if rule is not None and data.get(delete_field_name): |
493 | + rule.destroySelf(self.user) |
494 | + rule_map[ref_pattern] = rule = None |
495 | + position_field_name = self._getFieldName("position", ref_pattern) |
496 | + if rule is not None: |
497 | + new_position = max(0, data[position_field_name] - 1) |
498 | + self.repository.moveRule(rule, new_position, self.user) |
499 | + new_pattern = prefix + data[pattern_field_name] |
500 | + if rule is not None and new_pattern != rule.ref_pattern: |
501 | + with notify_modified(rule, ["ref_pattern"]): |
502 | + rule.ref_pattern = new_pattern |
503 | + |
504 | + for new_pattern_field_name in new_pattern_field_names: |
505 | + _, prefix, _ = self._parseFieldName(new_pattern_field_name) |
506 | + if data[new_pattern_field_name]: |
507 | + # This is an "add rule" entry. |
508 | + new_position_field_name = self._getFieldName( |
509 | + "position", prefix) |
510 | + new_pattern = prefix + data[new_pattern_field_name] |
511 | + rule = rule_map.get(new_pattern) |
512 | + if rule is None: |
513 | + if new_position_field_name in data: |
514 | + new_position = max( |
515 | + 0, data[new_position_field_name] - 1) |
516 | + else: |
517 | + new_position = None |
518 | + rule = repository.addRule( |
519 | + new_pattern, self.user, position=new_position) |
520 | + if prefix == "refs/tags/": |
521 | + # Tags are a special case: on creation, they |
522 | + # automatically get a grant of create permissions to |
523 | + # the repository owner (suppressing the normal |
524 | + # ability of the repository owner to push protected |
525 | + # references). |
526 | + rule.addGrant( |
527 | + GitGranteeType.REPOSITORY_OWNER, self.user, |
528 | + can_create=True) |
529 | + |
530 | + for permissions_field_name in permissions_field_names: |
531 | + _, ref_pattern, grantee = self._parseFieldName( |
532 | + permissions_field_name) |
533 | + if ref_pattern not in rule_map: |
534 | + self.addError(structured( |
535 | + "Cannot edit grants for nonexistent rule %s", ref_pattern)) |
536 | + return |
537 | + rule = rule_map.get(ref_pattern) |
538 | + if rule is None: |
539 | + # Already deleted. |
540 | + continue |
541 | + |
542 | + # Find or create the corresponding grant. We only create a |
543 | + # grant if explicitly processing an "add grant" entry in the UI; |
544 | + # if there isn't already a grant for an existing entry that's |
545 | + # being modified, implicitly adding it is probably too |
546 | + # confusing. |
547 | + permissions = data[permissions_field_name] |
548 | + grant = None |
549 | + if grantee is not None: |
550 | + # This entry should correspond to an existing grant. Make |
551 | + # whatever changes were requested to it. |
552 | + grant = grant_map.get((ref_pattern, grantee)) |
553 | + delete_field_name = self._getFieldName( |
554 | + "delete", ref_pattern, grantee) |
555 | + # If the grant was already deleted by somebody else, then we |
556 | + # have nothing to do. |
557 | + if grant is not None and data.get(delete_field_name): |
558 | + grant.destroySelf(self.user) |
559 | + grant = None |
560 | + if grant is not None and permissions != grant.permissions: |
561 | + with notify_modified( |
562 | + grant, |
563 | + ["can_create", "can_push", "can_force_push"]): |
564 | + grant.permissions = permissions |
565 | + else: |
566 | + # This is an "add grant" entry. |
567 | + grantee_field_name = self._getFieldName("grantee", ref_pattern) |
568 | + grantee = data.get(grantee_field_name) |
569 | + if grantee: |
570 | + grant = grant_map.get((ref_pattern, grantee)) |
571 | + if grant is None: |
572 | + rule.addGrant( |
573 | + grantee, self.user, permissions=permissions) |
574 | + elif permissions != grant.permissions: |
575 | + # Somebody else added the grant since the form was |
576 | + # last rendered. Updating it with the permissions |
577 | + # from this request seems best. |
578 | + with notify_modified( |
579 | + grant, |
580 | + ["can_create", "can_push", "can_force_push"]): |
581 | + grant.permissions = permissions |
582 | + |
583 | + self.request.response.addNotification( |
584 | + "Saved permissions for %s" % self.context.identity) |
585 | + self.next_url = canonical_url(self.context, view_name="+permissions") |
586 | + |
587 | + @action("Save", name="save") |
588 | + def save_action(self, action, data): |
589 | + with notify_modified(self.repository, []): |
590 | + self.updateRepositoryFromData(self.repository, data) |
591 | + |
592 | + |
593 | class GitRepositoryDeletionView(LaunchpadFormView): |
594 | |
595 | schema = IGitRepository |
596 | |
597 | === modified file 'lib/lp/code/browser/tests/test_gitrepository.py' |
598 | --- lib/lp/code/browser/tests/test_gitrepository.py 2018-11-08 15:33:03 +0000 |
599 | +++ lib/lp/code/browser/tests/test_gitrepository.py 2018-11-09 22:50:10 +0000 |
600 | @@ -7,16 +7,26 @@ |
601 | |
602 | __metaclass__ = type |
603 | |
604 | +import base64 |
605 | from datetime import datetime |
606 | import doctest |
607 | +from operator import attrgetter |
608 | +import re |
609 | from textwrap import dedent |
610 | |
611 | from fixtures import FakeLogger |
612 | import pytz |
613 | +import soupmatchers |
614 | from storm.store import Store |
615 | from testtools.matchers import ( |
616 | + AfterPreprocessing, |
617 | DocTestMatches, |
618 | Equals, |
619 | + Is, |
620 | + MatchesDict, |
621 | + MatchesListwise, |
622 | + MatchesSetwise, |
623 | + MatchesStructure, |
624 | ) |
625 | import transaction |
626 | from zope.component import getUtility |
627 | @@ -26,11 +36,16 @@ |
628 | from zope.security.proxy import removeSecurityProxy |
629 | |
630 | from lp.app.enums import InformationType |
631 | +from lp.app.errors import UnexpectedFormData |
632 | from lp.app.interfaces.launchpad import ILaunchpadCelebrities |
633 | from lp.app.interfaces.services import IService |
634 | +from lp.code.browser.gitrepository import encode_form_field_id |
635 | from lp.code.enums import ( |
636 | BranchMergeProposalStatus, |
637 | CodeReviewVote, |
638 | + GitActivityType, |
639 | + GitGranteeType, |
640 | + GitPermissionType, |
641 | GitRepositoryType, |
642 | ) |
643 | from lp.code.interfaces.revision import IRevisionSet |
644 | @@ -40,7 +55,10 @@ |
645 | VCSType, |
646 | ) |
647 | from lp.registry.interfaces.accesspolicy import IAccessPolicySource |
648 | -from lp.registry.interfaces.person import PersonVisibility |
649 | +from lp.registry.interfaces.person import ( |
650 | + IPerson, |
651 | + PersonVisibility, |
652 | + ) |
653 | from lp.services.beautifulsoup import BeautifulSoup |
654 | from lp.services.database.constants import UTC_NOW |
655 | from lp.services.features.testing import FeatureFixture |
656 | @@ -1097,6 +1115,599 @@ |
657 | browser.headers["Content-Disposition"]) |
658 | |
659 | |
660 | +class TestGitRepositoryPermissionsView(BrowserTestCase): |
661 | + |
662 | + layer = DatabaseFunctionalLayer |
663 | + |
664 | + def test_rules_properties(self): |
665 | + repository = self.factory.makeGitRepository() |
666 | + heads_rule = self.factory.makeGitRule( |
667 | + repository=repository, ref_pattern="refs/heads/*") |
668 | + tags_rule = self.factory.makeGitRule( |
669 | + repository=repository, ref_pattern="refs/tags/*") |
670 | + catch_all_rule = self.factory.makeGitRule( |
671 | + repository=repository, ref_pattern="*") |
672 | + login_person(repository.owner) |
673 | + view = create_initialized_view(repository, name="+permissions") |
674 | + self.assertEqual([heads_rule], view.branch_rules) |
675 | + self.assertEqual([tags_rule], view.tag_rules) |
676 | + self.assertEqual([catch_all_rule], view.other_rules) |
677 | + |
678 | + def test__getRuleGrants(self): |
679 | + rule = self.factory.makeGitRule() |
680 | + grantees = sorted( |
681 | + [self.factory.makePerson() for _ in range(3)], |
682 | + key=attrgetter("name")) |
683 | + for grantee in (grantees[1], grantees[0], grantees[2]): |
684 | + self.factory.makeGitRuleGrant(rule=rule, grantee=grantee) |
685 | + self.factory.makeGitRuleGrant( |
686 | + rule=rule, grantee=GitGranteeType.REPOSITORY_OWNER) |
687 | + login_person(rule.repository.owner) |
688 | + view = create_initialized_view(rule.repository, name="+permissions") |
689 | + self.assertThat(view._getRuleGrants(rule), MatchesListwise([ |
690 | + MatchesStructure.byEquality( |
691 | + grantee_type=GitGranteeType.REPOSITORY_OWNER), |
692 | + MatchesStructure.byEquality(grantee=grantees[0]), |
693 | + MatchesStructure.byEquality(grantee=grantees[1]), |
694 | + MatchesStructure.byEquality(grantee=grantees[2]), |
695 | + ])) |
696 | + |
697 | + def test__parseRefPattern(self): |
698 | + repository = self.factory.makeGitRepository() |
699 | + login_person(repository.owner) |
700 | + view = create_initialized_view(repository, name="+permissions") |
701 | + self.assertEqual( |
702 | + ("refs/heads/", "stable/*"), |
703 | + view._parseRefPattern("refs/heads/stable/*")) |
704 | + self.assertEqual( |
705 | + ("refs/tags/", "1.0"), view._parseRefPattern("refs/tags/1.0")) |
706 | + self.assertEqual( |
707 | + ("", "refs/other/*"), view._parseRefPattern("refs/other/*")) |
708 | + self.assertEqual(("", "*"), view._parseRefPattern("*")) |
709 | + |
710 | + def test__getFieldName_no_grantee(self): |
711 | + repository = self.factory.makeGitRepository() |
712 | + login_person(repository.owner) |
713 | + view = create_initialized_view(repository, name="+permissions") |
714 | + encoded_ref_pattern = base64.b32encode( |
715 | + b"refs/heads/*").replace("=", "_").decode("UTF-8") |
716 | + self.assertEqual( |
717 | + "field.%s" % encoded_ref_pattern, |
718 | + view._getFieldName("field", "refs/heads/*")) |
719 | + |
720 | + def test__getFieldName_grantee_repository_owner(self): |
721 | + repository = self.factory.makeGitRepository() |
722 | + login_person(repository.owner) |
723 | + view = create_initialized_view(repository, name="+permissions") |
724 | + encoded_ref_pattern = base64.b32encode( |
725 | + b"refs/tags/*").replace("=", "_").decode("UTF-8") |
726 | + self.assertEqual( |
727 | + "field.%s._repository_owner" % encoded_ref_pattern, |
728 | + view._getFieldName( |
729 | + "field", "refs/tags/*", |
730 | + grantee=GitGranteeType.REPOSITORY_OWNER)) |
731 | + |
732 | + def test__getFieldName_grantee_person(self): |
733 | + repository = self.factory.makeGitRepository() |
734 | + grantee = self.factory.makePerson() |
735 | + login_person(repository.owner) |
736 | + view = create_initialized_view(repository, name="+permissions") |
737 | + encoded_ref_pattern = base64.b32encode( |
738 | + b"refs/*").replace("=", "_").decode("UTF-8") |
739 | + self.assertEqual( |
740 | + "field.%s.%s" % (encoded_ref_pattern, grantee.id), |
741 | + view._getFieldName("field", "refs/*", grantee=grantee)) |
742 | + |
743 | + def test__parseFieldName_too_few_components(self): |
744 | + repository = self.factory.makeGitRepository() |
745 | + login_person(repository.owner) |
746 | + view = create_initialized_view(repository, name="+permissions") |
747 | + self.assertRaises(UnexpectedFormData, view._parseFieldName, "field") |
748 | + |
749 | + def test__parseFieldName_bad_ref_pattern(self): |
750 | + repository = self.factory.makeGitRepository() |
751 | + login_person(repository.owner) |
752 | + view = create_initialized_view(repository, name="+permissions") |
753 | + self.assertRaises( |
754 | + UnexpectedFormData, view._parseFieldName, "field.nonsense") |
755 | + |
756 | + def test__parseFieldName_no_grantee(self): |
757 | + repository = self.factory.makeGitRepository() |
758 | + login_person(repository.owner) |
759 | + view = create_initialized_view(repository, name="+permissions") |
760 | + encoded_ref_pattern = base64.b32encode( |
761 | + b"refs/heads/*").replace("=", "_").decode("UTF-8") |
762 | + self.assertEqual( |
763 | + ("permissions", "refs/heads/*", None), |
764 | + view._parseFieldName("permissions.%s" % encoded_ref_pattern)) |
765 | + |
766 | + def test__parseFieldName_grantee_unknown_type(self): |
767 | + repository = self.factory.makeGitRepository() |
768 | + login_person(repository.owner) |
769 | + view = create_initialized_view(repository, name="+permissions") |
770 | + encoded_ref_pattern = base64.b32encode( |
771 | + b"refs/tags/*").replace("=", "_").decode("UTF-8") |
772 | + self.assertRaises( |
773 | + UnexpectedFormData, view._parseFieldName, |
774 | + "field.%s._nonsense" % encoded_ref_pattern) |
775 | + self.assertRaises( |
776 | + UnexpectedFormData, view._parseFieldName, |
777 | + "field.%s._person" % encoded_ref_pattern) |
778 | + |
779 | + def test__parseFieldName_grantee_repository_owner(self): |
780 | + repository = self.factory.makeGitRepository() |
781 | + login_person(repository.owner) |
782 | + view = create_initialized_view(repository, name="+permissions") |
783 | + encoded_ref_pattern = base64.b32encode( |
784 | + b"refs/tags/*").replace("=", "_").decode("UTF-8") |
785 | + self.assertEqual( |
786 | + ("pattern", "refs/tags/*", GitGranteeType.REPOSITORY_OWNER), |
787 | + view._parseFieldName( |
788 | + "pattern.%s._repository_owner" % encoded_ref_pattern)) |
789 | + |
790 | + def test__parseFieldName_grantee_unknown_person(self): |
791 | + repository = self.factory.makeGitRepository() |
792 | + grantee = self.factory.makePerson() |
793 | + login_person(repository.owner) |
794 | + view = create_initialized_view(repository, name="+permissions") |
795 | + encoded_ref_pattern = base64.b32encode( |
796 | + b"refs/*").replace("=", "_").decode("UTF-8") |
797 | + self.assertRaises( |
798 | + UnexpectedFormData, view._parseFieldName, |
799 | + "delete.%s.%s" % (encoded_ref_pattern, grantee.id * 2)) |
800 | + |
801 | + def test__parseFieldName_grantee_person(self): |
802 | + repository = self.factory.makeGitRepository() |
803 | + grantee = self.factory.makePerson() |
804 | + login_person(repository.owner) |
805 | + view = create_initialized_view(repository, name="+permissions") |
806 | + encoded_ref_pattern = base64.b32encode( |
807 | + b"refs/*").replace("=", "_").decode("UTF-8") |
808 | + self.assertEqual( |
809 | + ("delete", "refs/*", grantee), |
810 | + view._parseFieldName( |
811 | + "delete.%s.%s" % (encoded_ref_pattern, grantee.id))) |
812 | + |
813 | + def test__getPermissionsTerm_standard(self): |
814 | + grant = self.factory.makeGitRuleGrant( |
815 | + ref_pattern="refs/heads/*", can_create=True, can_push=True) |
816 | + login_person(grant.repository.owner) |
817 | + view = create_initialized_view(grant.repository, name="+permissions") |
818 | + self.assertThat( |
819 | + view._getPermissionsTerm(grant), MatchesStructure.byEquality( |
820 | + value={ |
821 | + GitPermissionType.CAN_CREATE, GitPermissionType.CAN_PUSH}, |
822 | + token="can_push", |
823 | + title="Can push")) |
824 | + |
825 | + def test__getPermissionsTerm_custom(self): |
826 | + grant = self.factory.makeGitRuleGrant( |
827 | + ref_pattern="refs/heads/*", can_force_push=True) |
828 | + login_person(grant.repository.owner) |
829 | + view = create_initialized_view(grant.repository, name="+permissions") |
830 | + self.assertThat( |
831 | + view._getPermissionsTerm(grant), MatchesStructure.byEquality( |
832 | + value={GitPermissionType.CAN_FORCE_PUSH}, |
833 | + token="custom", |
834 | + title="Custom permissions: force-push")) |
835 | + |
836 | + def _matchesCells(self, row_tag, cell_matchers): |
837 | + return AfterPreprocessing( |
838 | + str, soupmatchers.HTMLContains(*( |
839 | + soupmatchers.Within(row_tag, cell_matcher) |
840 | + for cell_matcher in cell_matchers))) |
841 | + |
842 | + def _matchesRule(self, position, pattern, short_pattern): |
843 | + rule_tag = soupmatchers.Tag( |
844 | + "rule row", "tr", attrs={"class": "git-rule"}) |
845 | + suffix = "." + encode_form_field_id(pattern) |
846 | + position_field_name = "field.position" + suffix |
847 | + pattern_field_name = "field.pattern" + suffix |
848 | + delete_field_name = "field.delete" + suffix |
849 | + return self._matchesCells(rule_tag, [ |
850 | + soupmatchers.Within( |
851 | + soupmatchers.Tag("position cell", "td"), |
852 | + soupmatchers.Tag( |
853 | + "position widget", "input", |
854 | + attrs={"name": position_field_name, "value": position})), |
855 | + soupmatchers.Within( |
856 | + soupmatchers.Tag("pattern cell", "td"), |
857 | + soupmatchers.Tag( |
858 | + "pattern widget", "input", |
859 | + attrs={ |
860 | + "name": pattern_field_name, |
861 | + "value": short_pattern, |
862 | + })), |
863 | + soupmatchers.Within( |
864 | + soupmatchers.Tag("delete cell", "td"), |
865 | + soupmatchers.Tag( |
866 | + "delete widget", "input", |
867 | + attrs={"name": delete_field_name})), |
868 | + ]) |
869 | + |
870 | + def _matchesNewRule(self, ref_prefix): |
871 | + new_rule_tag = soupmatchers.Tag( |
872 | + "new rule row", "tr", attrs={"class": "git-new-rule"}) |
873 | + suffix = "." + encode_form_field_id(ref_prefix) |
874 | + new_position_field_name = "field.new-position" + suffix |
875 | + new_pattern_field_name = "field.new-pattern" + suffix |
876 | + return self._matchesCells(new_rule_tag, [ |
877 | + soupmatchers.Within( |
878 | + soupmatchers.Tag("position cell", "td"), |
879 | + soupmatchers.Tag( |
880 | + "position widget", "input", |
881 | + attrs={"name": new_position_field_name, "value": ""})), |
882 | + soupmatchers.Within( |
883 | + soupmatchers.Tag("pattern cell", "td"), |
884 | + soupmatchers.Tag( |
885 | + "pattern widget", "input", |
886 | + attrs={"name": new_pattern_field_name, "value": ""})), |
887 | + ]) |
888 | + |
889 | + def _matchesRuleGrant(self, pattern, grantee, permissions_token, |
890 | + permissions_title): |
891 | + rule_grant_tag = soupmatchers.Tag( |
892 | + "rule grant row", "tr", attrs={"class": "git-rule-grant"}) |
893 | + suffix = "." + encode_form_field_id(pattern) |
894 | + if IPerson.providedBy(grantee): |
895 | + suffix += "." + str(grantee.id) |
896 | + grantee_widget_matcher = soupmatchers.Tag( |
897 | + "grantee widget", "a", attrs={"href": canonical_url(grantee)}, |
898 | + text=" " + grantee.display_name) |
899 | + else: |
900 | + suffix += "._" + grantee.name.lower() |
901 | + grantee_widget_matcher = soupmatchers.Tag( |
902 | + "grantee widget", "label", |
903 | + text=re.compile(re.escape(grantee.title))) |
904 | + permissions_field_name = "field.permissions" + suffix |
905 | + delete_field_name = "field.delete" + suffix |
906 | + return self._matchesCells(rule_grant_tag, [ |
907 | + soupmatchers.Within( |
908 | + soupmatchers.Tag("grantee cell", "td"), |
909 | + grantee_widget_matcher), |
910 | + soupmatchers.Within( |
911 | + soupmatchers.Tag("permissions cell", "td"), |
912 | + soupmatchers.Within( |
913 | + soupmatchers.Tag( |
914 | + "permissions widget", "select", |
915 | + attrs={"name": permissions_field_name}), |
916 | + soupmatchers.Tag( |
917 | + "selected permissions option", "option", |
918 | + attrs={ |
919 | + "selected": "selected", |
920 | + "value": permissions_token, |
921 | + }, |
922 | + text=permissions_title))), |
923 | + soupmatchers.Within( |
924 | + soupmatchers.Tag("delete cell", "td"), |
925 | + soupmatchers.Tag( |
926 | + "delete widget", "input", |
927 | + attrs={"name": delete_field_name})), |
928 | + ]) |
929 | + |
930 | + def _matchesNewRuleGrant(self, pattern, permissions_token): |
931 | + rule_grant_tag = soupmatchers.Tag( |
932 | + "rule grant row", "tr", attrs={"class": "git-new-rule-grant"}) |
933 | + suffix = "." + encode_form_field_id(pattern) |
934 | + grantee_field_name = "field.grantee" + suffix |
935 | + permissions_field_name = "field.permissions" + suffix |
936 | + return self._matchesCells(rule_grant_tag, [ |
937 | + soupmatchers.Within( |
938 | + soupmatchers.Tag("grantee cell", "td"), |
939 | + soupmatchers.Tag( |
940 | + "grantee widget", "input", |
941 | + attrs={"name": grantee_field_name})), |
942 | + soupmatchers.Within( |
943 | + soupmatchers.Tag("permissions cell", "td"), |
944 | + soupmatchers.Within( |
945 | + soupmatchers.Tag( |
946 | + "permissions widget", "select", |
947 | + attrs={"name": permissions_field_name}), |
948 | + soupmatchers.Tag( |
949 | + "selected permissions option", "option", |
950 | + attrs={ |
951 | + "selected": "selected", |
952 | + "value": permissions_token, |
953 | + }))), |
954 | + ]) |
955 | + |
956 | + def test_rules_table(self): |
957 | + repository = self.factory.makeGitRepository() |
958 | + heads_rule = self.factory.makeGitRule( |
959 | + repository=repository, ref_pattern="refs/heads/stable/*") |
960 | + heads_grantee_1 = self.factory.makePerson( |
961 | + name=self.factory.getUniqueString("person-name-a")) |
962 | + heads_grantee_2 = self.factory.makePerson( |
963 | + name=self.factory.getUniqueString("person-name-b")) |
964 | + self.factory.makeGitRuleGrant( |
965 | + rule=heads_rule, grantee=heads_grantee_1, can_push=True) |
966 | + self.factory.makeGitRuleGrant( |
967 | + rule=heads_rule, grantee=heads_grantee_2, can_force_push=True) |
968 | + tags_rule = self.factory.makeGitRule( |
969 | + repository=repository, ref_pattern="refs/tags/*") |
970 | + self.factory.makeGitRuleGrant( |
971 | + rule=tags_rule, grantee=GitGranteeType.REPOSITORY_OWNER) |
972 | + login_person(repository.owner) |
973 | + view = create_initialized_view( |
974 | + repository, name="+permissions", principal=repository.owner) |
975 | + rules_table = find_tag_by_id(view(), "rules-table") |
976 | + rows = rules_table.findAll("tr", {"class": True}) |
977 | + self.assertThat(rows, MatchesListwise([ |
978 | + self._matchesRule("1", "refs/heads/stable/*", "stable/*"), |
979 | + self._matchesRuleGrant( |
980 | + "refs/heads/stable/*", heads_grantee_1, "can_push_existing", |
981 | + "Can push if the branch already exists"), |
982 | + self._matchesRuleGrant( |
983 | + "refs/heads/stable/*", heads_grantee_2, "custom", |
984 | + "Custom permissions: force-push"), |
985 | + self._matchesNewRuleGrant("refs/heads/stable/*", "can_push"), |
986 | + self._matchesNewRule("refs/heads/"), |
987 | + self._matchesRule("2", "refs/tags/*", "*"), |
988 | + self._matchesRuleGrant( |
989 | + "refs/tags/*", GitGranteeType.REPOSITORY_OWNER, |
990 | + "cannot_create", "Cannot create"), |
991 | + self._matchesNewRuleGrant("refs/tags/*", "can_create"), |
992 | + self._matchesNewRule("refs/tags/"), |
993 | + ])) |
994 | + |
995 | + def assertHasRules(self, repository, ref_patterns): |
996 | + self.assertThat(list(repository.rules), MatchesListwise([ |
997 | + MatchesStructure.byEquality(ref_pattern=ref_pattern) |
998 | + for ref_pattern in ref_patterns |
999 | + ])) |
1000 | + |
1001 | + def assertHasSavedNotification(self, view, repository): |
1002 | + self.assertThat(view.request.response.notifications, MatchesListwise([ |
1003 | + MatchesStructure.byEquality( |
1004 | + message="Saved permissions for %s" % repository.identity), |
1005 | + ])) |
1006 | + |
1007 | + def test_save_add_rules(self): |
1008 | + repository = self.factory.makeGitRepository() |
1009 | + self.factory.makeGitRule( |
1010 | + repository=repository, ref_pattern="refs/heads/stable/*") |
1011 | + removeSecurityProxy(repository.getActivity()).remove() |
1012 | + login_person(repository.owner) |
1013 | + encoded_heads_prefix = encode_form_field_id("refs/heads/") |
1014 | + encoded_tags_prefix = encode_form_field_id("refs/tags/") |
1015 | + form = { |
1016 | + "field.new-pattern." + encoded_heads_prefix: "*", |
1017 | + "field.new-pattern." + encoded_tags_prefix: "1.0", |
1018 | + "field.actions.save": "Save", |
1019 | + } |
1020 | + view = create_initialized_view( |
1021 | + repository, name="+permissions", form=form, |
1022 | + principal=repository.owner) |
1023 | + self.assertHasRules( |
1024 | + repository, |
1025 | + ["refs/tags/1.0", "refs/heads/stable/*", "refs/heads/*"]) |
1026 | + self.assertThat(list(repository.getActivity()), MatchesListwise([ |
1027 | + # Adding a tag rule automatically adds a repository owner grant. |
1028 | + MatchesStructure( |
1029 | + changer=Equals(repository.owner), |
1030 | + changee=Is(None), |
1031 | + what_changed=Equals(GitActivityType.GRANT_ADDED), |
1032 | + new_value=MatchesDict({ |
1033 | + "changee_type": Equals("Repository owner"), |
1034 | + "ref_pattern": Equals("refs/tags/1.0"), |
1035 | + "can_create": Is(True), |
1036 | + "can_push": Is(False), |
1037 | + "can_force_push": Is(False), |
1038 | + })), |
1039 | + MatchesStructure( |
1040 | + changer=Equals(repository.owner), |
1041 | + what_changed=Equals(GitActivityType.RULE_ADDED), |
1042 | + new_value=MatchesDict({ |
1043 | + "ref_pattern": Equals("refs/tags/1.0"), |
1044 | + "position": Equals(0), |
1045 | + })), |
1046 | + MatchesStructure( |
1047 | + changer=Equals(repository.owner), |
1048 | + what_changed=Equals(GitActivityType.RULE_ADDED), |
1049 | + new_value=MatchesDict({ |
1050 | + "ref_pattern": Equals("refs/heads/*"), |
1051 | + # Initially inserted at 1, although refs/tags/1.0 was |
1052 | + # later inserted before it. |
1053 | + "position": Equals(1), |
1054 | + })), |
1055 | + ])) |
1056 | + self.assertHasSavedNotification(view, repository) |
1057 | + |
1058 | + def test_save_add_duplicate_rule(self): |
1059 | + repository = self.factory.makeGitRepository() |
1060 | + self.factory.makeGitRule( |
1061 | + repository=repository, ref_pattern="refs/heads/stable/*") |
1062 | + transaction.commit() |
1063 | + login_person(repository.owner) |
1064 | + encoded_heads_prefix = encode_form_field_id("refs/heads/") |
1065 | + form = { |
1066 | + "field.new-pattern." + encoded_heads_prefix: "stable/*", |
1067 | + "field.actions.save": "Save", |
1068 | + } |
1069 | + view = create_initialized_view( |
1070 | + repository, name="+permissions", form=form, |
1071 | + principal=repository.owner) |
1072 | + self.assertThat(view.errors, MatchesListwise([ |
1073 | + MatchesStructure( |
1074 | + field_name=Equals("new-pattern." + encoded_heads_prefix), |
1075 | + errors=MatchesStructure.byEquality( |
1076 | + args=("stable/* is already in use by another rule",))), |
1077 | + ])) |
1078 | + self.assertHasRules(repository, ["refs/heads/stable/*"]) |
1079 | + |
1080 | + def test_save_move_rule(self): |
1081 | + repository = self.factory.makeGitRepository() |
1082 | + self.factory.makeGitRule( |
1083 | + repository=repository, ref_pattern="refs/heads/stable/*") |
1084 | + self.factory.makeGitRule( |
1085 | + repository=repository, ref_pattern="refs/heads/*/next") |
1086 | + encoded_patterns = [ |
1087 | + encode_form_field_id(rule.ref_pattern) |
1088 | + for rule in repository.rules] |
1089 | + removeSecurityProxy(repository.getActivity()).remove() |
1090 | + login_person(repository.owner) |
1091 | + # Positions are 1-based in the UI. |
1092 | + form = { |
1093 | + "field.position." + encoded_patterns[0]: "2", |
1094 | + "field.pattern." + encoded_patterns[0]: "stable/*", |
1095 | + "field.position." + encoded_patterns[1]: "1", |
1096 | + "field.pattern." + encoded_patterns[1]: "*/more-next", |
1097 | + "field.actions.save": "Save", |
1098 | + } |
1099 | + view = create_initialized_view( |
1100 | + repository, name="+permissions", form=form, |
1101 | + principal=repository.owner) |
1102 | + self.assertHasRules( |
1103 | + repository, ["refs/heads/*/more-next", "refs/heads/stable/*"]) |
1104 | + self.assertThat(list(repository.getActivity()), MatchesListwise([ |
1105 | + MatchesStructure( |
1106 | + changer=Equals(repository.owner), |
1107 | + what_changed=Equals(GitActivityType.RULE_CHANGED), |
1108 | + old_value=MatchesDict({ |
1109 | + "ref_pattern": Equals("refs/heads/*/next"), |
1110 | + "position": Equals(0), |
1111 | + }), |
1112 | + new_value=MatchesDict({ |
1113 | + "ref_pattern": Equals("refs/heads/*/more-next"), |
1114 | + "position": Equals(0), |
1115 | + })), |
1116 | + # Only one rule is recorded as moving; the other is already in |
1117 | + # its new position by the time it's processed. |
1118 | + MatchesStructure( |
1119 | + changer=Equals(repository.owner), |
1120 | + what_changed=Equals(GitActivityType.RULE_MOVED), |
1121 | + old_value=MatchesDict({ |
1122 | + "ref_pattern": Equals("refs/heads/stable/*"), |
1123 | + "position": Equals(0), |
1124 | + }), |
1125 | + new_value=MatchesDict({ |
1126 | + "ref_pattern": Equals("refs/heads/stable/*"), |
1127 | + "position": Equals(1), |
1128 | + })), |
1129 | + ])) |
1130 | + self.assertHasSavedNotification(view, repository) |
1131 | + |
1132 | + def test_save_change_grants(self): |
1133 | + repository = self.factory.makeGitRepository() |
1134 | + stable_rule = self.factory.makeGitRule( |
1135 | + repository=repository, ref_pattern="refs/heads/stable/*") |
1136 | + next_rule = self.factory.makeGitRule( |
1137 | + repository=repository, ref_pattern="refs/heads/*/next") |
1138 | + grantees = [self.factory.makePerson() for _ in range(3)] |
1139 | + self.factory.makeGitRuleGrant( |
1140 | + rule=stable_rule, grantee=GitGranteeType.REPOSITORY_OWNER, |
1141 | + can_create=True) |
1142 | + self.factory.makeGitRuleGrant( |
1143 | + rule=stable_rule, |
1144 | + grantee=grantees[0], can_create=True, can_push=True) |
1145 | + self.factory.makeGitRuleGrant( |
1146 | + rule=next_rule, grantee=grantees[1], |
1147 | + can_create=True, can_push=True, can_force_push=True) |
1148 | + encoded_patterns = [ |
1149 | + encode_form_field_id(rule.ref_pattern) |
1150 | + for rule in repository.rules] |
1151 | + removeSecurityProxy(repository.getActivity()).remove() |
1152 | + login_person(repository.owner) |
1153 | + form = { |
1154 | + "field.permissions.%s._repository_owner" % encoded_patterns[0]: ( |
1155 | + "can_push"), |
1156 | + "field.permissions.%s.%s" % ( |
1157 | + encoded_patterns[0], grantees[0].id): "can_push", |
1158 | + "field.delete.%s.%s" % (encoded_patterns[0], grantees[0].id): "on", |
1159 | + "field.grantee.%s" % encoded_patterns[1]: "person", |
1160 | + "field.grantee.%s.person" % encoded_patterns[1]: grantees[2].name, |
1161 | + "field.permissions.%s" % encoded_patterns[1]: "can_push_existing", |
1162 | + "field.actions.save": "Save", |
1163 | + } |
1164 | + view = create_initialized_view( |
1165 | + repository, name="+permissions", form=form, |
1166 | + principal=repository.owner) |
1167 | + self.assertHasRules( |
1168 | + repository, ["refs/heads/stable/*", "refs/heads/*/next"]) |
1169 | + self.assertThat(stable_rule.grants, MatchesSetwise( |
1170 | + MatchesStructure.byEquality( |
1171 | + grantee_type=GitGranteeType.REPOSITORY_OWNER, |
1172 | + can_create=True, can_push=True, can_force_push=False))) |
1173 | + self.assertThat(next_rule.grants, MatchesSetwise( |
1174 | + MatchesStructure.byEquality( |
1175 | + grantee=grantees[1], |
1176 | + can_create=True, can_push=True, can_force_push=True), |
1177 | + MatchesStructure.byEquality( |
1178 | + grantee=grantees[2], |
1179 | + can_create=False, can_push=True, can_force_push=False))) |
1180 | + self.assertThat(repository.getActivity(), MatchesSetwise( |
1181 | + MatchesStructure( |
1182 | + changer=Equals(repository.owner), |
1183 | + changee=Is(None), |
1184 | + what_changed=Equals(GitActivityType.GRANT_CHANGED), |
1185 | + old_value=Equals({ |
1186 | + "changee_type": "Repository owner", |
1187 | + "ref_pattern": "refs/heads/stable/*", |
1188 | + "can_create": True, |
1189 | + "can_push": False, |
1190 | + "can_force_push": False, |
1191 | + }), |
1192 | + new_value=Equals({ |
1193 | + "changee_type": "Repository owner", |
1194 | + "ref_pattern": "refs/heads/stable/*", |
1195 | + "can_create": True, |
1196 | + "can_push": True, |
1197 | + "can_force_push": False, |
1198 | + })), |
1199 | + MatchesStructure( |
1200 | + changer=Equals(repository.owner), |
1201 | + changee=Equals(grantees[0]), |
1202 | + what_changed=Equals(GitActivityType.GRANT_REMOVED), |
1203 | + old_value=Equals({ |
1204 | + "changee_type": "Person", |
1205 | + "ref_pattern": "refs/heads/stable/*", |
1206 | + "can_create": True, |
1207 | + "can_push": True, |
1208 | + "can_force_push": False, |
1209 | + })), |
1210 | + MatchesStructure( |
1211 | + changer=Equals(repository.owner), |
1212 | + changee=Equals(grantees[2]), |
1213 | + what_changed=Equals(GitActivityType.GRANT_ADDED), |
1214 | + new_value=Equals({ |
1215 | + "changee_type": "Person", |
1216 | + "ref_pattern": "refs/heads/*/next", |
1217 | + "can_create": False, |
1218 | + "can_push": True, |
1219 | + "can_force_push": False, |
1220 | + })))) |
1221 | + self.assertHasSavedNotification(view, repository) |
1222 | + |
1223 | + def test_save_delete_rule(self): |
1224 | + repository = self.factory.makeGitRepository() |
1225 | + self.factory.makeGitRule( |
1226 | + repository=repository, ref_pattern="refs/heads/stable/*") |
1227 | + self.factory.makeGitRule( |
1228 | + repository=repository, ref_pattern="refs/heads/*") |
1229 | + removeSecurityProxy(repository.getActivity()).remove() |
1230 | + login_person(repository.owner) |
1231 | + encoded_pattern = encode_form_field_id("refs/heads/*") |
1232 | + form = { |
1233 | + "field.pattern." + encoded_pattern: "*", |
1234 | + "field.delete." + encoded_pattern: "on", |
1235 | + "field.actions.save": "Save", |
1236 | + } |
1237 | + view = create_initialized_view( |
1238 | + repository, name="+permissions", form=form, |
1239 | + principal=repository.owner) |
1240 | + self.assertHasRules(repository, ["refs/heads/stable/*"]) |
1241 | + self.assertThat(list(repository.getActivity()), MatchesListwise([ |
1242 | + MatchesStructure( |
1243 | + changer=Equals(repository.owner), |
1244 | + what_changed=Equals(GitActivityType.RULE_REMOVED), |
1245 | + old_value=MatchesDict({ |
1246 | + "ref_pattern": Equals("refs/heads/*"), |
1247 | + "position": Equals(1), |
1248 | + })), |
1249 | + ])) |
1250 | + self.assertHasSavedNotification(view, repository) |
1251 | + |
1252 | + |
1253 | class TestGitRepositoryDeletionView(BrowserTestCase): |
1254 | |
1255 | layer = DatabaseFunctionalLayer |
1256 | |
1257 | === added file 'lib/lp/code/browser/widgets/gitgrantee.py' |
1258 | --- lib/lp/code/browser/widgets/gitgrantee.py 1970-01-01 00:00:00 +0000 |
1259 | +++ lib/lp/code/browser/widgets/gitgrantee.py 2018-11-09 22:50:10 +0000 |
1260 | @@ -0,0 +1,253 @@ |
1261 | +# Copyright 2018 Canonical Ltd. This software is licensed under the |
1262 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
1263 | + |
1264 | +from __future__ import absolute_import, print_function, unicode_literals |
1265 | + |
1266 | +__metaclass__ = type |
1267 | +__all__ = [ |
1268 | + 'GitGranteeDisplayWidget', |
1269 | + 'GitGranteeField', |
1270 | + 'GitGranteeWidget', |
1271 | + ] |
1272 | + |
1273 | +from lazr.enum import DBItem |
1274 | +from lazr.restful.fields import Reference |
1275 | +from z3c.ptcompat import ViewPageTemplateFile |
1276 | +from zope.formlib.interfaces import ( |
1277 | + ConversionError, |
1278 | + IDisplayWidget, |
1279 | + IInputWidget, |
1280 | + InputErrors, |
1281 | + MissingInputError, |
1282 | + WidgetInputError, |
1283 | + ) |
1284 | +from zope.formlib.utility import setUpWidget |
1285 | +from zope.formlib.widget import ( |
1286 | + BrowserWidget, |
1287 | + CustomWidgetFactory, |
1288 | + DisplayWidget, |
1289 | + InputWidget, |
1290 | + renderElement, |
1291 | + ) |
1292 | +from zope.interface import implementer |
1293 | +from zope.schema import ( |
1294 | + Choice, |
1295 | + Field, |
1296 | + ) |
1297 | +from zope.schema.interfaces import IField |
1298 | +from zope.schema.vocabulary import getVocabularyRegistry |
1299 | +from zope.security.proxy import isinstance as zope_isinstance |
1300 | + |
1301 | +from lp import _ |
1302 | +from lp.app.errors import UnexpectedFormData |
1303 | +from lp.app.validators import LaunchpadValidationError |
1304 | +from lp.app.widgets.popup import PersonPickerWidget |
1305 | +from lp.code.enums import GitGranteeType |
1306 | +from lp.code.interfaces.gitrule import IGitRule |
1307 | +from lp.registry.interfaces.person import IPerson |
1308 | +from lp.services.webapp.escaping import structured |
1309 | +from lp.services.webapp.interfaces import ( |
1310 | + IAlwaysSubmittedWidget, |
1311 | + IMultiLineWidgetLayout, |
1312 | + ) |
1313 | +from lp.services.webapp.publisher import canonical_url |
1314 | + |
1315 | + |
1316 | +class IGitGranteeField(IField): |
1317 | + """An interface for a Git access grantee field.""" |
1318 | + |
1319 | + rule = Reference( |
1320 | + title=_("Rule"), required=True, readonly=True, schema=IGitRule, |
1321 | + description=_("The rule that this grantee is for.")) |
1322 | + |
1323 | + |
1324 | +@implementer(IGitGranteeField) |
1325 | +class GitGranteeField(Field): |
1326 | + """A field that holds a Git access grantee.""" |
1327 | + |
1328 | + def __init__(self, rule, *args, **kwargs): |
1329 | + super(GitGranteeField, self).__init__(*args, **kwargs) |
1330 | + self.rule = rule |
1331 | + |
1332 | + def constraint(self, value): |
1333 | + """See `IField`.""" |
1334 | + if zope_isinstance(value, DBItem) and value.enum == GitGranteeType: |
1335 | + return value != GitGranteeType.PERSON |
1336 | + else: |
1337 | + return value in getVocabularyRegistry().get( |
1338 | + None, "ValidPersonOrTeam") |
1339 | + |
1340 | + |
1341 | +@implementer(IDisplayWidget) |
1342 | +class GitGranteePersonDisplayWidget(BrowserWidget): |
1343 | + |
1344 | + def __init__(self, context, vocabulary, request): |
1345 | + super(GitGranteePersonDisplayWidget, self).__init__(context, request) |
1346 | + |
1347 | + def __call__(self): |
1348 | + if self._renderedValueSet(): |
1349 | + grantee = self._data |
1350 | + person_img = renderElement( |
1351 | + "img", style="padding-bottom: 2px", src="/@@/person", alt="") |
1352 | + return renderElement( |
1353 | + "a", href=canonical_url(grantee), |
1354 | + contents="%s %s" % ( |
1355 | + person_img, |
1356 | + structured("%s", grantee.display_name).escapedtext)) |
1357 | + else: |
1358 | + return "" |
1359 | + |
1360 | + |
1361 | +@implementer(IMultiLineWidgetLayout) |
1362 | +class GitGranteeWidgetBase(BrowserWidget): |
1363 | + |
1364 | + template = ViewPageTemplateFile("templates/gitgrantee.pt") |
1365 | + default_option = "person" |
1366 | + _widgets_set_up = False |
1367 | + |
1368 | + def setUpSubWidgets(self): |
1369 | + if self._widgets_set_up: |
1370 | + return |
1371 | + fields = [ |
1372 | + Choice( |
1373 | + __name__="person", title=u"Person", |
1374 | + required=False, vocabulary="ValidPersonOrTeam"), |
1375 | + ] |
1376 | + if self._read_only: |
1377 | + self.person_widget = CustomWidgetFactory( |
1378 | + GitGranteePersonDisplayWidget) |
1379 | + else: |
1380 | + self.person_widget = CustomWidgetFactory( |
1381 | + PersonPickerWidget, |
1382 | + # XXX cjwatson 2018-10-18: This is a little unfortunate, but |
1383 | + # otherwise there's no spacing at all between the |
1384 | + # (deliberately unlabelled) radio button and the text box. |
1385 | + style="margin-left: 4px;") |
1386 | + for field in fields: |
1387 | + setUpWidget( |
1388 | + self, field.__name__, field, self._sub_widget_interface, |
1389 | + prefix=self.name) |
1390 | + self._widgets_set_up = True |
1391 | + |
1392 | + def setUpOptions(self): |
1393 | + """Set up options to be rendered.""" |
1394 | + self.options = {} |
1395 | + for option in ("repository_owner", "person"): |
1396 | + attributes = { |
1397 | + "type": "radio", "name": self.name, "value": option, |
1398 | + "id": "%s.option.%s" % (self.name, option), |
1399 | + # XXX cjwatson 2018-10-18: Ugly, but it's worse without |
1400 | + # this, especially in a permissions table where this widget |
1401 | + # is normally used. |
1402 | + "style": "margin-left: 0;", |
1403 | + } |
1404 | + if self.request.form_ng.getOne( |
1405 | + self.name, self.default_option) == option: |
1406 | + attributes["checked"] = "checked" |
1407 | + if self._read_only: |
1408 | + attributes["disabled"] = "disabled" |
1409 | + self.options[option] = renderElement("input", **attributes) |
1410 | + |
1411 | + @property |
1412 | + def show_options(self): |
1413 | + return { |
1414 | + option: not self._read_only or self.default_option == option |
1415 | + for option in ("repository_owner", "person")} |
1416 | + |
1417 | + def setRenderedValue(self, value): |
1418 | + """See `IWidget`.""" |
1419 | + self.setUpSubWidgets() |
1420 | + if value == GitGranteeType.REPOSITORY_OWNER: |
1421 | + self.default_option = "repository_owner" |
1422 | + return |
1423 | + elif value is None or IPerson.providedBy(value): |
1424 | + self.default_option = "person" |
1425 | + self.person_widget.setRenderedValue(value) |
1426 | + return |
1427 | + else: |
1428 | + raise AssertionError("Not a valid value: %r" % value) |
1429 | + |
1430 | + def __call__(self): |
1431 | + """See `zope.formlib.interfaces.IBrowserWidget`.""" |
1432 | + self.setUpSubWidgets() |
1433 | + self.setUpOptions() |
1434 | + return self.template() |
1435 | + |
1436 | + |
1437 | +@implementer(IDisplayWidget) |
1438 | +class GitGranteeDisplayWidget(GitGranteeWidgetBase, DisplayWidget): |
1439 | + """Widget for displaying a Git access grantee.""" |
1440 | + |
1441 | + _sub_widget_interface = IDisplayWidget |
1442 | + _read_only = True |
1443 | + |
1444 | + |
1445 | +@implementer(IAlwaysSubmittedWidget, IInputWidget) |
1446 | +class GitGranteeWidget(GitGranteeWidgetBase, InputWidget): |
1447 | + """Widget for selecting a Git access grantee.""" |
1448 | + |
1449 | + _sub_widget_interface = IInputWidget |
1450 | + _read_only = False |
1451 | + _widgets_set_up = False |
1452 | + |
1453 | + @property |
1454 | + def show_options(self): |
1455 | + show_options = super(GitGranteeWidget, self).show_options |
1456 | + # Hide options that indicate unique grantee_types (e.g. |
1457 | + # repository_owner) if they already exist for the context rule. |
1458 | + if (show_options["repository_owner"] and |
1459 | + not self.context.rule.repository.findRuleGrantsByGrantee( |
1460 | + GitGranteeType.REPOSITORY_OWNER, |
1461 | + ref_pattern=self.context.rule.ref_pattern, |
1462 | + exact_grantee=True).is_empty()): |
1463 | + show_options["repository_owner"] = False |
1464 | + return show_options |
1465 | + |
1466 | + def hasInput(self): |
1467 | + self.setUpSubWidgets() |
1468 | + form_value = self.request.form_ng.getOne(self.name) |
1469 | + if form_value is None: |
1470 | + return False |
1471 | + return form_value != "person" or self.person_widget.hasInput() |
1472 | + |
1473 | + def hasValidInput(self): |
1474 | + """See `zope.formlib.interfaces.IInputWidget`.""" |
1475 | + try: |
1476 | + self.getInputValue() |
1477 | + return True |
1478 | + except (InputErrors, UnexpectedFormData): |
1479 | + return False |
1480 | + |
1481 | + def getInputValue(self): |
1482 | + """See `zope.formlib.interfaces.IInputWidget`.""" |
1483 | + self.setUpSubWidgets() |
1484 | + form_value = self.request.form_ng.getOne(self.name) |
1485 | + if form_value == "repository_owner": |
1486 | + return GitGranteeType.REPOSITORY_OWNER |
1487 | + elif form_value == "person": |
1488 | + try: |
1489 | + return self.person_widget.getInputValue() |
1490 | + except MissingInputError: |
1491 | + raise WidgetInputError( |
1492 | + self.name, self.label, |
1493 | + LaunchpadValidationError( |
1494 | + "Please enter a person or team name")) |
1495 | + except ConversionError: |
1496 | + entered_name = self.request.form_ng.getOne( |
1497 | + "%s.person" % self.name) |
1498 | + raise WidgetInputError( |
1499 | + self.name, self.label, |
1500 | + LaunchpadValidationError( |
1501 | + "There is no person or team named '%s' registered in " |
1502 | + "Launchpad" % entered_name)) |
1503 | + else: |
1504 | + raise UnexpectedFormData("No valid option was selected.") |
1505 | + |
1506 | + def error(self): |
1507 | + """See `zope.formlib.interfaces.IBrowserWidget`.""" |
1508 | + try: |
1509 | + if self.hasInput(): |
1510 | + self.getInputValue() |
1511 | + except InputErrors as error: |
1512 | + self._error = error |
1513 | + return super(GitGranteeWidget, self).error() |
1514 | |
1515 | === added file 'lib/lp/code/browser/widgets/templates/gitgrantee.pt' |
1516 | --- lib/lp/code/browser/widgets/templates/gitgrantee.pt 1970-01-01 00:00:00 +0000 |
1517 | +++ lib/lp/code/browser/widgets/templates/gitgrantee.pt 2018-11-09 22:50:10 +0000 |
1518 | @@ -0,0 +1,27 @@ |
1519 | +<table> |
1520 | + <tr tal:condition="view/show_options/repository_owner"> |
1521 | + <td colspan="2"> |
1522 | + <label> |
1523 | + <input |
1524 | + type="radio" value="repository_owner" |
1525 | + tal:condition="not: context/readonly" |
1526 | + tal:replace="structure view/options/repository_owner" /> |
1527 | + Repository owner |
1528 | + </label> |
1529 | + </td> |
1530 | + </tr> |
1531 | + |
1532 | + <tr tal:condition="view/show_options/person"> |
1533 | + <td> |
1534 | + <label> |
1535 | + <input |
1536 | + type="radio" value="person" |
1537 | + tal:condition="not: context/readonly" |
1538 | + tal:replace="structure view/options/person" /> |
1539 | + </label> |
1540 | + </td> |
1541 | + <td> |
1542 | + <tal:person replace="structure view/person_widget" /> |
1543 | + </td> |
1544 | + </tr> |
1545 | +</table> |
1546 | |
1547 | === added file 'lib/lp/code/browser/widgets/tests/test_gitgrantee.py' |
1548 | --- lib/lp/code/browser/widgets/tests/test_gitgrantee.py 1970-01-01 00:00:00 +0000 |
1549 | +++ lib/lp/code/browser/widgets/tests/test_gitgrantee.py 2018-11-09 22:50:10 +0000 |
1550 | @@ -0,0 +1,305 @@ |
1551 | +# Copyright 2018 Canonical Ltd. This software is licensed under the |
1552 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
1553 | + |
1554 | +from __future__ import absolute_import, print_function, unicode_literals |
1555 | + |
1556 | +__metaclass__ = type |
1557 | + |
1558 | +import re |
1559 | + |
1560 | +from zope.formlib.interfaces import ( |
1561 | + IBrowserWidget, |
1562 | + IDisplayWidget, |
1563 | + IInputWidget, |
1564 | + WidgetInputError, |
1565 | + ) |
1566 | + |
1567 | +from lp.app.validators import LaunchpadValidationError |
1568 | +from lp.code.browser.widgets.gitgrantee import ( |
1569 | + GitGranteeDisplayWidget, |
1570 | + GitGranteeField, |
1571 | + GitGranteeWidget, |
1572 | + ) |
1573 | +from lp.code.enums import GitGranteeType |
1574 | +from lp.registry.vocabularies import ValidPersonOrTeamVocabulary |
1575 | +from lp.services.beautifulsoup import BeautifulSoup |
1576 | +from lp.services.webapp.escaping import html_escape |
1577 | +from lp.services.webapp.publisher import canonical_url |
1578 | +from lp.services.webapp.servers import LaunchpadTestRequest |
1579 | +from lp.testing import ( |
1580 | + TestCaseWithFactory, |
1581 | + verifyObject, |
1582 | + ) |
1583 | +from lp.testing.layers import DatabaseFunctionalLayer |
1584 | + |
1585 | + |
1586 | +class TestGitGranteeWidgetBase: |
1587 | + |
1588 | + layer = DatabaseFunctionalLayer |
1589 | + |
1590 | + def setUp(self): |
1591 | + super(TestGitGranteeWidgetBase, self).setUp() |
1592 | + [self.ref] = self.factory.makeGitRefs() |
1593 | + self.rule = self.factory.makeGitRule( |
1594 | + repository=self.ref.repository, ref_pattern=self.ref.path) |
1595 | + self.field = GitGranteeField(__name__="grantee", rule=self.rule) |
1596 | + self.request = LaunchpadTestRequest() |
1597 | + self.widget = self.widget_factory(self.field, self.request) |
1598 | + |
1599 | + def test_implements(self): |
1600 | + self.assertTrue(verifyObject(IBrowserWidget, self.widget)) |
1601 | + self.assertTrue( |
1602 | + verifyObject(self.expected_widget_interface, self.widget)) |
1603 | + |
1604 | + def test_template(self): |
1605 | + # The render template is setup. |
1606 | + self.assertTrue( |
1607 | + self.widget.template.filename.endswith("gitgrantee.pt"), |
1608 | + "Template was not set up.") |
1609 | + |
1610 | + def test_default_option(self): |
1611 | + # The person field is the default option. |
1612 | + self.assertEqual("person", self.widget.default_option) |
1613 | + |
1614 | + def test_setUpSubWidgets_first_call(self): |
1615 | + # The subwidget is set up and a flag is set. |
1616 | + self.widget.setUpSubWidgets() |
1617 | + self.assertTrue(self.widget._widgets_set_up) |
1618 | + self.assertIsInstance( |
1619 | + self.widget.person_widget.context.vocabulary, |
1620 | + ValidPersonOrTeamVocabulary) |
1621 | + |
1622 | + def test_setUpSubWidgets_second_call(self): |
1623 | + # The setUpSubWidgets method exits early if a flag is set to |
1624 | + # indicate that the subwidget was set up. |
1625 | + self.widget._widgets_set_up = True |
1626 | + self.widget.setUpSubWidgets() |
1627 | + self.assertIsNone(getattr(self.widget, "person_widget", None)) |
1628 | + |
1629 | + def test_setUpOptions_default_person_checked(self): |
1630 | + # The radio button options are composed of the setup widgets with |
1631 | + # the person widget set as the default. |
1632 | + self.widget.setUpSubWidgets() |
1633 | + self.widget.setUpOptions() |
1634 | + self.assertEqual( |
1635 | + '<input class="radioType" style="margin-left: 0;" ' + |
1636 | + self.expected_disabled_attr + |
1637 | + 'id="field.grantee.option.repository_owner" name="field.grantee" ' |
1638 | + 'type="radio" value="repository_owner" />', |
1639 | + self.widget.options["repository_owner"]) |
1640 | + self.assertEqual( |
1641 | + '<input class="radioType" style="margin-left: 0;" ' + |
1642 | + 'checked="checked" ' + self.expected_disabled_attr + |
1643 | + 'id="field.grantee.option.person" name="field.grantee" ' |
1644 | + 'type="radio" value="person" />', |
1645 | + self.widget.options["person"]) |
1646 | + |
1647 | + def test_setUpOptions_repository_owner_checked(self): |
1648 | + # The repository owner radio button is selected when the form is |
1649 | + # submitted when the grantee field's value is 'repository_owner'. |
1650 | + form = {"field.grantee": "repository_owner"} |
1651 | + self.widget.request = LaunchpadTestRequest(form=form) |
1652 | + self.widget.setUpSubWidgets() |
1653 | + self.widget.setUpOptions() |
1654 | + self.assertEqual( |
1655 | + '<input class="radioType" style="margin-left: 0;" ' |
1656 | + 'checked="checked" ' + self.expected_disabled_attr + |
1657 | + 'id="field.grantee.option.repository_owner" name="field.grantee" ' |
1658 | + 'type="radio" value="repository_owner" />', |
1659 | + self.widget.options["repository_owner"]) |
1660 | + self.assertEqual( |
1661 | + '<input class="radioType" style="margin-left: 0;" ' + |
1662 | + self.expected_disabled_attr + |
1663 | + 'id="field.grantee.option.person" name="field.grantee" ' |
1664 | + 'type="radio" value="person" />', |
1665 | + self.widget.options["person"]) |
1666 | + |
1667 | + def test_setUpOptions_person_checked(self): |
1668 | + # The person radio button is selected when the form is submitted |
1669 | + # when the grantee field's value is 'person'. |
1670 | + form = {"field.grantee": "person"} |
1671 | + self.widget.request = LaunchpadTestRequest(form=form) |
1672 | + self.widget.setUpSubWidgets() |
1673 | + self.widget.setUpOptions() |
1674 | + self.assertEqual( |
1675 | + '<input class="radioType" style="margin-left: 0;" ' + |
1676 | + self.expected_disabled_attr + |
1677 | + 'id="field.grantee.option.repository_owner" name="field.grantee" ' |
1678 | + 'type="radio" value="repository_owner" />', |
1679 | + self.widget.options["repository_owner"]) |
1680 | + self.assertEqual( |
1681 | + '<input class="radioType" style="margin-left: 0;" ' + |
1682 | + 'checked="checked" ' + self.expected_disabled_attr + |
1683 | + 'id="field.grantee.option.person" name="field.grantee" ' |
1684 | + 'type="radio" value="person" />', |
1685 | + self.widget.options["person"]) |
1686 | + |
1687 | + def test_setRenderedValue_repository_owner(self): |
1688 | + # Passing GitGranteeType.REPOSITORY_OWNER will set the widget's |
1689 | + # render state to "repository_owner". |
1690 | + self.widget.setUpSubWidgets() |
1691 | + self.widget.setRenderedValue(GitGranteeType.REPOSITORY_OWNER) |
1692 | + self.assertEqual("repository_owner", self.widget.default_option) |
1693 | + |
1694 | + def test_setRenderedValue_person(self): |
1695 | + # Passing a person will set the widget's render state to "person". |
1696 | + self.widget.setUpSubWidgets() |
1697 | + person = self.factory.makePerson() |
1698 | + self.widget.setRenderedValue(person) |
1699 | + self.assertEqual("person", self.widget.default_option) |
1700 | + self.assertEqual(person, self.widget.person_widget._data) |
1701 | + |
1702 | + def test_call(self): |
1703 | + # The __call__ method sets up the widgets and the options. |
1704 | + markup = self.widget() |
1705 | + self.assertIsNotNone(self.widget.person_widget) |
1706 | + self.assertIn("repository_owner", self.widget.options) |
1707 | + self.assertIn("person", self.widget.options) |
1708 | + soup = BeautifulSoup(markup) |
1709 | + fields = soup.findAll(["input", "select"], {"id": re.compile(".*")}) |
1710 | + ids = [field["id"] for field in fields] |
1711 | + self.assertContentEqual(self.expected_ids, ids) |
1712 | + |
1713 | + |
1714 | +class TestGitGranteeDisplayWidget( |
1715 | + TestGitGranteeWidgetBase, TestCaseWithFactory): |
1716 | + """Test the GitGranteeDisplayWidget class.""" |
1717 | + |
1718 | + widget_factory = GitGranteeDisplayWidget |
1719 | + expected_widget_interface = IDisplayWidget |
1720 | + expected_disabled_attr = 'disabled="disabled" ' |
1721 | + expected_ids = ["field.grantee.option.person"] |
1722 | + |
1723 | + def test_setRenderedValue_person_display_widget(self): |
1724 | + # If the widget's render state is "person", a customised display |
1725 | + # widget is used. |
1726 | + self.widget.setUpSubWidgets() |
1727 | + person = self.factory.makePerson() |
1728 | + self.widget.setRenderedValue(person) |
1729 | + person_url = canonical_url(person) |
1730 | + self.assertEqual( |
1731 | + '<a href="%s">' |
1732 | + '<img style="padding-bottom: 2px" alt="" src="/@@/person" /> ' |
1733 | + '%s</a>' % (person_url, html_escape(person.display_name)), |
1734 | + self.widget.person_widget()) |
1735 | + |
1736 | + |
1737 | +class TestGitGranteeWidget(TestGitGranteeWidgetBase, TestCaseWithFactory): |
1738 | + """Test the GitGranteeWidget class.""" |
1739 | + |
1740 | + widget_factory = GitGranteeWidget |
1741 | + expected_widget_interface = IInputWidget |
1742 | + expected_disabled_attr = "" |
1743 | + expected_ids = [ |
1744 | + "field.grantee.option.repository_owner", |
1745 | + "field.grantee.option.person", |
1746 | + "field.grantee.person", |
1747 | + ] |
1748 | + |
1749 | + def setUp(self): |
1750 | + super(TestGitGranteeWidget, self).setUp() |
1751 | + self.person = self.factory.makePerson() |
1752 | + |
1753 | + def test_show_options_repository_owner_grant_already_exists(self): |
1754 | + # If the rule already has a repository owner grant, the input widget |
1755 | + # doesn't offer that option. |
1756 | + self.factory.makeGitRuleGrant( |
1757 | + rule=self.rule, grantee=GitGranteeType.REPOSITORY_OWNER) |
1758 | + self.assertEqual( |
1759 | + {"repository_owner": False, "person": True}, |
1760 | + self.widget.show_options) |
1761 | + |
1762 | + def test_show_options_repository_owner_grant_does_not_exist(self): |
1763 | + # If the rule doesn't have a repository owner grant, the input |
1764 | + # widget offers that option. |
1765 | + self.factory.makeGitRuleGrant(rule=self.rule) |
1766 | + self.assertEqual( |
1767 | + {"repository_owner": True, "person": True}, |
1768 | + self.widget.show_options) |
1769 | + |
1770 | + @property |
1771 | + def form(self): |
1772 | + return { |
1773 | + "field.grantee": "person", |
1774 | + "field.grantee.person": self.person.name, |
1775 | + } |
1776 | + |
1777 | + def test_hasInput_not_in_form(self): |
1778 | + # hasInput is false when the widget's name is not in the form data. |
1779 | + self.widget.request = LaunchpadTestRequest(form={}) |
1780 | + self.assertEqual("field.grantee", self.widget.name) |
1781 | + self.assertFalse(self.widget.hasInput()) |
1782 | + |
1783 | + def test_hasInput_no_person(self): |
1784 | + # hasInput is false when the person radio button is selected and the |
1785 | + # person widget's name is not in the form data. |
1786 | + self.widget.request = LaunchpadTestRequest( |
1787 | + form={"field.grantee": "person"}) |
1788 | + self.assertEqual("field.grantee", self.widget.name) |
1789 | + self.assertFalse(self.widget.hasInput()) |
1790 | + |
1791 | + def test_hasInput_repository_owner(self): |
1792 | + # hasInput is true when the repository owner radio button is selected. |
1793 | + self.widget.request = LaunchpadTestRequest( |
1794 | + form={"field.grantee": "repository_owner"}) |
1795 | + self.assertEqual("field.grantee", self.widget.name) |
1796 | + self.assertTrue(self.widget.hasInput()) |
1797 | + |
1798 | + def test_hasInput_person(self): |
1799 | + # hasInput is true when the person radio button is selected and the |
1800 | + # person widget's name is in the form data. |
1801 | + self.widget.request = LaunchpadTestRequest(form=self.form) |
1802 | + self.assertEqual("field.grantee", self.widget.name) |
1803 | + self.assertTrue(self.widget.hasInput()) |
1804 | + |
1805 | + def test_hasValidInput_true(self): |
1806 | + # The field input is valid when all submitted parts are valid. |
1807 | + self.widget.request = LaunchpadTestRequest(form=self.form) |
1808 | + self.assertTrue(self.widget.hasValidInput()) |
1809 | + |
1810 | + def test_hasValidInput_false(self): |
1811 | + # The field input is invalid if any of the submitted parts are invalid. |
1812 | + form = self.form |
1813 | + form["field.grantee.person"] = "non-existent" |
1814 | + self.widget.request = LaunchpadTestRequest(form=form) |
1815 | + self.assertFalse(self.widget.hasValidInput()) |
1816 | + |
1817 | + def test_getInputValue_repository_owner(self): |
1818 | + # The field value is GitGranteeType.REPOSITORY_OWNER when the |
1819 | + # repository owner radio button is selected. |
1820 | + form = self.form |
1821 | + form["field.grantee"] = "repository_owner" |
1822 | + self.widget.request = LaunchpadTestRequest(form=form) |
1823 | + self.assertEqual( |
1824 | + GitGranteeType.REPOSITORY_OWNER, self.widget.getInputValue()) |
1825 | + |
1826 | + def test_getInputValue_person(self): |
1827 | + # The field value is the person when the person radio button is |
1828 | + # selected and the person sub field is valid. |
1829 | + form = self.form |
1830 | + form["field.grantee"] = "person" |
1831 | + self.widget.request = LaunchpadTestRequest(form=form) |
1832 | + self.assertEqual(self.person, self.widget.getInputValue()) |
1833 | + |
1834 | + def test_getInputValue_person_missing(self): |
1835 | + # An error is raised when the person field is missing. |
1836 | + form = self.form |
1837 | + form["field.grantee"] = "person" |
1838 | + del form["field.grantee.person"] |
1839 | + self.widget.request = LaunchpadTestRequest(form=form) |
1840 | + message = "Please enter a person or team name" |
1841 | + e = self.assertRaises(WidgetInputError, self.widget.getInputValue) |
1842 | + self.assertEqual(LaunchpadValidationError(message), e.errors) |
1843 | + |
1844 | + def test_getInputValue_person_invalid(self): |
1845 | + # An error is raised when the person is not valid. |
1846 | + form = self.form |
1847 | + form["field.grantee"] = "person" |
1848 | + form["field.grantee.person"] = "non-existent" |
1849 | + self.widget.request = LaunchpadTestRequest(form=form) |
1850 | + message = ( |
1851 | + "There is no person or team named 'non-existent' registered in " |
1852 | + "Launchpad") |
1853 | + e = self.assertRaises(WidgetInputError, self.widget.getInputValue) |
1854 | + self.assertEqual(LaunchpadValidationError(message), e.errors) |
1855 | + self.assertEqual(html_escape(message), self.widget.error()) |
1856 | |
1857 | === modified file 'lib/lp/code/interfaces/gitrepository.py' |
1858 | --- lib/lp/code/interfaces/gitrepository.py 2018-11-09 22:06:43 +0000 |
1859 | +++ lib/lp/code/interfaces/gitrepository.py 2018-11-09 22:50:10 +0000 |
1860 | @@ -766,12 +766,17 @@ |
1861 | :param user: The `IPerson` who is moving the rule. |
1862 | """ |
1863 | |
1864 | - def findRuleGrantsByGrantee(grantee): |
1865 | + def findRuleGrantsByGrantee(grantee, exact_grantee=False, |
1866 | + ref_pattern=None): |
1867 | """Find the grants for a grantee applied to this repository. |
1868 | |
1869 | :param grantee: The `IPerson` to search for, or an item of |
1870 | `GitGranteeType` other than `GitGranteeType.PERSON` to search |
1871 | for some other kind of entity. |
1872 | + :param exact_grantee: If True, match `grantee` exactly; if False |
1873 | + (the default), also accept teams of which `grantee` is a member. |
1874 | + :param ref_pattern: If not None, only return grants for rules with |
1875 | + this ref_pattern. |
1876 | """ |
1877 | |
1878 | @export_read_operation() |
1879 | |
1880 | === modified file 'lib/lp/code/interfaces/gitrule.py' |
1881 | --- lib/lp/code/interfaces/gitrule.py 2018-10-23 16:17:39 +0000 |
1882 | +++ lib/lp/code/interfaces/gitrule.py 2018-11-09 22:50:10 +0000 |
1883 | @@ -158,6 +158,10 @@ |
1884 | vocabulary="ValidPersonOrTeam", |
1885 | description=_("The person being granted access.")) |
1886 | |
1887 | + combined_grantee = Attribute( |
1888 | + "The overall grantee of this grant: either a `GitGranteeType` (other " |
1889 | + "than `PERSON`) or an `IPerson`.") |
1890 | + |
1891 | date_created = Datetime( |
1892 | title=_("Date created"), required=True, readonly=True, |
1893 | description=_("The time when this grant was created.")) |
1894 | |
1895 | === modified file 'lib/lp/code/model/gitrepository.py' |
1896 | --- lib/lp/code/model/gitrepository.py 2018-11-09 22:06:43 +0000 |
1897 | +++ lib/lp/code/model/gitrepository.py 2018-11-09 22:50:10 +0000 |
1898 | @@ -1216,7 +1216,8 @@ |
1899 | return Store.of(self).find( |
1900 | GitRuleGrant, GitRuleGrant.repository_id == self.id) |
1901 | |
1902 | - def findRuleGrantsByGrantee(self, grantee): |
1903 | + def findRuleGrantsByGrantee(self, grantee, exact_grantee=False, |
1904 | + ref_pattern=None): |
1905 | """See `IGitRepository`.""" |
1906 | if isinstance(grantee, DBItem) and grantee.enum == GitGranteeType: |
1907 | if grantee == GitGranteeType.PERSON: |
1908 | @@ -1224,12 +1225,22 @@ |
1909 | "grantee may not be GitGranteeType.PERSON; pass a person " |
1910 | "object instead") |
1911 | clauses = [GitRuleGrant.grantee_type == grantee] |
1912 | + elif exact_grantee: |
1913 | + clauses = [ |
1914 | + GitRuleGrant.grantee_type == GitGranteeType.PERSON, |
1915 | + GitRuleGrant.grantee == grantee, |
1916 | + ] |
1917 | else: |
1918 | clauses = [ |
1919 | GitRuleGrant.grantee_type == GitGranteeType.PERSON, |
1920 | TeamParticipation.person == grantee, |
1921 | GitRuleGrant.grantee == TeamParticipation.teamID |
1922 | ] |
1923 | + if ref_pattern is not None: |
1924 | + clauses.extend([ |
1925 | + GitRuleGrant.rule_id == GitRule.id, |
1926 | + GitRule.ref_pattern == ref_pattern, |
1927 | + ]) |
1928 | return self.grants.find(*clauses).config(distinct=True) |
1929 | |
1930 | def getRules(self): |
1931 | |
1932 | === modified file 'lib/lp/code/model/gitrule.py' |
1933 | --- lib/lp/code/model/gitrule.py 2018-10-29 14:27:36 +0000 |
1934 | +++ lib/lp/code/model/gitrule.py 2018-11-09 22:50:10 +0000 |
1935 | @@ -310,6 +310,13 @@ |
1936 | self.date_created = date_created |
1937 | self.date_last_modified = date_created |
1938 | |
1939 | + @property |
1940 | + def combined_grantee(self): |
1941 | + if self.grantee_type == GitGranteeType.PERSON: |
1942 | + return self.grantee |
1943 | + else: |
1944 | + return self.grantee_type |
1945 | + |
1946 | def __repr__(self): |
1947 | if self.grantee_type == GitGranteeType.PERSON: |
1948 | grantee_name = "~%s" % self.grantee.name |
1949 | |
1950 | === modified file 'lib/lp/code/model/tests/test_gitrepository.py' |
1951 | --- lib/lp/code/model/tests/test_gitrepository.py 2018-11-09 22:06:43 +0000 |
1952 | +++ lib/lp/code/model/tests/test_gitrepository.py 2018-11-09 22:50:10 +0000 |
1953 | @@ -265,7 +265,7 @@ |
1954 | grant = self.factory.makeGitRuleGrant( |
1955 | rule=rule, grantee=requester, can_push=True, can_create=True) |
1956 | |
1957 | - results = repository.findRuleGrantsByGrantee(requester) |
1958 | + results = repository.findRuleGrantsByGrantee(member) |
1959 | self.assertEqual([grant], list(results)) |
1960 | |
1961 | def test_findRuleGrantsByGrantee_team_in_team(self): |
1962 | @@ -357,6 +357,116 @@ |
1963 | results = repository.findRuleGrantsByGrantee(requester) |
1964 | self.assertEqual([owner_grant], list(results)) |
1965 | |
1966 | + def test_findRuleGrantsByGrantee_ref_pattern(self): |
1967 | + requester = self.factory.makePerson() |
1968 | + repository = removeSecurityProxy( |
1969 | + self.factory.makeGitRepository(owner=requester)) |
1970 | + [ref] = self.factory.makeGitRefs(repository=repository) |
1971 | + |
1972 | + exact_grant = self.factory.makeGitRuleGrant( |
1973 | + repository=repository, ref_pattern=ref.path, grantee=requester) |
1974 | + self.factory.makeGitRuleGrant( |
1975 | + repository=repository, ref_pattern="refs/heads/*", |
1976 | + grantee=requester) |
1977 | + |
1978 | + results = repository.findRuleGrantsByGrantee( |
1979 | + requester, ref_pattern=ref.path) |
1980 | + self.assertEqual([exact_grant], list(results)) |
1981 | + |
1982 | + def test_findRuleGrantsByGrantee_exact_grantee_person(self): |
1983 | + requester = self.factory.makePerson() |
1984 | + repository = removeSecurityProxy( |
1985 | + self.factory.makeGitRepository(owner=requester)) |
1986 | + |
1987 | + rule = self.factory.makeGitRule(repository) |
1988 | + grant = self.factory.makeGitRuleGrant(rule=rule, grantee=requester) |
1989 | + |
1990 | + results = repository.findRuleGrantsByGrantee( |
1991 | + requester, exact_grantee=True) |
1992 | + self.assertEqual([grant], list(results)) |
1993 | + |
1994 | + def test_findRuleGrantsByGrantee_exact_grantee_team(self): |
1995 | + team = self.factory.makeTeam() |
1996 | + repository = removeSecurityProxy( |
1997 | + self.factory.makeGitRepository(owner=team)) |
1998 | + |
1999 | + rule = self.factory.makeGitRule(repository) |
2000 | + grant = self.factory.makeGitRuleGrant(rule=rule, grantee=team) |
2001 | + |
2002 | + results = repository.findRuleGrantsByGrantee(team, exact_grantee=True) |
2003 | + self.assertEqual([grant], list(results)) |
2004 | + |
2005 | + def test_findRuleGrantsByGrantee_exact_grantee_member_of_team(self): |
2006 | + member = self.factory.makePerson() |
2007 | + team = self.factory.makeTeam(members=[member]) |
2008 | + repository = removeSecurityProxy( |
2009 | + self.factory.makeGitRepository(owner=team)) |
2010 | + |
2011 | + rule = self.factory.makeGitRule(repository) |
2012 | + self.factory.makeGitRuleGrant(rule=rule, grantee=team) |
2013 | + |
2014 | + results = repository.findRuleGrantsByGrantee( |
2015 | + member, exact_grantee=True) |
2016 | + self.assertEqual([], list(results)) |
2017 | + |
2018 | + def test_findRuleGrantsByGrantee_no_owner_grant(self): |
2019 | + repository = removeSecurityProxy(self.factory.makeGitRepository()) |
2020 | + |
2021 | + rule = self.factory.makeGitRule(repository=repository) |
2022 | + self.factory.makeGitRuleGrant(rule=rule) |
2023 | + |
2024 | + results = repository.findRuleGrantsByGrantee( |
2025 | + GitGranteeType.REPOSITORY_OWNER) |
2026 | + self.assertEqual([], list(results)) |
2027 | + |
2028 | + def test_findRuleGrantsByGrantee_owner_grant(self): |
2029 | + repository = removeSecurityProxy(self.factory.makeGitRepository()) |
2030 | + |
2031 | + rule = self.factory.makeGitRule(repository=repository) |
2032 | + grant = self.factory.makeGitRuleGrant( |
2033 | + rule=rule, grantee=GitGranteeType.REPOSITORY_OWNER) |
2034 | + self.factory.makeGitRuleGrant(rule=rule) |
2035 | + |
2036 | + results = repository.findRuleGrantsByGrantee( |
2037 | + GitGranteeType.REPOSITORY_OWNER) |
2038 | + self.assertEqual([grant], list(results)) |
2039 | + |
2040 | + def test_findRuleGrantsByGrantee_owner_ref_pattern(self): |
2041 | + repository = removeSecurityProxy(self.factory.makeGitRepository()) |
2042 | + [ref] = self.factory.makeGitRefs(repository=repository) |
2043 | + |
2044 | + exact_grant = self.factory.makeGitRuleGrant( |
2045 | + repository=repository, ref_pattern=ref.path, |
2046 | + grantee=GitGranteeType.REPOSITORY_OWNER) |
2047 | + self.factory.makeGitRuleGrant( |
2048 | + repository=repository, ref_pattern="refs/heads/*", |
2049 | + grantee=GitGranteeType.REPOSITORY_OWNER) |
2050 | + |
2051 | + results = ref.repository.findRuleGrantsByGrantee( |
2052 | + GitGranteeType.REPOSITORY_OWNER, ref_pattern=ref.path) |
2053 | + self.assertEqual([exact_grant], list(results)) |
2054 | + |
2055 | + def test_findRuleGrantsByGrantee_owner_exact_grantee(self): |
2056 | + repository = removeSecurityProxy(self.factory.makeGitRepository()) |
2057 | + [ref] = self.factory.makeGitRefs(repository=repository) |
2058 | + |
2059 | + exact_grant = self.factory.makeGitRuleGrant( |
2060 | + repository=repository, ref_pattern=ref.path, |
2061 | + grantee=GitGranteeType.REPOSITORY_OWNER) |
2062 | + self.factory.makeGitRuleGrant( |
2063 | + rule=exact_grant.rule, grantee=repository.owner) |
2064 | + wildcard_grant = self.factory.makeGitRuleGrant( |
2065 | + repository=repository, ref_pattern="refs/heads/*", |
2066 | + grantee=GitGranteeType.REPOSITORY_OWNER) |
2067 | + |
2068 | + results = ref.repository.findRuleGrantsByGrantee( |
2069 | + GitGranteeType.REPOSITORY_OWNER, exact_grantee=True) |
2070 | + self.assertItemsEqual([exact_grant, wildcard_grant], list(results)) |
2071 | + results = ref.repository.findRuleGrantsByGrantee( |
2072 | + GitGranteeType.REPOSITORY_OWNER, ref_pattern=ref.path, |
2073 | + exact_grantee=True) |
2074 | + self.assertEqual([exact_grant], list(results)) |
2075 | + |
2076 | |
2077 | class TestGitIdentityMixin(TestCaseWithFactory): |
2078 | """Test the defaults and identities provided by GitIdentityMixin.""" |
2079 | |
2080 | === modified file 'lib/lp/code/model/tests/test_gitrule.py' |
2081 | --- lib/lp/code/model/tests/test_gitrule.py 2018-10-21 17:38:05 +0000 |
2082 | +++ lib/lp/code/model/tests/test_gitrule.py 2018-11-09 22:50:10 +0000 |
2083 | @@ -594,6 +594,7 @@ |
2084 | rule=Equals(rule), |
2085 | grantee_type=Equals(GitGranteeType.REPOSITORY_OWNER), |
2086 | grantee=Is(None), |
2087 | + combined_grantee=Equals(GitGranteeType.REPOSITORY_OWNER), |
2088 | can_create=Is(True), |
2089 | can_push=Is(False), |
2090 | can_force_push=Is(True), |
2091 | @@ -614,6 +615,7 @@ |
2092 | rule=Equals(rule), |
2093 | grantee_type=Equals(GitGranteeType.PERSON), |
2094 | grantee=Equals(grantee), |
2095 | + combined_grantee=Equals(grantee), |
2096 | can_create=Is(False), |
2097 | can_push=Is(True), |
2098 | can_force_push=Is(False), |
2099 | |
2100 | === added file 'lib/lp/code/templates/gitrepository-permissions.pt' |
2101 | --- lib/lp/code/templates/gitrepository-permissions.pt 1970-01-01 00:00:00 +0000 |
2102 | +++ lib/lp/code/templates/gitrepository-permissions.pt 2018-11-09 22:50:10 +0000 |
2103 | @@ -0,0 +1,192 @@ |
2104 | +<html |
2105 | + xmlns="http://www.w3.org/1999/xhtml" |
2106 | + xmlns:tal="http://xml.zope.org/namespaces/tal" |
2107 | + xmlns:metal="http://xml.zope.org/namespaces/metal" |
2108 | + xmlns:i18n="http://xml.zope.org/namespaces/i18n" |
2109 | + metal:use-macro="view/macro:page/main_only" |
2110 | + i18n:domain="launchpad"> |
2111 | +<body> |
2112 | + |
2113 | + <metal:macros fill-slot="bogus"> |
2114 | + <metal:macro define-macro="rule-rows"> |
2115 | + <tal:rule repeat="rule rules"> |
2116 | + <tal:rule_widgets |
2117 | + define="rule_widgets python:view.getRuleWidgets(rule)"> |
2118 | + <tr class="git-rule"> |
2119 | + <td tal:define="widget nocall:rule_widgets/position"> |
2120 | + <metal:block use-macro="context/@@launchpad_form/widget_div" /> |
2121 | + </td> |
2122 | + <td tal:define="widget nocall:rule_widgets/pattern" colspan="2"> |
2123 | + <metal:block use-macro="context/@@launchpad_form/widget_div" /> |
2124 | + </td> |
2125 | + <td tal:define="widget nocall:rule_widgets/delete"> |
2126 | + <metal:block use-macro="context/@@launchpad_form/widget_div" /> |
2127 | + </td> |
2128 | + </tr> |
2129 | + <tr class="git-rule-grant" |
2130 | + tal:repeat="grant_widgets rule_widgets/grants"> |
2131 | + <td></td> |
2132 | + <td tal:define="widget nocall:grant_widgets/grantee"> |
2133 | + <metal:block use-macro="context/@@launchpad_form/widget_div" /> |
2134 | + </td> |
2135 | + <td tal:define="widget nocall:grant_widgets/permissions"> |
2136 | + <metal:block use-macro="context/@@launchpad_form/widget_div" /> |
2137 | + </td> |
2138 | + <td tal:define="widget nocall:grant_widgets/delete"> |
2139 | + <metal:block use-macro="context/@@launchpad_form/widget_div" /> |
2140 | + </td> |
2141 | + </tr> |
2142 | + <tr class="git-new-rule-grant" |
2143 | + tal:define="new_grant_widgets rule_widgets/new_grant"> |
2144 | + <td></td> |
2145 | + <td tal:define="widget nocall:new_grant_widgets/grantee"> |
2146 | + <metal:block use-macro="context/@@launchpad_form/widget_div" /> |
2147 | + </td> |
2148 | + <td tal:define="widget nocall:new_grant_widgets/permissions"> |
2149 | + <metal:block use-macro="context/@@launchpad_form/widget_div" /> |
2150 | + </td> |
2151 | + <td></td> |
2152 | + </tr> |
2153 | + </tal:rule_widgets> |
2154 | + </tal:rule> |
2155 | + <tal:allows-new-rule condition="ref_prefix"> |
2156 | + <tr class="git-new-rule" |
2157 | + tal:define="new_rule_widgets python:view.getNewRuleWidgets(ref_prefix)"> |
2158 | + <td tal:define="widget nocall:new_rule_widgets/position"> |
2159 | + <metal:block use-macro="context/@@launchpad_form/widget_div" /> |
2160 | + </td> |
2161 | + <td tal:define="widget nocall:new_rule_widgets/pattern" colspan="2"> |
2162 | + <metal:block use-macro="context/@@launchpad_form/widget_div" /> |
2163 | + </td> |
2164 | + <td></td> |
2165 | + </tr> |
2166 | + </tal:allows-new-rule> |
2167 | + </metal:macro> |
2168 | + </metal:macros> |
2169 | + |
2170 | + <div metal:fill-slot="main"> |
2171 | + <p> |
2172 | + By default, repository owners may create, push, force-push, or delete |
2173 | + any branch or tag in their repositories, and nobody else may modify |
2174 | + them in any way. |
2175 | + </p> |
2176 | + <p> |
2177 | + If any of the rules below matches a branch or tag, then it is |
2178 | + <em>protected</em>. By default, protecting a branch implicitly |
2179 | + prevents repository owners from force-pushing to it or deleting it, |
2180 | + while protecting a tag prevents repository owners from moving it. |
2181 | + Protecting a branch or tag also allows you to grant other permissions. |
2182 | + </p> |
2183 | + <p> |
2184 | + You may create rules that match a single branch or tag, or wildcard |
2185 | + rules that match a pattern: for example, <code>*</code> matches |
2186 | + everything, while <code>stable/*</code> matches |
2187 | + <code>stable/1.0</code> but not <code>master</code>. |
2188 | + </p> |
2189 | + |
2190 | + <metal:grants-form use-macro="context/@@launchpad_form/form"> |
2191 | + <div class="form" metal:fill-slot="widgets"> |
2192 | + <table id="rules-table" class="listing" |
2193 | + style="max-width: 60em; margin-bottom: 1em;"> |
2194 | + <thead> |
2195 | + <tr> |
2196 | + <th>Position</th> |
2197 | + <th colspan="2">Rule</th> |
2198 | + <th>Delete?</th> |
2199 | + </tr> |
2200 | + </thead> |
2201 | + <tbody> |
2202 | + <tr> |
2203 | + <td colspan="4"> |
2204 | + <h3>Protected branches (under <code>refs/heads/</code>)</h3> |
2205 | + </td> |
2206 | + </tr> |
2207 | + <tal:branches define="rules view/branch_rules; |
2208 | + ref_prefix string:refs/heads/"> |
2209 | + <metal:grants use-macro="template/macros/rule-rows" /> |
2210 | + </tal:branches> |
2211 | + |
2212 | + <tr> |
2213 | + <td colspan="4"> |
2214 | + <h3>Protected tags (under <code>refs/tags/</code>)</h3> |
2215 | + </td> |
2216 | + </tr> |
2217 | + <tal:tags define="rules view/tag_rules; |
2218 | + ref_prefix string:refs/tags/"> |
2219 | + <metal:grants use-macro="template/macros/rule-rows" /> |
2220 | + </tal:tags> |
2221 | + |
2222 | + <tal:has-other condition="view/other_rules"> |
2223 | + <tr><td colspan="4"><h3>Other protected references</h3></td></tr> |
2224 | + <tal:other define="rules view/other_rules; ref_prefix nothing"> |
2225 | + <metal:grants use-macro="template/macros/rule-rows" /> |
2226 | + </tal:other> |
2227 | + </tal:has-other> |
2228 | + </tbody> |
2229 | + </table> |
2230 | + |
2231 | + <p class="actions"> |
2232 | + <input tal:replace="structure view/save_action/render" /> |
2233 | + or <a tal:attributes="href view/cancel_url">Cancel</a> |
2234 | + </p> |
2235 | + </div> |
2236 | + |
2237 | + <metal:buttons fill-slot="buttons" /> |
2238 | + </metal:grants-form> |
2239 | + |
2240 | + <h2>Wildcards</h2> |
2241 | + <p>The special characters used in wildcard rules are:</p> |
2242 | + <table class="listing narrow-listing"> |
2243 | + <thead> |
2244 | + <tr> |
2245 | + <th>Pattern</th> |
2246 | + <th>Meaning</th> |
2247 | + </tr> |
2248 | + </thead> |
2249 | + <tbody> |
2250 | + <tr> |
2251 | + <td><code>*</code></td> |
2252 | + <td>matches zero or more characters</td> |
2253 | + </tr> |
2254 | + <tr> |
2255 | + <td><code>?</code></td> |
2256 | + <td>matches any single character</td> |
2257 | + </tr> |
2258 | + <tr> |
2259 | + <td><code>[chars]</code></td> |
2260 | + <td>matches any character in <em>chars</em></td> |
2261 | + </tr> |
2262 | + <tr> |
2263 | + <td><code>[!chars]</code></td> |
2264 | + <td>matches any character not in <em>chars</em></td> |
2265 | + </tr> |
2266 | + </tbody> |
2267 | + </table> |
2268 | + |
2269 | + <h2>Effective permissions</h2> |
2270 | + <p> |
2271 | + Launchpad works out the effective permissions that a user has on a |
2272 | + protected branch as follows: |
2273 | + </p> |
2274 | + <ol> |
2275 | + <li>Take all the rules that match the branch.</li> |
2276 | + <li> |
2277 | + For each matching rule, select any grants whose grantee matches the |
2278 | + user, as long as the same grantee has not already been seen in an |
2279 | + earlier matching rule. (A user can be matched by more than one |
2280 | + grantee: for example, they might be in multiple teams.) |
2281 | + </li> |
2282 | + <li> |
2283 | + If the user is an owner of the repository and there was no previous |
2284 | + “Repository owner” grant, then add an implicit grant allowing them |
2285 | + to create or push. |
2286 | + </li> |
2287 | + <li> |
2288 | + The effective permission set is the union of the permissions granted |
2289 | + by all the selected grants. |
2290 | + </li> |
2291 | + </ol> |
2292 | + </div> |
2293 | + |
2294 | +</body> |
2295 | +</html> |