##// END OF EJS Templates
integrations: fix re-submit of form on repo integrations.
marcink -
r1384:13f7d9a4 default
parent child Browse files
Show More
@@ -1,393 +1,391 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2
2
3 # Copyright (C) 2012-2017 RhodeCode GmbH
3 # Copyright (C) 2012-2017 RhodeCode GmbH
4 #
4 #
5 # This program is free software: you can redistribute it and/or modify
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
7 # (only), as published by the Free Software Foundation.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU Affero General Public License
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
16 #
17 # This program is dual-licensed. If you wish to learn more about the
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
20
21 import pylons
21 import pylons
22 import deform
22 import deform
23 import logging
23 import logging
24 import colander
24 import colander
25 import peppercorn
25 import peppercorn
26 import webhelpers.paginate
26 import webhelpers.paginate
27
27
28 from pyramid.httpexceptions import HTTPFound, HTTPForbidden, HTTPBadRequest
28 from pyramid.httpexceptions import HTTPFound, HTTPForbidden, HTTPBadRequest
29 from pyramid.renderers import render
29 from pyramid.renderers import render
30 from pyramid.response import Response
30 from pyramid.response import Response
31
31
32 from rhodecode.lib import auth
32 from rhodecode.lib import auth
33 from rhodecode.lib.auth import LoginRequired, HasPermissionAllDecorator
33 from rhodecode.lib.auth import LoginRequired, HasPermissionAllDecorator
34 from rhodecode.lib.utils2 import safe_int
34 from rhodecode.lib.utils2 import safe_int
35 from rhodecode.lib.helpers import Page
35 from rhodecode.lib.helpers import Page
36 from rhodecode.model.db import Repository, RepoGroup, Session, Integration
36 from rhodecode.model.db import Repository, RepoGroup, Session, Integration
37 from rhodecode.model.scm import ScmModel
37 from rhodecode.model.scm import ScmModel
38 from rhodecode.model.integration import IntegrationModel
38 from rhodecode.model.integration import IntegrationModel
39 from rhodecode.admin.navigation import navigation_list
39 from rhodecode.admin.navigation import navigation_list
40 from rhodecode.translation import _
40 from rhodecode.translation import _
41 from rhodecode.integrations import integration_type_registry
41 from rhodecode.integrations import integration_type_registry
42 from rhodecode.model.validation_schema.schemas.integration_schema import (
42 from rhodecode.model.validation_schema.schemas.integration_schema import (
43 make_integration_schema, IntegrationScopeType)
43 make_integration_schema, IntegrationScopeType)
44
44
45 log = logging.getLogger(__name__)
45 log = logging.getLogger(__name__)
46
46
47
47
48 class IntegrationSettingsViewBase(object):
48 class IntegrationSettingsViewBase(object):
49 """ Base Integration settings view used by both repo / global settings """
49 """ Base Integration settings view used by both repo / global settings """
50
50
51 def __init__(self, context, request):
51 def __init__(self, context, request):
52 self.context = context
52 self.context = context
53 self.request = request
53 self.request = request
54 self._load_general_context()
54 self._load_general_context()
55
55
56 if not self.perm_check(request.user):
56 if not self.perm_check(request.user):
57 raise HTTPForbidden()
57 raise HTTPForbidden()
58
58
59 def _load_general_context(self):
59 def _load_general_context(self):
60 """
60 """
61 This avoids boilerplate for repo/global+list/edit+views/templates
61 This avoids boilerplate for repo/global+list/edit+views/templates
62 by doing all possible contexts at the same time however it should
62 by doing all possible contexts at the same time however it should
63 be split up into separate functions once more "contexts" exist
63 be split up into separate functions once more "contexts" exist
64 """
64 """
65
65
66 self.IntegrationType = None
66 self.IntegrationType = None
67 self.repo = None
67 self.repo = None
68 self.repo_group = None
68 self.repo_group = None
69 self.integration = None
69 self.integration = None
70 self.integrations = {}
70 self.integrations = {}
71
71
72 request = self.request
72 request = self.request
73
73
74 if 'repo_name' in request.matchdict: # in repo settings context
74 if 'repo_name' in request.matchdict: # in repo settings context
75 repo_name = request.matchdict['repo_name']
75 repo_name = request.matchdict['repo_name']
76 self.repo = Repository.get_by_repo_name(repo_name)
76 self.repo = Repository.get_by_repo_name(repo_name)
77
77
78 if 'repo_group_name' in request.matchdict: # in group settings context
78 if 'repo_group_name' in request.matchdict: # in group settings context
79 repo_group_name = request.matchdict['repo_group_name']
79 repo_group_name = request.matchdict['repo_group_name']
80 self.repo_group = RepoGroup.get_by_group_name(repo_group_name)
80 self.repo_group = RepoGroup.get_by_group_name(repo_group_name)
81
81
82
82
83 if 'integration' in request.matchdict: # integration type context
83 if 'integration' in request.matchdict: # integration type context
84 integration_type = request.matchdict['integration']
84 integration_type = request.matchdict['integration']
85 self.IntegrationType = integration_type_registry[integration_type]
85 self.IntegrationType = integration_type_registry[integration_type]
86
86
87 if 'integration_id' in request.matchdict: # single integration context
87 if 'integration_id' in request.matchdict: # single integration context
88 integration_id = request.matchdict['integration_id']
88 integration_id = request.matchdict['integration_id']
89 self.integration = Integration.get(integration_id)
89 self.integration = Integration.get(integration_id)
90
90
91 # extra perms check just in case
91 # extra perms check just in case
92 if not self._has_perms_for_integration(self.integration):
92 if not self._has_perms_for_integration(self.integration):
93 raise HTTPForbidden()
93 raise HTTPForbidden()
94
94
95 self.settings = self.integration and self.integration.settings or {}
95 self.settings = self.integration and self.integration.settings or {}
96 self.admin_view = not (self.repo or self.repo_group)
96 self.admin_view = not (self.repo or self.repo_group)
97
97
98 def _has_perms_for_integration(self, integration):
98 def _has_perms_for_integration(self, integration):
99 perms = self.request.user.permissions
99 perms = self.request.user.permissions
100
100
101 if 'hg.admin' in perms['global']:
101 if 'hg.admin' in perms['global']:
102 return True
102 return True
103
103
104 if integration.repo:
104 if integration.repo:
105 return perms['repositories'].get(
105 return perms['repositories'].get(
106 integration.repo.repo_name) == 'repository.admin'
106 integration.repo.repo_name) == 'repository.admin'
107
107
108 if integration.repo_group:
108 if integration.repo_group:
109 return perms['repositories_groups'].get(
109 return perms['repositories_groups'].get(
110 integration.repo_group.group_name) == 'group.admin'
110 integration.repo_group.group_name) == 'group.admin'
111
111
112 return False
112 return False
113
113
114 def _template_c_context(self):
114 def _template_c_context(self):
115 # TODO: dan: this is a stopgap in order to inherit from current pylons
115 # TODO: dan: this is a stopgap in order to inherit from current pylons
116 # based admin/repo settings templates - this should be removed entirely
116 # based admin/repo settings templates - this should be removed entirely
117 # after port to pyramid
117 # after port to pyramid
118
118
119 c = pylons.tmpl_context
119 c = pylons.tmpl_context
120 c.active = 'integrations'
120 c.active = 'integrations'
121 c.rhodecode_user = self.request.user
121 c.rhodecode_user = self.request.user
122 c.repo = self.repo
122 c.repo = self.repo
123 c.repo_group = self.repo_group
123 c.repo_group = self.repo_group
124 c.repo_name = self.repo and self.repo.repo_name or None
124 c.repo_name = self.repo and self.repo.repo_name or None
125 c.repo_group_name = self.repo_group and self.repo_group.group_name or None
125 c.repo_group_name = self.repo_group and self.repo_group.group_name or None
126
126
127 if self.repo:
127 if self.repo:
128 c.repo_info = self.repo
128 c.repo_info = self.repo
129 c.rhodecode_db_repo = self.repo
129 c.rhodecode_db_repo = self.repo
130 c.repository_pull_requests = ScmModel().get_pull_requests(self.repo)
130 c.repository_pull_requests = ScmModel().get_pull_requests(self.repo)
131 else:
131 else:
132 c.navlist = navigation_list(self.request)
132 c.navlist = navigation_list(self.request)
133
133
134 return c
134 return c
135
135
136 def _form_schema(self):
136 def _form_schema(self):
137 schema = make_integration_schema(IntegrationType=self.IntegrationType,
137 schema = make_integration_schema(IntegrationType=self.IntegrationType,
138 settings=self.settings)
138 settings=self.settings)
139
139
140 # returns a clone, important if mutating the schema later
140 # returns a clone, important if mutating the schema later
141 return schema.bind(
141 return schema.bind(
142 permissions=self.request.user.permissions,
142 permissions=self.request.user.permissions,
143 no_scope=not self.admin_view)
143 no_scope=not self.admin_view)
144
144
145
146 def _form_defaults(self):
145 def _form_defaults(self):
147 defaults = {}
146 defaults = {}
148
147
149 if self.integration:
148 if self.integration:
150 defaults['settings'] = self.integration.settings or {}
149 defaults['settings'] = self.integration.settings or {}
151 defaults['options'] = {
150 defaults['options'] = {
152 'name': self.integration.name,
151 'name': self.integration.name,
153 'enabled': self.integration.enabled,
152 'enabled': self.integration.enabled,
154 'scope': {
153 'scope': {
155 'repo': self.integration.repo,
154 'repo': self.integration.repo,
156 'repo_group': self.integration.repo_group,
155 'repo_group': self.integration.repo_group,
157 'child_repos_only': self.integration.child_repos_only,
156 'child_repos_only': self.integration.child_repos_only,
158 },
157 },
159 }
158 }
160 else:
159 else:
161 if self.repo:
160 if self.repo:
162 scope = _('{repo_name} repository').format(
161 scope = _('{repo_name} repository').format(
163 repo_name=self.repo.repo_name)
162 repo_name=self.repo.repo_name)
164 elif self.repo_group:
163 elif self.repo_group:
165 scope = _('{repo_group_name} repo group').format(
164 scope = _('{repo_group_name} repo group').format(
166 repo_group_name=self.repo_group.group_name)
165 repo_group_name=self.repo_group.group_name)
167 else:
166 else:
168 scope = _('Global')
167 scope = _('Global')
169
168
170 defaults['options'] = {
169 defaults['options'] = {
171 'enabled': True,
170 'enabled': True,
172 'name': _('{name} integration').format(
171 'name': _('{name} integration').format(
173 name=self.IntegrationType.display_name),
172 name=self.IntegrationType.display_name),
174 }
173 }
175 defaults['options']['scope'] = {
174 defaults['options']['scope'] = {
176 'repo': self.repo,
175 'repo': self.repo,
177 'repo_group': self.repo_group,
176 'repo_group': self.repo_group,
178 }
177 }
179
178
180 return defaults
179 return defaults
181
180
182 def _delete_integration(self, integration):
181 def _delete_integration(self, integration):
183 Session().delete(self.integration)
182 Session().delete(self.integration)
184 Session().commit()
183 Session().commit()
185 self.request.session.flash(
184 self.request.session.flash(
186 _('Integration {integration_name} deleted successfully.').format(
185 _('Integration {integration_name} deleted successfully.').format(
187 integration_name=self.integration.name),
186 integration_name=self.integration.name),
188 queue='success')
187 queue='success')
189
188
190 if self.repo:
189 if self.repo:
191 redirect_to = self.request.route_url(
190 redirect_to = self.request.route_url(
192 'repo_integrations_home', repo_name=self.repo.repo_name)
191 'repo_integrations_home', repo_name=self.repo.repo_name)
193 elif self.repo_group:
192 elif self.repo_group:
194 redirect_to = self.request.route_url(
193 redirect_to = self.request.route_url(
195 'repo_group_integrations_home',
194 'repo_group_integrations_home',
196 repo_group_name=self.repo_group.group_name)
195 repo_group_name=self.repo_group.group_name)
197 else:
196 else:
198 redirect_to = self.request.route_url('global_integrations_home')
197 redirect_to = self.request.route_url('global_integrations_home')
199 raise HTTPFound(redirect_to)
198 raise HTTPFound(redirect_to)
200
199
201 def settings_get(self, defaults=None, form=None):
200 def settings_get(self, defaults=None, form=None):
202 """
201 """
203 View that displays the integration settings as a form.
202 View that displays the integration settings as a form.
204 """
203 """
205
204
206 defaults = defaults or self._form_defaults()
205 defaults = defaults or self._form_defaults()
207 schema = self._form_schema()
206 schema = self._form_schema()
208
207
209 if self.integration:
208 if self.integration:
210 buttons = ('submit', 'delete')
209 buttons = ('submit', 'delete')
211 else:
210 else:
212 buttons = ('submit',)
211 buttons = ('submit',)
213
212
214 form = form or deform.Form(schema, appstruct=defaults, buttons=buttons)
213 form = form or deform.Form(schema, appstruct=defaults, buttons=buttons)
215
214
216 template_context = {
215 template_context = {
217 'form': form,
216 'form': form,
218 'current_IntegrationType': self.IntegrationType,
217 'current_IntegrationType': self.IntegrationType,
219 'integration': self.integration,
218 'integration': self.integration,
220 'c': self._template_c_context(),
219 'c': self._template_c_context(),
221 }
220 }
222
221
223 return template_context
222 return template_context
224
223
225 @auth.CSRFRequired()
224 @auth.CSRFRequired()
226 def settings_post(self):
225 def settings_post(self):
227 """
226 """
228 View that validates and stores the integration settings.
227 View that validates and stores the integration settings.
229 """
228 """
230 controls = self.request.POST.items()
229 controls = self.request.POST.items()
231 pstruct = peppercorn.parse(controls)
230 pstruct = peppercorn.parse(controls)
232
231
233 if self.integration and pstruct.get('delete'):
232 if self.integration and pstruct.get('delete'):
234 return self._delete_integration(self.integration)
233 return self._delete_integration(self.integration)
235
234
236 schema = self._form_schema()
235 schema = self._form_schema()
237
236
238 skip_settings_validation = False
237 skip_settings_validation = False
239 if self.integration and 'enabled' not in pstruct.get('options', {}):
238 if self.integration and 'enabled' not in pstruct.get('options', {}):
240 skip_settings_validation = True
239 skip_settings_validation = True
241 schema['settings'].validator = None
240 schema['settings'].validator = None
242 for field in schema['settings'].children:
241 for field in schema['settings'].children:
243 field.validator = None
242 field.validator = None
244 field.missing = ''
243 field.missing = ''
245
244
246 if self.integration:
245 if self.integration:
247 buttons = ('submit', 'delete')
246 buttons = ('submit', 'delete')
248 else:
247 else:
249 buttons = ('submit',)
248 buttons = ('submit',)
250
249
251 form = deform.Form(schema, buttons=buttons)
250 form = deform.Form(schema, buttons=buttons)
252
251
253 if not self.admin_view:
252 if not self.admin_view:
254 # scope is read only field in these cases, and has to be added
253 # scope is read only field in these cases, and has to be added
255 options = pstruct.setdefault('options', {})
254 options = pstruct.setdefault('options', {})
256 if 'scope' not in options:
255 if 'scope' not in options:
257 options['scope'] = IntegrationScopeType().serialize(None, {
256 options['scope'] = IntegrationScopeType().serialize(None, {
258 'repo': self.repo,
257 'repo': self.repo,
259 'repo_group': self.repo_group,
258 'repo_group': self.repo_group,
260 })
259 })
261
260
262 try:
261 try:
263 valid_data = form.validate_pstruct(pstruct)
262 valid_data = form.validate_pstruct(pstruct)
264 except deform.ValidationFailure as e:
263 except deform.ValidationFailure as e:
265 self.request.session.flash(
264 self.request.session.flash(
266 _('Errors exist when saving integration settings. '
265 _('Errors exist when saving integration settings. '
267 'Please check the form inputs.'),
266 'Please check the form inputs.'),
268 queue='error')
267 queue='error')
269 return self.settings_get(form=e)
268 return self.settings_get(form=e)
270
269
271 if not self.integration:
270 if not self.integration:
272 self.integration = Integration()
271 self.integration = Integration()
273 self.integration.integration_type = self.IntegrationType.key
272 self.integration.integration_type = self.IntegrationType.key
274 Session().add(self.integration)
273 Session().add(self.integration)
275
274
276 scope = valid_data['options']['scope']
275 scope = valid_data['options']['scope']
277
276
278 IntegrationModel().update_integration(self.integration,
277 IntegrationModel().update_integration(self.integration,
279 name=valid_data['options']['name'],
278 name=valid_data['options']['name'],
280 enabled=valid_data['options']['enabled'],
279 enabled=valid_data['options']['enabled'],
281 settings=valid_data['settings'],
280 settings=valid_data['settings'],
282 repo=scope['repo'],
281 repo=scope['repo'],
283 repo_group=scope['repo_group'],
282 repo_group=scope['repo_group'],
284 child_repos_only=scope['child_repos_only'],
283 child_repos_only=scope['child_repos_only'],
285 )
284 )
286
285
287
288 self.integration.settings = valid_data['settings']
286 self.integration.settings = valid_data['settings']
289 Session().commit()
287 Session().commit()
290 # Display success message and redirect.
288 # Display success message and redirect.
291 self.request.session.flash(
289 self.request.session.flash(
292 _('Integration {integration_name} updated successfully.').format(
290 _('Integration {integration_name} updated successfully.').format(
293 integration_name=self.IntegrationType.display_name),
291 integration_name=self.IntegrationType.display_name),
294 queue='success')
292 queue='success')
295
293
296
297 # if integration scope changes, we must redirect to the right place
294 # if integration scope changes, we must redirect to the right place
298 # keeping in mind if the original view was for /repo/ or /_admin/
295 # keeping in mind if the original view was for /repo/ or /_admin/
299 admin_view = not (self.repo or self.repo_group)
296 admin_view = not (self.repo or self.repo_group)
300
297
301 if self.integration.repo and not admin_view:
298 if self.integration.repo and not admin_view:
302 redirect_to = self.request.route_path(
299 redirect_to = self.request.route_path(
303 'repo_integrations_edit',
300 'repo_integrations_edit',
304 repo_name=self.integration.repo.repo_name,
301 repo_name=self.integration.repo.repo_name,
305 integration=self.integration.integration_type,
302 integration=self.integration.integration_type,
306 integration_id=self.integration.integration_id)
303 integration_id=self.integration.integration_id)
307 elif self.integration.repo_group and not admin_view:
304 elif self.integration.repo_group and not admin_view:
308 redirect_to = self.request.route_path(
305 redirect_to = self.request.route_path(
309 'repo_group_integrations_edit',
306 'repo_group_integrations_edit',
310 repo_group_name=self.integration.repo_group.group_name,
307 repo_group_name=self.integration.repo_group.group_name,
311 integration=self.integration.integration_type,
308 integration=self.integration.integration_type,
312 integration_id=self.integration.integration_id)
309 integration_id=self.integration.integration_id)
313 else:
310 else:
314 redirect_to = self.request.route_path(
311 redirect_to = self.request.route_path(
315 'global_integrations_edit',
312 'global_integrations_edit',
316 integration=self.integration.integration_type,
313 integration=self.integration.integration_type,
317 integration_id=self.integration.integration_id)
314 integration_id=self.integration.integration_id)
318
315
319 return HTTPFound(redirect_to)
316 return HTTPFound(redirect_to)
320
317
321 def index(self):
318 def index(self):
322 """ List integrations """
319 """ List integrations """
323 if self.repo:
320 if self.repo:
324 scope = self.repo
321 scope = self.repo
325 elif self.repo_group:
322 elif self.repo_group:
326 scope = self.repo_group
323 scope = self.repo_group
327 else:
324 else:
328 scope = 'all'
325 scope = 'all'
329
326
330 integrations = []
327 integrations = []
331
328
332 for IntType, integration in IntegrationModel().get_integrations(
329 for IntType, integration in IntegrationModel().get_integrations(
333 scope=scope, IntegrationType=self.IntegrationType):
330 scope=scope, IntegrationType=self.IntegrationType):
334
331
335 # extra permissions check *just in case*
332 # extra permissions check *just in case*
336 if not self._has_perms_for_integration(integration):
333 if not self._has_perms_for_integration(integration):
337 continue
334 continue
338
335
339 integrations.append((IntType, integration))
336 integrations.append((IntType, integration))
340
337
341 sort_arg = self.request.GET.get('sort', 'name:asc')
338 sort_arg = self.request.GET.get('sort', 'name:asc')
342 if ':' in sort_arg:
339 if ':' in sort_arg:
343 sort_field, sort_dir = sort_arg.split(':')
340 sort_field, sort_dir = sort_arg.split(':')
344 else:
341 else:
345 sort_field = sort_arg, 'asc'
342 sort_field = sort_arg, 'asc'
346
343
347 assert sort_field in ('name', 'integration_type', 'enabled', 'scope')
344 assert sort_field in ('name', 'integration_type', 'enabled', 'scope')
348
345
349 integrations.sort(
346 integrations.sort(
350 key=lambda x: getattr(x[1], sort_field), reverse=(sort_dir=='desc'))
347 key=lambda x: getattr(x[1], sort_field),
351
348 reverse=(sort_dir == 'desc'))
352
349
353 page_url = webhelpers.paginate.PageURL(
350 page_url = webhelpers.paginate.PageURL(
354 self.request.path, self.request.GET)
351 self.request.path, self.request.GET)
355 page = safe_int(self.request.GET.get('page', 1), 1)
352 page = safe_int(self.request.GET.get('page', 1), 1)
356
353
357 integrations = Page(integrations, page=page, items_per_page=10,
354 integrations = Page(integrations, page=page, items_per_page=10,
358 url=page_url)
355 url=page_url)
359
356
360 template_context = {
357 template_context = {
361 'sort_field': sort_field,
358 'sort_field': sort_field,
362 'rev_sort_dir': sort_dir != 'desc' and 'desc' or 'asc',
359 'rev_sort_dir': sort_dir != 'desc' and 'desc' or 'asc',
363 'current_IntegrationType': self.IntegrationType,
360 'current_IntegrationType': self.IntegrationType,
364 'integrations_list': integrations,
361 'integrations_list': integrations,
365 'available_integrations': integration_type_registry,
362 'available_integrations': integration_type_registry,
366 'c': self._template_c_context(),
363 'c': self._template_c_context(),
367 'request': self.request,
364 'request': self.request,
368 }
365 }
369 return template_context
366 return template_context
370
367
371 def new_integration(self):
368 def new_integration(self):
372 template_context = {
369 template_context = {
373 'available_integrations': integration_type_registry,
370 'available_integrations': integration_type_registry,
374 'c': self._template_c_context(),
371 'c': self._template_c_context(),
375 }
372 }
376 return template_context
373 return template_context
377
374
375
378 class GlobalIntegrationsView(IntegrationSettingsViewBase):
376 class GlobalIntegrationsView(IntegrationSettingsViewBase):
379 def perm_check(self, user):
377 def perm_check(self, user):
380 return auth.HasPermissionAll('hg.admin').check_permissions(user=user)
378 return auth.HasPermissionAll('hg.admin').check_permissions(user=user)
381
379
382
380
383 class RepoIntegrationsView(IntegrationSettingsViewBase):
381 class RepoIntegrationsView(IntegrationSettingsViewBase):
384 def perm_check(self, user):
382 def perm_check(self, user):
385 return auth.HasRepoPermissionAll('repository.admin'
383 return auth.HasRepoPermissionAll('repository.admin'
386 )(repo_name=self.repo.repo_name, user=user)
384 )(repo_name=self.repo.repo_name, user=user)
387
385
388
386
389 class RepoGroupIntegrationsView(IntegrationSettingsViewBase):
387 class RepoGroupIntegrationsView(IntegrationSettingsViewBase):
390 def perm_check(self, user):
388 def perm_check(self, user):
391 return auth.HasRepoGroupPermissionAll('group.admin'
389 return auth.HasRepoGroupPermissionAll('group.admin'
392 )(group_name=self.repo_group.group_name, user=user)
390 )(group_name=self.repo_group.group_name, user=user)
393
391
@@ -1,226 +1,226 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2
2
3 # Copyright (C) 2016-2017 RhodeCode GmbH
3 # Copyright (C) 2016-2017 RhodeCode GmbH
4 #
4 #
5 # This program is free software: you can redistribute it and/or modify
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
7 # (only), as published by the Free Software Foundation.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU Affero General Public License
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
16 #
17 # This program is dual-licensed. If you wish to learn more about the
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
20
21 import os
21 import os
22
22
23 import deform
23 import deform
24 import colander
24 import colander
25
25
26 from rhodecode.translation import _
26 from rhodecode.translation import _
27 from rhodecode.model.db import Repository, RepoGroup
27 from rhodecode.model.db import Repository, RepoGroup
28 from rhodecode.model.validation_schema import validators, preparers
28 from rhodecode.model.validation_schema import validators, preparers
29
29
30
30
31 def integration_scope_choices(permissions):
31 def integration_scope_choices(permissions):
32 """
32 """
33 Return list of (value, label) choices for integration scopes depending on
33 Return list of (value, label) choices for integration scopes depending on
34 the permissions
34 the permissions
35 """
35 """
36 result = [('', _('Pick a scope:'))]
36 result = [('', _('Pick a scope:'))]
37 if 'hg.admin' in permissions['global']:
37 if 'hg.admin' in permissions['global']:
38 result.extend([
38 result.extend([
39 ('global', _('Global (all repositories)')),
39 ('global', _('Global (all repositories)')),
40 ('root-repos', _('Top level repositories only')),
40 ('root-repos', _('Top level repositories only')),
41 ])
41 ])
42
42
43 repo_choices = [
43 repo_choices = [
44 ('repo:%s' % repo_name, '/' + repo_name)
44 ('repo:%s' % repo_name, '/' + repo_name)
45 for repo_name, repo_perm
45 for repo_name, repo_perm
46 in permissions['repositories'].items()
46 in permissions['repositories'].items()
47 if repo_perm == 'repository.admin'
47 if repo_perm == 'repository.admin'
48 ]
48 ]
49 repogroup_choices = [
49 repogroup_choices = [
50 ('repogroup:%s' % repo_group_name, '/' + repo_group_name + '/ (child repos only)')
50 ('repogroup:%s' % repo_group_name, '/' + repo_group_name + '/ (child repos only)')
51 for repo_group_name, repo_group_perm
51 for repo_group_name, repo_group_perm
52 in permissions['repositories_groups'].items()
52 in permissions['repositories_groups'].items()
53 if repo_group_perm == 'group.admin'
53 if repo_group_perm == 'group.admin'
54 ]
54 ]
55 repogroup_recursive_choices = [
55 repogroup_recursive_choices = [
56 ('repogroup-recursive:%s' % repo_group_name, '/' + repo_group_name + '/ (recursive)')
56 ('repogroup-recursive:%s' % repo_group_name, '/' + repo_group_name + '/ (recursive)')
57 for repo_group_name, repo_group_perm
57 for repo_group_name, repo_group_perm
58 in permissions['repositories_groups'].items()
58 in permissions['repositories_groups'].items()
59 if repo_group_perm == 'group.admin'
59 if repo_group_perm == 'group.admin'
60 ]
60 ]
61 result.extend(
61 result.extend(
62 sorted(repogroup_recursive_choices + repogroup_choices + repo_choices,
62 sorted(repogroup_recursive_choices + repogroup_choices + repo_choices,
63 key=lambda (choice, label): choice.split(':', 1)[1]
63 key=lambda (choice, label): choice.split(':', 1)[1]
64 )
64 )
65 )
65 )
66 return result
66 return result
67
67
68
68
69 @colander.deferred
69 @colander.deferred
70 def deferred_integration_scopes_validator(node, kw):
70 def deferred_integration_scopes_validator(node, kw):
71 perms = kw.get('permissions')
71 perms = kw.get('permissions')
72 def _scope_validator(_node, scope):
72 def _scope_validator(_node, scope):
73 is_super_admin = 'hg.admin' in perms['global']
73 is_super_admin = 'hg.admin' in perms['global']
74
74
75 if scope.get('repo'):
75 if scope.get('repo'):
76 if (is_super_admin or perms['repositories'].get(
76 if (is_super_admin or perms['repositories'].get(
77 scope['repo'].repo_name) == 'repository.admin'):
77 scope['repo'].repo_name) == 'repository.admin'):
78 return True
78 return True
79 msg = _('Only repo admins can create integrations')
79 msg = _('Only repo admins can create integrations')
80 raise colander.Invalid(_node, msg)
80 raise colander.Invalid(_node, msg)
81 elif scope.get('repo_group'):
81 elif scope.get('repo_group'):
82 if (is_super_admin or perms['repositories_groups'].get(
82 if (is_super_admin or perms['repositories_groups'].get(
83 scope['repo_group'].group_name) == 'group.admin'):
83 scope['repo_group'].group_name) == 'group.admin'):
84 return True
84 return True
85
85
86 msg = _('Only repogroup admins can create integrations')
86 msg = _('Only repogroup admins can create integrations')
87 raise colander.Invalid(_node, msg)
87 raise colander.Invalid(_node, msg)
88 else:
88 else:
89 if is_super_admin:
89 if is_super_admin:
90 return True
90 return True
91 msg = _('Only superadmins can create global integrations')
91 msg = _('Only superadmins can create global integrations')
92 raise colander.Invalid(_node, msg)
92 raise colander.Invalid(_node, msg)
93
93
94 return _scope_validator
94 return _scope_validator
95
95
96
96
97 @colander.deferred
97 @colander.deferred
98 def deferred_integration_scopes_widget(node, kw):
98 def deferred_integration_scopes_widget(node, kw):
99 if kw.get('no_scope'):
99 if kw.get('no_scope'):
100 return deform.widget.TextInputWidget(readonly=True)
100 return deform.widget.TextInputWidget(readonly=True)
101
101
102 choices = integration_scope_choices(kw.get('permissions'))
102 choices = integration_scope_choices(kw.get('permissions'))
103 widget = deform.widget.Select2Widget(values=choices)
103 widget = deform.widget.Select2Widget(values=choices)
104 return widget
104 return widget
105
105
106
106
107 class IntegrationScopeType(colander.SchemaType):
107 class IntegrationScopeType(colander.SchemaType):
108 def serialize(self, node, appstruct):
108 def serialize(self, node, appstruct):
109 if appstruct is colander.null:
109 if appstruct is colander.null:
110 return colander.null
110 return colander.null
111
111
112 if appstruct.get('repo'):
112 if appstruct.get('repo'):
113 return 'repo:%s' % appstruct['repo'].repo_name
113 return 'repo:%s' % appstruct['repo'].repo_name
114 elif appstruct.get('repo_group'):
114 elif appstruct.get('repo_group'):
115 if appstruct.get('child_repos_only'):
115 if appstruct.get('child_repos_only'):
116 return 'repogroup:%s' % appstruct['repo_group'].group_name
116 return 'repogroup:%s' % appstruct['repo_group'].group_name
117 else:
117 else:
118 return 'repogroup-recursive:%s' % (
118 return 'repogroup-recursive:%s' % (
119 appstruct['repo_group'].group_name)
119 appstruct['repo_group'].group_name)
120 else:
120 else:
121 if appstruct.get('child_repos_only'):
121 if appstruct.get('child_repos_only'):
122 return 'root-repos'
122 return 'root-repos'
123 else:
123 else:
124 return 'global'
124 return 'global'
125
125
126 raise colander.Invalid(node, '%r is not a valid scope' % appstruct)
126 raise colander.Invalid(node, '%r is not a valid scope' % appstruct)
127
127
128 def deserialize(self, node, cstruct):
128 def deserialize(self, node, cstruct):
129 if cstruct is colander.null:
129 if cstruct is colander.null:
130 return colander.null
130 return colander.null
131
131
132 if cstruct.startswith('repo:'):
132 if cstruct.startswith('repo:'):
133 repo = Repository.get_by_repo_name(cstruct.split(':')[1])
133 repo = Repository.get_by_repo_name(cstruct.split(':')[1])
134 if repo:
134 if repo:
135 return {
135 return {
136 'repo': repo,
136 'repo': repo,
137 'repo_group': None,
137 'repo_group': None,
138 'child_repos_only': None,
138 'child_repos_only': False,
139 }
139 }
140 elif cstruct.startswith('repogroup-recursive:'):
140 elif cstruct.startswith('repogroup-recursive:'):
141 repo_group = RepoGroup.get_by_group_name(cstruct.split(':')[1])
141 repo_group = RepoGroup.get_by_group_name(cstruct.split(':')[1])
142 if repo_group:
142 if repo_group:
143 return {
143 return {
144 'repo': None,
144 'repo': None,
145 'repo_group': repo_group,
145 'repo_group': repo_group,
146 'child_repos_only': False
146 'child_repos_only': False
147 }
147 }
148 elif cstruct.startswith('repogroup:'):
148 elif cstruct.startswith('repogroup:'):
149 repo_group = RepoGroup.get_by_group_name(cstruct.split(':')[1])
149 repo_group = RepoGroup.get_by_group_name(cstruct.split(':')[1])
150 if repo_group:
150 if repo_group:
151 return {
151 return {
152 'repo': None,
152 'repo': None,
153 'repo_group': repo_group,
153 'repo_group': repo_group,
154 'child_repos_only': True
154 'child_repos_only': True
155 }
155 }
156 elif cstruct == 'global':
156 elif cstruct == 'global':
157 return {
157 return {
158 'repo': None,
158 'repo': None,
159 'repo_group': None,
159 'repo_group': None,
160 'child_repos_only': False
160 'child_repos_only': False
161 }
161 }
162 elif cstruct == 'root-repos':
162 elif cstruct == 'root-repos':
163 return {
163 return {
164 'repo': None,
164 'repo': None,
165 'repo_group': None,
165 'repo_group': None,
166 'child_repos_only': True
166 'child_repos_only': True
167 }
167 }
168
168
169 raise colander.Invalid(node, '%r is not a valid scope' % cstruct)
169 raise colander.Invalid(node, '%r is not a valid scope' % cstruct)
170
170
171
171
172 class IntegrationOptionsSchemaBase(colander.MappingSchema):
172 class IntegrationOptionsSchemaBase(colander.MappingSchema):
173
173
174 name = colander.SchemaNode(
174 name = colander.SchemaNode(
175 colander.String(),
175 colander.String(),
176 description=_('Short name for this integration.'),
176 description=_('Short name for this integration.'),
177 missing=colander.required,
177 missing=colander.required,
178 title=_('Integration name'),
178 title=_('Integration name'),
179 )
179 )
180
180
181 scope = colander.SchemaNode(
181 scope = colander.SchemaNode(
182 IntegrationScopeType(),
182 IntegrationScopeType(),
183 description=_(
183 description=_(
184 'Scope of the integration. Recursive means the integration '
184 'Scope of the integration. Recursive means the integration '
185 ' runs on all repos of that group and children recursively.'),
185 ' runs on all repos of that group and children recursively.'),
186 title=_('Integration scope'),
186 title=_('Integration scope'),
187 validator=deferred_integration_scopes_validator,
187 validator=deferred_integration_scopes_validator,
188 widget=deferred_integration_scopes_widget,
188 widget=deferred_integration_scopes_widget,
189 missing=colander.required,
189 missing=colander.required,
190 )
190 )
191
191
192 enabled = colander.SchemaNode(
192 enabled = colander.SchemaNode(
193 colander.Bool(),
193 colander.Bool(),
194 default=True,
194 default=True,
195 description=_('Enable or disable this integration.'),
195 description=_('Enable or disable this integration.'),
196 missing=False,
196 missing=False,
197 title=_('Enabled'),
197 title=_('Enabled'),
198 )
198 )
199
199
200
200
201
201
202 def make_integration_schema(IntegrationType, settings=None):
202 def make_integration_schema(IntegrationType, settings=None):
203 """
203 """
204 Return a colander schema for an integration type
204 Return a colander schema for an integration type
205
205
206 :param IntegrationType: the integration type class
206 :param IntegrationType: the integration type class
207 :param settings: existing integration settings dict (optional)
207 :param settings: existing integration settings dict (optional)
208 """
208 """
209
209
210 settings = settings or {}
210 settings = settings or {}
211 settings_schema = IntegrationType(settings=settings).settings_schema()
211 settings_schema = IntegrationType(settings=settings).settings_schema()
212
212
213 class IntegrationSchema(colander.Schema):
213 class IntegrationSchema(colander.Schema):
214 options = IntegrationOptionsSchemaBase()
214 options = IntegrationOptionsSchemaBase()
215
215
216 schema = IntegrationSchema()
216 schema = IntegrationSchema()
217 schema['options'].title = _('General integration options')
217 schema['options'].title = _('General integration options')
218
218
219 settings_schema.name = 'settings'
219 settings_schema.name = 'settings'
220 settings_schema.title = _('{integration_type} settings').format(
220 settings_schema.title = _('{integration_type} settings').format(
221 integration_type=IntegrationType.display_name)
221 integration_type=IntegrationType.display_name)
222 schema.add(settings_schema)
222 schema.add(settings_schema)
223
223
224 return schema
224 return schema
225
225
226
226
@@ -1,167 +1,167 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2
2
3 # Copyright (C) 2016-2017 RhodeCode GmbH
3 # Copyright (C) 2016-2017 RhodeCode GmbH
4 #
4 #
5 # This program is free software: you can redistribute it and/or modify
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
7 # (only), as published by the Free Software Foundation.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU Affero General Public License
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
16 #
17 # This program is dual-licensed. If you wish to learn more about the
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
20
21 import colander
21 import colander
22 import pytest
22 import pytest
23
23
24 from rhodecode.integrations import integration_type_registry
24 from rhodecode.integrations import integration_type_registry
25 from rhodecode.integrations.types.base import IntegrationTypeBase
25 from rhodecode.integrations.types.base import IntegrationTypeBase
26 from rhodecode.model.validation_schema.schemas.integration_schema import (
26 from rhodecode.model.validation_schema.schemas.integration_schema import (
27 make_integration_schema
27 make_integration_schema
28 )
28 )
29
29
30
30
31 @pytest.mark.usefixtures('app', 'autologin_user')
31 @pytest.mark.usefixtures('app', 'autologin_user')
32 class TestIntegrationSchema(object):
32 class TestIntegrationSchema(object):
33
33
34 def test_deserialize_integration_schema_perms(
34 def test_deserialize_integration_schema_perms(
35 self, backend_random, test_repo_group, StubIntegrationType):
35 self, backend_random, test_repo_group, StubIntegrationType):
36
36
37 repo = backend_random.repo
37 repo = backend_random.repo
38 repo_group = test_repo_group
38 repo_group = test_repo_group
39
39
40 empty_perms_dict = {
40 empty_perms_dict = {
41 'global': [],
41 'global': [],
42 'repositories': {},
42 'repositories': {},
43 'repositories_groups': {},
43 'repositories_groups': {},
44 }
44 }
45
45
46 perms_tests = [
46 perms_tests = [
47 (
47 (
48 'repo:%s' % repo.repo_name,
48 'repo:%s' % repo.repo_name,
49 {
49 {
50 'child_repos_only': None,
50 'child_repos_only': False,
51 'repo_group': None,
51 'repo_group': None,
52 'repo': repo,
52 'repo': repo,
53 },
53 },
54 [
54 [
55 ({}, False),
55 ({}, False),
56 ({'global': ['hg.admin']}, True),
56 ({'global': ['hg.admin']}, True),
57 ({'global': []}, False),
57 ({'global': []}, False),
58 ({'repositories': {repo.repo_name: 'repository.admin'}}, True),
58 ({'repositories': {repo.repo_name: 'repository.admin'}}, True),
59 ({'repositories': {repo.repo_name: 'repository.read'}}, False),
59 ({'repositories': {repo.repo_name: 'repository.read'}}, False),
60 ({'repositories': {repo.repo_name: 'repository.write'}}, False),
60 ({'repositories': {repo.repo_name: 'repository.write'}}, False),
61 ({'repositories': {repo.repo_name: 'repository.none'}}, False),
61 ({'repositories': {repo.repo_name: 'repository.none'}}, False),
62 ]
62 ]
63 ),
63 ),
64 (
64 (
65 'repogroup:%s' % repo_group.group_name,
65 'repogroup:%s' % repo_group.group_name,
66 {
66 {
67 'repo': None,
67 'repo': None,
68 'repo_group': repo_group,
68 'repo_group': repo_group,
69 'child_repos_only': True,
69 'child_repos_only': True,
70 },
70 },
71 [
71 [
72 ({}, False),
72 ({}, False),
73 ({'global': ['hg.admin']}, True),
73 ({'global': ['hg.admin']}, True),
74 ({'global': []}, False),
74 ({'global': []}, False),
75 ({'repositories_groups':
75 ({'repositories_groups':
76 {repo_group.group_name: 'group.admin'}}, True),
76 {repo_group.group_name: 'group.admin'}}, True),
77 ({'repositories_groups':
77 ({'repositories_groups':
78 {repo_group.group_name: 'group.read'}}, False),
78 {repo_group.group_name: 'group.read'}}, False),
79 ({'repositories_groups':
79 ({'repositories_groups':
80 {repo_group.group_name: 'group.write'}}, False),
80 {repo_group.group_name: 'group.write'}}, False),
81 ({'repositories_groups':
81 ({'repositories_groups':
82 {repo_group.group_name: 'group.none'}}, False),
82 {repo_group.group_name: 'group.none'}}, False),
83 ]
83 ]
84 ),
84 ),
85 (
85 (
86 'repogroup-recursive:%s' % repo_group.group_name,
86 'repogroup-recursive:%s' % repo_group.group_name,
87 {
87 {
88 'repo': None,
88 'repo': None,
89 'repo_group': repo_group,
89 'repo_group': repo_group,
90 'child_repos_only': False,
90 'child_repos_only': False,
91 },
91 },
92 [
92 [
93 ({}, False),
93 ({}, False),
94 ({'global': ['hg.admin']}, True),
94 ({'global': ['hg.admin']}, True),
95 ({'global': []}, False),
95 ({'global': []}, False),
96 ({'repositories_groups':
96 ({'repositories_groups':
97 {repo_group.group_name: 'group.admin'}}, True),
97 {repo_group.group_name: 'group.admin'}}, True),
98 ({'repositories_groups':
98 ({'repositories_groups':
99 {repo_group.group_name: 'group.read'}}, False),
99 {repo_group.group_name: 'group.read'}}, False),
100 ({'repositories_groups':
100 ({'repositories_groups':
101 {repo_group.group_name: 'group.write'}}, False),
101 {repo_group.group_name: 'group.write'}}, False),
102 ({'repositories_groups':
102 ({'repositories_groups':
103 {repo_group.group_name: 'group.none'}}, False),
103 {repo_group.group_name: 'group.none'}}, False),
104 ]
104 ]
105 ),
105 ),
106 (
106 (
107 'global',
107 'global',
108 {
108 {
109 'repo': None,
109 'repo': None,
110 'repo_group': None,
110 'repo_group': None,
111 'child_repos_only': False,
111 'child_repos_only': False,
112 }, [
112 }, [
113 ({}, False),
113 ({}, False),
114 ({'global': ['hg.admin']}, True),
114 ({'global': ['hg.admin']}, True),
115 ({'global': []}, False),
115 ({'global': []}, False),
116 ]
116 ]
117 ),
117 ),
118 (
118 (
119 'root-repos',
119 'root-repos',
120 {
120 {
121 'repo': None,
121 'repo': None,
122 'repo_group': None,
122 'repo_group': None,
123 'child_repos_only': True,
123 'child_repos_only': True,
124 }, [
124 }, [
125 ({}, False),
125 ({}, False),
126 ({'global': ['hg.admin']}, True),
126 ({'global': ['hg.admin']}, True),
127 ({'global': []}, False),
127 ({'global': []}, False),
128 ]
128 ]
129 ),
129 ),
130 ]
130 ]
131
131
132 for scope_input, scope_output, perms_allowed in perms_tests:
132 for scope_input, scope_output, perms_allowed in perms_tests:
133 for perms_update, allowed in perms_allowed:
133 for perms_update, allowed in perms_allowed:
134 perms = dict(empty_perms_dict, **perms_update)
134 perms = dict(empty_perms_dict, **perms_update)
135
135
136 schema = make_integration_schema(
136 schema = make_integration_schema(
137 IntegrationType=StubIntegrationType
137 IntegrationType=StubIntegrationType
138 ).bind(permissions=perms)
138 ).bind(permissions=perms)
139
139
140 input_data = {
140 input_data = {
141 'options': {
141 'options': {
142 'enabled': 'true',
142 'enabled': 'true',
143 'scope': scope_input,
143 'scope': scope_input,
144 'name': 'test integration',
144 'name': 'test integration',
145 },
145 },
146 'settings': {
146 'settings': {
147 'test_string_field': 'stringy',
147 'test_string_field': 'stringy',
148 'test_int_field': '100',
148 'test_int_field': '100',
149 }
149 }
150 }
150 }
151
151
152 if not allowed:
152 if not allowed:
153 with pytest.raises(colander.Invalid):
153 with pytest.raises(colander.Invalid):
154 schema.deserialize(input_data)
154 schema.deserialize(input_data)
155 else:
155 else:
156 assert schema.deserialize(input_data) == {
156 assert schema.deserialize(input_data) == {
157 'options': {
157 'options': {
158 'enabled': True,
158 'enabled': True,
159 'scope': scope_output,
159 'scope': scope_output,
160 'name': 'test integration',
160 'name': 'test integration',
161 },
161 },
162 'settings': {
162 'settings': {
163 'test_string_field': 'stringy',
163 'test_string_field': 'stringy',
164 'test_int_field': 100,
164 'test_int_field': 100,
165 }
165 }
166 }
166 }
167
167
General Comments 0
You need to be logged in to leave comments. Login now