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
=== modified file 'lib/lp/code/browser/configure.zcml'
--- lib/lp/code/browser/configure.zcml 2018-11-08 11:40:25 +0000
+++ lib/lp/code/browser/configure.zcml 2018-11-09 22:50:10 +0000
@@ -871,6 +871,12 @@
871 </class>871 </class>
872 <browser:page872 <browser:page
873 for="lp.code.interfaces.gitrepository.IGitRepository"873 for="lp.code.interfaces.gitrepository.IGitRepository"
874 class="lp.code.browser.gitrepository.GitRepositoryPermissionsView"
875 name="+permissions"
876 permission="launchpad.Edit"
877 template="../templates/gitrepository-permissions.pt"/>
878 <browser:page
879 for="lp.code.interfaces.gitrepository.IGitRepository"
874 class="lp.code.browser.gitrepository.GitRepositoryDeletionView"880 class="lp.code.browser.gitrepository.GitRepositoryDeletionView"
875 permission="launchpad.Edit"881 permission="launchpad.Edit"
876 name="+delete"882 name="+delete"
877883
=== modified file 'lib/lp/code/browser/gitrepository.py'
--- lib/lp/code/browser/gitrepository.py 2018-11-08 15:53:56 +0000
+++ lib/lp/code/browser/gitrepository.py 2018-11-09 22:50:10 +0000
@@ -17,10 +17,13 @@
17 'GitRepositoryEditReviewerView',17 'GitRepositoryEditReviewerView',
18 'GitRepositoryEditView',18 'GitRepositoryEditView',
19 'GitRepositoryNavigation',19 'GitRepositoryNavigation',
20 'GitRepositoryPermissionsView',
20 'GitRepositoryURL',21 'GitRepositoryURL',
21 'GitRepositoryView',22 'GitRepositoryView',
22 ]23 ]
2324
25import base64
26
24from lazr.lifecycle.event import ObjectModifiedEvent27from lazr.lifecycle.event import ObjectModifiedEvent
25from lazr.lifecycle.snapshot import Snapshot28from lazr.lifecycle.snapshot import Snapshot
26from lazr.restful.interface import (29from lazr.restful.interface import (
@@ -34,14 +37,21 @@
34from zope.component import getUtility37from zope.component import getUtility
35from zope.event import notify38from zope.event import notify
36from zope.formlib import form39from zope.formlib import form
40from zope.formlib.textwidgets import IntWidget
41from zope.formlib.widget import CustomWidgetFactory
37from zope.interface import (42from zope.interface import (
38 implementer,43 implementer,
39 Interface,44 Interface,
40 providedBy,45 providedBy,
41 )46 )
42from zope.publisher.interfaces.browser import IBrowserPublisher47from zope.publisher.interfaces.browser import IBrowserPublisher
43from zope.schema import Choice48from zope.schema import (
49 Bool,
50 Choice,
51 Int,
52 )
44from zope.schema.vocabulary import (53from zope.schema.vocabulary import (
54 getVocabularyRegistry,
45 SimpleTerm,55 SimpleTerm,
46 SimpleVocabulary,56 SimpleVocabulary,
47 )57 )
@@ -53,7 +63,10 @@
53 LaunchpadEditFormView,63 LaunchpadEditFormView,
54 LaunchpadFormView,64 LaunchpadFormView,
55 )65 )
56from lp.app.errors import NotFoundError66from lp.app.errors import (
67 NotFoundError,
68 UnexpectedFormData,
69 )
57from lp.app.vocabularies import InformationTypeVocabulary70from lp.app.vocabularies import InformationTypeVocabulary
58from lp.app.widgets.itemswidgets import LaunchpadRadioWidgetWithDescription71from lp.app.widgets.itemswidgets import LaunchpadRadioWidgetWithDescription
59from lp.code.browser.branch import CodeEditOwnerMixin72from lp.code.browser.branch import CodeEditOwnerMixin
@@ -62,11 +75,19 @@
62 )75 )
63from lp.code.browser.codeimport import CodeImportTargetMixin76from lp.code.browser.codeimport import CodeImportTargetMixin
64from lp.code.browser.sourcepackagerecipelisting import HasRecipesMenuMixin77from lp.code.browser.sourcepackagerecipelisting import HasRecipesMenuMixin
78from lp.code.browser.widgets.gitgrantee import (
79 GitGranteeDisplayWidget,
80 GitGranteeField,
81 GitGranteeWidget,
82 )
65from lp.code.browser.widgets.gitrepositorytarget import (83from lp.code.browser.widgets.gitrepositorytarget import (
66 GitRepositoryTargetDisplayWidget,84 GitRepositoryTargetDisplayWidget,
67 GitRepositoryTargetWidget,85 GitRepositoryTargetWidget,
68 )86 )
69from lp.code.enums import GitRepositoryType87from lp.code.enums import (
88 GitGranteeType,
89 GitRepositoryType,
90 )
70from lp.code.errors import (91from lp.code.errors import (
71 GitDefaultConflict,92 GitDefaultConflict,
72 GitRepositoryCreationForbidden,93 GitRepositoryCreationForbidden,
@@ -76,6 +97,7 @@
76from lp.code.interfaces.gitnamespace import get_git_namespace97from lp.code.interfaces.gitnamespace import get_git_namespace
77from lp.code.interfaces.gitref import IGitRefBatchNavigator98from lp.code.interfaces.gitref import IGitRefBatchNavigator
78from lp.code.interfaces.gitrepository import IGitRepository99from lp.code.interfaces.gitrepository import IGitRepository
100from lp.code.vocabularies.gitrule import GitPermissionsVocabulary
79from lp.registry.interfaces.person import (101from lp.registry.interfaces.person import (
80 IPerson,102 IPerson,
81 IPersonSet,103 IPersonSet,
@@ -84,6 +106,7 @@
84from lp.services.config import config106from lp.services.config import config
85from lp.services.database.constants import UTC_NOW107from lp.services.database.constants import UTC_NOW
86from lp.services.features import getFeatureFlag108from lp.services.features import getFeatureFlag
109from lp.services.fields import UniqueField
87from lp.services.propertycache import cachedproperty110from lp.services.propertycache import cachedproperty
88from lp.services.webapp import (111from lp.services.webapp import (
89 canonical_url,112 canonical_url,
@@ -105,6 +128,7 @@
105from lp.services.webapp.escaping import structured128from lp.services.webapp.escaping import structured
106from lp.services.webapp.interfaces import ICanonicalUrlData129from lp.services.webapp.interfaces import ICanonicalUrlData
107from lp.services.webapp.publisher import DataDownloadView130from lp.services.webapp.publisher import DataDownloadView
131from lp.services.webapp.snapshot import notify_modified
108from lp.services.webhooks.browser import WebhookTargetNavigationMixin132from lp.services.webhooks.browser import WebhookTargetNavigationMixin
109from lp.snappy.browser.hassnaps import HasSnapsViewMixin133from lp.snappy.browser.hassnaps import HasSnapsViewMixin
110134
@@ -211,7 +235,14 @@
211 usedfor = IGitRepository235 usedfor = IGitRepository
212 facet = "branches"236 facet = "branches"
213 title = "Edit Git repository"237 title = "Edit Git repository"
214 links = ["edit", "reviewer", "webhooks", "activity", "delete"]238 links = [
239 "edit",
240 "reviewer",
241 "permissions",
242 "activity",
243 "webhooks",
244 "delete",
245 ]
215246
216 @enabled_with_permission("launchpad.Edit")247 @enabled_with_permission("launchpad.Edit")
217 def edit(self):248 def edit(self):
@@ -224,6 +255,11 @@
224 return Link("+reviewer", text, icon="edit")255 return Link("+reviewer", text, icon="edit")
225256
226 @enabled_with_permission("launchpad.Edit")257 @enabled_with_permission("launchpad.Edit")
258 def permissions(self):
259 text = "Manage permissions"
260 return Link("+permissions", text, icon="edit")
261
262 @enabled_with_permission("launchpad.Edit")
227 def webhooks(self):263 def webhooks(self):
228 text = "Manage webhooks"264 text = "Manage webhooks"
229 return Link(265 return Link(
@@ -709,6 +745,452 @@
709 return self, ()745 return self, ()
710746
711747
748def encode_form_field_id(value):
749 """Encode text for use in form field names.
750
751 We use a modified version of base32 which fits into CSS identifiers and
752 so doesn't cause FormattersAPI.zope_css_id to do unhelpful things.
753 """
754 return base64.b32encode(
755 value.encode("UTF-8")).decode("UTF-8").replace("=", "_")
756
757
758def decode_form_field_id(encoded):
759 """Inverse of `encode_form_field_id`."""
760 return base64.b32decode(
761 encoded.replace("_", "=").encode("UTF-8")).decode("UTF-8")
762
763
764class GitRulePatternField(UniqueField):
765
766 errormessage = _("%s is already in use by another rule")
767 attribute = "ref_pattern"
768 _content_iface = IGitRepository
769
770 def __init__(self, ref_prefix, rule=None, *args, **kwargs):
771 self.ref_prefix = ref_prefix
772 self.rule = rule
773 super(GitRulePatternField, self).__init__(*args, **kwargs)
774
775 def _getByAttribute(self, ref_pattern):
776 """See `UniqueField`."""
777 if self._content_iface.providedBy(self.context):
778 return self.context.getRule(self.ref_prefix + ref_pattern)
779 else:
780 return None
781
782 def unchanged(self, input):
783 """See `UniqueField`."""
784 return (
785 self.rule is not None and
786 self.ref_prefix + input == self.rule.ref_pattern)
787
788 def set(self, object, value):
789 """See `IField`."""
790 if value is not None:
791 value = value.strip()
792 super(GitRulePatternField, self).set(object, value)
793
794
795class GitRepositoryPermissionsView(LaunchpadFormView):
796 """A view to manage repository permissions."""
797
798 @property
799 def label(self):
800 return "Manage permissions for %s" % self.context.identity
801
802 page_title = "Manage permissions"
803
804 @cachedproperty
805 def repository(self):
806 return self.context
807
808 @cachedproperty
809 def rules(self):
810 return self.repository.getRules()
811
812 @cachedproperty
813 def branch_rules(self):
814 return [
815 rule for rule in self.rules
816 if rule.ref_pattern.startswith(u"refs/heads/")]
817
818 @cachedproperty
819 def tag_rules(self):
820 return [
821 rule for rule in self.rules
822 if rule.ref_pattern.startswith(u"refs/tags/")]
823
824 @cachedproperty
825 def other_rules(self):
826 return [
827 rule for rule in self.rules
828 if not rule.ref_pattern.startswith(u"refs/heads/") and
829 not rule.ref_pattern.startswith(u"refs/tags/")]
830
831 def _getRuleGrants(self, rule):
832 def grantee_key(grant):
833 if grant.grantee is not None:
834 return grant.grantee_type, grant.grantee.name
835 else:
836 return (grant.grantee_type,)
837
838 return sorted(rule.grants, key=grantee_key)
839
840 def _parseRefPattern(self, ref_pattern):
841 """Parse a pattern into a prefix and the displayed portion."""
842 for prefix in (u"refs/heads/", u"refs/tags/"):
843 if ref_pattern.startswith(prefix):
844 return prefix, ref_pattern[len(prefix):]
845 return u"", ref_pattern
846
847 def _getFieldName(self, name, ref_pattern, grantee=None):
848 """Get the combined field name for a ref pattern and optional grantee.
849
850 In order to be able to render a permissions table, we encode the ref
851 pattern and the grantee in the form field name.
852 """
853 suffix = "." + encode_form_field_id(ref_pattern)
854 if grantee is not None:
855 if IPerson.providedBy(grantee):
856 suffix += "." + str(grantee.id)
857 else:
858 suffix += "._" + grantee.name.lower()
859 return name + suffix
860
861 def _parseFieldName(self, field_name):
862 """Parse a combined field name as described in `_getFieldName`.
863
864 :raises UnexpectedFormData: if the field name cannot be parsed or
865 the grantee cannot be found.
866 """
867 field_bits = field_name.split(".")
868 if len(field_bits) < 2:
869 raise UnexpectedFormData(
870 "Cannot parse field name: %s" % field_name)
871 field_type = field_bits[0]
872 try:
873 ref_pattern = decode_form_field_id(field_bits[1])
874 except TypeError:
875 raise UnexpectedFormData(
876 "Cannot parse field name: %s" % field_name)
877 if len(field_bits) > 2:
878 grantee_id = field_bits[2]
879 if grantee_id.startswith("_"):
880 grantee_id = grantee_id[1:]
881 try:
882 grantee = GitGranteeType.getTermByToken(grantee_id).value
883 except LookupError:
884 grantee = None
885 else:
886 try:
887 grantee_id = int(grantee_id)
888 except ValueError:
889 grantee = None
890 else:
891 grantee = getUtility(IPersonSet).get(grantee_id)
892 if grantee is None or grantee == GitGranteeType.PERSON:
893 raise UnexpectedFormData("No such grantee: %s" % grantee_id)
894 else:
895 grantee = None
896 return field_type, ref_pattern, grantee
897
898 def _getPermissionsTerm(self, grant):
899 """Return a term from `GitPermissionsVocabulary` for this grant."""
900 vocabulary = getVocabularyRegistry().get(grant, "GitPermissions")
901 try:
902 return vocabulary.getTerm(grant.permissions)
903 except LookupError:
904 # This should never happen, because GitPermissionsVocabulary
905 # adds a custom term for the context grant if necessary.
906 raise AssertionError(
907 "Could not find GitPermissions term for %r" % grant)
908
909 def setUpFields(self):
910 """See `LaunchpadFormView`."""
911 position_fields = []
912 pattern_fields = []
913 delete_fields = []
914 readonly_grantee_fields = []
915 grantee_fields = []
916 permissions_fields = []
917
918 default_permissions_by_prefix = {
919 "refs/heads/": "can_push",
920 "refs/tags/": "can_create",
921 "": "can_push",
922 }
923
924 for rule_index, rule in enumerate(self.rules):
925 # Remove the usual branch/tag prefixes from patterns. The full
926 # pattern goes into form field names, so no data is lost here.
927 ref_pattern = rule.ref_pattern
928 ref_prefix, short_pattern = self._parseRefPattern(ref_pattern)
929 position_fields.append(
930 Int(
931 __name__=self._getFieldName("position", ref_pattern),
932 required=True, readonly=False, default=rule_index + 1))
933 pattern_fields.append(
934 GitRulePatternField(
935 __name__=self._getFieldName("pattern", ref_pattern),
936 required=True, readonly=False, ref_prefix=ref_prefix,
937 rule=rule, default=short_pattern))
938 delete_fields.append(
939 Bool(
940 __name__=self._getFieldName("delete", ref_pattern),
941 readonly=False, default=False))
942 for grant in self._getRuleGrants(rule):
943 grantee = grant.combined_grantee
944 readonly_grantee_fields.append(
945 GitGranteeField(
946 __name__=self._getFieldName(
947 "grantee", ref_pattern, grantee),
948 required=False, readonly=True, default=grantee,
949 rule=rule))
950 permissions_fields.append(
951 Choice(
952 __name__=self._getFieldName(
953 "permissions", ref_pattern, grantee),
954 source=GitPermissionsVocabulary(grant),
955 readonly=False,
956 default=self._getPermissionsTerm(grant).value))
957 delete_fields.append(
958 Bool(
959 __name__=self._getFieldName(
960 "delete", ref_pattern, grantee),
961 readonly=False, default=False))
962 grantee_fields.append(
963 GitGranteeField(
964 __name__=self._getFieldName("grantee", ref_pattern),
965 required=False, readonly=False, rule=rule))
966 permissions_vocabulary = GitPermissionsVocabulary(rule)
967 permissions_fields.append(
968 Choice(
969 __name__=self._getFieldName(
970 "permissions", ref_pattern),
971 source=permissions_vocabulary, readonly=False,
972 default=permissions_vocabulary.getTermByToken(
973 default_permissions_by_prefix[ref_prefix]).value))
974 for ref_prefix in ("refs/heads/", "refs/tags/"):
975 position_fields.append(
976 Int(
977 __name__=self._getFieldName("new-position", ref_prefix),
978 required=False, readonly=True))
979 pattern_fields.append(
980 GitRulePatternField(
981 __name__=self._getFieldName("new-pattern", ref_prefix),
982 required=False, readonly=False, ref_prefix=ref_prefix))
983
984 self.form_fields = (
985 form.FormFields(
986 *position_fields,
987 custom_widget=CustomWidgetFactory(IntWidget, displayWidth=2)) +
988 form.FormFields(*pattern_fields) +
989 form.FormFields(*delete_fields) +
990 form.FormFields(
991 *readonly_grantee_fields,
992 custom_widget=CustomWidgetFactory(GitGranteeDisplayWidget)) +
993 form.FormFields(
994 *grantee_fields,
995 custom_widget=CustomWidgetFactory(GitGranteeWidget)) +
996 form.FormFields(*permissions_fields))
997
998 def setUpWidgets(self, context=None):
999 """See `LaunchpadFormView`."""
1000 super(GitRepositoryPermissionsView, self).setUpWidgets(
1001 context=context)
1002 for widget in self.widgets:
1003 widget.display_label = False
1004 widget.hint = None
1005
1006 @property
1007 def cancel_url(self):
1008 return canonical_url(self.context)
1009
1010 def getRuleWidgets(self, rule):
1011 widgets_by_name = {widget.name: widget for widget in self.widgets}
1012 ref_pattern = rule.ref_pattern
1013 position_field_name = (
1014 "field." + self._getFieldName("position", ref_pattern))
1015 pattern_field_name = (
1016 "field." + self._getFieldName("pattern", ref_pattern))
1017 delete_field_name = (
1018 "field." + self._getFieldName("delete", ref_pattern))
1019 grant_widgets = []
1020 for grant in self._getRuleGrants(rule):
1021 grantee = grant.combined_grantee
1022 grantee_field_name = (
1023 "field." + self._getFieldName("grantee", ref_pattern, grantee))
1024 permissions_field_name = (
1025 "field." +
1026 self._getFieldName("permissions", ref_pattern, grantee))
1027 delete_grant_field_name = (
1028 "field." + self._getFieldName("delete", ref_pattern, grantee))
1029 grant_widgets.append({
1030 "grantee": widgets_by_name[grantee_field_name],
1031 "permissions": widgets_by_name[permissions_field_name],
1032 "delete": widgets_by_name[delete_grant_field_name],
1033 })
1034 new_grantee_field_name = (
1035 "field." + self._getFieldName("grantee", ref_pattern))
1036 new_permissions_field_name = (
1037 "field." + self._getFieldName("permissions", ref_pattern))
1038 new_grant_widgets = {
1039 "grantee": widgets_by_name[new_grantee_field_name],
1040 "permissions": widgets_by_name[new_permissions_field_name],
1041 }
1042 return {
1043 "position": widgets_by_name[position_field_name],
1044 "pattern": widgets_by_name[pattern_field_name],
1045 "delete": widgets_by_name.get(delete_field_name),
1046 "grants": grant_widgets,
1047 "new_grant": new_grant_widgets,
1048 }
1049
1050 def getNewRuleWidgets(self, ref_prefix):
1051 widgets_by_name = {widget.name: widget for widget in self.widgets}
1052 new_position_field_name = (
1053 "field." + self._getFieldName("new-position", ref_prefix))
1054 new_pattern_field_name = (
1055 "field." + self._getFieldName("new-pattern", ref_prefix))
1056 return {
1057 "position": widgets_by_name[new_position_field_name],
1058 "pattern": widgets_by_name[new_pattern_field_name],
1059 }
1060
1061 def updateRepositoryFromData(self, repository, data):
1062 pattern_field_names = sorted(
1063 name for name in data if name.split(".")[0] == "pattern")
1064 new_pattern_field_names = sorted(
1065 name for name in data if name.split(".")[0] == "new-pattern")
1066 permissions_field_names = sorted(
1067 name for name in data if name.split(".")[0] == "permissions")
1068
1069 # Fetch rules before making any changes, since their ref_patterns
1070 # may change as a result of this update.
1071 rule_map = {rule.ref_pattern: rule for rule in self.repository.rules}
1072 grant_map = {
1073 (grant.rule.ref_pattern, grant.combined_grantee): grant
1074 for grant in self.repository.grants}
1075
1076 # Patterns must be processed in rule order so that position changes
1077 # work in a reasonably natural way.
1078 ordered_patterns = []
1079 for pattern_field_name in pattern_field_names:
1080 _, ref_pattern, _ = self._parseFieldName(pattern_field_name)
1081 if ref_pattern is not None:
1082 rule = rule_map.get(ref_pattern)
1083 ordered_patterns.append(
1084 (pattern_field_name, ref_pattern, rule))
1085 ordered_patterns.sort(key=lambda item: item[2].position)
1086
1087 for pattern_field_name, ref_pattern, rule in ordered_patterns:
1088 prefix, _ = self._parseRefPattern(ref_pattern)
1089 rule = rule_map.get(ref_pattern)
1090 delete_field_name = self._getFieldName("delete", ref_pattern)
1091 # If the rule was already deleted by somebody else, then we
1092 # have nothing to do.
1093 if rule is not None and data.get(delete_field_name):
1094 rule.destroySelf(self.user)
1095 rule_map[ref_pattern] = rule = None
1096 position_field_name = self._getFieldName("position", ref_pattern)
1097 if rule is not None:
1098 new_position = max(0, data[position_field_name] - 1)
1099 self.repository.moveRule(rule, new_position, self.user)
1100 new_pattern = prefix + data[pattern_field_name]
1101 if rule is not None and new_pattern != rule.ref_pattern:
1102 with notify_modified(rule, ["ref_pattern"]):
1103 rule.ref_pattern = new_pattern
1104
1105 for new_pattern_field_name in new_pattern_field_names:
1106 _, prefix, _ = self._parseFieldName(new_pattern_field_name)
1107 if data[new_pattern_field_name]:
1108 # This is an "add rule" entry.
1109 new_position_field_name = self._getFieldName(
1110 "position", prefix)
1111 new_pattern = prefix + data[new_pattern_field_name]
1112 rule = rule_map.get(new_pattern)
1113 if rule is None:
1114 if new_position_field_name in data:
1115 new_position = max(
1116 0, data[new_position_field_name] - 1)
1117 else:
1118 new_position = None
1119 rule = repository.addRule(
1120 new_pattern, self.user, position=new_position)
1121 if prefix == "refs/tags/":
1122 # Tags are a special case: on creation, they
1123 # automatically get a grant of create permissions to
1124 # the repository owner (suppressing the normal
1125 # ability of the repository owner to push protected
1126 # references).
1127 rule.addGrant(
1128 GitGranteeType.REPOSITORY_OWNER, self.user,
1129 can_create=True)
1130
1131 for permissions_field_name in permissions_field_names:
1132 _, ref_pattern, grantee = self._parseFieldName(
1133 permissions_field_name)
1134 if ref_pattern not in rule_map:
1135 self.addError(structured(
1136 "Cannot edit grants for nonexistent rule %s", ref_pattern))
1137 return
1138 rule = rule_map.get(ref_pattern)
1139 if rule is None:
1140 # Already deleted.
1141 continue
1142
1143 # Find or create the corresponding grant. We only create a
1144 # grant if explicitly processing an "add grant" entry in the UI;
1145 # if there isn't already a grant for an existing entry that's
1146 # being modified, implicitly adding it is probably too
1147 # confusing.
1148 permissions = data[permissions_field_name]
1149 grant = None
1150 if grantee is not None:
1151 # This entry should correspond to an existing grant. Make
1152 # whatever changes were requested to it.
1153 grant = grant_map.get((ref_pattern, grantee))
1154 delete_field_name = self._getFieldName(
1155 "delete", ref_pattern, grantee)
1156 # If the grant was already deleted by somebody else, then we
1157 # have nothing to do.
1158 if grant is not None and data.get(delete_field_name):
1159 grant.destroySelf(self.user)
1160 grant = None
1161 if grant is not None and permissions != grant.permissions:
1162 with notify_modified(
1163 grant,
1164 ["can_create", "can_push", "can_force_push"]):
1165 grant.permissions = permissions
1166 else:
1167 # This is an "add grant" entry.
1168 grantee_field_name = self._getFieldName("grantee", ref_pattern)
1169 grantee = data.get(grantee_field_name)
1170 if grantee:
1171 grant = grant_map.get((ref_pattern, grantee))
1172 if grant is None:
1173 rule.addGrant(
1174 grantee, self.user, permissions=permissions)
1175 elif permissions != grant.permissions:
1176 # Somebody else added the grant since the form was
1177 # last rendered. Updating it with the permissions
1178 # from this request seems best.
1179 with notify_modified(
1180 grant,
1181 ["can_create", "can_push", "can_force_push"]):
1182 grant.permissions = permissions
1183
1184 self.request.response.addNotification(
1185 "Saved permissions for %s" % self.context.identity)
1186 self.next_url = canonical_url(self.context, view_name="+permissions")
1187
1188 @action("Save", name="save")
1189 def save_action(self, action, data):
1190 with notify_modified(self.repository, []):
1191 self.updateRepositoryFromData(self.repository, data)
1192
1193
712class GitRepositoryDeletionView(LaunchpadFormView):1194class GitRepositoryDeletionView(LaunchpadFormView):
7131195
714 schema = IGitRepository1196 schema = IGitRepository
7151197
=== modified file 'lib/lp/code/browser/tests/test_gitrepository.py'
--- lib/lp/code/browser/tests/test_gitrepository.py 2018-11-08 15:33:03 +0000
+++ lib/lp/code/browser/tests/test_gitrepository.py 2018-11-09 22:50:10 +0000
@@ -7,16 +7,26 @@
77
8__metaclass__ = type8__metaclass__ = type
99
10import base64
10from datetime import datetime11from datetime import datetime
11import doctest12import doctest
13from operator import attrgetter
14import re
12from textwrap import dedent15from textwrap import dedent
1316
14from fixtures import FakeLogger17from fixtures import FakeLogger
15import pytz18import pytz
19import soupmatchers
16from storm.store import Store20from storm.store import Store
17from testtools.matchers import (21from testtools.matchers import (
22 AfterPreprocessing,
18 DocTestMatches,23 DocTestMatches,
19 Equals,24 Equals,
25 Is,
26 MatchesDict,
27 MatchesListwise,
28 MatchesSetwise,
29 MatchesStructure,
20 )30 )
21import transaction31import transaction
22from zope.component import getUtility32from zope.component import getUtility
@@ -26,11 +36,16 @@
26from zope.security.proxy import removeSecurityProxy36from zope.security.proxy import removeSecurityProxy
2737
28from lp.app.enums import InformationType38from lp.app.enums import InformationType
39from lp.app.errors import UnexpectedFormData
29from lp.app.interfaces.launchpad import ILaunchpadCelebrities40from lp.app.interfaces.launchpad import ILaunchpadCelebrities
30from lp.app.interfaces.services import IService41from lp.app.interfaces.services import IService
42from lp.code.browser.gitrepository import encode_form_field_id
31from lp.code.enums import (43from lp.code.enums import (
32 BranchMergeProposalStatus,44 BranchMergeProposalStatus,
33 CodeReviewVote,45 CodeReviewVote,
46 GitActivityType,
47 GitGranteeType,
48 GitPermissionType,
34 GitRepositoryType,49 GitRepositoryType,
35 )50 )
36from lp.code.interfaces.revision import IRevisionSet51from lp.code.interfaces.revision import IRevisionSet
@@ -40,7 +55,10 @@
40 VCSType,55 VCSType,
41 )56 )
42from lp.registry.interfaces.accesspolicy import IAccessPolicySource57from lp.registry.interfaces.accesspolicy import IAccessPolicySource
43from lp.registry.interfaces.person import PersonVisibility58from lp.registry.interfaces.person import (
59 IPerson,
60 PersonVisibility,
61 )
44from lp.services.beautifulsoup import BeautifulSoup62from lp.services.beautifulsoup import BeautifulSoup
45from lp.services.database.constants import UTC_NOW63from lp.services.database.constants import UTC_NOW
46from lp.services.features.testing import FeatureFixture64from lp.services.features.testing import FeatureFixture
@@ -1097,6 +1115,599 @@
1097 browser.headers["Content-Disposition"])1115 browser.headers["Content-Disposition"])
10981116
10991117
1118class TestGitRepositoryPermissionsView(BrowserTestCase):
1119
1120 layer = DatabaseFunctionalLayer
1121
1122 def test_rules_properties(self):
1123 repository = self.factory.makeGitRepository()
1124 heads_rule = self.factory.makeGitRule(
1125 repository=repository, ref_pattern="refs/heads/*")
1126 tags_rule = self.factory.makeGitRule(
1127 repository=repository, ref_pattern="refs/tags/*")
1128 catch_all_rule = self.factory.makeGitRule(
1129 repository=repository, ref_pattern="*")
1130 login_person(repository.owner)
1131 view = create_initialized_view(repository, name="+permissions")
1132 self.assertEqual([heads_rule], view.branch_rules)
1133 self.assertEqual([tags_rule], view.tag_rules)
1134 self.assertEqual([catch_all_rule], view.other_rules)
1135
1136 def test__getRuleGrants(self):
1137 rule = self.factory.makeGitRule()
1138 grantees = sorted(
1139 [self.factory.makePerson() for _ in range(3)],
1140 key=attrgetter("name"))
1141 for grantee in (grantees[1], grantees[0], grantees[2]):
1142 self.factory.makeGitRuleGrant(rule=rule, grantee=grantee)
1143 self.factory.makeGitRuleGrant(
1144 rule=rule, grantee=GitGranteeType.REPOSITORY_OWNER)
1145 login_person(rule.repository.owner)
1146 view = create_initialized_view(rule.repository, name="+permissions")
1147 self.assertThat(view._getRuleGrants(rule), MatchesListwise([
1148 MatchesStructure.byEquality(
1149 grantee_type=GitGranteeType.REPOSITORY_OWNER),
1150 MatchesStructure.byEquality(grantee=grantees[0]),
1151 MatchesStructure.byEquality(grantee=grantees[1]),
1152 MatchesStructure.byEquality(grantee=grantees[2]),
1153 ]))
1154
1155 def test__parseRefPattern(self):
1156 repository = self.factory.makeGitRepository()
1157 login_person(repository.owner)
1158 view = create_initialized_view(repository, name="+permissions")
1159 self.assertEqual(
1160 ("refs/heads/", "stable/*"),
1161 view._parseRefPattern("refs/heads/stable/*"))
1162 self.assertEqual(
1163 ("refs/tags/", "1.0"), view._parseRefPattern("refs/tags/1.0"))
1164 self.assertEqual(
1165 ("", "refs/other/*"), view._parseRefPattern("refs/other/*"))
1166 self.assertEqual(("", "*"), view._parseRefPattern("*"))
1167
1168 def test__getFieldName_no_grantee(self):
1169 repository = self.factory.makeGitRepository()
1170 login_person(repository.owner)
1171 view = create_initialized_view(repository, name="+permissions")
1172 encoded_ref_pattern = base64.b32encode(
1173 b"refs/heads/*").replace("=", "_").decode("UTF-8")
1174 self.assertEqual(
1175 "field.%s" % encoded_ref_pattern,
1176 view._getFieldName("field", "refs/heads/*"))
1177
1178 def test__getFieldName_grantee_repository_owner(self):
1179 repository = self.factory.makeGitRepository()
1180 login_person(repository.owner)
1181 view = create_initialized_view(repository, name="+permissions")
1182 encoded_ref_pattern = base64.b32encode(
1183 b"refs/tags/*").replace("=", "_").decode("UTF-8")
1184 self.assertEqual(
1185 "field.%s._repository_owner" % encoded_ref_pattern,
1186 view._getFieldName(
1187 "field", "refs/tags/*",
1188 grantee=GitGranteeType.REPOSITORY_OWNER))
1189
1190 def test__getFieldName_grantee_person(self):
1191 repository = self.factory.makeGitRepository()
1192 grantee = self.factory.makePerson()
1193 login_person(repository.owner)
1194 view = create_initialized_view(repository, name="+permissions")
1195 encoded_ref_pattern = base64.b32encode(
1196 b"refs/*").replace("=", "_").decode("UTF-8")
1197 self.assertEqual(
1198 "field.%s.%s" % (encoded_ref_pattern, grantee.id),
1199 view._getFieldName("field", "refs/*", grantee=grantee))
1200
1201 def test__parseFieldName_too_few_components(self):
1202 repository = self.factory.makeGitRepository()
1203 login_person(repository.owner)
1204 view = create_initialized_view(repository, name="+permissions")
1205 self.assertRaises(UnexpectedFormData, view._parseFieldName, "field")
1206
1207 def test__parseFieldName_bad_ref_pattern(self):
1208 repository = self.factory.makeGitRepository()
1209 login_person(repository.owner)
1210 view = create_initialized_view(repository, name="+permissions")
1211 self.assertRaises(
1212 UnexpectedFormData, view._parseFieldName, "field.nonsense")
1213
1214 def test__parseFieldName_no_grantee(self):
1215 repository = self.factory.makeGitRepository()
1216 login_person(repository.owner)
1217 view = create_initialized_view(repository, name="+permissions")
1218 encoded_ref_pattern = base64.b32encode(
1219 b"refs/heads/*").replace("=", "_").decode("UTF-8")
1220 self.assertEqual(
1221 ("permissions", "refs/heads/*", None),
1222 view._parseFieldName("permissions.%s" % encoded_ref_pattern))
1223
1224 def test__parseFieldName_grantee_unknown_type(self):
1225 repository = self.factory.makeGitRepository()
1226 login_person(repository.owner)
1227 view = create_initialized_view(repository, name="+permissions")
1228 encoded_ref_pattern = base64.b32encode(
1229 b"refs/tags/*").replace("=", "_").decode("UTF-8")
1230 self.assertRaises(
1231 UnexpectedFormData, view._parseFieldName,
1232 "field.%s._nonsense" % encoded_ref_pattern)
1233 self.assertRaises(
1234 UnexpectedFormData, view._parseFieldName,
1235 "field.%s._person" % encoded_ref_pattern)
1236
1237 def test__parseFieldName_grantee_repository_owner(self):
1238 repository = self.factory.makeGitRepository()
1239 login_person(repository.owner)
1240 view = create_initialized_view(repository, name="+permissions")
1241 encoded_ref_pattern = base64.b32encode(
1242 b"refs/tags/*").replace("=", "_").decode("UTF-8")
1243 self.assertEqual(
1244 ("pattern", "refs/tags/*", GitGranteeType.REPOSITORY_OWNER),
1245 view._parseFieldName(
1246 "pattern.%s._repository_owner" % encoded_ref_pattern))
1247
1248 def test__parseFieldName_grantee_unknown_person(self):
1249 repository = self.factory.makeGitRepository()
1250 grantee = self.factory.makePerson()
1251 login_person(repository.owner)
1252 view = create_initialized_view(repository, name="+permissions")
1253 encoded_ref_pattern = base64.b32encode(
1254 b"refs/*").replace("=", "_").decode("UTF-8")
1255 self.assertRaises(
1256 UnexpectedFormData, view._parseFieldName,
1257 "delete.%s.%s" % (encoded_ref_pattern, grantee.id * 2))
1258
1259 def test__parseFieldName_grantee_person(self):
1260 repository = self.factory.makeGitRepository()
1261 grantee = self.factory.makePerson()
1262 login_person(repository.owner)
1263 view = create_initialized_view(repository, name="+permissions")
1264 encoded_ref_pattern = base64.b32encode(
1265 b"refs/*").replace("=", "_").decode("UTF-8")
1266 self.assertEqual(
1267 ("delete", "refs/*", grantee),
1268 view._parseFieldName(
1269 "delete.%s.%s" % (encoded_ref_pattern, grantee.id)))
1270
1271 def test__getPermissionsTerm_standard(self):
1272 grant = self.factory.makeGitRuleGrant(
1273 ref_pattern="refs/heads/*", can_create=True, can_push=True)
1274 login_person(grant.repository.owner)
1275 view = create_initialized_view(grant.repository, name="+permissions")
1276 self.assertThat(
1277 view._getPermissionsTerm(grant), MatchesStructure.byEquality(
1278 value={
1279 GitPermissionType.CAN_CREATE, GitPermissionType.CAN_PUSH},
1280 token="can_push",
1281 title="Can push"))
1282
1283 def test__getPermissionsTerm_custom(self):
1284 grant = self.factory.makeGitRuleGrant(
1285 ref_pattern="refs/heads/*", can_force_push=True)
1286 login_person(grant.repository.owner)
1287 view = create_initialized_view(grant.repository, name="+permissions")
1288 self.assertThat(
1289 view._getPermissionsTerm(grant), MatchesStructure.byEquality(
1290 value={GitPermissionType.CAN_FORCE_PUSH},
1291 token="custom",
1292 title="Custom permissions: force-push"))
1293
1294 def _matchesCells(self, row_tag, cell_matchers):
1295 return AfterPreprocessing(
1296 str, soupmatchers.HTMLContains(*(
1297 soupmatchers.Within(row_tag, cell_matcher)
1298 for cell_matcher in cell_matchers)))
1299
1300 def _matchesRule(self, position, pattern, short_pattern):
1301 rule_tag = soupmatchers.Tag(
1302 "rule row", "tr", attrs={"class": "git-rule"})
1303 suffix = "." + encode_form_field_id(pattern)
1304 position_field_name = "field.position" + suffix
1305 pattern_field_name = "field.pattern" + suffix
1306 delete_field_name = "field.delete" + suffix
1307 return self._matchesCells(rule_tag, [
1308 soupmatchers.Within(
1309 soupmatchers.Tag("position cell", "td"),
1310 soupmatchers.Tag(
1311 "position widget", "input",
1312 attrs={"name": position_field_name, "value": position})),
1313 soupmatchers.Within(
1314 soupmatchers.Tag("pattern cell", "td"),
1315 soupmatchers.Tag(
1316 "pattern widget", "input",
1317 attrs={
1318 "name": pattern_field_name,
1319 "value": short_pattern,
1320 })),
1321 soupmatchers.Within(
1322 soupmatchers.Tag("delete cell", "td"),
1323 soupmatchers.Tag(
1324 "delete widget", "input",
1325 attrs={"name": delete_field_name})),
1326 ])
1327
1328 def _matchesNewRule(self, ref_prefix):
1329 new_rule_tag = soupmatchers.Tag(
1330 "new rule row", "tr", attrs={"class": "git-new-rule"})
1331 suffix = "." + encode_form_field_id(ref_prefix)
1332 new_position_field_name = "field.new-position" + suffix
1333 new_pattern_field_name = "field.new-pattern" + suffix
1334 return self._matchesCells(new_rule_tag, [
1335 soupmatchers.Within(
1336 soupmatchers.Tag("position cell", "td"),
1337 soupmatchers.Tag(
1338 "position widget", "input",
1339 attrs={"name": new_position_field_name, "value": ""})),
1340 soupmatchers.Within(
1341 soupmatchers.Tag("pattern cell", "td"),
1342 soupmatchers.Tag(
1343 "pattern widget", "input",
1344 attrs={"name": new_pattern_field_name, "value": ""})),
1345 ])
1346
1347 def _matchesRuleGrant(self, pattern, grantee, permissions_token,
1348 permissions_title):
1349 rule_grant_tag = soupmatchers.Tag(
1350 "rule grant row", "tr", attrs={"class": "git-rule-grant"})
1351 suffix = "." + encode_form_field_id(pattern)
1352 if IPerson.providedBy(grantee):
1353 suffix += "." + str(grantee.id)
1354 grantee_widget_matcher = soupmatchers.Tag(
1355 "grantee widget", "a", attrs={"href": canonical_url(grantee)},
1356 text=" " + grantee.display_name)
1357 else:
1358 suffix += "._" + grantee.name.lower()
1359 grantee_widget_matcher = soupmatchers.Tag(
1360 "grantee widget", "label",
1361 text=re.compile(re.escape(grantee.title)))
1362 permissions_field_name = "field.permissions" + suffix
1363 delete_field_name = "field.delete" + suffix
1364 return self._matchesCells(rule_grant_tag, [
1365 soupmatchers.Within(
1366 soupmatchers.Tag("grantee cell", "td"),
1367 grantee_widget_matcher),
1368 soupmatchers.Within(
1369 soupmatchers.Tag("permissions cell", "td"),
1370 soupmatchers.Within(
1371 soupmatchers.Tag(
1372 "permissions widget", "select",
1373 attrs={"name": permissions_field_name}),
1374 soupmatchers.Tag(
1375 "selected permissions option", "option",
1376 attrs={
1377 "selected": "selected",
1378 "value": permissions_token,
1379 },
1380 text=permissions_title))),
1381 soupmatchers.Within(
1382 soupmatchers.Tag("delete cell", "td"),
1383 soupmatchers.Tag(
1384 "delete widget", "input",
1385 attrs={"name": delete_field_name})),
1386 ])
1387
1388 def _matchesNewRuleGrant(self, pattern, permissions_token):
1389 rule_grant_tag = soupmatchers.Tag(
1390 "rule grant row", "tr", attrs={"class": "git-new-rule-grant"})
1391 suffix = "." + encode_form_field_id(pattern)
1392 grantee_field_name = "field.grantee" + suffix
1393 permissions_field_name = "field.permissions" + suffix
1394 return self._matchesCells(rule_grant_tag, [
1395 soupmatchers.Within(
1396 soupmatchers.Tag("grantee cell", "td"),
1397 soupmatchers.Tag(
1398 "grantee widget", "input",
1399 attrs={"name": grantee_field_name})),
1400 soupmatchers.Within(
1401 soupmatchers.Tag("permissions cell", "td"),
1402 soupmatchers.Within(
1403 soupmatchers.Tag(
1404 "permissions widget", "select",
1405 attrs={"name": permissions_field_name}),
1406 soupmatchers.Tag(
1407 "selected permissions option", "option",
1408 attrs={
1409 "selected": "selected",
1410 "value": permissions_token,
1411 }))),
1412 ])
1413
1414 def test_rules_table(self):
1415 repository = self.factory.makeGitRepository()
1416 heads_rule = self.factory.makeGitRule(
1417 repository=repository, ref_pattern="refs/heads/stable/*")
1418 heads_grantee_1 = self.factory.makePerson(
1419 name=self.factory.getUniqueString("person-name-a"))
1420 heads_grantee_2 = self.factory.makePerson(
1421 name=self.factory.getUniqueString("person-name-b"))
1422 self.factory.makeGitRuleGrant(
1423 rule=heads_rule, grantee=heads_grantee_1, can_push=True)
1424 self.factory.makeGitRuleGrant(
1425 rule=heads_rule, grantee=heads_grantee_2, can_force_push=True)
1426 tags_rule = self.factory.makeGitRule(
1427 repository=repository, ref_pattern="refs/tags/*")
1428 self.factory.makeGitRuleGrant(
1429 rule=tags_rule, grantee=GitGranteeType.REPOSITORY_OWNER)
1430 login_person(repository.owner)
1431 view = create_initialized_view(
1432 repository, name="+permissions", principal=repository.owner)
1433 rules_table = find_tag_by_id(view(), "rules-table")
1434 rows = rules_table.findAll("tr", {"class": True})
1435 self.assertThat(rows, MatchesListwise([
1436 self._matchesRule("1", "refs/heads/stable/*", "stable/*"),
1437 self._matchesRuleGrant(
1438 "refs/heads/stable/*", heads_grantee_1, "can_push_existing",
1439 "Can push if the branch already exists"),
1440 self._matchesRuleGrant(
1441 "refs/heads/stable/*", heads_grantee_2, "custom",
1442 "Custom permissions: force-push"),
1443 self._matchesNewRuleGrant("refs/heads/stable/*", "can_push"),
1444 self._matchesNewRule("refs/heads/"),
1445 self._matchesRule("2", "refs/tags/*", "*"),
1446 self._matchesRuleGrant(
1447 "refs/tags/*", GitGranteeType.REPOSITORY_OWNER,
1448 "cannot_create", "Cannot create"),
1449 self._matchesNewRuleGrant("refs/tags/*", "can_create"),
1450 self._matchesNewRule("refs/tags/"),
1451 ]))
1452
1453 def assertHasRules(self, repository, ref_patterns):
1454 self.assertThat(list(repository.rules), MatchesListwise([
1455 MatchesStructure.byEquality(ref_pattern=ref_pattern)
1456 for ref_pattern in ref_patterns
1457 ]))
1458
1459 def assertHasSavedNotification(self, view, repository):
1460 self.assertThat(view.request.response.notifications, MatchesListwise([
1461 MatchesStructure.byEquality(
1462 message="Saved permissions for %s" % repository.identity),
1463 ]))
1464
1465 def test_save_add_rules(self):
1466 repository = self.factory.makeGitRepository()
1467 self.factory.makeGitRule(
1468 repository=repository, ref_pattern="refs/heads/stable/*")
1469 removeSecurityProxy(repository.getActivity()).remove()
1470 login_person(repository.owner)
1471 encoded_heads_prefix = encode_form_field_id("refs/heads/")
1472 encoded_tags_prefix = encode_form_field_id("refs/tags/")
1473 form = {
1474 "field.new-pattern." + encoded_heads_prefix: "*",
1475 "field.new-pattern." + encoded_tags_prefix: "1.0",
1476 "field.actions.save": "Save",
1477 }
1478 view = create_initialized_view(
1479 repository, name="+permissions", form=form,
1480 principal=repository.owner)
1481 self.assertHasRules(
1482 repository,
1483 ["refs/tags/1.0", "refs/heads/stable/*", "refs/heads/*"])
1484 self.assertThat(list(repository.getActivity()), MatchesListwise([
1485 # Adding a tag rule automatically adds a repository owner grant.
1486 MatchesStructure(
1487 changer=Equals(repository.owner),
1488 changee=Is(None),
1489 what_changed=Equals(GitActivityType.GRANT_ADDED),
1490 new_value=MatchesDict({
1491 "changee_type": Equals("Repository owner"),
1492 "ref_pattern": Equals("refs/tags/1.0"),
1493 "can_create": Is(True),
1494 "can_push": Is(False),
1495 "can_force_push": Is(False),
1496 })),
1497 MatchesStructure(
1498 changer=Equals(repository.owner),
1499 what_changed=Equals(GitActivityType.RULE_ADDED),
1500 new_value=MatchesDict({
1501 "ref_pattern": Equals("refs/tags/1.0"),
1502 "position": Equals(0),
1503 })),
1504 MatchesStructure(
1505 changer=Equals(repository.owner),
1506 what_changed=Equals(GitActivityType.RULE_ADDED),
1507 new_value=MatchesDict({
1508 "ref_pattern": Equals("refs/heads/*"),
1509 # Initially inserted at 1, although refs/tags/1.0 was
1510 # later inserted before it.
1511 "position": Equals(1),
1512 })),
1513 ]))
1514 self.assertHasSavedNotification(view, repository)
1515
1516 def test_save_add_duplicate_rule(self):
1517 repository = self.factory.makeGitRepository()
1518 self.factory.makeGitRule(
1519 repository=repository, ref_pattern="refs/heads/stable/*")
1520 transaction.commit()
1521 login_person(repository.owner)
1522 encoded_heads_prefix = encode_form_field_id("refs/heads/")
1523 form = {
1524 "field.new-pattern." + encoded_heads_prefix: "stable/*",
1525 "field.actions.save": "Save",
1526 }
1527 view = create_initialized_view(
1528 repository, name="+permissions", form=form,
1529 principal=repository.owner)
1530 self.assertThat(view.errors, MatchesListwise([
1531 MatchesStructure(
1532 field_name=Equals("new-pattern." + encoded_heads_prefix),
1533 errors=MatchesStructure.byEquality(
1534 args=("stable/* is already in use by another rule",))),
1535 ]))
1536 self.assertHasRules(repository, ["refs/heads/stable/*"])
1537
1538 def test_save_move_rule(self):
1539 repository = self.factory.makeGitRepository()
1540 self.factory.makeGitRule(
1541 repository=repository, ref_pattern="refs/heads/stable/*")
1542 self.factory.makeGitRule(
1543 repository=repository, ref_pattern="refs/heads/*/next")
1544 encoded_patterns = [
1545 encode_form_field_id(rule.ref_pattern)
1546 for rule in repository.rules]
1547 removeSecurityProxy(repository.getActivity()).remove()
1548 login_person(repository.owner)
1549 # Positions are 1-based in the UI.
1550 form = {
1551 "field.position." + encoded_patterns[0]: "2",
1552 "field.pattern." + encoded_patterns[0]: "stable/*",
1553 "field.position." + encoded_patterns[1]: "1",
1554 "field.pattern." + encoded_patterns[1]: "*/more-next",
1555 "field.actions.save": "Save",
1556 }
1557 view = create_initialized_view(
1558 repository, name="+permissions", form=form,
1559 principal=repository.owner)
1560 self.assertHasRules(
1561 repository, ["refs/heads/*/more-next", "refs/heads/stable/*"])
1562 self.assertThat(list(repository.getActivity()), MatchesListwise([
1563 MatchesStructure(
1564 changer=Equals(repository.owner),
1565 what_changed=Equals(GitActivityType.RULE_CHANGED),
1566 old_value=MatchesDict({
1567 "ref_pattern": Equals("refs/heads/*/next"),
1568 "position": Equals(0),
1569 }),
1570 new_value=MatchesDict({
1571 "ref_pattern": Equals("refs/heads/*/more-next"),
1572 "position": Equals(0),
1573 })),
1574 # Only one rule is recorded as moving; the other is already in
1575 # its new position by the time it's processed.
1576 MatchesStructure(
1577 changer=Equals(repository.owner),
1578 what_changed=Equals(GitActivityType.RULE_MOVED),
1579 old_value=MatchesDict({
1580 "ref_pattern": Equals("refs/heads/stable/*"),
1581 "position": Equals(0),
1582 }),
1583 new_value=MatchesDict({
1584 "ref_pattern": Equals("refs/heads/stable/*"),
1585 "position": Equals(1),
1586 })),
1587 ]))
1588 self.assertHasSavedNotification(view, repository)
1589
1590 def test_save_change_grants(self):
1591 repository = self.factory.makeGitRepository()
1592 stable_rule = self.factory.makeGitRule(
1593 repository=repository, ref_pattern="refs/heads/stable/*")
1594 next_rule = self.factory.makeGitRule(
1595 repository=repository, ref_pattern="refs/heads/*/next")
1596 grantees = [self.factory.makePerson() for _ in range(3)]
1597 self.factory.makeGitRuleGrant(
1598 rule=stable_rule, grantee=GitGranteeType.REPOSITORY_OWNER,
1599 can_create=True)
1600 self.factory.makeGitRuleGrant(
1601 rule=stable_rule,
1602 grantee=grantees[0], can_create=True, can_push=True)
1603 self.factory.makeGitRuleGrant(
1604 rule=next_rule, grantee=grantees[1],
1605 can_create=True, can_push=True, can_force_push=True)
1606 encoded_patterns = [
1607 encode_form_field_id(rule.ref_pattern)
1608 for rule in repository.rules]
1609 removeSecurityProxy(repository.getActivity()).remove()
1610 login_person(repository.owner)
1611 form = {
1612 "field.permissions.%s._repository_owner" % encoded_patterns[0]: (
1613 "can_push"),
1614 "field.permissions.%s.%s" % (
1615 encoded_patterns[0], grantees[0].id): "can_push",
1616 "field.delete.%s.%s" % (encoded_patterns[0], grantees[0].id): "on",
1617 "field.grantee.%s" % encoded_patterns[1]: "person",
1618 "field.grantee.%s.person" % encoded_patterns[1]: grantees[2].name,
1619 "field.permissions.%s" % encoded_patterns[1]: "can_push_existing",
1620 "field.actions.save": "Save",
1621 }
1622 view = create_initialized_view(
1623 repository, name="+permissions", form=form,
1624 principal=repository.owner)
1625 self.assertHasRules(
1626 repository, ["refs/heads/stable/*", "refs/heads/*/next"])
1627 self.assertThat(stable_rule.grants, MatchesSetwise(
1628 MatchesStructure.byEquality(
1629 grantee_type=GitGranteeType.REPOSITORY_OWNER,
1630 can_create=True, can_push=True, can_force_push=False)))
1631 self.assertThat(next_rule.grants, MatchesSetwise(
1632 MatchesStructure.byEquality(
1633 grantee=grantees[1],
1634 can_create=True, can_push=True, can_force_push=True),
1635 MatchesStructure.byEquality(
1636 grantee=grantees[2],
1637 can_create=False, can_push=True, can_force_push=False)))
1638 self.assertThat(repository.getActivity(), MatchesSetwise(
1639 MatchesStructure(
1640 changer=Equals(repository.owner),
1641 changee=Is(None),
1642 what_changed=Equals(GitActivityType.GRANT_CHANGED),
1643 old_value=Equals({
1644 "changee_type": "Repository owner",
1645 "ref_pattern": "refs/heads/stable/*",
1646 "can_create": True,
1647 "can_push": False,
1648 "can_force_push": False,
1649 }),
1650 new_value=Equals({
1651 "changee_type": "Repository owner",
1652 "ref_pattern": "refs/heads/stable/*",
1653 "can_create": True,
1654 "can_push": True,
1655 "can_force_push": False,
1656 })),
1657 MatchesStructure(
1658 changer=Equals(repository.owner),
1659 changee=Equals(grantees[0]),
1660 what_changed=Equals(GitActivityType.GRANT_REMOVED),
1661 old_value=Equals({
1662 "changee_type": "Person",
1663 "ref_pattern": "refs/heads/stable/*",
1664 "can_create": True,
1665 "can_push": True,
1666 "can_force_push": False,
1667 })),
1668 MatchesStructure(
1669 changer=Equals(repository.owner),
1670 changee=Equals(grantees[2]),
1671 what_changed=Equals(GitActivityType.GRANT_ADDED),
1672 new_value=Equals({
1673 "changee_type": "Person",
1674 "ref_pattern": "refs/heads/*/next",
1675 "can_create": False,
1676 "can_push": True,
1677 "can_force_push": False,
1678 }))))
1679 self.assertHasSavedNotification(view, repository)
1680
1681 def test_save_delete_rule(self):
1682 repository = self.factory.makeGitRepository()
1683 self.factory.makeGitRule(
1684 repository=repository, ref_pattern="refs/heads/stable/*")
1685 self.factory.makeGitRule(
1686 repository=repository, ref_pattern="refs/heads/*")
1687 removeSecurityProxy(repository.getActivity()).remove()
1688 login_person(repository.owner)
1689 encoded_pattern = encode_form_field_id("refs/heads/*")
1690 form = {
1691 "field.pattern." + encoded_pattern: "*",
1692 "field.delete." + encoded_pattern: "on",
1693 "field.actions.save": "Save",
1694 }
1695 view = create_initialized_view(
1696 repository, name="+permissions", form=form,
1697 principal=repository.owner)
1698 self.assertHasRules(repository, ["refs/heads/stable/*"])
1699 self.assertThat(list(repository.getActivity()), MatchesListwise([
1700 MatchesStructure(
1701 changer=Equals(repository.owner),
1702 what_changed=Equals(GitActivityType.RULE_REMOVED),
1703 old_value=MatchesDict({
1704 "ref_pattern": Equals("refs/heads/*"),
1705 "position": Equals(1),
1706 })),
1707 ]))
1708 self.assertHasSavedNotification(view, repository)
1709
1710
1100class TestGitRepositoryDeletionView(BrowserTestCase):1711class TestGitRepositoryDeletionView(BrowserTestCase):
11011712
1102 layer = DatabaseFunctionalLayer1713 layer = DatabaseFunctionalLayer
11031714
=== added file 'lib/lp/code/browser/widgets/gitgrantee.py'
--- lib/lp/code/browser/widgets/gitgrantee.py 1970-01-01 00:00:00 +0000
+++ lib/lp/code/browser/widgets/gitgrantee.py 2018-11-09 22:50:10 +0000
@@ -0,0 +1,253 @@
1# Copyright 2018 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4from __future__ import absolute_import, print_function, unicode_literals
5
6__metaclass__ = type
7__all__ = [
8 'GitGranteeDisplayWidget',
9 'GitGranteeField',
10 'GitGranteeWidget',
11 ]
12
13from lazr.enum import DBItem
14from lazr.restful.fields import Reference
15from z3c.ptcompat import ViewPageTemplateFile
16from zope.formlib.interfaces import (
17 ConversionError,
18 IDisplayWidget,
19 IInputWidget,
20 InputErrors,
21 MissingInputError,
22 WidgetInputError,
23 )
24from zope.formlib.utility import setUpWidget
25from zope.formlib.widget import (
26 BrowserWidget,
27 CustomWidgetFactory,
28 DisplayWidget,
29 InputWidget,
30 renderElement,
31 )
32from zope.interface import implementer
33from zope.schema import (
34 Choice,
35 Field,
36 )
37from zope.schema.interfaces import IField
38from zope.schema.vocabulary import getVocabularyRegistry
39from zope.security.proxy import isinstance as zope_isinstance
40
41from lp import _
42from lp.app.errors import UnexpectedFormData
43from lp.app.validators import LaunchpadValidationError
44from lp.app.widgets.popup import PersonPickerWidget
45from lp.code.enums import GitGranteeType
46from lp.code.interfaces.gitrule import IGitRule
47from lp.registry.interfaces.person import IPerson
48from lp.services.webapp.escaping import structured
49from lp.services.webapp.interfaces import (
50 IAlwaysSubmittedWidget,
51 IMultiLineWidgetLayout,
52 )
53from lp.services.webapp.publisher import canonical_url
54
55
56class IGitGranteeField(IField):
57 """An interface for a Git access grantee field."""
58
59 rule = Reference(
60 title=_("Rule"), required=True, readonly=True, schema=IGitRule,
61 description=_("The rule that this grantee is for."))
62
63
64@implementer(IGitGranteeField)
65class GitGranteeField(Field):
66 """A field that holds a Git access grantee."""
67
68 def __init__(self, rule, *args, **kwargs):
69 super(GitGranteeField, self).__init__(*args, **kwargs)
70 self.rule = rule
71
72 def constraint(self, value):
73 """See `IField`."""
74 if zope_isinstance(value, DBItem) and value.enum == GitGranteeType:
75 return value != GitGranteeType.PERSON
76 else:
77 return value in getVocabularyRegistry().get(
78 None, "ValidPersonOrTeam")
79
80
81@implementer(IDisplayWidget)
82class GitGranteePersonDisplayWidget(BrowserWidget):
83
84 def __init__(self, context, vocabulary, request):
85 super(GitGranteePersonDisplayWidget, self).__init__(context, request)
86
87 def __call__(self):
88 if self._renderedValueSet():
89 grantee = self._data
90 person_img = renderElement(
91 "img", style="padding-bottom: 2px", src="/@@/person", alt="")
92 return renderElement(
93 "a", href=canonical_url(grantee),
94 contents="%s %s" % (
95 person_img,
96 structured("%s", grantee.display_name).escapedtext))
97 else:
98 return ""
99
100
101@implementer(IMultiLineWidgetLayout)
102class GitGranteeWidgetBase(BrowserWidget):
103
104 template = ViewPageTemplateFile("templates/gitgrantee.pt")
105 default_option = "person"
106 _widgets_set_up = False
107
108 def setUpSubWidgets(self):
109 if self._widgets_set_up:
110 return
111 fields = [
112 Choice(
113 __name__="person", title=u"Person",
114 required=False, vocabulary="ValidPersonOrTeam"),
115 ]
116 if self._read_only:
117 self.person_widget = CustomWidgetFactory(
118 GitGranteePersonDisplayWidget)
119 else:
120 self.person_widget = CustomWidgetFactory(
121 PersonPickerWidget,
122 # XXX cjwatson 2018-10-18: This is a little unfortunate, but
123 # otherwise there's no spacing at all between the
124 # (deliberately unlabelled) radio button and the text box.
125 style="margin-left: 4px;")
126 for field in fields:
127 setUpWidget(
128 self, field.__name__, field, self._sub_widget_interface,
129 prefix=self.name)
130 self._widgets_set_up = True
131
132 def setUpOptions(self):
133 """Set up options to be rendered."""
134 self.options = {}
135 for option in ("repository_owner", "person"):
136 attributes = {
137 "type": "radio", "name": self.name, "value": option,
138 "id": "%s.option.%s" % (self.name, option),
139 # XXX cjwatson 2018-10-18: Ugly, but it's worse without
140 # this, especially in a permissions table where this widget
141 # is normally used.
142 "style": "margin-left: 0;",
143 }
144 if self.request.form_ng.getOne(
145 self.name, self.default_option) == option:
146 attributes["checked"] = "checked"
147 if self._read_only:
148 attributes["disabled"] = "disabled"
149 self.options[option] = renderElement("input", **attributes)
150
151 @property
152 def show_options(self):
153 return {
154 option: not self._read_only or self.default_option == option
155 for option in ("repository_owner", "person")}
156
157 def setRenderedValue(self, value):
158 """See `IWidget`."""
159 self.setUpSubWidgets()
160 if value == GitGranteeType.REPOSITORY_OWNER:
161 self.default_option = "repository_owner"
162 return
163 elif value is None or IPerson.providedBy(value):
164 self.default_option = "person"
165 self.person_widget.setRenderedValue(value)
166 return
167 else:
168 raise AssertionError("Not a valid value: %r" % value)
169
170 def __call__(self):
171 """See `zope.formlib.interfaces.IBrowserWidget`."""
172 self.setUpSubWidgets()
173 self.setUpOptions()
174 return self.template()
175
176
177@implementer(IDisplayWidget)
178class GitGranteeDisplayWidget(GitGranteeWidgetBase, DisplayWidget):
179 """Widget for displaying a Git access grantee."""
180
181 _sub_widget_interface = IDisplayWidget
182 _read_only = True
183
184
185@implementer(IAlwaysSubmittedWidget, IInputWidget)
186class GitGranteeWidget(GitGranteeWidgetBase, InputWidget):
187 """Widget for selecting a Git access grantee."""
188
189 _sub_widget_interface = IInputWidget
190 _read_only = False
191 _widgets_set_up = False
192
193 @property
194 def show_options(self):
195 show_options = super(GitGranteeWidget, self).show_options
196 # Hide options that indicate unique grantee_types (e.g.
197 # repository_owner) if they already exist for the context rule.
198 if (show_options["repository_owner"] and
199 not self.context.rule.repository.findRuleGrantsByGrantee(
200 GitGranteeType.REPOSITORY_OWNER,
201 ref_pattern=self.context.rule.ref_pattern,
202 exact_grantee=True).is_empty()):
203 show_options["repository_owner"] = False
204 return show_options
205
206 def hasInput(self):
207 self.setUpSubWidgets()
208 form_value = self.request.form_ng.getOne(self.name)
209 if form_value is None:
210 return False
211 return form_value != "person" or self.person_widget.hasInput()
212
213 def hasValidInput(self):
214 """See `zope.formlib.interfaces.IInputWidget`."""
215 try:
216 self.getInputValue()
217 return True
218 except (InputErrors, UnexpectedFormData):
219 return False
220
221 def getInputValue(self):
222 """See `zope.formlib.interfaces.IInputWidget`."""
223 self.setUpSubWidgets()
224 form_value = self.request.form_ng.getOne(self.name)
225 if form_value == "repository_owner":
226 return GitGranteeType.REPOSITORY_OWNER
227 elif form_value == "person":
228 try:
229 return self.person_widget.getInputValue()
230 except MissingInputError:
231 raise WidgetInputError(
232 self.name, self.label,
233 LaunchpadValidationError(
234 "Please enter a person or team name"))
235 except ConversionError:
236 entered_name = self.request.form_ng.getOne(
237 "%s.person" % self.name)
238 raise WidgetInputError(
239 self.name, self.label,
240 LaunchpadValidationError(
241 "There is no person or team named '%s' registered in "
242 "Launchpad" % entered_name))
243 else:
244 raise UnexpectedFormData("No valid option was selected.")
245
246 def error(self):
247 """See `zope.formlib.interfaces.IBrowserWidget`."""
248 try:
249 if self.hasInput():
250 self.getInputValue()
251 except InputErrors as error:
252 self._error = error
253 return super(GitGranteeWidget, self).error()
0254
=== added file 'lib/lp/code/browser/widgets/templates/gitgrantee.pt'
--- lib/lp/code/browser/widgets/templates/gitgrantee.pt 1970-01-01 00:00:00 +0000
+++ lib/lp/code/browser/widgets/templates/gitgrantee.pt 2018-11-09 22:50:10 +0000
@@ -0,0 +1,27 @@
1<table>
2 <tr tal:condition="view/show_options/repository_owner">
3 <td colspan="2">
4 <label>
5 <input
6 type="radio" value="repository_owner"
7 tal:condition="not: context/readonly"
8 tal:replace="structure view/options/repository_owner" />
9 Repository owner
10 </label>
11 </td>
12 </tr>
13
14 <tr tal:condition="view/show_options/person">
15 <td>
16 <label>
17 <input
18 type="radio" value="person"
19 tal:condition="not: context/readonly"
20 tal:replace="structure view/options/person" />
21 </label>
22 </td>
23 <td>
24 <tal:person replace="structure view/person_widget" />
25 </td>
26 </tr>
27</table>
028
=== added file 'lib/lp/code/browser/widgets/tests/test_gitgrantee.py'
--- lib/lp/code/browser/widgets/tests/test_gitgrantee.py 1970-01-01 00:00:00 +0000
+++ lib/lp/code/browser/widgets/tests/test_gitgrantee.py 2018-11-09 22:50:10 +0000
@@ -0,0 +1,305 @@
1# Copyright 2018 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4from __future__ import absolute_import, print_function, unicode_literals
5
6__metaclass__ = type
7
8import re
9
10from zope.formlib.interfaces import (
11 IBrowserWidget,
12 IDisplayWidget,
13 IInputWidget,
14 WidgetInputError,
15 )
16
17from lp.app.validators import LaunchpadValidationError
18from lp.code.browser.widgets.gitgrantee import (
19 GitGranteeDisplayWidget,
20 GitGranteeField,
21 GitGranteeWidget,
22 )
23from lp.code.enums import GitGranteeType
24from lp.registry.vocabularies import ValidPersonOrTeamVocabulary
25from lp.services.beautifulsoup import BeautifulSoup
26from lp.services.webapp.escaping import html_escape
27from lp.services.webapp.publisher import canonical_url
28from lp.services.webapp.servers import LaunchpadTestRequest
29from lp.testing import (
30 TestCaseWithFactory,
31 verifyObject,
32 )
33from lp.testing.layers import DatabaseFunctionalLayer
34
35
36class TestGitGranteeWidgetBase:
37
38 layer = DatabaseFunctionalLayer
39
40 def setUp(self):
41 super(TestGitGranteeWidgetBase, self).setUp()
42 [self.ref] = self.factory.makeGitRefs()
43 self.rule = self.factory.makeGitRule(
44 repository=self.ref.repository, ref_pattern=self.ref.path)
45 self.field = GitGranteeField(__name__="grantee", rule=self.rule)
46 self.request = LaunchpadTestRequest()
47 self.widget = self.widget_factory(self.field, self.request)
48
49 def test_implements(self):
50 self.assertTrue(verifyObject(IBrowserWidget, self.widget))
51 self.assertTrue(
52 verifyObject(self.expected_widget_interface, self.widget))
53
54 def test_template(self):
55 # The render template is setup.
56 self.assertTrue(
57 self.widget.template.filename.endswith("gitgrantee.pt"),
58 "Template was not set up.")
59
60 def test_default_option(self):
61 # The person field is the default option.
62 self.assertEqual("person", self.widget.default_option)
63
64 def test_setUpSubWidgets_first_call(self):
65 # The subwidget is set up and a flag is set.
66 self.widget.setUpSubWidgets()
67 self.assertTrue(self.widget._widgets_set_up)
68 self.assertIsInstance(
69 self.widget.person_widget.context.vocabulary,
70 ValidPersonOrTeamVocabulary)
71
72 def test_setUpSubWidgets_second_call(self):
73 # The setUpSubWidgets method exits early if a flag is set to
74 # indicate that the subwidget was set up.
75 self.widget._widgets_set_up = True
76 self.widget.setUpSubWidgets()
77 self.assertIsNone(getattr(self.widget, "person_widget", None))
78
79 def test_setUpOptions_default_person_checked(self):
80 # The radio button options are composed of the setup widgets with
81 # the person widget set as the default.
82 self.widget.setUpSubWidgets()
83 self.widget.setUpOptions()
84 self.assertEqual(
85 '<input class="radioType" style="margin-left: 0;" ' +
86 self.expected_disabled_attr +
87 'id="field.grantee.option.repository_owner" name="field.grantee" '
88 'type="radio" value="repository_owner" />',
89 self.widget.options["repository_owner"])
90 self.assertEqual(
91 '<input class="radioType" style="margin-left: 0;" ' +
92 'checked="checked" ' + self.expected_disabled_attr +
93 'id="field.grantee.option.person" name="field.grantee" '
94 'type="radio" value="person" />',
95 self.widget.options["person"])
96
97 def test_setUpOptions_repository_owner_checked(self):
98 # The repository owner radio button is selected when the form is
99 # submitted when the grantee field's value is 'repository_owner'.
100 form = {"field.grantee": "repository_owner"}
101 self.widget.request = LaunchpadTestRequest(form=form)
102 self.widget.setUpSubWidgets()
103 self.widget.setUpOptions()
104 self.assertEqual(
105 '<input class="radioType" style="margin-left: 0;" '
106 'checked="checked" ' + self.expected_disabled_attr +
107 'id="field.grantee.option.repository_owner" name="field.grantee" '
108 'type="radio" value="repository_owner" />',
109 self.widget.options["repository_owner"])
110 self.assertEqual(
111 '<input class="radioType" style="margin-left: 0;" ' +
112 self.expected_disabled_attr +
113 'id="field.grantee.option.person" name="field.grantee" '
114 'type="radio" value="person" />',
115 self.widget.options["person"])
116
117 def test_setUpOptions_person_checked(self):
118 # The person radio button is selected when the form is submitted
119 # when the grantee field's value is 'person'.
120 form = {"field.grantee": "person"}
121 self.widget.request = LaunchpadTestRequest(form=form)
122 self.widget.setUpSubWidgets()
123 self.widget.setUpOptions()
124 self.assertEqual(
125 '<input class="radioType" style="margin-left: 0;" ' +
126 self.expected_disabled_attr +
127 'id="field.grantee.option.repository_owner" name="field.grantee" '
128 'type="radio" value="repository_owner" />',
129 self.widget.options["repository_owner"])
130 self.assertEqual(
131 '<input class="radioType" style="margin-left: 0;" ' +
132 'checked="checked" ' + self.expected_disabled_attr +
133 'id="field.grantee.option.person" name="field.grantee" '
134 'type="radio" value="person" />',
135 self.widget.options["person"])
136
137 def test_setRenderedValue_repository_owner(self):
138 # Passing GitGranteeType.REPOSITORY_OWNER will set the widget's
139 # render state to "repository_owner".
140 self.widget.setUpSubWidgets()
141 self.widget.setRenderedValue(GitGranteeType.REPOSITORY_OWNER)
142 self.assertEqual("repository_owner", self.widget.default_option)
143
144 def test_setRenderedValue_person(self):
145 # Passing a person will set the widget's render state to "person".
146 self.widget.setUpSubWidgets()
147 person = self.factory.makePerson()
148 self.widget.setRenderedValue(person)
149 self.assertEqual("person", self.widget.default_option)
150 self.assertEqual(person, self.widget.person_widget._data)
151
152 def test_call(self):
153 # The __call__ method sets up the widgets and the options.
154 markup = self.widget()
155 self.assertIsNotNone(self.widget.person_widget)
156 self.assertIn("repository_owner", self.widget.options)
157 self.assertIn("person", self.widget.options)
158 soup = BeautifulSoup(markup)
159 fields = soup.findAll(["input", "select"], {"id": re.compile(".*")})
160 ids = [field["id"] for field in fields]
161 self.assertContentEqual(self.expected_ids, ids)
162
163
164class TestGitGranteeDisplayWidget(
165 TestGitGranteeWidgetBase, TestCaseWithFactory):
166 """Test the GitGranteeDisplayWidget class."""
167
168 widget_factory = GitGranteeDisplayWidget
169 expected_widget_interface = IDisplayWidget
170 expected_disabled_attr = 'disabled="disabled" '
171 expected_ids = ["field.grantee.option.person"]
172
173 def test_setRenderedValue_person_display_widget(self):
174 # If the widget's render state is "person", a customised display
175 # widget is used.
176 self.widget.setUpSubWidgets()
177 person = self.factory.makePerson()
178 self.widget.setRenderedValue(person)
179 person_url = canonical_url(person)
180 self.assertEqual(
181 '<a href="%s">'
182 '<img style="padding-bottom: 2px" alt="" src="/@@/person" /> '
183 '%s</a>' % (person_url, html_escape(person.display_name)),
184 self.widget.person_widget())
185
186
187class TestGitGranteeWidget(TestGitGranteeWidgetBase, TestCaseWithFactory):
188 """Test the GitGranteeWidget class."""
189
190 widget_factory = GitGranteeWidget
191 expected_widget_interface = IInputWidget
192 expected_disabled_attr = ""
193 expected_ids = [
194 "field.grantee.option.repository_owner",
195 "field.grantee.option.person",
196 "field.grantee.person",
197 ]
198
199 def setUp(self):
200 super(TestGitGranteeWidget, self).setUp()
201 self.person = self.factory.makePerson()
202
203 def test_show_options_repository_owner_grant_already_exists(self):
204 # If the rule already has a repository owner grant, the input widget
205 # doesn't offer that option.
206 self.factory.makeGitRuleGrant(
207 rule=self.rule, grantee=GitGranteeType.REPOSITORY_OWNER)
208 self.assertEqual(
209 {"repository_owner": False, "person": True},
210 self.widget.show_options)
211
212 def test_show_options_repository_owner_grant_does_not_exist(self):
213 # If the rule doesn't have a repository owner grant, the input
214 # widget offers that option.
215 self.factory.makeGitRuleGrant(rule=self.rule)
216 self.assertEqual(
217 {"repository_owner": True, "person": True},
218 self.widget.show_options)
219
220 @property
221 def form(self):
222 return {
223 "field.grantee": "person",
224 "field.grantee.person": self.person.name,
225 }
226
227 def test_hasInput_not_in_form(self):
228 # hasInput is false when the widget's name is not in the form data.
229 self.widget.request = LaunchpadTestRequest(form={})
230 self.assertEqual("field.grantee", self.widget.name)
231 self.assertFalse(self.widget.hasInput())
232
233 def test_hasInput_no_person(self):
234 # hasInput is false when the person radio button is selected and the
235 # person widget's name is not in the form data.
236 self.widget.request = LaunchpadTestRequest(
237 form={"field.grantee": "person"})
238 self.assertEqual("field.grantee", self.widget.name)
239 self.assertFalse(self.widget.hasInput())
240
241 def test_hasInput_repository_owner(self):
242 # hasInput is true when the repository owner radio button is selected.
243 self.widget.request = LaunchpadTestRequest(
244 form={"field.grantee": "repository_owner"})
245 self.assertEqual("field.grantee", self.widget.name)
246 self.assertTrue(self.widget.hasInput())
247
248 def test_hasInput_person(self):
249 # hasInput is true when the person radio button is selected and the
250 # person widget's name is in the form data.
251 self.widget.request = LaunchpadTestRequest(form=self.form)
252 self.assertEqual("field.grantee", self.widget.name)
253 self.assertTrue(self.widget.hasInput())
254
255 def test_hasValidInput_true(self):
256 # The field input is valid when all submitted parts are valid.
257 self.widget.request = LaunchpadTestRequest(form=self.form)
258 self.assertTrue(self.widget.hasValidInput())
259
260 def test_hasValidInput_false(self):
261 # The field input is invalid if any of the submitted parts are invalid.
262 form = self.form
263 form["field.grantee.person"] = "non-existent"
264 self.widget.request = LaunchpadTestRequest(form=form)
265 self.assertFalse(self.widget.hasValidInput())
266
267 def test_getInputValue_repository_owner(self):
268 # The field value is GitGranteeType.REPOSITORY_OWNER when the
269 # repository owner radio button is selected.
270 form = self.form
271 form["field.grantee"] = "repository_owner"
272 self.widget.request = LaunchpadTestRequest(form=form)
273 self.assertEqual(
274 GitGranteeType.REPOSITORY_OWNER, self.widget.getInputValue())
275
276 def test_getInputValue_person(self):
277 # The field value is the person when the person radio button is
278 # selected and the person sub field is valid.
279 form = self.form
280 form["field.grantee"] = "person"
281 self.widget.request = LaunchpadTestRequest(form=form)
282 self.assertEqual(self.person, self.widget.getInputValue())
283
284 def test_getInputValue_person_missing(self):
285 # An error is raised when the person field is missing.
286 form = self.form
287 form["field.grantee"] = "person"
288 del form["field.grantee.person"]
289 self.widget.request = LaunchpadTestRequest(form=form)
290 message = "Please enter a person or team name"
291 e = self.assertRaises(WidgetInputError, self.widget.getInputValue)
292 self.assertEqual(LaunchpadValidationError(message), e.errors)
293
294 def test_getInputValue_person_invalid(self):
295 # An error is raised when the person is not valid.
296 form = self.form
297 form["field.grantee"] = "person"
298 form["field.grantee.person"] = "non-existent"
299 self.widget.request = LaunchpadTestRequest(form=form)
300 message = (
301 "There is no person or team named 'non-existent' registered in "
302 "Launchpad")
303 e = self.assertRaises(WidgetInputError, self.widget.getInputValue)
304 self.assertEqual(LaunchpadValidationError(message), e.errors)
305 self.assertEqual(html_escape(message), self.widget.error())
0306
=== modified file 'lib/lp/code/interfaces/gitrepository.py'
--- lib/lp/code/interfaces/gitrepository.py 2018-11-09 22:06:43 +0000
+++ lib/lp/code/interfaces/gitrepository.py 2018-11-09 22:50:10 +0000
@@ -766,12 +766,17 @@
766 :param user: The `IPerson` who is moving the rule.766 :param user: The `IPerson` who is moving the rule.
767 """767 """
768768
769 def findRuleGrantsByGrantee(grantee):769 def findRuleGrantsByGrantee(grantee, exact_grantee=False,
770 ref_pattern=None):
770 """Find the grants for a grantee applied to this repository.771 """Find the grants for a grantee applied to this repository.
771772
772 :param grantee: The `IPerson` to search for, or an item of773 :param grantee: The `IPerson` to search for, or an item of
773 `GitGranteeType` other than `GitGranteeType.PERSON` to search774 `GitGranteeType` other than `GitGranteeType.PERSON` to search
774 for some other kind of entity.775 for some other kind of entity.
776 :param exact_grantee: If True, match `grantee` exactly; if False
777 (the default), also accept teams of which `grantee` is a member.
778 :param ref_pattern: If not None, only return grants for rules with
779 this ref_pattern.
775 """780 """
776781
777 @export_read_operation()782 @export_read_operation()
778783
=== modified file 'lib/lp/code/interfaces/gitrule.py'
--- lib/lp/code/interfaces/gitrule.py 2018-10-23 16:17:39 +0000
+++ lib/lp/code/interfaces/gitrule.py 2018-11-09 22:50:10 +0000
@@ -158,6 +158,10 @@
158 vocabulary="ValidPersonOrTeam",158 vocabulary="ValidPersonOrTeam",
159 description=_("The person being granted access."))159 description=_("The person being granted access."))
160160
161 combined_grantee = Attribute(
162 "The overall grantee of this grant: either a `GitGranteeType` (other "
163 "than `PERSON`) or an `IPerson`.")
164
161 date_created = Datetime(165 date_created = Datetime(
162 title=_("Date created"), required=True, readonly=True,166 title=_("Date created"), required=True, readonly=True,
163 description=_("The time when this grant was created."))167 description=_("The time when this grant was created."))
164168
=== modified file 'lib/lp/code/model/gitrepository.py'
--- lib/lp/code/model/gitrepository.py 2018-11-09 22:06:43 +0000
+++ lib/lp/code/model/gitrepository.py 2018-11-09 22:50:10 +0000
@@ -1216,7 +1216,8 @@
1216 return Store.of(self).find(1216 return Store.of(self).find(
1217 GitRuleGrant, GitRuleGrant.repository_id == self.id)1217 GitRuleGrant, GitRuleGrant.repository_id == self.id)
12181218
1219 def findRuleGrantsByGrantee(self, grantee):1219 def findRuleGrantsByGrantee(self, grantee, exact_grantee=False,
1220 ref_pattern=None):
1220 """See `IGitRepository`."""1221 """See `IGitRepository`."""
1221 if isinstance(grantee, DBItem) and grantee.enum == GitGranteeType:1222 if isinstance(grantee, DBItem) and grantee.enum == GitGranteeType:
1222 if grantee == GitGranteeType.PERSON:1223 if grantee == GitGranteeType.PERSON:
@@ -1224,12 +1225,22 @@
1224 "grantee may not be GitGranteeType.PERSON; pass a person "1225 "grantee may not be GitGranteeType.PERSON; pass a person "
1225 "object instead")1226 "object instead")
1226 clauses = [GitRuleGrant.grantee_type == grantee]1227 clauses = [GitRuleGrant.grantee_type == grantee]
1228 elif exact_grantee:
1229 clauses = [
1230 GitRuleGrant.grantee_type == GitGranteeType.PERSON,
1231 GitRuleGrant.grantee == grantee,
1232 ]
1227 else:1233 else:
1228 clauses = [1234 clauses = [
1229 GitRuleGrant.grantee_type == GitGranteeType.PERSON,1235 GitRuleGrant.grantee_type == GitGranteeType.PERSON,
1230 TeamParticipation.person == grantee,1236 TeamParticipation.person == grantee,
1231 GitRuleGrant.grantee == TeamParticipation.teamID1237 GitRuleGrant.grantee == TeamParticipation.teamID
1232 ]1238 ]
1239 if ref_pattern is not None:
1240 clauses.extend([
1241 GitRuleGrant.rule_id == GitRule.id,
1242 GitRule.ref_pattern == ref_pattern,
1243 ])
1233 return self.grants.find(*clauses).config(distinct=True)1244 return self.grants.find(*clauses).config(distinct=True)
12341245
1235 def getRules(self):1246 def getRules(self):
12361247
=== modified file 'lib/lp/code/model/gitrule.py'
--- lib/lp/code/model/gitrule.py 2018-10-29 14:27:36 +0000
+++ lib/lp/code/model/gitrule.py 2018-11-09 22:50:10 +0000
@@ -310,6 +310,13 @@
310 self.date_created = date_created310 self.date_created = date_created
311 self.date_last_modified = date_created311 self.date_last_modified = date_created
312312
313 @property
314 def combined_grantee(self):
315 if self.grantee_type == GitGranteeType.PERSON:
316 return self.grantee
317 else:
318 return self.grantee_type
319
313 def __repr__(self):320 def __repr__(self):
314 if self.grantee_type == GitGranteeType.PERSON:321 if self.grantee_type == GitGranteeType.PERSON:
315 grantee_name = "~%s" % self.grantee.name322 grantee_name = "~%s" % self.grantee.name
316323
=== modified file 'lib/lp/code/model/tests/test_gitrepository.py'
--- lib/lp/code/model/tests/test_gitrepository.py 2018-11-09 22:06:43 +0000
+++ lib/lp/code/model/tests/test_gitrepository.py 2018-11-09 22:50:10 +0000
@@ -265,7 +265,7 @@
265 grant = self.factory.makeGitRuleGrant(265 grant = self.factory.makeGitRuleGrant(
266 rule=rule, grantee=requester, can_push=True, can_create=True)266 rule=rule, grantee=requester, can_push=True, can_create=True)
267267
268 results = repository.findRuleGrantsByGrantee(requester)268 results = repository.findRuleGrantsByGrantee(member)
269 self.assertEqual([grant], list(results))269 self.assertEqual([grant], list(results))
270270
271 def test_findRuleGrantsByGrantee_team_in_team(self):271 def test_findRuleGrantsByGrantee_team_in_team(self):
@@ -357,6 +357,116 @@
357 results = repository.findRuleGrantsByGrantee(requester)357 results = repository.findRuleGrantsByGrantee(requester)
358 self.assertEqual([owner_grant], list(results))358 self.assertEqual([owner_grant], list(results))
359359
360 def test_findRuleGrantsByGrantee_ref_pattern(self):
361 requester = self.factory.makePerson()
362 repository = removeSecurityProxy(
363 self.factory.makeGitRepository(owner=requester))
364 [ref] = self.factory.makeGitRefs(repository=repository)
365
366 exact_grant = self.factory.makeGitRuleGrant(
367 repository=repository, ref_pattern=ref.path, grantee=requester)
368 self.factory.makeGitRuleGrant(
369 repository=repository, ref_pattern="refs/heads/*",
370 grantee=requester)
371
372 results = repository.findRuleGrantsByGrantee(
373 requester, ref_pattern=ref.path)
374 self.assertEqual([exact_grant], list(results))
375
376 def test_findRuleGrantsByGrantee_exact_grantee_person(self):
377 requester = self.factory.makePerson()
378 repository = removeSecurityProxy(
379 self.factory.makeGitRepository(owner=requester))
380
381 rule = self.factory.makeGitRule(repository)
382 grant = self.factory.makeGitRuleGrant(rule=rule, grantee=requester)
383
384 results = repository.findRuleGrantsByGrantee(
385 requester, exact_grantee=True)
386 self.assertEqual([grant], list(results))
387
388 def test_findRuleGrantsByGrantee_exact_grantee_team(self):
389 team = self.factory.makeTeam()
390 repository = removeSecurityProxy(
391 self.factory.makeGitRepository(owner=team))
392
393 rule = self.factory.makeGitRule(repository)
394 grant = self.factory.makeGitRuleGrant(rule=rule, grantee=team)
395
396 results = repository.findRuleGrantsByGrantee(team, exact_grantee=True)
397 self.assertEqual([grant], list(results))
398
399 def test_findRuleGrantsByGrantee_exact_grantee_member_of_team(self):
400 member = self.factory.makePerson()
401 team = self.factory.makeTeam(members=[member])
402 repository = removeSecurityProxy(
403 self.factory.makeGitRepository(owner=team))
404
405 rule = self.factory.makeGitRule(repository)
406 self.factory.makeGitRuleGrant(rule=rule, grantee=team)
407
408 results = repository.findRuleGrantsByGrantee(
409 member, exact_grantee=True)
410 self.assertEqual([], list(results))
411
412 def test_findRuleGrantsByGrantee_no_owner_grant(self):
413 repository = removeSecurityProxy(self.factory.makeGitRepository())
414
415 rule = self.factory.makeGitRule(repository=repository)
416 self.factory.makeGitRuleGrant(rule=rule)
417
418 results = repository.findRuleGrantsByGrantee(
419 GitGranteeType.REPOSITORY_OWNER)
420 self.assertEqual([], list(results))
421
422 def test_findRuleGrantsByGrantee_owner_grant(self):
423 repository = removeSecurityProxy(self.factory.makeGitRepository())
424
425 rule = self.factory.makeGitRule(repository=repository)
426 grant = self.factory.makeGitRuleGrant(
427 rule=rule, grantee=GitGranteeType.REPOSITORY_OWNER)
428 self.factory.makeGitRuleGrant(rule=rule)
429
430 results = repository.findRuleGrantsByGrantee(
431 GitGranteeType.REPOSITORY_OWNER)
432 self.assertEqual([grant], list(results))
433
434 def test_findRuleGrantsByGrantee_owner_ref_pattern(self):
435 repository = removeSecurityProxy(self.factory.makeGitRepository())
436 [ref] = self.factory.makeGitRefs(repository=repository)
437
438 exact_grant = self.factory.makeGitRuleGrant(
439 repository=repository, ref_pattern=ref.path,
440 grantee=GitGranteeType.REPOSITORY_OWNER)
441 self.factory.makeGitRuleGrant(
442 repository=repository, ref_pattern="refs/heads/*",
443 grantee=GitGranteeType.REPOSITORY_OWNER)
444
445 results = ref.repository.findRuleGrantsByGrantee(
446 GitGranteeType.REPOSITORY_OWNER, ref_pattern=ref.path)
447 self.assertEqual([exact_grant], list(results))
448
449 def test_findRuleGrantsByGrantee_owner_exact_grantee(self):
450 repository = removeSecurityProxy(self.factory.makeGitRepository())
451 [ref] = self.factory.makeGitRefs(repository=repository)
452
453 exact_grant = self.factory.makeGitRuleGrant(
454 repository=repository, ref_pattern=ref.path,
455 grantee=GitGranteeType.REPOSITORY_OWNER)
456 self.factory.makeGitRuleGrant(
457 rule=exact_grant.rule, grantee=repository.owner)
458 wildcard_grant = self.factory.makeGitRuleGrant(
459 repository=repository, ref_pattern="refs/heads/*",
460 grantee=GitGranteeType.REPOSITORY_OWNER)
461
462 results = ref.repository.findRuleGrantsByGrantee(
463 GitGranteeType.REPOSITORY_OWNER, exact_grantee=True)
464 self.assertItemsEqual([exact_grant, wildcard_grant], list(results))
465 results = ref.repository.findRuleGrantsByGrantee(
466 GitGranteeType.REPOSITORY_OWNER, ref_pattern=ref.path,
467 exact_grantee=True)
468 self.assertEqual([exact_grant], list(results))
469
360470
361class TestGitIdentityMixin(TestCaseWithFactory):471class TestGitIdentityMixin(TestCaseWithFactory):
362 """Test the defaults and identities provided by GitIdentityMixin."""472 """Test the defaults and identities provided by GitIdentityMixin."""
363473
=== modified file 'lib/lp/code/model/tests/test_gitrule.py'
--- lib/lp/code/model/tests/test_gitrule.py 2018-10-21 17:38:05 +0000
+++ lib/lp/code/model/tests/test_gitrule.py 2018-11-09 22:50:10 +0000
@@ -594,6 +594,7 @@
594 rule=Equals(rule),594 rule=Equals(rule),
595 grantee_type=Equals(GitGranteeType.REPOSITORY_OWNER),595 grantee_type=Equals(GitGranteeType.REPOSITORY_OWNER),
596 grantee=Is(None),596 grantee=Is(None),
597 combined_grantee=Equals(GitGranteeType.REPOSITORY_OWNER),
597 can_create=Is(True),598 can_create=Is(True),
598 can_push=Is(False),599 can_push=Is(False),
599 can_force_push=Is(True),600 can_force_push=Is(True),
@@ -614,6 +615,7 @@
614 rule=Equals(rule),615 rule=Equals(rule),
615 grantee_type=Equals(GitGranteeType.PERSON),616 grantee_type=Equals(GitGranteeType.PERSON),
616 grantee=Equals(grantee),617 grantee=Equals(grantee),
618 combined_grantee=Equals(grantee),
617 can_create=Is(False),619 can_create=Is(False),
618 can_push=Is(True),620 can_push=Is(True),
619 can_force_push=Is(False),621 can_force_push=Is(False),
620622
=== added file 'lib/lp/code/templates/gitrepository-permissions.pt'
--- lib/lp/code/templates/gitrepository-permissions.pt 1970-01-01 00:00:00 +0000
+++ lib/lp/code/templates/gitrepository-permissions.pt 2018-11-09 22:50:10 +0000
@@ -0,0 +1,192 @@
1<html
2 xmlns="http://www.w3.org/1999/xhtml"
3 xmlns:tal="http://xml.zope.org/namespaces/tal"
4 xmlns:metal="http://xml.zope.org/namespaces/metal"
5 xmlns:i18n="http://xml.zope.org/namespaces/i18n"
6 metal:use-macro="view/macro:page/main_only"
7 i18n:domain="launchpad">
8<body>
9
10 <metal:macros fill-slot="bogus">
11 <metal:macro define-macro="rule-rows">
12 <tal:rule repeat="rule rules">
13 <tal:rule_widgets
14 define="rule_widgets python:view.getRuleWidgets(rule)">
15 <tr class="git-rule">
16 <td tal:define="widget nocall:rule_widgets/position">
17 <metal:block use-macro="context/@@launchpad_form/widget_div" />
18 </td>
19 <td tal:define="widget nocall:rule_widgets/pattern" colspan="2">
20 <metal:block use-macro="context/@@launchpad_form/widget_div" />
21 </td>
22 <td tal:define="widget nocall:rule_widgets/delete">
23 <metal:block use-macro="context/@@launchpad_form/widget_div" />
24 </td>
25 </tr>
26 <tr class="git-rule-grant"
27 tal:repeat="grant_widgets rule_widgets/grants">
28 <td></td>
29 <td tal:define="widget nocall:grant_widgets/grantee">
30 <metal:block use-macro="context/@@launchpad_form/widget_div" />
31 </td>
32 <td tal:define="widget nocall:grant_widgets/permissions">
33 <metal:block use-macro="context/@@launchpad_form/widget_div" />
34 </td>
35 <td tal:define="widget nocall:grant_widgets/delete">
36 <metal:block use-macro="context/@@launchpad_form/widget_div" />
37 </td>
38 </tr>
39 <tr class="git-new-rule-grant"
40 tal:define="new_grant_widgets rule_widgets/new_grant">
41 <td></td>
42 <td tal:define="widget nocall:new_grant_widgets/grantee">
43 <metal:block use-macro="context/@@launchpad_form/widget_div" />
44 </td>
45 <td tal:define="widget nocall:new_grant_widgets/permissions">
46 <metal:block use-macro="context/@@launchpad_form/widget_div" />
47 </td>
48 <td></td>
49 </tr>
50 </tal:rule_widgets>
51 </tal:rule>
52 <tal:allows-new-rule condition="ref_prefix">
53 <tr class="git-new-rule"
54 tal:define="new_rule_widgets python:view.getNewRuleWidgets(ref_prefix)">
55 <td tal:define="widget nocall:new_rule_widgets/position">
56 <metal:block use-macro="context/@@launchpad_form/widget_div" />
57 </td>
58 <td tal:define="widget nocall:new_rule_widgets/pattern" colspan="2">
59 <metal:block use-macro="context/@@launchpad_form/widget_div" />
60 </td>
61 <td></td>
62 </tr>
63 </tal:allows-new-rule>
64 </metal:macro>
65 </metal:macros>
66
67 <div metal:fill-slot="main">
68 <p>
69 By default, repository owners may create, push, force-push, or delete
70 any branch or tag in their repositories, and nobody else may modify
71 them in any way.
72 </p>
73 <p>
74 If any of the rules below matches a branch or tag, then it is
75 <em>protected</em>. By default, protecting a branch implicitly
76 prevents repository owners from force-pushing to it or deleting it,
77 while protecting a tag prevents repository owners from moving it.
78 Protecting a branch or tag also allows you to grant other permissions.
79 </p>
80 <p>
81 You may create rules that match a single branch or tag, or wildcard
82 rules that match a pattern: for example, <code>*</code> matches
83 everything, while <code>stable/*</code> matches
84 <code>stable/1.0</code> but not <code>master</code>.
85 </p>
86
87 <metal:grants-form use-macro="context/@@launchpad_form/form">
88 <div class="form" metal:fill-slot="widgets">
89 <table id="rules-table" class="listing"
90 style="max-width: 60em; margin-bottom: 1em;">
91 <thead>
92 <tr>
93 <th>Position</th>
94 <th colspan="2">Rule</th>
95 <th>Delete?</th>
96 </tr>
97 </thead>
98 <tbody>
99 <tr>
100 <td colspan="4">
101 <h3>Protected branches (under <code>refs/heads/</code>)</h3>
102 </td>
103 </tr>
104 <tal:branches define="rules view/branch_rules;
105 ref_prefix string:refs/heads/">
106 <metal:grants use-macro="template/macros/rule-rows" />
107 </tal:branches>
108
109 <tr>
110 <td colspan="4">
111 <h3>Protected tags (under <code>refs/tags/</code>)</h3>
112 </td>
113 </tr>
114 <tal:tags define="rules view/tag_rules;
115 ref_prefix string:refs/tags/">
116 <metal:grants use-macro="template/macros/rule-rows" />
117 </tal:tags>
118
119 <tal:has-other condition="view/other_rules">
120 <tr><td colspan="4"><h3>Other protected references</h3></td></tr>
121 <tal:other define="rules view/other_rules; ref_prefix nothing">
122 <metal:grants use-macro="template/macros/rule-rows" />
123 </tal:other>
124 </tal:has-other>
125 </tbody>
126 </table>
127
128 <p class="actions">
129 <input tal:replace="structure view/save_action/render" />
130 or <a tal:attributes="href view/cancel_url">Cancel</a>
131 </p>
132 </div>
133
134 <metal:buttons fill-slot="buttons" />
135 </metal:grants-form>
136
137 <h2>Wildcards</h2>
138 <p>The special characters used in wildcard rules are:</p>
139 <table class="listing narrow-listing">
140 <thead>
141 <tr>
142 <th>Pattern</th>
143 <th>Meaning</th>
144 </tr>
145 </thead>
146 <tbody>
147 <tr>
148 <td><code>*</code></td>
149 <td>matches zero or more characters</td>
150 </tr>
151 <tr>
152 <td><code>?</code></td>
153 <td>matches any single character</td>
154 </tr>
155 <tr>
156 <td><code>[chars]</code></td>
157 <td>matches any character in <em>chars</em></td>
158 </tr>
159 <tr>
160 <td><code>[!chars]</code></td>
161 <td>matches any character not in <em>chars</em></td>
162 </tr>
163 </tbody>
164 </table>
165
166 <h2>Effective permissions</h2>
167 <p>
168 Launchpad works out the effective permissions that a user has on a
169 protected branch as follows:
170 </p>
171 <ol>
172 <li>Take all the rules that match the branch.</li>
173 <li>
174 For each matching rule, select any grants whose grantee matches the
175 user, as long as the same grantee has not already been seen in an
176 earlier matching rule. (A user can be matched by more than one
177 grantee: for example, they might be in multiple teams.)
178 </li>
179 <li>
180 If the user is an owner of the repository and there was no previous
181 “Repository owner” grant, then add an implicit grant allowing them
182 to create or push.
183 </li>
184 <li>
185 The effective permission set is the union of the permissions granted
186 by all the selected grants.
187 </li>
188 </ol>
189 </div>
190
191</body>
192</html>