Merge lp:~cjwatson/launchpad/git-permissions-ui-edit into lp:launchpad
- git-permissions-ui-edit
- Merge into devel
Status: | Merged | ||||
---|---|---|---|---|---|
Merged at revision: | 18855 | ||||
Proposed branch: | lp:~cjwatson/launchpad/git-permissions-ui-edit | ||||
Merge into: | lp:launchpad | ||||
Prerequisite: | lp:~cjwatson/launchpad/git-grantee-widgets | ||||
Diff against target: |
1630 lines (+1417/-7) 7 files modified
lib/lp/code/browser/configure.zcml (+6/-0) lib/lp/code/browser/gitrepository.py (+525/-5) lib/lp/code/browser/tests/test_gitrepository.py (+641/-2) lib/lp/code/interfaces/gitrule.py (+4/-0) lib/lp/code/model/gitrule.py (+7/-0) lib/lp/code/model/tests/test_gitrule.py (+2/-0) lib/lp/code/templates/gitrepository-permissions.pt (+232/-0) |
||||
To merge this branch: | bzr merge lp:~cjwatson/launchpad/git-permissions-ui-edit | ||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
William Grant | code | Approve | |
Review via email: mp+358582@code.launchpad.net |
This proposal supersedes a proposal from 2018-10-31.
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:/
William Grant (wgrant) : | # |
Colin Watson (cjwatson) : | # |
Preview Diff
1 | === modified file 'lib/lp/code/browser/configure.zcml' | |||
2 | --- lib/lp/code/browser/configure.zcml 2018-11-08 11:40:25 +0000 | |||
3 | +++ lib/lp/code/browser/configure.zcml 2019-01-10 10:50:57 +0000 | |||
4 | @@ -871,6 +871,12 @@ | |||
5 | 871 | </class> | 871 | </class> |
6 | 872 | <browser:page | 872 | <browser:page |
7 | 873 | for="lp.code.interfaces.gitrepository.IGitRepository" | 873 | for="lp.code.interfaces.gitrepository.IGitRepository" |
8 | 874 | class="lp.code.browser.gitrepository.GitRepositoryPermissionsView" | ||
9 | 875 | name="+permissions" | ||
10 | 876 | permission="launchpad.Edit" | ||
11 | 877 | template="../templates/gitrepository-permissions.pt"/> | ||
12 | 878 | <browser:page | ||
13 | 879 | for="lp.code.interfaces.gitrepository.IGitRepository" | ||
14 | 874 | class="lp.code.browser.gitrepository.GitRepositoryDeletionView" | 880 | class="lp.code.browser.gitrepository.GitRepositoryDeletionView" |
15 | 875 | permission="launchpad.Edit" | 881 | permission="launchpad.Edit" |
16 | 876 | name="+delete" | 882 | name="+delete" |
17 | 877 | 883 | ||
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 2019-01-10 10:50:57 +0000 | |||
21 | @@ -1,4 +1,4 @@ | |||
23 | 1 | # Copyright 2015-2018 Canonical Ltd. This software is licensed under the | 1 | # Copyright 2015-2019 Canonical Ltd. This software is licensed under the |
24 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). |
25 | 3 | 3 | ||
26 | 4 | """Git repository views.""" | 4 | """Git repository views.""" |
27 | @@ -17,10 +17,14 @@ | |||
28 | 17 | 'GitRepositoryEditReviewerView', | 17 | 'GitRepositoryEditReviewerView', |
29 | 18 | 'GitRepositoryEditView', | 18 | 'GitRepositoryEditView', |
30 | 19 | 'GitRepositoryNavigation', | 19 | 'GitRepositoryNavigation', |
31 | 20 | 'GitRepositoryPermissionsView', | ||
32 | 20 | 'GitRepositoryURL', | 21 | 'GitRepositoryURL', |
33 | 21 | 'GitRepositoryView', | 22 | 'GitRepositoryView', |
34 | 22 | ] | 23 | ] |
35 | 23 | 24 | ||
36 | 25 | import base64 | ||
37 | 26 | from collections import defaultdict | ||
38 | 27 | |||
39 | 24 | from lazr.lifecycle.event import ObjectModifiedEvent | 28 | from lazr.lifecycle.event import ObjectModifiedEvent |
40 | 25 | from lazr.lifecycle.snapshot import Snapshot | 29 | from lazr.lifecycle.snapshot import Snapshot |
41 | 26 | from lazr.restful.interface import ( | 30 | from lazr.restful.interface import ( |
42 | @@ -34,14 +38,21 @@ | |||
43 | 34 | from zope.component import getUtility | 38 | from zope.component import getUtility |
44 | 35 | from zope.event import notify | 39 | from zope.event import notify |
45 | 36 | from zope.formlib import form | 40 | from zope.formlib import form |
46 | 41 | from zope.formlib.textwidgets import IntWidget | ||
47 | 42 | from zope.formlib.widget import CustomWidgetFactory | ||
48 | 37 | from zope.interface import ( | 43 | from zope.interface import ( |
49 | 38 | implementer, | 44 | implementer, |
50 | 39 | Interface, | 45 | Interface, |
51 | 40 | providedBy, | 46 | providedBy, |
52 | 41 | ) | 47 | ) |
53 | 42 | from zope.publisher.interfaces.browser import IBrowserPublisher | 48 | from zope.publisher.interfaces.browser import IBrowserPublisher |
55 | 43 | from zope.schema import Choice | 49 | from zope.schema import ( |
56 | 50 | Bool, | ||
57 | 51 | Choice, | ||
58 | 52 | Int, | ||
59 | 53 | ) | ||
60 | 44 | from zope.schema.vocabulary import ( | 54 | from zope.schema.vocabulary import ( |
61 | 55 | getVocabularyRegistry, | ||
62 | 45 | SimpleTerm, | 56 | SimpleTerm, |
63 | 46 | SimpleVocabulary, | 57 | SimpleVocabulary, |
64 | 47 | ) | 58 | ) |
65 | @@ -53,7 +64,10 @@ | |||
66 | 53 | LaunchpadEditFormView, | 64 | LaunchpadEditFormView, |
67 | 54 | LaunchpadFormView, | 65 | LaunchpadFormView, |
68 | 55 | ) | 66 | ) |
70 | 56 | from lp.app.errors import NotFoundError | 67 | from lp.app.errors import ( |
71 | 68 | NotFoundError, | ||
72 | 69 | UnexpectedFormData, | ||
73 | 70 | ) | ||
74 | 57 | from lp.app.vocabularies import InformationTypeVocabulary | 71 | from lp.app.vocabularies import InformationTypeVocabulary |
75 | 58 | from lp.app.widgets.itemswidgets import LaunchpadRadioWidgetWithDescription | 72 | from lp.app.widgets.itemswidgets import LaunchpadRadioWidgetWithDescription |
76 | 59 | from lp.code.browser.branch import CodeEditOwnerMixin | 73 | from lp.code.browser.branch import CodeEditOwnerMixin |
77 | @@ -62,11 +76,19 @@ | |||
78 | 62 | ) | 76 | ) |
79 | 63 | from lp.code.browser.codeimport import CodeImportTargetMixin | 77 | from lp.code.browser.codeimport import CodeImportTargetMixin |
80 | 64 | from lp.code.browser.sourcepackagerecipelisting import HasRecipesMenuMixin | 78 | from lp.code.browser.sourcepackagerecipelisting import HasRecipesMenuMixin |
81 | 79 | from lp.code.browser.widgets.gitgrantee import ( | ||
82 | 80 | GitGranteeDisplayWidget, | ||
83 | 81 | GitGranteeField, | ||
84 | 82 | GitGranteeWidget, | ||
85 | 83 | ) | ||
86 | 65 | from lp.code.browser.widgets.gitrepositorytarget import ( | 84 | from lp.code.browser.widgets.gitrepositorytarget import ( |
87 | 66 | GitRepositoryTargetDisplayWidget, | 85 | GitRepositoryTargetDisplayWidget, |
88 | 67 | GitRepositoryTargetWidget, | 86 | GitRepositoryTargetWidget, |
89 | 68 | ) | 87 | ) |
91 | 69 | from lp.code.enums import GitRepositoryType | 88 | from lp.code.enums import ( |
92 | 89 | GitGranteeType, | ||
93 | 90 | GitRepositoryType, | ||
94 | 91 | ) | ||
95 | 70 | from lp.code.errors import ( | 92 | from lp.code.errors import ( |
96 | 71 | GitDefaultConflict, | 93 | GitDefaultConflict, |
97 | 72 | GitRepositoryCreationForbidden, | 94 | GitRepositoryCreationForbidden, |
98 | @@ -76,6 +98,7 @@ | |||
99 | 76 | from lp.code.interfaces.gitnamespace import get_git_namespace | 98 | from lp.code.interfaces.gitnamespace import get_git_namespace |
100 | 77 | from lp.code.interfaces.gitref import IGitRefBatchNavigator | 99 | from lp.code.interfaces.gitref import IGitRefBatchNavigator |
101 | 78 | from lp.code.interfaces.gitrepository import IGitRepository | 100 | from lp.code.interfaces.gitrepository import IGitRepository |
102 | 101 | from lp.code.vocabularies.gitrule import GitPermissionsVocabulary | ||
103 | 79 | from lp.registry.interfaces.person import ( | 102 | from lp.registry.interfaces.person import ( |
104 | 80 | IPerson, | 103 | IPerson, |
105 | 81 | IPersonSet, | 104 | IPersonSet, |
106 | @@ -84,6 +107,7 @@ | |||
107 | 84 | from lp.services.config import config | 107 | from lp.services.config import config |
108 | 85 | from lp.services.database.constants import UTC_NOW | 108 | from lp.services.database.constants import UTC_NOW |
109 | 86 | from lp.services.features import getFeatureFlag | 109 | from lp.services.features import getFeatureFlag |
110 | 110 | from lp.services.fields import UniqueField | ||
111 | 87 | from lp.services.propertycache import cachedproperty | 111 | from lp.services.propertycache import cachedproperty |
112 | 88 | from lp.services.webapp import ( | 112 | from lp.services.webapp import ( |
113 | 89 | canonical_url, | 113 | canonical_url, |
114 | @@ -105,6 +129,7 @@ | |||
115 | 105 | from lp.services.webapp.escaping import structured | 129 | from lp.services.webapp.escaping import structured |
116 | 106 | from lp.services.webapp.interfaces import ICanonicalUrlData | 130 | from lp.services.webapp.interfaces import ICanonicalUrlData |
117 | 107 | from lp.services.webapp.publisher import DataDownloadView | 131 | from lp.services.webapp.publisher import DataDownloadView |
118 | 132 | from lp.services.webapp.snapshot import notify_modified | ||
119 | 108 | from lp.services.webhooks.browser import WebhookTargetNavigationMixin | 133 | from lp.services.webhooks.browser import WebhookTargetNavigationMixin |
120 | 109 | from lp.snappy.browser.hassnaps import HasSnapsViewMixin | 134 | from lp.snappy.browser.hassnaps import HasSnapsViewMixin |
121 | 110 | 135 | ||
122 | @@ -211,7 +236,14 @@ | |||
123 | 211 | usedfor = IGitRepository | 236 | usedfor = IGitRepository |
124 | 212 | facet = "branches" | 237 | facet = "branches" |
125 | 213 | title = "Edit Git repository" | 238 | title = "Edit Git repository" |
127 | 214 | links = ["edit", "reviewer", "webhooks", "activity", "delete"] | 239 | links = [ |
128 | 240 | "edit", | ||
129 | 241 | "reviewer", | ||
130 | 242 | "permissions", | ||
131 | 243 | "activity", | ||
132 | 244 | "webhooks", | ||
133 | 245 | "delete", | ||
134 | 246 | ] | ||
135 | 215 | 247 | ||
136 | 216 | @enabled_with_permission("launchpad.Edit") | 248 | @enabled_with_permission("launchpad.Edit") |
137 | 217 | def edit(self): | 249 | def edit(self): |
138 | @@ -224,6 +256,11 @@ | |||
139 | 224 | return Link("+reviewer", text, icon="edit") | 256 | return Link("+reviewer", text, icon="edit") |
140 | 225 | 257 | ||
141 | 226 | @enabled_with_permission("launchpad.Edit") | 258 | @enabled_with_permission("launchpad.Edit") |
142 | 259 | def permissions(self): | ||
143 | 260 | text = "Manage permissions" | ||
144 | 261 | return Link("+permissions", text, icon="edit") | ||
145 | 262 | |||
146 | 263 | @enabled_with_permission("launchpad.Edit") | ||
147 | 227 | def webhooks(self): | 264 | def webhooks(self): |
148 | 228 | text = "Manage webhooks" | 265 | text = "Manage webhooks" |
149 | 229 | return Link( | 266 | return Link( |
150 | @@ -709,6 +746,489 @@ | |||
151 | 709 | return self, () | 746 | return self, () |
152 | 710 | 747 | ||
153 | 711 | 748 | ||
154 | 749 | def encode_form_field_id(value): | ||
155 | 750 | """Encode text for use in form field names. | ||
156 | 751 | |||
157 | 752 | We use a modified version of base32 which fits into CSS identifiers and | ||
158 | 753 | so doesn't cause FormattersAPI.zope_css_id to do unhelpful things. | ||
159 | 754 | """ | ||
160 | 755 | return base64.b32encode( | ||
161 | 756 | value.encode("UTF-8")).decode("UTF-8").replace("=", "_") | ||
162 | 757 | |||
163 | 758 | |||
164 | 759 | def decode_form_field_id(encoded): | ||
165 | 760 | """Inverse of `encode_form_field_id`.""" | ||
166 | 761 | return base64.b32decode( | ||
167 | 762 | encoded.replace("_", "=").encode("UTF-8")).decode("UTF-8") | ||
168 | 763 | |||
169 | 764 | |||
170 | 765 | class GitRulePatternField(UniqueField): | ||
171 | 766 | |||
172 | 767 | errormessage = _("%s is already in use by another rule") | ||
173 | 768 | attribute = "ref_pattern" | ||
174 | 769 | _content_iface = IGitRepository | ||
175 | 770 | |||
176 | 771 | def __init__(self, ref_prefix, rule=None, *args, **kwargs): | ||
177 | 772 | self.ref_prefix = ref_prefix | ||
178 | 773 | self.rule = rule | ||
179 | 774 | super(GitRulePatternField, self).__init__(*args, **kwargs) | ||
180 | 775 | |||
181 | 776 | def _getByAttribute(self, ref_pattern): | ||
182 | 777 | """See `UniqueField`.""" | ||
183 | 778 | if self._content_iface.providedBy(self.context): | ||
184 | 779 | return self.context.getRule(self.ref_prefix + ref_pattern) | ||
185 | 780 | else: | ||
186 | 781 | return None | ||
187 | 782 | |||
188 | 783 | def unchanged(self, input): | ||
189 | 784 | """See `UniqueField`.""" | ||
190 | 785 | return ( | ||
191 | 786 | self.rule is not None and | ||
192 | 787 | self.ref_prefix + input == self.rule.ref_pattern) | ||
193 | 788 | |||
194 | 789 | def set(self, object, value): | ||
195 | 790 | """See `IField`.""" | ||
196 | 791 | if value is not None: | ||
197 | 792 | value = value.strip() | ||
198 | 793 | super(GitRulePatternField, self).set(object, value) | ||
199 | 794 | |||
200 | 795 | |||
201 | 796 | class GitRepositoryPermissionsView(LaunchpadFormView): | ||
202 | 797 | """A view to manage repository permissions.""" | ||
203 | 798 | |||
204 | 799 | heads_prefix = u"refs/heads/" | ||
205 | 800 | tags_prefix = u"refs/tags/" | ||
206 | 801 | |||
207 | 802 | @property | ||
208 | 803 | def label(self): | ||
209 | 804 | return "Manage permissions for %s" % self.context.identity | ||
210 | 805 | |||
211 | 806 | page_title = "Manage permissions" | ||
212 | 807 | |||
213 | 808 | @property | ||
214 | 809 | def repository(self): | ||
215 | 810 | return self.context | ||
216 | 811 | |||
217 | 812 | @cachedproperty | ||
218 | 813 | def rules(self): | ||
219 | 814 | return self.repository.getRules() | ||
220 | 815 | |||
221 | 816 | @property | ||
222 | 817 | def branch_rules(self): | ||
223 | 818 | return [ | ||
224 | 819 | rule for rule in self.rules | ||
225 | 820 | if rule.ref_pattern.startswith(self.heads_prefix)] | ||
226 | 821 | |||
227 | 822 | @property | ||
228 | 823 | def tag_rules(self): | ||
229 | 824 | return [ | ||
230 | 825 | rule for rule in self.rules | ||
231 | 826 | if rule.ref_pattern.startswith(self.tags_prefix)] | ||
232 | 827 | |||
233 | 828 | @property | ||
234 | 829 | def other_rules(self): | ||
235 | 830 | return [ | ||
236 | 831 | rule for rule in self.rules | ||
237 | 832 | if not rule.ref_pattern.startswith(self.heads_prefix) and | ||
238 | 833 | not rule.ref_pattern.startswith(self.tags_prefix)] | ||
239 | 834 | |||
240 | 835 | def _getRuleGrants(self, rule): | ||
241 | 836 | def grantee_key(grant): | ||
242 | 837 | if grant.grantee is not None: | ||
243 | 838 | return (grant.grantee_type, grant.grantee.name) | ||
244 | 839 | else: | ||
245 | 840 | return (grant.grantee_type,) | ||
246 | 841 | |||
247 | 842 | return sorted(rule.grants, key=grantee_key) | ||
248 | 843 | |||
249 | 844 | def _parseRefPattern(self, ref_pattern): | ||
250 | 845 | """Parse a pattern into a prefix and the displayed portion.""" | ||
251 | 846 | for prefix in (self.heads_prefix, self.tags_prefix): | ||
252 | 847 | if ref_pattern.startswith(prefix): | ||
253 | 848 | return prefix, ref_pattern[len(prefix):] | ||
254 | 849 | return u"", ref_pattern | ||
255 | 850 | |||
256 | 851 | def _getFieldName(self, name, ref_pattern, grantee=None): | ||
257 | 852 | """Get the combined field name for a ref pattern and optional grantee. | ||
258 | 853 | |||
259 | 854 | In order to be able to render a permissions table, we encode the ref | ||
260 | 855 | pattern and the grantee in the form field name. | ||
261 | 856 | """ | ||
262 | 857 | suffix = "." + encode_form_field_id(ref_pattern) | ||
263 | 858 | if grantee is not None: | ||
264 | 859 | if IPerson.providedBy(grantee): | ||
265 | 860 | suffix += "." + str(grantee.id) | ||
266 | 861 | else: | ||
267 | 862 | suffix += "._" + grantee.name.lower() | ||
268 | 863 | return name + suffix | ||
269 | 864 | |||
270 | 865 | def _parseFieldName(self, field_name): | ||
271 | 866 | """Parse a combined field name as described in `_getFieldName`. | ||
272 | 867 | |||
273 | 868 | :raises UnexpectedFormData: if the field name cannot be parsed or | ||
274 | 869 | the grantee cannot be found. | ||
275 | 870 | """ | ||
276 | 871 | field_bits = field_name.split(".") | ||
277 | 872 | if len(field_bits) < 2: | ||
278 | 873 | raise UnexpectedFormData( | ||
279 | 874 | "Cannot parse field name: %s" % field_name) | ||
280 | 875 | field_type = field_bits[0] | ||
281 | 876 | try: | ||
282 | 877 | ref_pattern = decode_form_field_id(field_bits[1]) | ||
283 | 878 | except TypeError: | ||
284 | 879 | raise UnexpectedFormData( | ||
285 | 880 | "Cannot parse field name: %s" % field_name) | ||
286 | 881 | if len(field_bits) > 2: | ||
287 | 882 | grantee_id = field_bits[2] | ||
288 | 883 | if grantee_id.startswith("_"): | ||
289 | 884 | grantee_id = grantee_id[1:] | ||
290 | 885 | try: | ||
291 | 886 | grantee = GitGranteeType.getTermByToken(grantee_id).value | ||
292 | 887 | except LookupError: | ||
293 | 888 | grantee = None | ||
294 | 889 | else: | ||
295 | 890 | try: | ||
296 | 891 | grantee_id = int(grantee_id) | ||
297 | 892 | except ValueError: | ||
298 | 893 | grantee = None | ||
299 | 894 | else: | ||
300 | 895 | grantee = getUtility(IPersonSet).get(grantee_id) | ||
301 | 896 | if grantee is None or grantee == GitGranteeType.PERSON: | ||
302 | 897 | raise UnexpectedFormData("No such grantee: %s" % grantee_id) | ||
303 | 898 | else: | ||
304 | 899 | grantee = None | ||
305 | 900 | return field_type, ref_pattern, grantee | ||
306 | 901 | |||
307 | 902 | def _getPermissionsTerm(self, grant): | ||
308 | 903 | """Return a term from `GitPermissionsVocabulary` for this grant.""" | ||
309 | 904 | vocabulary = getVocabularyRegistry().get(grant, "GitPermissions") | ||
310 | 905 | try: | ||
311 | 906 | return vocabulary.getTerm(grant.permissions) | ||
312 | 907 | except LookupError: | ||
313 | 908 | # This should never happen, because GitPermissionsVocabulary | ||
314 | 909 | # adds a custom term for the context grant if necessary. | ||
315 | 910 | raise AssertionError( | ||
316 | 911 | "Could not find GitPermissions term for %r" % grant) | ||
317 | 912 | |||
318 | 913 | def setUpFields(self): | ||
319 | 914 | """See `LaunchpadFormView`.""" | ||
320 | 915 | position_fields = [] | ||
321 | 916 | pattern_fields = [] | ||
322 | 917 | delete_fields = [] | ||
323 | 918 | readonly_grantee_fields = [] | ||
324 | 919 | grantee_fields = [] | ||
325 | 920 | permissions_fields = [] | ||
326 | 921 | |||
327 | 922 | default_permissions_by_prefix = { | ||
328 | 923 | self.heads_prefix: "can_push", | ||
329 | 924 | self.tags_prefix: "can_create", | ||
330 | 925 | "": "can_push", | ||
331 | 926 | } | ||
332 | 927 | |||
333 | 928 | for rule_index, rule in enumerate(self.rules): | ||
334 | 929 | # Remove the usual branch/tag prefixes from patterns. The full | ||
335 | 930 | # pattern goes into form field names, so no data is lost here. | ||
336 | 931 | ref_pattern = rule.ref_pattern | ||
337 | 932 | ref_prefix, short_pattern = self._parseRefPattern(ref_pattern) | ||
338 | 933 | position_fields.append( | ||
339 | 934 | Int( | ||
340 | 935 | __name__=self._getFieldName("position", ref_pattern), | ||
341 | 936 | required=True, readonly=False, default=rule_index + 1)) | ||
342 | 937 | pattern_fields.append( | ||
343 | 938 | GitRulePatternField( | ||
344 | 939 | __name__=self._getFieldName("pattern", ref_pattern), | ||
345 | 940 | required=True, readonly=False, ref_prefix=ref_prefix, | ||
346 | 941 | rule=rule, default=short_pattern)) | ||
347 | 942 | delete_fields.append( | ||
348 | 943 | Bool( | ||
349 | 944 | __name__=self._getFieldName("delete", ref_pattern), | ||
350 | 945 | readonly=False, default=False)) | ||
351 | 946 | for grant in self._getRuleGrants(rule): | ||
352 | 947 | grantee = grant.combined_grantee | ||
353 | 948 | readonly_grantee_fields.append( | ||
354 | 949 | GitGranteeField( | ||
355 | 950 | __name__=self._getFieldName( | ||
356 | 951 | "grantee", ref_pattern, grantee), | ||
357 | 952 | required=False, readonly=True, default=grantee, | ||
358 | 953 | rule=rule)) | ||
359 | 954 | permissions_fields.append( | ||
360 | 955 | Choice( | ||
361 | 956 | __name__=self._getFieldName( | ||
362 | 957 | "permissions", ref_pattern, grantee), | ||
363 | 958 | source=GitPermissionsVocabulary(grant), | ||
364 | 959 | readonly=False, | ||
365 | 960 | default=self._getPermissionsTerm(grant).value)) | ||
366 | 961 | delete_fields.append( | ||
367 | 962 | Bool( | ||
368 | 963 | __name__=self._getFieldName( | ||
369 | 964 | "delete", ref_pattern, grantee), | ||
370 | 965 | readonly=False, default=False)) | ||
371 | 966 | grantee_fields.append( | ||
372 | 967 | GitGranteeField( | ||
373 | 968 | __name__=self._getFieldName("grantee", ref_pattern), | ||
374 | 969 | required=False, readonly=False, rule=rule)) | ||
375 | 970 | permissions_vocabulary = GitPermissionsVocabulary(rule) | ||
376 | 971 | permissions_fields.append( | ||
377 | 972 | Choice( | ||
378 | 973 | __name__=self._getFieldName( | ||
379 | 974 | "permissions", ref_pattern), | ||
380 | 975 | source=permissions_vocabulary, readonly=False, | ||
381 | 976 | default=permissions_vocabulary.getTermByToken( | ||
382 | 977 | default_permissions_by_prefix[ref_prefix]).value)) | ||
383 | 978 | for ref_prefix in (self.heads_prefix, self.tags_prefix): | ||
384 | 979 | position_fields.append( | ||
385 | 980 | Int( | ||
386 | 981 | __name__=self._getFieldName("new-position", ref_prefix), | ||
387 | 982 | required=False, readonly=True)) | ||
388 | 983 | pattern_fields.append( | ||
389 | 984 | GitRulePatternField( | ||
390 | 985 | __name__=self._getFieldName("new-pattern", ref_prefix), | ||
391 | 986 | required=False, readonly=False, ref_prefix=ref_prefix)) | ||
392 | 987 | |||
393 | 988 | self.form_fields = ( | ||
394 | 989 | form.FormFields( | ||
395 | 990 | *position_fields, | ||
396 | 991 | custom_widget=CustomWidgetFactory(IntWidget, displayWidth=2)) + | ||
397 | 992 | form.FormFields(*pattern_fields) + | ||
398 | 993 | form.FormFields(*delete_fields) + | ||
399 | 994 | form.FormFields( | ||
400 | 995 | *readonly_grantee_fields, | ||
401 | 996 | custom_widget=CustomWidgetFactory(GitGranteeDisplayWidget)) + | ||
402 | 997 | form.FormFields( | ||
403 | 998 | *grantee_fields, | ||
404 | 999 | custom_widget=CustomWidgetFactory(GitGranteeWidget)) + | ||
405 | 1000 | form.FormFields(*permissions_fields)) | ||
406 | 1001 | |||
407 | 1002 | def setUpWidgets(self, context=None): | ||
408 | 1003 | """See `LaunchpadFormView`.""" | ||
409 | 1004 | super(GitRepositoryPermissionsView, self).setUpWidgets( | ||
410 | 1005 | context=context) | ||
411 | 1006 | for widget in self.widgets: | ||
412 | 1007 | widget.display_label = False | ||
413 | 1008 | widget.hint = None | ||
414 | 1009 | |||
415 | 1010 | @property | ||
416 | 1011 | def cancel_url(self): | ||
417 | 1012 | return canonical_url(self.context) | ||
418 | 1013 | |||
419 | 1014 | def getRuleWidgets(self, rule): | ||
420 | 1015 | widgets_by_name = {widget.name: widget for widget in self.widgets} | ||
421 | 1016 | ref_pattern = rule.ref_pattern | ||
422 | 1017 | position_field_name = ( | ||
423 | 1018 | "field." + self._getFieldName("position", ref_pattern)) | ||
424 | 1019 | pattern_field_name = ( | ||
425 | 1020 | "field." + self._getFieldName("pattern", ref_pattern)) | ||
426 | 1021 | delete_field_name = ( | ||
427 | 1022 | "field." + self._getFieldName("delete", ref_pattern)) | ||
428 | 1023 | grant_widgets = [] | ||
429 | 1024 | for grant in self._getRuleGrants(rule): | ||
430 | 1025 | grantee = grant.combined_grantee | ||
431 | 1026 | grantee_field_name = ( | ||
432 | 1027 | "field." + self._getFieldName("grantee", ref_pattern, grantee)) | ||
433 | 1028 | permissions_field_name = ( | ||
434 | 1029 | "field." + | ||
435 | 1030 | self._getFieldName("permissions", ref_pattern, grantee)) | ||
436 | 1031 | delete_grant_field_name = ( | ||
437 | 1032 | "field." + self._getFieldName("delete", ref_pattern, grantee)) | ||
438 | 1033 | grant_widgets.append({ | ||
439 | 1034 | "grantee": widgets_by_name[grantee_field_name], | ||
440 | 1035 | "permissions": widgets_by_name[permissions_field_name], | ||
441 | 1036 | "delete": widgets_by_name[delete_grant_field_name], | ||
442 | 1037 | }) | ||
443 | 1038 | new_grantee_field_name = ( | ||
444 | 1039 | "field." + self._getFieldName("grantee", ref_pattern)) | ||
445 | 1040 | new_permissions_field_name = ( | ||
446 | 1041 | "field." + self._getFieldName("permissions", ref_pattern)) | ||
447 | 1042 | new_grant_widgets = { | ||
448 | 1043 | "grantee": widgets_by_name[new_grantee_field_name], | ||
449 | 1044 | "permissions": widgets_by_name[new_permissions_field_name], | ||
450 | 1045 | } | ||
451 | 1046 | return { | ||
452 | 1047 | "position": widgets_by_name[position_field_name], | ||
453 | 1048 | "pattern": widgets_by_name[pattern_field_name], | ||
454 | 1049 | "delete": widgets_by_name.get(delete_field_name), | ||
455 | 1050 | "grants": grant_widgets, | ||
456 | 1051 | "new_grant": new_grant_widgets, | ||
457 | 1052 | } | ||
458 | 1053 | |||
459 | 1054 | def getNewRuleWidgets(self, ref_prefix): | ||
460 | 1055 | widgets_by_name = {widget.name: widget for widget in self.widgets} | ||
461 | 1056 | new_position_field_name = ( | ||
462 | 1057 | "field." + self._getFieldName("new-position", ref_prefix)) | ||
463 | 1058 | new_pattern_field_name = ( | ||
464 | 1059 | "field." + self._getFieldName("new-pattern", ref_prefix)) | ||
465 | 1060 | return { | ||
466 | 1061 | "position": widgets_by_name[new_position_field_name], | ||
467 | 1062 | "pattern": widgets_by_name[new_pattern_field_name], | ||
468 | 1063 | } | ||
469 | 1064 | |||
470 | 1065 | def parseData(self, data): | ||
471 | 1066 | """Rearrange form data to make it easier to process.""" | ||
472 | 1067 | parsed_data = { | ||
473 | 1068 | "rules": {}, | ||
474 | 1069 | "grants": defaultdict(list), | ||
475 | 1070 | } | ||
476 | 1071 | |||
477 | 1072 | for field_name in sorted( | ||
478 | 1073 | name for name in data if name.split(".")[0] == "pattern"): | ||
479 | 1074 | _, ref_pattern, _ = self._parseFieldName(field_name) | ||
480 | 1075 | prefix, _ = self._parseRefPattern(ref_pattern) | ||
481 | 1076 | position_field_name = self._getFieldName("position", ref_pattern) | ||
482 | 1077 | delete_field_name = self._getFieldName("delete", ref_pattern) | ||
483 | 1078 | if data.get(delete_field_name): | ||
484 | 1079 | position = None | ||
485 | 1080 | action = "delete" | ||
486 | 1081 | else: | ||
487 | 1082 | position = max(0, data[position_field_name] - 1) | ||
488 | 1083 | action = "change" | ||
489 | 1084 | parsed_data["rules"].setdefault(ref_pattern, { | ||
490 | 1085 | "position": position, | ||
491 | 1086 | "pattern": ref_pattern, | ||
492 | 1087 | "new_pattern": prefix + data[field_name], | ||
493 | 1088 | "action": action, | ||
494 | 1089 | }) | ||
495 | 1090 | |||
496 | 1091 | for field_name in sorted( | ||
497 | 1092 | name for name in data if name.split(".")[0] == "new-pattern"): | ||
498 | 1093 | _, prefix, _ = self._parseFieldName(field_name) | ||
499 | 1094 | position_field_name = self._getFieldName("position", prefix) | ||
500 | 1095 | if not data[field_name]: | ||
501 | 1096 | continue | ||
502 | 1097 | if position_field_name in data: | ||
503 | 1098 | position = max(0, data[position_field_name] - 1) | ||
504 | 1099 | else: | ||
505 | 1100 | position = None | ||
506 | 1101 | parsed_data["rules"].setdefault(prefix, { | ||
507 | 1102 | "position": position, | ||
508 | 1103 | "pattern": prefix + data[field_name], | ||
509 | 1104 | "action": "add", | ||
510 | 1105 | }) | ||
511 | 1106 | |||
512 | 1107 | for field_name in sorted( | ||
513 | 1108 | name for name in data if name.split(".")[0] == "permissions"): | ||
514 | 1109 | _, ref_pattern, grantee = self._parseFieldName(field_name) | ||
515 | 1110 | grantee_field_name = self._getFieldName("grantee", ref_pattern) | ||
516 | 1111 | delete_field_name = self._getFieldName( | ||
517 | 1112 | "delete", ref_pattern, grantee) | ||
518 | 1113 | if grantee is None: | ||
519 | 1114 | grantee = data.get(grantee_field_name) | ||
520 | 1115 | if grantee is None: | ||
521 | 1116 | continue | ||
522 | 1117 | action = "add" | ||
523 | 1118 | elif data.get(delete_field_name): | ||
524 | 1119 | action = "delete" | ||
525 | 1120 | else: | ||
526 | 1121 | action = "change" | ||
527 | 1122 | parsed_data["grants"][ref_pattern].append({ | ||
528 | 1123 | "grantee": grantee, | ||
529 | 1124 | "permissions": data[field_name], | ||
530 | 1125 | "action": action, | ||
531 | 1126 | }) | ||
532 | 1127 | |||
533 | 1128 | return parsed_data | ||
534 | 1129 | |||
535 | 1130 | def updateRepositoryFromData(self, repository, parsed_data): | ||
536 | 1131 | # Fetch rules before making any changes, since their ref_patterns | ||
537 | 1132 | # may change as a result of this update. | ||
538 | 1133 | rule_map = {rule.ref_pattern: rule for rule in self.repository.rules} | ||
539 | 1134 | grant_map = { | ||
540 | 1135 | (grant.rule.ref_pattern, grant.combined_grantee): grant | ||
541 | 1136 | for grant in self.repository.grants} | ||
542 | 1137 | |||
543 | 1138 | # Patterns must be processed in rule order so that position changes | ||
544 | 1139 | # work in a reasonably natural way. Process new rules last. | ||
545 | 1140 | ordered_rules = [] | ||
546 | 1141 | for ref_pattern, parsed_rule in parsed_data["rules"].items(): | ||
547 | 1142 | rule = rule_map.get(ref_pattern) | ||
548 | 1143 | if parsed_rule["action"] == "add": | ||
549 | 1144 | ordered_rules.append((ref_pattern, parsed_rule, -1)) | ||
550 | 1145 | elif rule is not None: | ||
551 | 1146 | # Ignore attempts to change or delete rules that have | ||
552 | 1147 | # already been deleted by somebody else. | ||
553 | 1148 | ordered_rules.append((ref_pattern, parsed_rule, rule.position)) | ||
554 | 1149 | ordered_rules.sort( | ||
555 | 1150 | key=lambda item: (item[1]["action"] != "add", item[2])) | ||
556 | 1151 | |||
557 | 1152 | for ref_pattern, parsed_rule, position in ordered_rules: | ||
558 | 1153 | rule = rule_map.get(parsed_rule["pattern"]) | ||
559 | 1154 | action = parsed_rule["action"] | ||
560 | 1155 | if action not in ("add", "change", "delete"): | ||
561 | 1156 | raise AssertionError( | ||
562 | 1157 | "unknown action: %s" % parsed_rule["action"]) | ||
563 | 1158 | |||
564 | 1159 | if action == "add" and rule is None: | ||
565 | 1160 | rule = repository.addRule( | ||
566 | 1161 | parsed_rule["pattern"], self.user, | ||
567 | 1162 | position=parsed_rule["position"]) | ||
568 | 1163 | if ref_pattern == self.tags_prefix: | ||
569 | 1164 | # Tags are a special case: on creation, they | ||
570 | 1165 | # automatically get a grant of create permissions to | ||
571 | 1166 | # the repository owner (suppressing the normal | ||
572 | 1167 | # ability of the repository owner to push protected | ||
573 | 1168 | # references). | ||
574 | 1169 | rule.addGrant( | ||
575 | 1170 | GitGranteeType.REPOSITORY_OWNER, self.user, | ||
576 | 1171 | can_create=True) | ||
577 | 1172 | elif action == "change" and rule is not None: | ||
578 | 1173 | self.repository.moveRule( | ||
579 | 1174 | rule, parsed_rule["position"], self.user) | ||
580 | 1175 | if parsed_rule["new_pattern"] != rule.ref_pattern: | ||
581 | 1176 | with notify_modified(rule, ["ref_pattern"]): | ||
582 | 1177 | rule.ref_pattern = parsed_rule["new_pattern"] | ||
583 | 1178 | elif action == "delete" and rule is not None: | ||
584 | 1179 | rule.destroySelf(self.user) | ||
585 | 1180 | rule_map[parsed_rule["pattern"]] = None | ||
586 | 1181 | else: | ||
587 | 1182 | raise AssertionError( | ||
588 | 1183 | "unknown action: %s" % parsed_rule["action"]) | ||
589 | 1184 | |||
590 | 1185 | for ref_pattern, parsed_grants in sorted( | ||
591 | 1186 | parsed_data["grants"].items()): | ||
592 | 1187 | if ref_pattern not in rule_map: | ||
593 | 1188 | self.addError(structured( | ||
594 | 1189 | "Cannot edit grants for nonexistent rule %s", ref_pattern)) | ||
595 | 1190 | return | ||
596 | 1191 | rule = rule_map.get(ref_pattern) | ||
597 | 1192 | if rule is None: | ||
598 | 1193 | # Already deleted. | ||
599 | 1194 | continue | ||
600 | 1195 | |||
601 | 1196 | for parsed_grant in parsed_grants: | ||
602 | 1197 | grant = grant_map.get((ref_pattern, parsed_grant["grantee"])) | ||
603 | 1198 | action = parsed_grant["action"] | ||
604 | 1199 | if action not in ("add", "change", "delete"): | ||
605 | 1200 | raise AssertionError( | ||
606 | 1201 | "unknown action: %s" % parsed_rule["action"]) | ||
607 | 1202 | |||
608 | 1203 | if action == "add" and grant is None: | ||
609 | 1204 | rule.addGrant( | ||
610 | 1205 | parsed_grant["grantee"], self.user, | ||
611 | 1206 | permissions=parsed_grant["permissions"]) | ||
612 | 1207 | elif (action in ("add", "change") and grant is not None and | ||
613 | 1208 | parsed_grant["permissions"] != grant.permissions): | ||
614 | 1209 | # Make the requested changes. This can happen in the | ||
615 | 1210 | # add case if somebody else added the grant since the | ||
616 | 1211 | # form was last rendered, in which case updating it with | ||
617 | 1212 | # the permissions from this request seems best. | ||
618 | 1213 | with notify_modified( | ||
619 | 1214 | grant, | ||
620 | 1215 | ["can_create", "can_push", "can_force_push"]): | ||
621 | 1216 | grant.permissions = parsed_grant["permissions"] | ||
622 | 1217 | elif action == "delete" and grant is not None: | ||
623 | 1218 | grant.destroySelf(self.user) | ||
624 | 1219 | grant_map[(ref_pattern, parsed_grant["grantee"])] = None | ||
625 | 1220 | |||
626 | 1221 | @action("Save", name="save") | ||
627 | 1222 | def save_action(self, action, data): | ||
628 | 1223 | with notify_modified(self.repository, []): | ||
629 | 1224 | parsed_data = self.parseData(data) | ||
630 | 1225 | self.updateRepositoryFromData(self.repository, parsed_data) | ||
631 | 1226 | |||
632 | 1227 | self.request.response.addNotification( | ||
633 | 1228 | "Saved permissions for %s" % self.context.identity) | ||
634 | 1229 | self.next_url = canonical_url(self.context, view_name="+permissions") | ||
635 | 1230 | |||
636 | 1231 | |||
637 | 712 | class GitRepositoryDeletionView(LaunchpadFormView): | 1232 | class GitRepositoryDeletionView(LaunchpadFormView): |
638 | 713 | 1233 | ||
639 | 714 | schema = IGitRepository | 1234 | schema = IGitRepository |
640 | 715 | 1235 | ||
641 | === modified file 'lib/lp/code/browser/tests/test_gitrepository.py' | |||
642 | --- lib/lp/code/browser/tests/test_gitrepository.py 2018-11-08 15:33:03 +0000 | |||
643 | +++ lib/lp/code/browser/tests/test_gitrepository.py 2019-01-10 10:50:57 +0000 | |||
644 | @@ -1,4 +1,4 @@ | |||
646 | 1 | # Copyright 2015-2018 Canonical Ltd. This software is licensed under the | 1 | # Copyright 2015-2019 Canonical Ltd. This software is licensed under the |
647 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). |
648 | 3 | 3 | ||
649 | 4 | """Unit tests for GitRepositoryView.""" | 4 | """Unit tests for GitRepositoryView.""" |
650 | @@ -7,16 +7,27 @@ | |||
651 | 7 | 7 | ||
652 | 8 | __metaclass__ = type | 8 | __metaclass__ = type |
653 | 9 | 9 | ||
654 | 10 | import base64 | ||
655 | 10 | from datetime import datetime | 11 | from datetime import datetime |
656 | 11 | import doctest | 12 | import doctest |
657 | 13 | from itertools import chain | ||
658 | 14 | from operator import attrgetter | ||
659 | 15 | import re | ||
660 | 12 | from textwrap import dedent | 16 | from textwrap import dedent |
661 | 13 | 17 | ||
662 | 14 | from fixtures import FakeLogger | 18 | from fixtures import FakeLogger |
663 | 15 | import pytz | 19 | import pytz |
664 | 20 | import soupmatchers | ||
665 | 16 | from storm.store import Store | 21 | from storm.store import Store |
666 | 17 | from testtools.matchers import ( | 22 | from testtools.matchers import ( |
667 | 23 | AfterPreprocessing, | ||
668 | 18 | DocTestMatches, | 24 | DocTestMatches, |
669 | 19 | Equals, | 25 | Equals, |
670 | 26 | Is, | ||
671 | 27 | MatchesDict, | ||
672 | 28 | MatchesListwise, | ||
673 | 29 | MatchesSetwise, | ||
674 | 30 | MatchesStructure, | ||
675 | 20 | ) | 31 | ) |
676 | 21 | import transaction | 32 | import transaction |
677 | 22 | from zope.component import getUtility | 33 | from zope.component import getUtility |
678 | @@ -26,11 +37,16 @@ | |||
679 | 26 | from zope.security.proxy import removeSecurityProxy | 37 | from zope.security.proxy import removeSecurityProxy |
680 | 27 | 38 | ||
681 | 28 | from lp.app.enums import InformationType | 39 | from lp.app.enums import InformationType |
682 | 40 | from lp.app.errors import UnexpectedFormData | ||
683 | 29 | from lp.app.interfaces.launchpad import ILaunchpadCelebrities | 41 | from lp.app.interfaces.launchpad import ILaunchpadCelebrities |
684 | 30 | from lp.app.interfaces.services import IService | 42 | from lp.app.interfaces.services import IService |
685 | 43 | from lp.code.browser.gitrepository import encode_form_field_id | ||
686 | 31 | from lp.code.enums import ( | 44 | from lp.code.enums import ( |
687 | 32 | BranchMergeProposalStatus, | 45 | BranchMergeProposalStatus, |
688 | 33 | CodeReviewVote, | 46 | CodeReviewVote, |
689 | 47 | GitActivityType, | ||
690 | 48 | GitGranteeType, | ||
691 | 49 | GitPermissionType, | ||
692 | 34 | GitRepositoryType, | 50 | GitRepositoryType, |
693 | 35 | ) | 51 | ) |
694 | 36 | from lp.code.interfaces.revision import IRevisionSet | 52 | from lp.code.interfaces.revision import IRevisionSet |
695 | @@ -40,7 +56,10 @@ | |||
696 | 40 | VCSType, | 56 | VCSType, |
697 | 41 | ) | 57 | ) |
698 | 42 | from lp.registry.interfaces.accesspolicy import IAccessPolicySource | 58 | from lp.registry.interfaces.accesspolicy import IAccessPolicySource |
700 | 43 | from lp.registry.interfaces.person import PersonVisibility | 59 | from lp.registry.interfaces.person import ( |
701 | 60 | IPerson, | ||
702 | 61 | PersonVisibility, | ||
703 | 62 | ) | ||
704 | 44 | from lp.services.beautifulsoup import BeautifulSoup | 63 | from lp.services.beautifulsoup import BeautifulSoup |
705 | 45 | from lp.services.database.constants import UTC_NOW | 64 | from lp.services.database.constants import UTC_NOW |
706 | 46 | from lp.services.features.testing import FeatureFixture | 65 | from lp.services.features.testing import FeatureFixture |
707 | @@ -67,6 +86,7 @@ | |||
708 | 67 | from lp.testing.pages import ( | 86 | from lp.testing.pages import ( |
709 | 68 | extract_text, | 87 | extract_text, |
710 | 69 | find_tag_by_id, | 88 | find_tag_by_id, |
711 | 89 | find_tags_by_class, | ||
712 | 70 | get_feedback_messages, | 90 | get_feedback_messages, |
713 | 71 | setupBrowser, | 91 | setupBrowser, |
714 | 72 | setupBrowserForUser, | 92 | setupBrowserForUser, |
715 | @@ -1097,6 +1117,625 @@ | |||
716 | 1097 | browser.headers["Content-Disposition"]) | 1117 | browser.headers["Content-Disposition"]) |
717 | 1098 | 1118 | ||
718 | 1099 | 1119 | ||
719 | 1120 | class TestGitRepositoryPermissionsView(BrowserTestCase): | ||
720 | 1121 | |||
721 | 1122 | layer = DatabaseFunctionalLayer | ||
722 | 1123 | |||
723 | 1124 | def test_rules_properties(self): | ||
724 | 1125 | repository = self.factory.makeGitRepository() | ||
725 | 1126 | heads_rule = self.factory.makeGitRule( | ||
726 | 1127 | repository=repository, ref_pattern="refs/heads/*") | ||
727 | 1128 | tags_rule = self.factory.makeGitRule( | ||
728 | 1129 | repository=repository, ref_pattern="refs/tags/*") | ||
729 | 1130 | catch_all_rule = self.factory.makeGitRule( | ||
730 | 1131 | repository=repository, ref_pattern="*") | ||
731 | 1132 | login_person(repository.owner) | ||
732 | 1133 | view = create_initialized_view(repository, name="+permissions") | ||
733 | 1134 | self.assertEqual([heads_rule], view.branch_rules) | ||
734 | 1135 | self.assertEqual([tags_rule], view.tag_rules) | ||
735 | 1136 | self.assertEqual([catch_all_rule], view.other_rules) | ||
736 | 1137 | |||
737 | 1138 | def test__getRuleGrants(self): | ||
738 | 1139 | rule = self.factory.makeGitRule() | ||
739 | 1140 | grantees = sorted( | ||
740 | 1141 | [self.factory.makePerson() for _ in range(3)], | ||
741 | 1142 | key=attrgetter("name")) | ||
742 | 1143 | for grantee in (grantees[1], grantees[0], grantees[2]): | ||
743 | 1144 | self.factory.makeGitRuleGrant(rule=rule, grantee=grantee) | ||
744 | 1145 | self.factory.makeGitRuleGrant( | ||
745 | 1146 | rule=rule, grantee=GitGranteeType.REPOSITORY_OWNER) | ||
746 | 1147 | login_person(rule.repository.owner) | ||
747 | 1148 | view = create_initialized_view(rule.repository, name="+permissions") | ||
748 | 1149 | self.assertThat(view._getRuleGrants(rule), MatchesListwise([ | ||
749 | 1150 | MatchesStructure.byEquality( | ||
750 | 1151 | grantee_type=GitGranteeType.REPOSITORY_OWNER), | ||
751 | 1152 | MatchesStructure.byEquality(grantee=grantees[0]), | ||
752 | 1153 | MatchesStructure.byEquality(grantee=grantees[1]), | ||
753 | 1154 | MatchesStructure.byEquality(grantee=grantees[2]), | ||
754 | 1155 | ])) | ||
755 | 1156 | |||
756 | 1157 | def test__parseRefPattern(self): | ||
757 | 1158 | repository = self.factory.makeGitRepository() | ||
758 | 1159 | login_person(repository.owner) | ||
759 | 1160 | view = create_initialized_view(repository, name="+permissions") | ||
760 | 1161 | self.assertEqual( | ||
761 | 1162 | ("refs/heads/", "stable/*"), | ||
762 | 1163 | view._parseRefPattern("refs/heads/stable/*")) | ||
763 | 1164 | self.assertEqual( | ||
764 | 1165 | ("refs/tags/", "1.0"), view._parseRefPattern("refs/tags/1.0")) | ||
765 | 1166 | self.assertEqual( | ||
766 | 1167 | ("", "refs/other/*"), view._parseRefPattern("refs/other/*")) | ||
767 | 1168 | self.assertEqual(("", "*"), view._parseRefPattern("*")) | ||
768 | 1169 | |||
769 | 1170 | def test__getFieldName_no_grantee(self): | ||
770 | 1171 | repository = self.factory.makeGitRepository() | ||
771 | 1172 | login_person(repository.owner) | ||
772 | 1173 | view = create_initialized_view(repository, name="+permissions") | ||
773 | 1174 | encoded_ref_pattern = base64.b32encode( | ||
774 | 1175 | b"refs/heads/*").replace("=", "_").decode("UTF-8") | ||
775 | 1176 | self.assertEqual( | ||
776 | 1177 | "field.%s" % encoded_ref_pattern, | ||
777 | 1178 | view._getFieldName("field", "refs/heads/*")) | ||
778 | 1179 | |||
779 | 1180 | def test__getFieldName_grantee_repository_owner(self): | ||
780 | 1181 | repository = self.factory.makeGitRepository() | ||
781 | 1182 | login_person(repository.owner) | ||
782 | 1183 | view = create_initialized_view(repository, name="+permissions") | ||
783 | 1184 | encoded_ref_pattern = base64.b32encode( | ||
784 | 1185 | b"refs/tags/*").replace("=", "_").decode("UTF-8") | ||
785 | 1186 | self.assertEqual( | ||
786 | 1187 | "field.%s._repository_owner" % encoded_ref_pattern, | ||
787 | 1188 | view._getFieldName( | ||
788 | 1189 | "field", "refs/tags/*", | ||
789 | 1190 | grantee=GitGranteeType.REPOSITORY_OWNER)) | ||
790 | 1191 | |||
791 | 1192 | def test__getFieldName_grantee_person(self): | ||
792 | 1193 | repository = self.factory.makeGitRepository() | ||
793 | 1194 | grantee = self.factory.makePerson() | ||
794 | 1195 | login_person(repository.owner) | ||
795 | 1196 | view = create_initialized_view(repository, name="+permissions") | ||
796 | 1197 | encoded_ref_pattern = base64.b32encode( | ||
797 | 1198 | b"refs/*").replace("=", "_").decode("UTF-8") | ||
798 | 1199 | self.assertEqual( | ||
799 | 1200 | "field.%s.%s" % (encoded_ref_pattern, grantee.id), | ||
800 | 1201 | view._getFieldName("field", "refs/*", grantee=grantee)) | ||
801 | 1202 | |||
802 | 1203 | def test__parseFieldName_too_few_components(self): | ||
803 | 1204 | repository = self.factory.makeGitRepository() | ||
804 | 1205 | login_person(repository.owner) | ||
805 | 1206 | view = create_initialized_view(repository, name="+permissions") | ||
806 | 1207 | self.assertRaises(UnexpectedFormData, view._parseFieldName, "field") | ||
807 | 1208 | |||
808 | 1209 | def test__parseFieldName_bad_ref_pattern(self): | ||
809 | 1210 | repository = self.factory.makeGitRepository() | ||
810 | 1211 | login_person(repository.owner) | ||
811 | 1212 | view = create_initialized_view(repository, name="+permissions") | ||
812 | 1213 | self.assertRaises( | ||
813 | 1214 | UnexpectedFormData, view._parseFieldName, "field.nonsense") | ||
814 | 1215 | |||
815 | 1216 | def test__parseFieldName_no_grantee(self): | ||
816 | 1217 | repository = self.factory.makeGitRepository() | ||
817 | 1218 | login_person(repository.owner) | ||
818 | 1219 | view = create_initialized_view(repository, name="+permissions") | ||
819 | 1220 | encoded_ref_pattern = base64.b32encode( | ||
820 | 1221 | b"refs/heads/*").replace("=", "_").decode("UTF-8") | ||
821 | 1222 | self.assertEqual( | ||
822 | 1223 | ("permissions", "refs/heads/*", None), | ||
823 | 1224 | view._parseFieldName("permissions.%s" % encoded_ref_pattern)) | ||
824 | 1225 | |||
825 | 1226 | def test__parseFieldName_grantee_unknown_type(self): | ||
826 | 1227 | repository = self.factory.makeGitRepository() | ||
827 | 1228 | login_person(repository.owner) | ||
828 | 1229 | view = create_initialized_view(repository, name="+permissions") | ||
829 | 1230 | encoded_ref_pattern = base64.b32encode( | ||
830 | 1231 | b"refs/tags/*").replace("=", "_").decode("UTF-8") | ||
831 | 1232 | self.assertRaises( | ||
832 | 1233 | UnexpectedFormData, view._parseFieldName, | ||
833 | 1234 | "field.%s._nonsense" % encoded_ref_pattern) | ||
834 | 1235 | self.assertRaises( | ||
835 | 1236 | UnexpectedFormData, view._parseFieldName, | ||
836 | 1237 | "field.%s._person" % encoded_ref_pattern) | ||
837 | 1238 | |||
838 | 1239 | def test__parseFieldName_grantee_repository_owner(self): | ||
839 | 1240 | repository = self.factory.makeGitRepository() | ||
840 | 1241 | login_person(repository.owner) | ||
841 | 1242 | view = create_initialized_view(repository, name="+permissions") | ||
842 | 1243 | encoded_ref_pattern = base64.b32encode( | ||
843 | 1244 | b"refs/tags/*").replace("=", "_").decode("UTF-8") | ||
844 | 1245 | self.assertEqual( | ||
845 | 1246 | ("pattern", "refs/tags/*", GitGranteeType.REPOSITORY_OWNER), | ||
846 | 1247 | view._parseFieldName( | ||
847 | 1248 | "pattern.%s._repository_owner" % encoded_ref_pattern)) | ||
848 | 1249 | |||
849 | 1250 | def test__parseFieldName_grantee_unknown_person(self): | ||
850 | 1251 | repository = self.factory.makeGitRepository() | ||
851 | 1252 | grantee = self.factory.makePerson() | ||
852 | 1253 | login_person(repository.owner) | ||
853 | 1254 | view = create_initialized_view(repository, name="+permissions") | ||
854 | 1255 | encoded_ref_pattern = base64.b32encode( | ||
855 | 1256 | b"refs/*").replace("=", "_").decode("UTF-8") | ||
856 | 1257 | self.assertRaises( | ||
857 | 1258 | UnexpectedFormData, view._parseFieldName, | ||
858 | 1259 | "delete.%s.%s" % (encoded_ref_pattern, grantee.id * 2)) | ||
859 | 1260 | |||
860 | 1261 | def test__parseFieldName_grantee_person(self): | ||
861 | 1262 | repository = self.factory.makeGitRepository() | ||
862 | 1263 | grantee = self.factory.makePerson() | ||
863 | 1264 | login_person(repository.owner) | ||
864 | 1265 | view = create_initialized_view(repository, name="+permissions") | ||
865 | 1266 | encoded_ref_pattern = base64.b32encode( | ||
866 | 1267 | b"refs/*").replace("=", "_").decode("UTF-8") | ||
867 | 1268 | self.assertEqual( | ||
868 | 1269 | ("delete", "refs/*", grantee), | ||
869 | 1270 | view._parseFieldName( | ||
870 | 1271 | "delete.%s.%s" % (encoded_ref_pattern, grantee.id))) | ||
871 | 1272 | |||
872 | 1273 | def test__getPermissionsTerm_standard(self): | ||
873 | 1274 | grant = self.factory.makeGitRuleGrant( | ||
874 | 1275 | ref_pattern="refs/heads/*", can_create=True, can_push=True) | ||
875 | 1276 | login_person(grant.repository.owner) | ||
876 | 1277 | view = create_initialized_view(grant.repository, name="+permissions") | ||
877 | 1278 | self.assertThat( | ||
878 | 1279 | view._getPermissionsTerm(grant), MatchesStructure.byEquality( | ||
879 | 1280 | value={ | ||
880 | 1281 | GitPermissionType.CAN_CREATE, GitPermissionType.CAN_PUSH}, | ||
881 | 1282 | token="can_push", | ||
882 | 1283 | title="Can push")) | ||
883 | 1284 | |||
884 | 1285 | def test__getPermissionsTerm_custom(self): | ||
885 | 1286 | grant = self.factory.makeGitRuleGrant( | ||
886 | 1287 | ref_pattern="refs/heads/*", can_force_push=True) | ||
887 | 1288 | login_person(grant.repository.owner) | ||
888 | 1289 | view = create_initialized_view(grant.repository, name="+permissions") | ||
889 | 1290 | self.assertThat( | ||
890 | 1291 | view._getPermissionsTerm(grant), MatchesStructure.byEquality( | ||
891 | 1292 | value={GitPermissionType.CAN_FORCE_PUSH}, | ||
892 | 1293 | token="custom", | ||
893 | 1294 | title="Custom permissions: force-push")) | ||
894 | 1295 | |||
895 | 1296 | def _matchesCells(self, row_tag, cell_matchers): | ||
896 | 1297 | return AfterPreprocessing( | ||
897 | 1298 | str, soupmatchers.HTMLContains(*( | ||
898 | 1299 | soupmatchers.Within(row_tag, cell_matcher) | ||
899 | 1300 | for cell_matcher in cell_matchers))) | ||
900 | 1301 | |||
901 | 1302 | def _matchesRule(self, position, pattern, short_pattern): | ||
902 | 1303 | rule_tag = soupmatchers.Tag( | ||
903 | 1304 | "rule row", "tr", attrs={"class": "git-rule"}) | ||
904 | 1305 | suffix = "." + encode_form_field_id(pattern) | ||
905 | 1306 | position_field_name = "field.position" + suffix | ||
906 | 1307 | pattern_field_name = "field.pattern" + suffix | ||
907 | 1308 | delete_field_name = "field.delete" + suffix | ||
908 | 1309 | return self._matchesCells(rule_tag, [ | ||
909 | 1310 | soupmatchers.Within( | ||
910 | 1311 | soupmatchers.Tag( | ||
911 | 1312 | "position cell", "td", | ||
912 | 1313 | attrs={"class": "git-rule-position"}), | ||
913 | 1314 | soupmatchers.Tag( | ||
914 | 1315 | "position widget", "input", | ||
915 | 1316 | attrs={"name": position_field_name, "value": position})), | ||
916 | 1317 | soupmatchers.Within( | ||
917 | 1318 | soupmatchers.Tag( | ||
918 | 1319 | "pattern cell", "td", attrs={"class": "git-rule-pattern"}), | ||
919 | 1320 | soupmatchers.Tag( | ||
920 | 1321 | "pattern widget", "input", | ||
921 | 1322 | attrs={ | ||
922 | 1323 | "name": pattern_field_name, | ||
923 | 1324 | "value": short_pattern, | ||
924 | 1325 | })), | ||
925 | 1326 | soupmatchers.Within( | ||
926 | 1327 | soupmatchers.Tag( | ||
927 | 1328 | "delete cell", "td", attrs={"class": "git-rule-delete"}), | ||
928 | 1329 | soupmatchers.Tag( | ||
929 | 1330 | "delete widget", "input", | ||
930 | 1331 | attrs={"name": delete_field_name})), | ||
931 | 1332 | ]) | ||
932 | 1333 | |||
933 | 1334 | def _matchesNewRule(self, ref_prefix): | ||
934 | 1335 | new_rule_tag = soupmatchers.Tag( | ||
935 | 1336 | "new rule row", "tr", attrs={"class": "git-new-rule"}) | ||
936 | 1337 | suffix = "." + encode_form_field_id(ref_prefix) | ||
937 | 1338 | new_position_field_name = "field.new-position" + suffix | ||
938 | 1339 | new_pattern_field_name = "field.new-pattern" + suffix | ||
939 | 1340 | return self._matchesCells(new_rule_tag, [ | ||
940 | 1341 | soupmatchers.Within( | ||
941 | 1342 | soupmatchers.Tag( | ||
942 | 1343 | "position cell", "td", | ||
943 | 1344 | attrs={"class": "git-rule-position"}), | ||
944 | 1345 | soupmatchers.Tag( | ||
945 | 1346 | "position widget", "input", | ||
946 | 1347 | attrs={"name": new_position_field_name, "value": ""})), | ||
947 | 1348 | soupmatchers.Within( | ||
948 | 1349 | soupmatchers.Tag( | ||
949 | 1350 | "pattern cell", "td", attrs={"class": "git-rule-pattern"}), | ||
950 | 1351 | soupmatchers.Tag( | ||
951 | 1352 | "pattern widget", "input", | ||
952 | 1353 | attrs={"name": new_pattern_field_name, "value": ""})), | ||
953 | 1354 | ]) | ||
954 | 1355 | |||
955 | 1356 | def _matchesRuleGrant(self, pattern, grantee, permissions_token, | ||
956 | 1357 | permissions_title): | ||
957 | 1358 | rule_grant_tag = soupmatchers.Tag( | ||
958 | 1359 | "rule grant row", "tr", attrs={"class": "git-rule-grant"}) | ||
959 | 1360 | suffix = "." + encode_form_field_id(pattern) | ||
960 | 1361 | if IPerson.providedBy(grantee): | ||
961 | 1362 | suffix += "." + str(grantee.id) | ||
962 | 1363 | grantee_url = canonical_url(grantee, path_only_if_possible=True) | ||
963 | 1364 | grantee_widget_matcher = soupmatchers.Tag( | ||
964 | 1365 | "grantee widget", "a", attrs={"href": grantee_url}, | ||
965 | 1366 | text=grantee.display_name) | ||
966 | 1367 | else: | ||
967 | 1368 | suffix += "._" + grantee.name.lower() | ||
968 | 1369 | grantee_widget_matcher = soupmatchers.Tag( | ||
969 | 1370 | "grantee widget", "label", | ||
970 | 1371 | text=re.compile(re.escape(grantee.title))) | ||
971 | 1372 | permissions_field_name = "field.permissions" + suffix | ||
972 | 1373 | delete_field_name = "field.delete" + suffix | ||
973 | 1374 | return self._matchesCells(rule_grant_tag, [ | ||
974 | 1375 | soupmatchers.Within( | ||
975 | 1376 | soupmatchers.Tag( | ||
976 | 1377 | "grantee cell", "td", | ||
977 | 1378 | attrs={"class": "git-rule-grant-grantee"}), | ||
978 | 1379 | grantee_widget_matcher), | ||
979 | 1380 | soupmatchers.Within( | ||
980 | 1381 | soupmatchers.Tag( | ||
981 | 1382 | "permissions cell", "td", | ||
982 | 1383 | attrs={"class": "git-rule-grant-permissions"}), | ||
983 | 1384 | soupmatchers.Within( | ||
984 | 1385 | soupmatchers.Tag( | ||
985 | 1386 | "permissions widget", "select", | ||
986 | 1387 | attrs={"name": permissions_field_name}), | ||
987 | 1388 | soupmatchers.Tag( | ||
988 | 1389 | "selected permissions option", "option", | ||
989 | 1390 | attrs={ | ||
990 | 1391 | "selected": "selected", | ||
991 | 1392 | "value": permissions_token, | ||
992 | 1393 | }, | ||
993 | 1394 | text=permissions_title))), | ||
994 | 1395 | soupmatchers.Within( | ||
995 | 1396 | soupmatchers.Tag( | ||
996 | 1397 | "delete cell", "td", attrs={"class": "git-rule-delete"}), | ||
997 | 1398 | soupmatchers.Tag( | ||
998 | 1399 | "delete widget", "input", | ||
999 | 1400 | attrs={"name": delete_field_name})), | ||
1000 | 1401 | ]) | ||
1001 | 1402 | |||
1002 | 1403 | def _matchesNewRuleGrant(self, pattern, permissions_token): | ||
1003 | 1404 | rule_grant_tag = soupmatchers.Tag( | ||
1004 | 1405 | "rule grant row", "tr", attrs={"class": "git-new-rule-grant"}) | ||
1005 | 1406 | suffix = "." + encode_form_field_id(pattern) | ||
1006 | 1407 | grantee_field_name = "field.grantee" + suffix | ||
1007 | 1408 | permissions_field_name = "field.permissions" + suffix | ||
1008 | 1409 | return self._matchesCells(rule_grant_tag, [ | ||
1009 | 1410 | soupmatchers.Within( | ||
1010 | 1411 | soupmatchers.Tag( | ||
1011 | 1412 | "grantee cell", "td", | ||
1012 | 1413 | attrs={"class": "git-rule-grant-grantee"}), | ||
1013 | 1414 | soupmatchers.Tag( | ||
1014 | 1415 | "grantee widget", "input", | ||
1015 | 1416 | attrs={"name": grantee_field_name})), | ||
1016 | 1417 | soupmatchers.Within( | ||
1017 | 1418 | soupmatchers.Tag( | ||
1018 | 1419 | "permissions cell", "td", | ||
1019 | 1420 | attrs={"class": "git-rule-grant-permissions"}), | ||
1020 | 1421 | soupmatchers.Within( | ||
1021 | 1422 | soupmatchers.Tag( | ||
1022 | 1423 | "permissions widget", "select", | ||
1023 | 1424 | attrs={"name": permissions_field_name}), | ||
1024 | 1425 | soupmatchers.Tag( | ||
1025 | 1426 | "selected permissions option", "option", | ||
1026 | 1427 | attrs={ | ||
1027 | 1428 | "selected": "selected", | ||
1028 | 1429 | "value": permissions_token, | ||
1029 | 1430 | }))), | ||
1030 | 1431 | ]) | ||
1031 | 1432 | |||
1032 | 1433 | def test_rules_tables(self): | ||
1033 | 1434 | repository = self.factory.makeGitRepository() | ||
1034 | 1435 | heads_rule = self.factory.makeGitRule( | ||
1035 | 1436 | repository=repository, ref_pattern="refs/heads/stable/*") | ||
1036 | 1437 | heads_grantee_1 = self.factory.makePerson( | ||
1037 | 1438 | name=self.factory.getUniqueString("person-name-a")) | ||
1038 | 1439 | heads_grantee_2 = self.factory.makePerson( | ||
1039 | 1440 | name=self.factory.getUniqueString("person-name-b")) | ||
1040 | 1441 | self.factory.makeGitRuleGrant( | ||
1041 | 1442 | rule=heads_rule, grantee=heads_grantee_1, can_push=True) | ||
1042 | 1443 | self.factory.makeGitRuleGrant( | ||
1043 | 1444 | rule=heads_rule, grantee=heads_grantee_2, can_force_push=True) | ||
1044 | 1445 | tags_rule = self.factory.makeGitRule( | ||
1045 | 1446 | repository=repository, ref_pattern="refs/tags/*") | ||
1046 | 1447 | self.factory.makeGitRuleGrant( | ||
1047 | 1448 | rule=tags_rule, grantee=GitGranteeType.REPOSITORY_OWNER) | ||
1048 | 1449 | login_person(repository.owner) | ||
1049 | 1450 | view = create_initialized_view( | ||
1050 | 1451 | repository, name="+permissions", principal=repository.owner) | ||
1051 | 1452 | rules_tables = find_tags_by_class(view(), "git-rules-table") | ||
1052 | 1453 | rows = list(chain.from_iterable([ | ||
1053 | 1454 | rules_table.findAll("tr", {"class": True}) | ||
1054 | 1455 | for rules_table in rules_tables])) | ||
1055 | 1456 | self.assertThat(rows, MatchesListwise([ | ||
1056 | 1457 | self._matchesRule("1", "refs/heads/stable/*", "stable/*"), | ||
1057 | 1458 | self._matchesRuleGrant( | ||
1058 | 1459 | "refs/heads/stable/*", heads_grantee_1, "can_push_existing", | ||
1059 | 1460 | "Can push if the branch already exists"), | ||
1060 | 1461 | self._matchesRuleGrant( | ||
1061 | 1462 | "refs/heads/stable/*", heads_grantee_2, "custom", | ||
1062 | 1463 | "Custom permissions: force-push"), | ||
1063 | 1464 | self._matchesNewRuleGrant("refs/heads/stable/*", "can_push"), | ||
1064 | 1465 | self._matchesNewRule("refs/heads/"), | ||
1065 | 1466 | self._matchesRule("2", "refs/tags/*", "*"), | ||
1066 | 1467 | self._matchesRuleGrant( | ||
1067 | 1468 | "refs/tags/*", GitGranteeType.REPOSITORY_OWNER, | ||
1068 | 1469 | "cannot_create", "Cannot create"), | ||
1069 | 1470 | self._matchesNewRuleGrant("refs/tags/*", "can_create"), | ||
1070 | 1471 | self._matchesNewRule("refs/tags/"), | ||
1071 | 1472 | ])) | ||
1072 | 1473 | |||
1073 | 1474 | def assertHasRules(self, repository, ref_patterns): | ||
1074 | 1475 | self.assertThat(list(repository.rules), MatchesListwise([ | ||
1075 | 1476 | MatchesStructure.byEquality(ref_pattern=ref_pattern) | ||
1076 | 1477 | for ref_pattern in ref_patterns | ||
1077 | 1478 | ])) | ||
1078 | 1479 | |||
1079 | 1480 | def assertHasSavedNotification(self, view, repository): | ||
1080 | 1481 | self.assertThat(view.request.response.notifications, MatchesListwise([ | ||
1081 | 1482 | MatchesStructure.byEquality( | ||
1082 | 1483 | message="Saved permissions for %s" % repository.identity), | ||
1083 | 1484 | ])) | ||
1084 | 1485 | |||
1085 | 1486 | def test_save_add_rules(self): | ||
1086 | 1487 | repository = self.factory.makeGitRepository() | ||
1087 | 1488 | self.factory.makeGitRule( | ||
1088 | 1489 | repository=repository, ref_pattern="refs/heads/stable/*") | ||
1089 | 1490 | removeSecurityProxy(repository.getActivity()).remove() | ||
1090 | 1491 | login_person(repository.owner) | ||
1091 | 1492 | encoded_heads_prefix = encode_form_field_id("refs/heads/") | ||
1092 | 1493 | encoded_tags_prefix = encode_form_field_id("refs/tags/") | ||
1093 | 1494 | form = { | ||
1094 | 1495 | "field.new-pattern." + encoded_heads_prefix: "*", | ||
1095 | 1496 | "field.new-pattern." + encoded_tags_prefix: "1.0", | ||
1096 | 1497 | "field.actions.save": "Save", | ||
1097 | 1498 | } | ||
1098 | 1499 | view = create_initialized_view( | ||
1099 | 1500 | repository, name="+permissions", form=form, | ||
1100 | 1501 | principal=repository.owner) | ||
1101 | 1502 | self.assertHasRules( | ||
1102 | 1503 | repository, | ||
1103 | 1504 | ["refs/tags/1.0", "refs/heads/stable/*", "refs/heads/*"]) | ||
1104 | 1505 | self.assertThat(list(repository.getActivity()), MatchesListwise([ | ||
1105 | 1506 | # Adding a tag rule automatically adds a repository owner grant. | ||
1106 | 1507 | MatchesStructure( | ||
1107 | 1508 | changer=Equals(repository.owner), | ||
1108 | 1509 | changee=Is(None), | ||
1109 | 1510 | what_changed=Equals(GitActivityType.GRANT_ADDED), | ||
1110 | 1511 | new_value=MatchesDict({ | ||
1111 | 1512 | "changee_type": Equals("Repository owner"), | ||
1112 | 1513 | "ref_pattern": Equals("refs/tags/1.0"), | ||
1113 | 1514 | "can_create": Is(True), | ||
1114 | 1515 | "can_push": Is(False), | ||
1115 | 1516 | "can_force_push": Is(False), | ||
1116 | 1517 | })), | ||
1117 | 1518 | MatchesStructure( | ||
1118 | 1519 | changer=Equals(repository.owner), | ||
1119 | 1520 | what_changed=Equals(GitActivityType.RULE_ADDED), | ||
1120 | 1521 | new_value=MatchesDict({ | ||
1121 | 1522 | "ref_pattern": Equals("refs/tags/1.0"), | ||
1122 | 1523 | "position": Equals(0), | ||
1123 | 1524 | })), | ||
1124 | 1525 | MatchesStructure( | ||
1125 | 1526 | changer=Equals(repository.owner), | ||
1126 | 1527 | what_changed=Equals(GitActivityType.RULE_ADDED), | ||
1127 | 1528 | new_value=MatchesDict({ | ||
1128 | 1529 | "ref_pattern": Equals("refs/heads/*"), | ||
1129 | 1530 | # Initially inserted at 1, although refs/tags/1.0 was | ||
1130 | 1531 | # later inserted before it. | ||
1131 | 1532 | "position": Equals(1), | ||
1132 | 1533 | })), | ||
1133 | 1534 | ])) | ||
1134 | 1535 | self.assertHasSavedNotification(view, repository) | ||
1135 | 1536 | |||
1136 | 1537 | def test_save_add_duplicate_rule(self): | ||
1137 | 1538 | repository = self.factory.makeGitRepository() | ||
1138 | 1539 | self.factory.makeGitRule( | ||
1139 | 1540 | repository=repository, ref_pattern="refs/heads/stable/*") | ||
1140 | 1541 | transaction.commit() | ||
1141 | 1542 | login_person(repository.owner) | ||
1142 | 1543 | encoded_heads_prefix = encode_form_field_id("refs/heads/") | ||
1143 | 1544 | form = { | ||
1144 | 1545 | "field.new-pattern." + encoded_heads_prefix: "stable/*", | ||
1145 | 1546 | "field.actions.save": "Save", | ||
1146 | 1547 | } | ||
1147 | 1548 | view = create_initialized_view( | ||
1148 | 1549 | repository, name="+permissions", form=form, | ||
1149 | 1550 | principal=repository.owner) | ||
1150 | 1551 | self.assertThat(view.errors, MatchesListwise([ | ||
1151 | 1552 | MatchesStructure( | ||
1152 | 1553 | field_name=Equals("new-pattern." + encoded_heads_prefix), | ||
1153 | 1554 | errors=MatchesStructure.byEquality( | ||
1154 | 1555 | args=("stable/* is already in use by another rule",))), | ||
1155 | 1556 | ])) | ||
1156 | 1557 | self.assertHasRules(repository, ["refs/heads/stable/*"]) | ||
1157 | 1558 | |||
1158 | 1559 | def test_save_move_rule(self): | ||
1159 | 1560 | repository = self.factory.makeGitRepository() | ||
1160 | 1561 | self.factory.makeGitRule( | ||
1161 | 1562 | repository=repository, ref_pattern="refs/heads/stable/*") | ||
1162 | 1563 | self.factory.makeGitRule( | ||
1163 | 1564 | repository=repository, ref_pattern="refs/heads/*/next") | ||
1164 | 1565 | encoded_patterns = [ | ||
1165 | 1566 | encode_form_field_id(rule.ref_pattern) | ||
1166 | 1567 | for rule in repository.rules] | ||
1167 | 1568 | removeSecurityProxy(repository.getActivity()).remove() | ||
1168 | 1569 | login_person(repository.owner) | ||
1169 | 1570 | # Positions are 1-based in the UI. | ||
1170 | 1571 | form = { | ||
1171 | 1572 | "field.position." + encoded_patterns[0]: "2", | ||
1172 | 1573 | "field.pattern." + encoded_patterns[0]: "stable/*", | ||
1173 | 1574 | "field.position." + encoded_patterns[1]: "1", | ||
1174 | 1575 | "field.pattern." + encoded_patterns[1]: "*/more-next", | ||
1175 | 1576 | "field.actions.save": "Save", | ||
1176 | 1577 | } | ||
1177 | 1578 | view = create_initialized_view( | ||
1178 | 1579 | repository, name="+permissions", form=form, | ||
1179 | 1580 | principal=repository.owner) | ||
1180 | 1581 | self.assertHasRules( | ||
1181 | 1582 | repository, ["refs/heads/*/more-next", "refs/heads/stable/*"]) | ||
1182 | 1583 | self.assertThat(list(repository.getActivity()), MatchesListwise([ | ||
1183 | 1584 | MatchesStructure( | ||
1184 | 1585 | changer=Equals(repository.owner), | ||
1185 | 1586 | what_changed=Equals(GitActivityType.RULE_CHANGED), | ||
1186 | 1587 | old_value=MatchesDict({ | ||
1187 | 1588 | "ref_pattern": Equals("refs/heads/*/next"), | ||
1188 | 1589 | "position": Equals(0), | ||
1189 | 1590 | }), | ||
1190 | 1591 | new_value=MatchesDict({ | ||
1191 | 1592 | "ref_pattern": Equals("refs/heads/*/more-next"), | ||
1192 | 1593 | "position": Equals(0), | ||
1193 | 1594 | })), | ||
1194 | 1595 | # Only one rule is recorded as moving; the other is already in | ||
1195 | 1596 | # its new position by the time it's processed. | ||
1196 | 1597 | MatchesStructure( | ||
1197 | 1598 | changer=Equals(repository.owner), | ||
1198 | 1599 | what_changed=Equals(GitActivityType.RULE_MOVED), | ||
1199 | 1600 | old_value=MatchesDict({ | ||
1200 | 1601 | "ref_pattern": Equals("refs/heads/stable/*"), | ||
1201 | 1602 | "position": Equals(0), | ||
1202 | 1603 | }), | ||
1203 | 1604 | new_value=MatchesDict({ | ||
1204 | 1605 | "ref_pattern": Equals("refs/heads/stable/*"), | ||
1205 | 1606 | "position": Equals(1), | ||
1206 | 1607 | })), | ||
1207 | 1608 | ])) | ||
1208 | 1609 | self.assertHasSavedNotification(view, repository) | ||
1209 | 1610 | |||
1210 | 1611 | def test_save_change_grants(self): | ||
1211 | 1612 | repository = self.factory.makeGitRepository() | ||
1212 | 1613 | stable_rule = self.factory.makeGitRule( | ||
1213 | 1614 | repository=repository, ref_pattern="refs/heads/stable/*") | ||
1214 | 1615 | next_rule = self.factory.makeGitRule( | ||
1215 | 1616 | repository=repository, ref_pattern="refs/heads/*/next") | ||
1216 | 1617 | grantees = [self.factory.makePerson() for _ in range(3)] | ||
1217 | 1618 | self.factory.makeGitRuleGrant( | ||
1218 | 1619 | rule=stable_rule, grantee=GitGranteeType.REPOSITORY_OWNER, | ||
1219 | 1620 | can_create=True) | ||
1220 | 1621 | self.factory.makeGitRuleGrant( | ||
1221 | 1622 | rule=stable_rule, | ||
1222 | 1623 | grantee=grantees[0], can_create=True, can_push=True) | ||
1223 | 1624 | self.factory.makeGitRuleGrant( | ||
1224 | 1625 | rule=next_rule, grantee=grantees[1], | ||
1225 | 1626 | can_create=True, can_push=True, can_force_push=True) | ||
1226 | 1627 | encoded_patterns = [ | ||
1227 | 1628 | encode_form_field_id(rule.ref_pattern) | ||
1228 | 1629 | for rule in repository.rules] | ||
1229 | 1630 | removeSecurityProxy(repository.getActivity()).remove() | ||
1230 | 1631 | login_person(repository.owner) | ||
1231 | 1632 | form = { | ||
1232 | 1633 | "field.permissions.%s._repository_owner" % encoded_patterns[0]: ( | ||
1233 | 1634 | "can_push"), | ||
1234 | 1635 | "field.permissions.%s.%s" % ( | ||
1235 | 1636 | encoded_patterns[0], grantees[0].id): "can_push", | ||
1236 | 1637 | "field.delete.%s.%s" % (encoded_patterns[0], grantees[0].id): "on", | ||
1237 | 1638 | "field.grantee.%s" % encoded_patterns[1]: "person", | ||
1238 | 1639 | "field.grantee.%s.person" % encoded_patterns[1]: grantees[2].name, | ||
1239 | 1640 | "field.permissions.%s" % encoded_patterns[1]: "can_push_existing", | ||
1240 | 1641 | "field.actions.save": "Save", | ||
1241 | 1642 | } | ||
1242 | 1643 | view = create_initialized_view( | ||
1243 | 1644 | repository, name="+permissions", form=form, | ||
1244 | 1645 | principal=repository.owner) | ||
1245 | 1646 | self.assertHasRules( | ||
1246 | 1647 | repository, ["refs/heads/stable/*", "refs/heads/*/next"]) | ||
1247 | 1648 | self.assertThat(stable_rule.grants, MatchesSetwise( | ||
1248 | 1649 | MatchesStructure.byEquality( | ||
1249 | 1650 | grantee_type=GitGranteeType.REPOSITORY_OWNER, | ||
1250 | 1651 | can_create=True, can_push=True, can_force_push=False))) | ||
1251 | 1652 | self.assertThat(next_rule.grants, MatchesSetwise( | ||
1252 | 1653 | MatchesStructure.byEquality( | ||
1253 | 1654 | grantee=grantees[1], | ||
1254 | 1655 | can_create=True, can_push=True, can_force_push=True), | ||
1255 | 1656 | MatchesStructure.byEquality( | ||
1256 | 1657 | grantee=grantees[2], | ||
1257 | 1658 | can_create=False, can_push=True, can_force_push=False))) | ||
1258 | 1659 | self.assertThat(repository.getActivity(), MatchesSetwise( | ||
1259 | 1660 | MatchesStructure( | ||
1260 | 1661 | changer=Equals(repository.owner), | ||
1261 | 1662 | changee=Is(None), | ||
1262 | 1663 | what_changed=Equals(GitActivityType.GRANT_CHANGED), | ||
1263 | 1664 | old_value=Equals({ | ||
1264 | 1665 | "changee_type": "Repository owner", | ||
1265 | 1666 | "ref_pattern": "refs/heads/stable/*", | ||
1266 | 1667 | "can_create": True, | ||
1267 | 1668 | "can_push": False, | ||
1268 | 1669 | "can_force_push": False, | ||
1269 | 1670 | }), | ||
1270 | 1671 | new_value=Equals({ | ||
1271 | 1672 | "changee_type": "Repository owner", | ||
1272 | 1673 | "ref_pattern": "refs/heads/stable/*", | ||
1273 | 1674 | "can_create": True, | ||
1274 | 1675 | "can_push": True, | ||
1275 | 1676 | "can_force_push": False, | ||
1276 | 1677 | })), | ||
1277 | 1678 | MatchesStructure( | ||
1278 | 1679 | changer=Equals(repository.owner), | ||
1279 | 1680 | changee=Equals(grantees[0]), | ||
1280 | 1681 | what_changed=Equals(GitActivityType.GRANT_REMOVED), | ||
1281 | 1682 | old_value=Equals({ | ||
1282 | 1683 | "changee_type": "Person", | ||
1283 | 1684 | "ref_pattern": "refs/heads/stable/*", | ||
1284 | 1685 | "can_create": True, | ||
1285 | 1686 | "can_push": True, | ||
1286 | 1687 | "can_force_push": False, | ||
1287 | 1688 | })), | ||
1288 | 1689 | MatchesStructure( | ||
1289 | 1690 | changer=Equals(repository.owner), | ||
1290 | 1691 | changee=Equals(grantees[2]), | ||
1291 | 1692 | what_changed=Equals(GitActivityType.GRANT_ADDED), | ||
1292 | 1693 | new_value=Equals({ | ||
1293 | 1694 | "changee_type": "Person", | ||
1294 | 1695 | "ref_pattern": "refs/heads/*/next", | ||
1295 | 1696 | "can_create": False, | ||
1296 | 1697 | "can_push": True, | ||
1297 | 1698 | "can_force_push": False, | ||
1298 | 1699 | })))) | ||
1299 | 1700 | self.assertHasSavedNotification(view, repository) | ||
1300 | 1701 | |||
1301 | 1702 | def test_save_delete_rule(self): | ||
1302 | 1703 | repository = self.factory.makeGitRepository() | ||
1303 | 1704 | self.factory.makeGitRule( | ||
1304 | 1705 | repository=repository, ref_pattern="refs/heads/stable/*") | ||
1305 | 1706 | heads_rule = self.factory.makeGitRule( | ||
1306 | 1707 | repository=repository, ref_pattern="refs/heads/*") | ||
1307 | 1708 | self.factory.makeGitRuleGrant( | ||
1308 | 1709 | rule=heads_rule, grantee=GitGranteeType.REPOSITORY_OWNER, | ||
1309 | 1710 | can_push=True) | ||
1310 | 1711 | removeSecurityProxy(repository.getActivity()).remove() | ||
1311 | 1712 | login_person(repository.owner) | ||
1312 | 1713 | encoded_pattern = encode_form_field_id("refs/heads/*") | ||
1313 | 1714 | form = { | ||
1314 | 1715 | "field.pattern." + encoded_pattern: "*", | ||
1315 | 1716 | "field.delete." + encoded_pattern: "on", | ||
1316 | 1717 | # Form data entries relating to grants for the deleted rule are | ||
1317 | 1718 | # ignored. | ||
1318 | 1719 | "field.permissions.%s._repository_owner" % encoded_pattern: ( | ||
1319 | 1720 | "can_push"), | ||
1320 | 1721 | "field.actions.save": "Save", | ||
1321 | 1722 | } | ||
1322 | 1723 | view = create_initialized_view( | ||
1323 | 1724 | repository, name="+permissions", form=form, | ||
1324 | 1725 | principal=repository.owner) | ||
1325 | 1726 | self.assertHasRules(repository, ["refs/heads/stable/*"]) | ||
1326 | 1727 | self.assertThat(list(repository.getActivity()), MatchesListwise([ | ||
1327 | 1728 | MatchesStructure( | ||
1328 | 1729 | changer=Equals(repository.owner), | ||
1329 | 1730 | what_changed=Equals(GitActivityType.RULE_REMOVED), | ||
1330 | 1731 | old_value=MatchesDict({ | ||
1331 | 1732 | "ref_pattern": Equals("refs/heads/*"), | ||
1332 | 1733 | "position": Equals(1), | ||
1333 | 1734 | })), | ||
1334 | 1735 | ])) | ||
1335 | 1736 | self.assertHasSavedNotification(view, repository) | ||
1336 | 1737 | |||
1337 | 1738 | |||
1338 | 1100 | class TestGitRepositoryDeletionView(BrowserTestCase): | 1739 | class TestGitRepositoryDeletionView(BrowserTestCase): |
1339 | 1101 | 1740 | ||
1340 | 1102 | layer = DatabaseFunctionalLayer | 1741 | layer = DatabaseFunctionalLayer |
1341 | 1103 | 1742 | ||
1342 | === modified file 'lib/lp/code/interfaces/gitrule.py' | |||
1343 | --- lib/lp/code/interfaces/gitrule.py 2018-10-23 16:17:39 +0000 | |||
1344 | +++ lib/lp/code/interfaces/gitrule.py 2019-01-10 10:50:57 +0000 | |||
1345 | @@ -158,6 +158,10 @@ | |||
1346 | 158 | vocabulary="ValidPersonOrTeam", | 158 | vocabulary="ValidPersonOrTeam", |
1347 | 159 | description=_("The person being granted access.")) | 159 | description=_("The person being granted access.")) |
1348 | 160 | 160 | ||
1349 | 161 | combined_grantee = Attribute( | ||
1350 | 162 | "The overall grantee of this grant: either a `GitGranteeType` (other " | ||
1351 | 163 | "than `PERSON`) or an `IPerson`.") | ||
1352 | 164 | |||
1353 | 161 | date_created = Datetime( | 165 | date_created = Datetime( |
1354 | 162 | title=_("Date created"), required=True, readonly=True, | 166 | title=_("Date created"), required=True, readonly=True, |
1355 | 163 | description=_("The time when this grant was created.")) | 167 | description=_("The time when this grant was created.")) |
1356 | 164 | 168 | ||
1357 | === modified file 'lib/lp/code/model/gitrule.py' | |||
1358 | --- lib/lp/code/model/gitrule.py 2018-10-29 14:27:36 +0000 | |||
1359 | +++ lib/lp/code/model/gitrule.py 2019-01-10 10:50:57 +0000 | |||
1360 | @@ -310,6 +310,13 @@ | |||
1361 | 310 | self.date_created = date_created | 310 | self.date_created = date_created |
1362 | 311 | self.date_last_modified = date_created | 311 | self.date_last_modified = date_created |
1363 | 312 | 312 | ||
1364 | 313 | @property | ||
1365 | 314 | def combined_grantee(self): | ||
1366 | 315 | if self.grantee_type == GitGranteeType.PERSON: | ||
1367 | 316 | return self.grantee | ||
1368 | 317 | else: | ||
1369 | 318 | return self.grantee_type | ||
1370 | 319 | |||
1371 | 313 | def __repr__(self): | 320 | def __repr__(self): |
1372 | 314 | if self.grantee_type == GitGranteeType.PERSON: | 321 | if self.grantee_type == GitGranteeType.PERSON: |
1373 | 315 | grantee_name = "~%s" % self.grantee.name | 322 | grantee_name = "~%s" % self.grantee.name |
1374 | 316 | 323 | ||
1375 | === modified file 'lib/lp/code/model/tests/test_gitrule.py' | |||
1376 | --- lib/lp/code/model/tests/test_gitrule.py 2018-10-21 17:38:05 +0000 | |||
1377 | +++ lib/lp/code/model/tests/test_gitrule.py 2019-01-10 10:50:57 +0000 | |||
1378 | @@ -594,6 +594,7 @@ | |||
1379 | 594 | rule=Equals(rule), | 594 | rule=Equals(rule), |
1380 | 595 | grantee_type=Equals(GitGranteeType.REPOSITORY_OWNER), | 595 | grantee_type=Equals(GitGranteeType.REPOSITORY_OWNER), |
1381 | 596 | grantee=Is(None), | 596 | grantee=Is(None), |
1382 | 597 | combined_grantee=Equals(GitGranteeType.REPOSITORY_OWNER), | ||
1383 | 597 | can_create=Is(True), | 598 | can_create=Is(True), |
1384 | 598 | can_push=Is(False), | 599 | can_push=Is(False), |
1385 | 599 | can_force_push=Is(True), | 600 | can_force_push=Is(True), |
1386 | @@ -614,6 +615,7 @@ | |||
1387 | 614 | rule=Equals(rule), | 615 | rule=Equals(rule), |
1388 | 615 | grantee_type=Equals(GitGranteeType.PERSON), | 616 | grantee_type=Equals(GitGranteeType.PERSON), |
1389 | 616 | grantee=Equals(grantee), | 617 | grantee=Equals(grantee), |
1390 | 618 | combined_grantee=Equals(grantee), | ||
1391 | 617 | can_create=Is(False), | 619 | can_create=Is(False), |
1392 | 618 | can_push=Is(True), | 620 | can_push=Is(True), |
1393 | 619 | can_force_push=Is(False), | 621 | can_force_push=Is(False), |
1394 | 620 | 622 | ||
1395 | === added file 'lib/lp/code/templates/gitrepository-permissions.pt' | |||
1396 | --- lib/lp/code/templates/gitrepository-permissions.pt 1970-01-01 00:00:00 +0000 | |||
1397 | +++ lib/lp/code/templates/gitrepository-permissions.pt 2019-01-10 10:50:57 +0000 | |||
1398 | @@ -0,0 +1,232 @@ | |||
1399 | 1 | <html | ||
1400 | 2 | xmlns="http://www.w3.org/1999/xhtml" | ||
1401 | 3 | xmlns:tal="http://xml.zope.org/namespaces/tal" | ||
1402 | 4 | xmlns:metal="http://xml.zope.org/namespaces/metal" | ||
1403 | 5 | xmlns:i18n="http://xml.zope.org/namespaces/i18n" | ||
1404 | 6 | metal:use-macro="view/macro:page/main_only" | ||
1405 | 7 | i18n:domain="launchpad"> | ||
1406 | 8 | <body> | ||
1407 | 9 | |||
1408 | 10 | <metal:block fill-slot="head_epilogue"> | ||
1409 | 11 | <style type="text/css"> | ||
1410 | 12 | table.git-rules-table tr.even { | ||
1411 | 13 | background-color: #eee; | ||
1412 | 14 | } | ||
1413 | 15 | tr.git-rule td, tr.git-new-rule td { | ||
1414 | 16 | padding-top: 1em; | ||
1415 | 17 | } | ||
1416 | 18 | tr.git-new-rule-grant td, tr.git-new-rule td { | ||
1417 | 19 | padding-bottom: 1em; | ||
1418 | 20 | } | ||
1419 | 21 | /* Position, pattern, and delete add up to 100%. */ | ||
1420 | 22 | tr .git-rule-position { | ||
1421 | 23 | width: 10%; | ||
1422 | 24 | } | ||
1423 | 25 | tr .git-rule-pattern { | ||
1424 | 26 | width: 85%; | ||
1425 | 27 | } | ||
1426 | 28 | tr .git-rule-delete { | ||
1427 | 29 | width: 5%; | ||
1428 | 30 | } | ||
1429 | 31 | /* Grantee and permissions add up to pattern (85%). */ | ||
1430 | 32 | tr .git-rule-grant-grantee { | ||
1431 | 33 | width: 40%; | ||
1432 | 34 | } | ||
1433 | 35 | tr .git-rule-grant-permissions { | ||
1434 | 36 | width: 45%; | ||
1435 | 37 | } | ||
1436 | 38 | </style> | ||
1437 | 39 | </metal:block> | ||
1438 | 40 | |||
1439 | 41 | <metal:macros fill-slot="bogus"> | ||
1440 | 42 | <metal:macro define-macro="rule-rows"> | ||
1441 | 43 | <tal:rule repeat="rule rules"> | ||
1442 | 44 | <tal:rule_widgets | ||
1443 | 45 | define="rule_widgets python:view.getRuleWidgets(rule); | ||
1444 | 46 | parity python:'even' if repeat['rule'].even() else 'odd'"> | ||
1445 | 47 | <tr tal:attributes="class string:git-rule ${parity}"> | ||
1446 | 48 | <td class="git-rule-position" | ||
1447 | 49 | tal:define="widget nocall:rule_widgets/position"> | ||
1448 | 50 | <metal:block use-macro="context/@@launchpad_form/widget_div" /> | ||
1449 | 51 | </td> | ||
1450 | 52 | <td class="git-rule-pattern" | ||
1451 | 53 | tal:define="widget nocall:rule_widgets/pattern" colspan="2"> | ||
1452 | 54 | <metal:block use-macro="context/@@launchpad_form/widget_div" /> | ||
1453 | 55 | </td> | ||
1454 | 56 | <td class="git-rule-delete" | ||
1455 | 57 | tal:define="widget nocall:rule_widgets/delete"> | ||
1456 | 58 | <metal:block use-macro="context/@@launchpad_form/widget_div" /> | ||
1457 | 59 | </td> | ||
1458 | 60 | </tr> | ||
1459 | 61 | <tr tal:attributes="class string:git-rule-grant ${parity}" | ||
1460 | 62 | tal:repeat="grant_widgets rule_widgets/grants"> | ||
1461 | 63 | <td></td> | ||
1462 | 64 | <td class="git-rule-grant-grantee" | ||
1463 | 65 | tal:define="widget nocall:grant_widgets/grantee"> | ||
1464 | 66 | <metal:block use-macro="context/@@launchpad_form/widget_div" /> | ||
1465 | 67 | </td> | ||
1466 | 68 | <td class="git-rule-grant-permissions" | ||
1467 | 69 | tal:define="widget nocall:grant_widgets/permissions"> | ||
1468 | 70 | <metal:block use-macro="context/@@launchpad_form/widget_div" /> | ||
1469 | 71 | </td> | ||
1470 | 72 | <td class="git-rule-delete" | ||
1471 | 73 | tal:define="widget nocall:grant_widgets/delete"> | ||
1472 | 74 | <metal:block use-macro="context/@@launchpad_form/widget_div" /> | ||
1473 | 75 | </td> | ||
1474 | 76 | </tr> | ||
1475 | 77 | <tr tal:attributes="class string:git-new-rule-grant ${parity}" | ||
1476 | 78 | tal:define="new_grant_widgets rule_widgets/new_grant"> | ||
1477 | 79 | <td></td> | ||
1478 | 80 | <td class="git-rule-grant-grantee" | ||
1479 | 81 | tal:define="widget nocall:new_grant_widgets/grantee"> | ||
1480 | 82 | <metal:block use-macro="context/@@launchpad_form/widget_div" /> | ||
1481 | 83 | </td> | ||
1482 | 84 | <td class="git-rule-grant-permissions" | ||
1483 | 85 | tal:define="widget nocall:new_grant_widgets/permissions"> | ||
1484 | 86 | <metal:block use-macro="context/@@launchpad_form/widget_div" /> | ||
1485 | 87 | </td> | ||
1486 | 88 | <td></td> | ||
1487 | 89 | </tr> | ||
1488 | 90 | </tal:rule_widgets> | ||
1489 | 91 | </tal:rule> | ||
1490 | 92 | <tal:allows-new-rule | ||
1491 | 93 | condition="ref_prefix" | ||
1492 | 94 | define="parity python:'odd' if len(rules) % 2 else 'even'"> | ||
1493 | 95 | <tr tal:attributes="class string:git-new-rule ${parity}" | ||
1494 | 96 | tal:define="new_rule_widgets python:view.getNewRuleWidgets(ref_prefix)"> | ||
1495 | 97 | <td class="git-rule-position" | ||
1496 | 98 | tal:define="widget nocall:new_rule_widgets/position"> | ||
1497 | 99 | <metal:block use-macro="context/@@launchpad_form/widget_div" /> | ||
1498 | 100 | </td> | ||
1499 | 101 | <td class="git-rule-pattern" | ||
1500 | 102 | tal:define="widget nocall:new_rule_widgets/pattern" colspan="2"> | ||
1501 | 103 | <metal:block use-macro="context/@@launchpad_form/widget_div" /> | ||
1502 | 104 | </td> | ||
1503 | 105 | <td></td> | ||
1504 | 106 | </tr> | ||
1505 | 107 | </tal:allows-new-rule> | ||
1506 | 108 | </metal:macro> | ||
1507 | 109 | |||
1508 | 110 | <metal:macro define-macro="rules-table"> | ||
1509 | 111 | <table class="listing git-rules-table" | ||
1510 | 112 | style="max-width: 60em; margin-bottom: 1em;"> | ||
1511 | 113 | <thead> | ||
1512 | 114 | <tr> | ||
1513 | 115 | <th class="git-rule-position">Position</th> | ||
1514 | 116 | <th class="git-rule-pattern" colspan="2">Rule</th> | ||
1515 | 117 | <th class="git-rule-delete">Delete?</th> | ||
1516 | 118 | </tr> | ||
1517 | 119 | </thead> | ||
1518 | 120 | <tbody> | ||
1519 | 121 | <metal:rules use-macro="template/macros/rule-rows" /> | ||
1520 | 122 | </tbody> | ||
1521 | 123 | </table> | ||
1522 | 124 | </metal:macro> | ||
1523 | 125 | </metal:macros> | ||
1524 | 126 | |||
1525 | 127 | <div metal:fill-slot="main"> | ||
1526 | 128 | <p> | ||
1527 | 129 | By default, repository owners may create, push, force-push, or delete | ||
1528 | 130 | any branch or tag in their repositories, and nobody else may modify | ||
1529 | 131 | them in any way. | ||
1530 | 132 | </p> | ||
1531 | 133 | <p> | ||
1532 | 134 | If any of the rules below matches a branch or tag, then it is | ||
1533 | 135 | <em>protected</em>. By default, protecting a branch implicitly | ||
1534 | 136 | prevents repository owners from force-pushing to it or deleting it, | ||
1535 | 137 | while protecting a tag prevents repository owners from moving it. | ||
1536 | 138 | Protecting a branch or tag also allows you to grant other permissions. | ||
1537 | 139 | </p> | ||
1538 | 140 | <p> | ||
1539 | 141 | You may create rules that match a single branch or tag, or wildcard | ||
1540 | 142 | rules that match a pattern: for example, <code>*</code> matches | ||
1541 | 143 | everything, while <code>stable/*</code> matches | ||
1542 | 144 | <code>stable/1.0</code> but not <code>master</code>. | ||
1543 | 145 | </p> | ||
1544 | 146 | |||
1545 | 147 | <metal:grants-form use-macro="context/@@launchpad_form/form"> | ||
1546 | 148 | <div class="form" metal:fill-slot="widgets"> | ||
1547 | 149 | <h3>Protected branches (under <code>refs/heads/</code>)</h3> | ||
1548 | 150 | <tal:branches define="rules view/branch_rules; | ||
1549 | 151 | ref_prefix string:refs/heads/"> | ||
1550 | 152 | <metal:table use-macro="template/macros/rules-table" /> | ||
1551 | 153 | </tal:branches> | ||
1552 | 154 | |||
1553 | 155 | <h3>Protected tags (under <code>refs/tags/</code>)</h3> | ||
1554 | 156 | <tal:tags define="rules view/tag_rules; | ||
1555 | 157 | ref_prefix string:refs/tags/"> | ||
1556 | 158 | <metal:table use-macro="template/macros/rules-table" /> | ||
1557 | 159 | </tal:tags> | ||
1558 | 160 | |||
1559 | 161 | <tal:has-other condition="view/other_rules"> | ||
1560 | 162 | <h3>Other protected references</h3> | ||
1561 | 163 | <tal:other define="rules view/other_rules; ref_prefix nothing"> | ||
1562 | 164 | <metal:table use-macro="template/macros/rules-table" /> | ||
1563 | 165 | </tal:other> | ||
1564 | 166 | </tal:has-other> | ||
1565 | 167 | |||
1566 | 168 | <p class="actions"> | ||
1567 | 169 | <input tal:replace="structure view/save_action/render" /> | ||
1568 | 170 | or <a tal:attributes="href view/cancel_url">Cancel</a> | ||
1569 | 171 | </p> | ||
1570 | 172 | </div> | ||
1571 | 173 | |||
1572 | 174 | <metal:buttons fill-slot="buttons" /> | ||
1573 | 175 | </metal:grants-form> | ||
1574 | 176 | |||
1575 | 177 | <h2>Wildcards</h2> | ||
1576 | 178 | <p>The special characters used in wildcard rules are:</p> | ||
1577 | 179 | <table class="listing narrow-listing"> | ||
1578 | 180 | <thead> | ||
1579 | 181 | <tr> | ||
1580 | 182 | <th>Pattern</th> | ||
1581 | 183 | <th>Meaning</th> | ||
1582 | 184 | </tr> | ||
1583 | 185 | </thead> | ||
1584 | 186 | <tbody> | ||
1585 | 187 | <tr> | ||
1586 | 188 | <td><code>*</code></td> | ||
1587 | 189 | <td>matches zero or more characters</td> | ||
1588 | 190 | </tr> | ||
1589 | 191 | <tr> | ||
1590 | 192 | <td><code>?</code></td> | ||
1591 | 193 | <td>matches any single character</td> | ||
1592 | 194 | </tr> | ||
1593 | 195 | <tr> | ||
1594 | 196 | <td><code>[chars]</code></td> | ||
1595 | 197 | <td>matches any character in <em>chars</em></td> | ||
1596 | 198 | </tr> | ||
1597 | 199 | <tr> | ||
1598 | 200 | <td><code>[!chars]</code></td> | ||
1599 | 201 | <td>matches any character not in <em>chars</em></td> | ||
1600 | 202 | </tr> | ||
1601 | 203 | </tbody> | ||
1602 | 204 | </table> | ||
1603 | 205 | |||
1604 | 206 | <h2>Effective permissions</h2> | ||
1605 | 207 | <p> | ||
1606 | 208 | Launchpad works out the effective permissions that a user has on a | ||
1607 | 209 | protected branch or tag as follows: | ||
1608 | 210 | </p> | ||
1609 | 211 | <ol> | ||
1610 | 212 | <li>Take all the rules that match the branch or tag.</li> | ||
1611 | 213 | <li> | ||
1612 | 214 | For each matching rule, select any grants whose grantee matches the | ||
1613 | 215 | user, as long as the same grantee has not already been seen in an | ||
1614 | 216 | earlier matching rule. (A user can be matched by more than one | ||
1615 | 217 | grantee: for example, they might be in multiple teams.) | ||
1616 | 218 | </li> | ||
1617 | 219 | <li> | ||
1618 | 220 | If the user is an owner of the repository and there was no previous | ||
1619 | 221 | “Repository owner” grant, then add an implicit grant allowing them | ||
1620 | 222 | to create or push. | ||
1621 | 223 | </li> | ||
1622 | 224 | <li> | ||
1623 | 225 | The effective permission set is the union of the permissions granted | ||
1624 | 226 | by all the selected grants. | ||
1625 | 227 | </li> | ||
1626 | 228 | </ol> | ||
1627 | 229 | </div> | ||
1628 | 230 | |||
1629 | 231 | </body> | ||
1630 | 232 | </html> |