Merge lp:~cjwatson/launchpad/git-permissions-ui-edit into lp:launchpad

Proposed by Colin Watson
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
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://people.canonical.com/~cjwatson/tmp/lp-git-repository-permissions.png

This isn't ready for review yet since it has multiple prerequisites (https://code.launchpad.net/~cjwatson/launchpad/snapshot-modifying-helper/+merge/357598 and https://code.launchpad.net/~cjwatson/launchpad/git-grantee-widgets/+merge/357600).

To post a comment you must log in.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
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>