##// END OF EJS Templates
permissions: allow users to update settings for repository groups they still own, or have admin perms, when they don't change their name....
dan -
r4421:73f70a03 default
parent child Browse files
Show More
@@ -1,289 +1,289 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import mock
22 22 import pytest
23 23
24 24 from rhodecode.model.meta import Session
25 25 from rhodecode.model.repo_group import RepoGroupModel
26 26 from rhodecode.model.user import UserModel
27 27 from rhodecode.tests import TEST_USER_ADMIN_LOGIN
28 28 from rhodecode.api.tests.utils import (
29 29 build_data, api_call, assert_ok, assert_error, crash)
30 30 from rhodecode.tests.fixture import Fixture
31 31
32 32
33 33 fixture = Fixture()
34 34
35 35
36 36 @pytest.mark.usefixtures("testuser_api", "app")
37 37 class TestCreateRepoGroup(object):
38 38 def test_api_create_repo_group(self):
39 39 repo_group_name = 'api-repo-group'
40 40
41 41 repo_group = RepoGroupModel.cls.get_by_group_name(repo_group_name)
42 42 assert repo_group is None
43 43
44 44 id_, params = build_data(
45 45 self.apikey, 'create_repo_group',
46 46 group_name=repo_group_name,
47 47 owner=TEST_USER_ADMIN_LOGIN,)
48 48 response = api_call(self.app, params)
49 49
50 50 repo_group = RepoGroupModel.cls.get_by_group_name(repo_group_name)
51 51 assert repo_group is not None
52 52 ret = {
53 53 'msg': 'Created new repo group `%s`' % (repo_group_name,),
54 54 'repo_group': repo_group.get_api_data()
55 55 }
56 56 expected = ret
57 57 try:
58 58 assert_ok(id_, expected, given=response.body)
59 59 finally:
60 60 fixture.destroy_repo_group(repo_group_name)
61 61
62 62 def test_api_create_repo_group_in_another_group(self):
63 63 repo_group_name = 'api-repo-group'
64 64
65 65 repo_group = RepoGroupModel.cls.get_by_group_name(repo_group_name)
66 66 assert repo_group is None
67 67 # create the parent
68 68 fixture.create_repo_group(repo_group_name)
69 69
70 70 full_repo_group_name = repo_group_name+'/'+repo_group_name
71 71 id_, params = build_data(
72 72 self.apikey, 'create_repo_group',
73 73 group_name=full_repo_group_name,
74 74 owner=TEST_USER_ADMIN_LOGIN,
75 75 copy_permissions=True)
76 76 response = api_call(self.app, params)
77 77
78 78 repo_group = RepoGroupModel.cls.get_by_group_name(full_repo_group_name)
79 79 assert repo_group is not None
80 80 ret = {
81 81 'msg': 'Created new repo group `%s`' % (full_repo_group_name,),
82 82 'repo_group': repo_group.get_api_data()
83 83 }
84 84 expected = ret
85 85 try:
86 86 assert_ok(id_, expected, given=response.body)
87 87 finally:
88 88 fixture.destroy_repo_group(full_repo_group_name)
89 89 fixture.destroy_repo_group(repo_group_name)
90 90
91 91 def test_api_create_repo_group_in_another_group_not_existing(self):
92 92 repo_group_name = 'api-repo-group-no'
93 93
94 94 repo_group = RepoGroupModel.cls.get_by_group_name(repo_group_name)
95 95 assert repo_group is None
96 96
97 97 full_repo_group_name = repo_group_name+'/'+repo_group_name
98 98 id_, params = build_data(
99 99 self.apikey, 'create_repo_group',
100 100 group_name=full_repo_group_name,
101 101 owner=TEST_USER_ADMIN_LOGIN,
102 102 copy_permissions=True)
103 103 response = api_call(self.app, params)
104 104 expected = {
105 105 'repo_group':
106 106 'Parent repository group `{}` does not exist'.format(
107 107 repo_group_name)}
108 108 assert_error(id_, expected, given=response.body)
109 109
110 110 def test_api_create_repo_group_that_exists(self):
111 111 repo_group_name = 'api-repo-group'
112 112
113 113 repo_group = RepoGroupModel.cls.get_by_group_name(repo_group_name)
114 114 assert repo_group is None
115 115
116 116 fixture.create_repo_group(repo_group_name)
117 117 id_, params = build_data(
118 118 self.apikey, 'create_repo_group',
119 119 group_name=repo_group_name,
120 120 owner=TEST_USER_ADMIN_LOGIN,)
121 121 response = api_call(self.app, params)
122 122 expected = {
123 123 'unique_repo_group_name':
124 124 'Repository group with name `{}` already exists'.format(
125 125 repo_group_name)}
126 126 try:
127 127 assert_error(id_, expected, given=response.body)
128 128 finally:
129 129 fixture.destroy_repo_group(repo_group_name)
130 130
131 131 def test_api_create_repo_group_regular_user_wit_root_location_perms(
132 132 self, user_util):
133 133 regular_user = user_util.create_user()
134 134 regular_user_api_key = regular_user.api_key
135 135
136 136 repo_group_name = 'api-repo-group-by-regular-user'
137 137
138 138 usr = UserModel().get_by_username(regular_user.username)
139 139 usr.inherit_default_permissions = False
140 140 Session().add(usr)
141 141
142 142 UserModel().grant_perm(
143 143 regular_user.username, 'hg.repogroup.create.true')
144 144 Session().commit()
145 145
146 146 repo_group = RepoGroupModel.cls.get_by_group_name(repo_group_name)
147 147 assert repo_group is None
148 148
149 149 id_, params = build_data(
150 150 regular_user_api_key, 'create_repo_group',
151 151 group_name=repo_group_name)
152 152 response = api_call(self.app, params)
153 153
154 154 repo_group = RepoGroupModel.cls.get_by_group_name(repo_group_name)
155 155 assert repo_group is not None
156 156 expected = {
157 157 'msg': 'Created new repo group `%s`' % (repo_group_name,),
158 158 'repo_group': repo_group.get_api_data()
159 159 }
160 160 try:
161 161 assert_ok(id_, expected, given=response.body)
162 162 finally:
163 163 fixture.destroy_repo_group(repo_group_name)
164 164
165 165 def test_api_create_repo_group_regular_user_with_admin_perms_to_parent(
166 166 self, user_util):
167 167
168 168 repo_group_name = 'api-repo-group-parent'
169 169
170 170 repo_group = RepoGroupModel.cls.get_by_group_name(repo_group_name)
171 171 assert repo_group is None
172 172 # create the parent
173 173 fixture.create_repo_group(repo_group_name)
174 174
175 175 # user perms
176 176 regular_user = user_util.create_user()
177 177 regular_user_api_key = regular_user.api_key
178 178
179 179 usr = UserModel().get_by_username(regular_user.username)
180 180 usr.inherit_default_permissions = False
181 181 Session().add(usr)
182 182
183 183 RepoGroupModel().grant_user_permission(
184 184 repo_group_name, regular_user.username, 'group.admin')
185 185 Session().commit()
186 186
187 187 full_repo_group_name = repo_group_name + '/' + repo_group_name
188 188 id_, params = build_data(
189 189 regular_user_api_key, 'create_repo_group',
190 190 group_name=full_repo_group_name)
191 191 response = api_call(self.app, params)
192 192
193 193 repo_group = RepoGroupModel.cls.get_by_group_name(full_repo_group_name)
194 194 assert repo_group is not None
195 195 expected = {
196 196 'msg': 'Created new repo group `{}`'.format(full_repo_group_name),
197 197 'repo_group': repo_group.get_api_data()
198 198 }
199 199 try:
200 200 assert_ok(id_, expected, given=response.body)
201 201 finally:
202 202 fixture.destroy_repo_group(full_repo_group_name)
203 203 fixture.destroy_repo_group(repo_group_name)
204 204
205 205 def test_api_create_repo_group_regular_user_no_permission_to_create_to_root_level(self):
206 206 repo_group_name = 'api-repo-group'
207 207
208 208 id_, params = build_data(
209 209 self.apikey_regular, 'create_repo_group',
210 210 group_name=repo_group_name)
211 211 response = api_call(self.app, params)
212 212
213 213 expected = {
214 214 'repo_group':
215 215 u'You do not have the permission to store '
216 216 u'repository groups in the root location.'}
217 217 assert_error(id_, expected, given=response.body)
218 218
219 219 def test_api_create_repo_group_regular_user_no_parent_group_perms(self):
220 220 repo_group_name = 'api-repo-group-regular-user'
221 221
222 222 repo_group = RepoGroupModel.cls.get_by_group_name(repo_group_name)
223 223 assert repo_group is None
224 224 # create the parent
225 225 fixture.create_repo_group(repo_group_name)
226 226
227 227 full_repo_group_name = repo_group_name+'/'+repo_group_name
228 228
229 229 id_, params = build_data(
230 230 self.apikey_regular, 'create_repo_group',
231 231 group_name=full_repo_group_name)
232 232 response = api_call(self.app, params)
233 233
234 234 expected = {
235 235 'repo_group':
236 'Parent repository group `{}` does not exist'.format(
237 repo_group_name)}
236 u"You do not have the permissions to store "
237 u"repository groups inside repository group `{}`".format(repo_group_name)}
238 238 try:
239 239 assert_error(id_, expected, given=response.body)
240 240 finally:
241 241 fixture.destroy_repo_group(repo_group_name)
242 242
243 243 def test_api_create_repo_group_regular_user_no_permission_to_specify_owner(
244 244 self):
245 245 repo_group_name = 'api-repo-group'
246 246
247 247 id_, params = build_data(
248 248 self.apikey_regular, 'create_repo_group',
249 249 group_name=repo_group_name,
250 250 owner=TEST_USER_ADMIN_LOGIN,)
251 251 response = api_call(self.app, params)
252 252
253 253 expected = "Only RhodeCode super-admin can specify `owner` param"
254 254 assert_error(id_, expected, given=response.body)
255 255
256 256 @mock.patch.object(RepoGroupModel, 'create', crash)
257 257 def test_api_create_repo_group_exception_occurred(self):
258 258 repo_group_name = 'api-repo-group'
259 259
260 260 repo_group = RepoGroupModel.cls.get_by_group_name(repo_group_name)
261 261 assert repo_group is None
262 262
263 263 id_, params = build_data(
264 264 self.apikey, 'create_repo_group',
265 265 group_name=repo_group_name,
266 266 owner=TEST_USER_ADMIN_LOGIN,)
267 267 response = api_call(self.app, params)
268 268 expected = 'failed to create repo group `%s`' % (repo_group_name,)
269 269 assert_error(id_, expected, given=response.body)
270 270
271 271 def test_create_group_with_extra_slashes_in_name(self, user_util):
272 272 existing_repo_group = user_util.create_repo_group()
273 273 dirty_group_name = '//{}//group2//'.format(
274 274 existing_repo_group.group_name)
275 275 cleaned_group_name = '{}/group2'.format(
276 276 existing_repo_group.group_name)
277 277
278 278 id_, params = build_data(
279 279 self.apikey, 'create_repo_group',
280 280 group_name=dirty_group_name,
281 281 owner=TEST_USER_ADMIN_LOGIN,)
282 282 response = api_call(self.app, params)
283 283 repo_group = RepoGroupModel.cls.get_by_group_name(cleaned_group_name)
284 284 expected = {
285 285 'msg': 'Created new repo group `%s`' % (cleaned_group_name,),
286 286 'repo_group': repo_group.get_api_data()
287 287 }
288 288 assert_ok(id_, expected, given=response.body)
289 289 fixture.destroy_repo_group(cleaned_group_name)
@@ -1,298 +1,311 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2016-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21
22 22 import colander
23 23 import deform.widget
24 24
25 25 from rhodecode.translation import _
26 26 from rhodecode.model.validation_schema import validators, preparers, types
27 27
28 28
29 29 def get_group_and_repo(repo_name):
30 30 from rhodecode.model.repo_group import RepoGroupModel
31 31 return RepoGroupModel()._get_group_name_and_parent(
32 32 repo_name, get_object=True)
33 33
34 34
35 35 def get_repo_group(repo_group_id):
36 36 from rhodecode.model.repo_group import RepoGroup
37 37 return RepoGroup.get(repo_group_id), RepoGroup.CHOICES_SEPARATOR
38 38
39 39
40 40 @colander.deferred
41 41 def deferred_can_write_to_group_validator(node, kw):
42 42 old_values = kw.get('old_values') or {}
43 43 request_user = kw.get('user')
44 44
45 45 def can_write_group_validator(node, value):
46 46 from rhodecode.lib.auth import (
47 47 HasPermissionAny, HasRepoGroupPermissionAny)
48 48 from rhodecode.model.repo_group import RepoGroupModel
49 49
50 50 messages = {
51 51 'invalid_parent_repo_group':
52 52 _(u"Parent repository group `{}` does not exist"),
53 53 # permissions denied we expose as not existing, to prevent
54 54 # resource discovery
55 55 'permission_denied_parent_group':
56 _(u"Parent repository group `{}` does not exist"),
56 _(u"You do not have the permissions to store "
57 u"repository groups inside repository group `{}`"),
57 58 'permission_denied_root':
58 59 _(u"You do not have the permission to store "
59 60 u"repository groups in the root location.")
60 61 }
61 62
62 63 value = value['repo_group_name']
63 64 parent_group_name = value
64 65
65 66 is_root_location = value is types.RootLocation
66 67
67 68 # NOT initialized validators, we must call them
68 69 can_create_repo_groups_at_root = HasPermissionAny(
69 70 'hg.admin', 'hg.repogroup.create.true')
70 71
71 72 if is_root_location:
72 73 if can_create_repo_groups_at_root(user=request_user):
73 74 # we can create repo group inside tool-level. No more checks
74 75 # are required
75 76 return
76 77 else:
77 78 raise colander.Invalid(node, messages['permission_denied_root'])
78 79
79 80 # check if the parent repo group actually exists
80 81 parent_group = None
81 82 if parent_group_name:
82 83 parent_group = RepoGroupModel().get_by_group_name(parent_group_name)
83 84 if value and not parent_group:
84 85 raise colander.Invalid(
85 86 node, messages['invalid_parent_repo_group'].format(
86 87 parent_group_name))
87 88
88 89 # check if we have permissions to create new groups under
89 90 # parent repo group
90 91 # create repositories with write permission on group is set to true
91 92 create_on_write = HasPermissionAny(
92 93 'hg.create.write_on_repogroup.true')(user=request_user)
93 94
94 95 group_admin = HasRepoGroupPermissionAny('group.admin')(
95 96 parent_group_name, 'can write into group validator', user=request_user)
96 97 group_write = HasRepoGroupPermissionAny('group.write')(
97 98 parent_group_name, 'can write into group validator', user=request_user)
98 99
99 100 # creation by write access is currently disabled. Needs thinking if
100 101 # we want to allow this...
101 102 forbidden = not (group_admin or (group_write and create_on_write and 0))
102 103
104 old_name = old_values.get('group_name')
105 if old_name and old_name == old_values.get('submitted_repo_group_name'):
106 # we're editing a repository group, we didn't change the name
107 # we skip the check for write into parent group now
108 # this allows changing settings for this repo group
109 return
110
103 111 if parent_group and forbidden:
104 msg = messages['permission_denied_parent_group'].format(
105 parent_group_name)
112 msg = messages['permission_denied_parent_group'].format(parent_group_name)
106 113 raise colander.Invalid(node, msg)
107 114
108 115 return can_write_group_validator
109 116
110 117
111 118 @colander.deferred
112 119 def deferred_repo_group_owner_validator(node, kw):
113 120
114 121 def repo_owner_validator(node, value):
115 122 from rhodecode.model.db import User
116 123 existing = User.get_by_username(value)
117 124 if not existing:
118 125 msg = _(u'Repo group owner with id `{}` does not exists').format(
119 126 value)
120 127 raise colander.Invalid(node, msg)
121 128
122 129 return repo_owner_validator
123 130
124 131
125 132 @colander.deferred
126 133 def deferred_unique_name_validator(node, kw):
127 134 request_user = kw.get('user')
128 135 old_values = kw.get('old_values') or {}
129 136
130 137 def unique_name_validator(node, value):
131 138 from rhodecode.model.db import Repository, RepoGroup
132 139 name_changed = value != old_values.get('group_name')
133 140
134 141 existing = Repository.get_by_repo_name(value)
135 142 if name_changed and existing:
136 143 msg = _(u'Repository with name `{}` already exists').format(value)
137 144 raise colander.Invalid(node, msg)
138 145
139 146 existing_group = RepoGroup.get_by_group_name(value)
140 147 if name_changed and existing_group:
141 148 msg = _(u'Repository group with name `{}` already exists').format(
142 149 value)
143 150 raise colander.Invalid(node, msg)
144 151 return unique_name_validator
145 152
146 153
147 154 @colander.deferred
148 155 def deferred_repo_group_name_validator(node, kw):
149 156 return validators.valid_name_validator
150 157
151 158
152 159 @colander.deferred
153 160 def deferred_repo_group_validator(node, kw):
154 161 options = kw.get(
155 162 'repo_group_repo_group_options')
156 163 return colander.OneOf([x for x in options])
157 164
158 165
159 166 @colander.deferred
160 167 def deferred_repo_group_widget(node, kw):
161 168 items = kw.get('repo_group_repo_group_items')
162 169 return deform.widget.Select2Widget(values=items)
163 170
164 171
165 172 class GroupType(colander.Mapping):
166 173 def _validate(self, node, value):
167 174 try:
168 175 return dict(repo_group_name=value)
169 176 except Exception as e:
170 177 raise colander.Invalid(
171 178 node, '"${val}" is not a mapping type: ${err}'.format(
172 179 val=value, err=e))
173 180
174 181 def deserialize(self, node, cstruct):
175 182 if cstruct is colander.null:
176 183 return cstruct
177 184
178 185 appstruct = super(GroupType, self).deserialize(node, cstruct)
179 186 validated_name = appstruct['repo_group_name']
180 187
181 188 # inject group based on once deserialized data
182 189 (repo_group_name_without_group,
183 190 parent_group_name,
184 191 parent_group) = get_group_and_repo(validated_name)
185 192
186 193 appstruct['repo_group_name_with_group'] = validated_name
187 194 appstruct['repo_group_name_without_group'] = repo_group_name_without_group
188 195 appstruct['repo_group_name'] = parent_group_name or types.RootLocation
189 196 if parent_group:
190 197 appstruct['repo_group_id'] = parent_group.group_id
191 198
192 199 return appstruct
193 200
194 201
195 202 class GroupSchema(colander.SchemaNode):
196 203 schema_type = GroupType
197 204 validator = deferred_can_write_to_group_validator
198 205 missing = colander.null
199 206
200 207
201 208 class RepoGroup(GroupSchema):
202 209 repo_group_name = colander.SchemaNode(
203 210 types.GroupNameType())
204 211 repo_group_id = colander.SchemaNode(
205 212 colander.String(), missing=None)
206 213 repo_group_name_without_group = colander.SchemaNode(
207 214 colander.String(), missing=None)
208 215
209 216
210 217 class RepoGroupAccessSchema(colander.MappingSchema):
211 218 repo_group = RepoGroup()
212 219
213 220
214 221 class RepoGroupNameUniqueSchema(colander.MappingSchema):
215 222 unique_repo_group_name = colander.SchemaNode(
216 223 colander.String(),
217 224 validator=deferred_unique_name_validator)
218 225
219 226
220 227 class RepoGroupSchema(colander.Schema):
221 228
222 229 repo_group_name = colander.SchemaNode(
223 230 types.GroupNameType(),
224 231 validator=deferred_repo_group_name_validator)
225 232
226 233 repo_group_owner = colander.SchemaNode(
227 234 colander.String(),
228 235 validator=deferred_repo_group_owner_validator)
229 236
230 237 repo_group_description = colander.SchemaNode(
231 238 colander.String(), missing='', widget=deform.widget.TextAreaWidget())
232 239
233 240 repo_group_copy_permissions = colander.SchemaNode(
234 241 types.StringBooleanType(),
235 242 missing=False, widget=deform.widget.CheckboxWidget())
236 243
237 244 repo_group_enable_locking = colander.SchemaNode(
238 245 types.StringBooleanType(),
239 246 missing=False, widget=deform.widget.CheckboxWidget())
240 247
241 248 def deserialize(self, cstruct):
242 249 """
243 250 Custom deserialize that allows to chain validation, and verify
244 251 permissions, and as last step uniqueness
245 252 """
246 253
247 254 appstruct = super(RepoGroupSchema, self).deserialize(cstruct)
248 255 validated_name = appstruct['repo_group_name']
249 256
250 257 # second pass to validate permissions to repo_group
258 if 'old_values' in self.bindings:
259 # save current repo name for name change checks
260 self.bindings['old_values']['submitted_repo_group_name'] = validated_name
251 261 second = RepoGroupAccessSchema().bind(**self.bindings)
252 262 appstruct_second = second.deserialize({'repo_group': validated_name})
253 263 # save result
254 264 appstruct['repo_group'] = appstruct_second['repo_group']
255 265
256 266 # thirds to validate uniqueness
257 267 third = RepoGroupNameUniqueSchema().bind(**self.bindings)
258 268 third.deserialize({'unique_repo_group_name': validated_name})
259 269
260 270 return appstruct
261 271
262 272
263 273 class RepoGroupSettingsSchema(RepoGroupSchema):
264 274 repo_group = colander.SchemaNode(
265 275 colander.Integer(),
266 276 validator=deferred_repo_group_validator,
267 277 widget=deferred_repo_group_widget,
268 278 missing='')
269 279
270 280 def deserialize(self, cstruct):
271 281 """
272 282 Custom deserialize that allows to chain validation, and verify
273 283 permissions, and as last step uniqueness
274 284 """
275 285
276 286 # first pass, to validate given data
277 287 appstruct = super(RepoGroupSchema, self).deserialize(cstruct)
278 288 validated_name = appstruct['repo_group_name']
279 289
280 290 # because of repoSchema adds repo-group as an ID, we inject it as
281 291 # full name here because validators require it, it's unwrapped later
282 292 # so it's safe to use and final name is going to be without group anyway
283 293
284 294 group, separator = get_repo_group(appstruct['repo_group'])
285 295 if group:
286 296 validated_name = separator.join([group.group_name, validated_name])
287 297
288 298 # second pass to validate permissions to repo_group
299 if 'old_values' in self.bindings:
300 # save current repo name for name change checks
301 self.bindings['old_values']['submitted_repo_group_name'] = validated_name
289 302 second = RepoGroupAccessSchema().bind(**self.bindings)
290 303 appstruct_second = second.deserialize({'repo_group': validated_name})
291 304 # save result
292 305 appstruct['repo_group'] = appstruct_second['repo_group']
293 306
294 307 # thirds to validate uniqueness
295 308 third = RepoGroupNameUniqueSchema().bind(**self.bindings)
296 309 third.deserialize({'unique_repo_group_name': validated_name})
297 310
298 311 return appstruct
General Comments 0
You need to be logged in to leave comments. Login now