0003-implement-JSON-import-export-fixes-13887.patch
passerelle/apps/choosit/models.py | ||
---|---|---|
196 | 196 |
reg = ChoositRegisterWS(self.url, self.key) |
197 | 197 |
ws = reg.update(subscriptions, user) |
198 | 198 |
return {"message": ws['status']} |
199 | ||
200 |
def export_json(self): |
|
201 |
raise NotImplementedError |
passerelle/apps/csvdatasource/models.py | ||
---|---|---|
83 | 83 |
return [] |
84 | 84 |
return getattr(self, attribute).strip().splitlines() |
85 | 85 | |
86 |
def export_json(self): |
|
87 |
return { |
|
88 |
'slug': self.slug, |
|
89 |
'label': self.label, |
|
90 |
'description': self.description, |
|
91 |
'filters': self.filters, |
|
92 |
'projections': self.projections, |
|
93 |
'order': self.order, |
|
94 |
'distinct': self.distinct, |
|
95 |
'structure': self.structure, |
|
96 |
} |
|
97 | ||
98 |
@classmethod |
|
99 |
def import_json(cls, d): |
|
100 |
return cls(**d) |
|
101 | ||
86 | 102 | |
87 | 103 |
class CsvDataSource(BaseResource): |
88 | 104 |
csv_file = models.FileField(_('CSV File'), upload_to='csv') |
... | ... | |
360 | 376 |
if len(data[0]) != 1: |
361 | 377 |
raise APIError('more or less than one column', data=data) |
362 | 378 |
return data[0].values()[0] |
379 | ||
380 |
def export_json(self): |
|
381 |
d = super(CsvDataSource, self).export_json() |
|
382 |
d['queries'] = [query.export_json() for query in Query.objects.filter(resource=self)] |
|
383 |
return d |
|
384 | ||
385 |
@classmethod |
|
386 |
def import_json_real(cls, overwrite, instance, d, **kwargs): |
|
387 |
queries = d.pop('queries', []) |
|
388 |
instance = super(CsvDataSource, cls).import_json_real(overwrite, instance, d, **kwargs) |
|
389 |
new = [] |
|
390 |
if instance and overwrite: |
|
391 |
Query.objects.filter(resource=instance).delete() |
|
392 |
for query in queries: |
|
393 |
q = Query.import_json(query) |
|
394 |
q.resource = instance |
|
395 |
new.append(q) |
|
396 |
Query.objects.bulk_create(new) |
|
397 |
return instance |
passerelle/base/management/commands/export_site.py | ||
---|---|---|
1 |
import json |
|
2 |
import sys |
|
3 | ||
4 |
from django.core.management.base import BaseCommand |
|
5 | ||
6 |
from passerelle.utils import export_site |
|
7 | ||
8 | ||
9 |
class Command(BaseCommand): |
|
10 |
args = '' |
|
11 |
help = 'Export the site' |
|
12 | ||
13 |
def handle(self, *args, **options): |
|
14 |
json.dump(export_site(), sys.stdout, indent=4) |
passerelle/base/management/commands/import_site.py | ||
---|---|---|
1 |
import json |
|
2 |
from optparse import make_option |
|
3 | ||
4 |
from django.core.management.base import BaseCommand |
|
5 | ||
6 |
from passerelle.base.models import ApiUser, BaseResource |
|
7 |
from passerelle.utils import import_site |
|
8 | ||
9 | ||
10 |
class Command(BaseCommand): |
|
11 |
args = '<filename>' |
|
12 |
help = 'Import an exported site' |
|
13 |
option_list = BaseCommand.option_list + ( |
|
14 |
make_option('--clean', action='store_true', default=False, |
|
15 |
help='Clean site before importing'), |
|
16 |
make_option('--import-users', action='store_true', default=False, |
|
17 |
help='Import users and access rights'), |
|
18 |
make_option('--if-empty', action='store_true', default=False, |
|
19 |
help='Import only if passerelle is empty'), |
|
20 |
make_option('--overwrite', action='store_true', default=False, |
|
21 |
help='Overwirte existing resources'), |
|
22 |
) |
|
23 | ||
24 |
def handle(self, filename, **options): |
|
25 |
import_site(json.load(open(filename)), |
|
26 |
if_empty=options['if_empty'], |
|
27 |
clean=options['clean'], |
|
28 |
overwrite=options['overwrite'], |
|
29 |
import_users=options['import_users']) |
passerelle/base/models.py | ||
---|---|---|
1 | 1 |
import logging |
2 |
import os |
|
3 |
import base64 |
|
2 | 4 | |
5 |
from django.apps import apps |
|
3 | 6 |
from django.conf import settings |
4 | 7 |
from django.core.exceptions import ValidationError, ObjectDoesNotExist |
5 | 8 |
from django.core.urlresolvers import reverse |
6 |
from django.db import models |
|
9 |
from django.db import models, transaction
|
|
7 | 10 |
from django.db.models import Q |
8 | 11 |
from django.utils.translation import ugettext_lazy as _ |
9 | 12 |
from django.utils.text import slugify |
13 |
from django.core.files.base import ContentFile |
|
10 | 14 | |
11 | 15 |
from django.contrib.contenttypes.models import ContentType |
12 | 16 |
from django.contrib.contenttypes import fields |
13 | 17 | |
18 |
from jsonfield import JSONField |
|
19 | ||
14 | 20 |
from model_utils.managers import InheritanceManager as ModelUtilsInheritanceManager |
15 | 21 | |
16 | 22 |
import passerelle |
... | ... | |
44 | 50 |
if self.keytype and not self.key: |
45 | 51 |
raise ValidationError(_('Key can not be empty for type %s.') % self.keytype) |
46 | 52 | |
53 |
def export_json(self): |
|
54 |
return { |
|
55 |
'@type': 'passerelle-user', |
|
56 |
'username': self.username, |
|
57 |
'fullname': self.fullname, |
|
58 |
'description': self.description, |
|
59 |
'keytype': self.keytype, |
|
60 |
'key': self.key, |
|
61 |
'ipsource': self.ipsource, |
|
62 |
} |
|
63 | ||
64 |
@classmethod |
|
65 |
def import_json(self, d, overwrite=False): |
|
66 |
if d.get('@type') != 'passerelle-user': |
|
67 |
raise ValueError('not a passerelle user export') |
|
68 |
d = d.copy() |
|
69 |
d.pop('@type') |
|
70 |
api_user, created = self.objects.get_or_create(username=d['username'], defaults=d) |
|
71 |
if overwrite and not created: |
|
72 |
for key in d: |
|
73 |
setattr(api_user, key, d[key]) |
|
74 |
api_user.save() |
|
75 | ||
47 | 76 | |
48 | 77 |
class TemplateVar(models.Model): |
49 | 78 |
name = models.CharField(max_length=64) |
... | ... | |
155 | 184 |
return [(x, getattr(self, x.name, None)) for x in self._meta.fields if x.name not in ( |
156 | 185 |
'id', 'title', 'slug', 'description', 'log_level', 'users')] |
157 | 186 | |
187 |
def export_json(self): |
|
188 |
d = { |
|
189 |
'@type': 'passerelle-resource', |
|
190 |
'resource_type': '%s.%s' % (self.__class__._meta.app_label, |
|
191 |
self.__class__._meta.model_name), |
|
192 |
'title': self.title, |
|
193 |
'slug': self.slug, |
|
194 |
'description': self.description, |
|
195 |
'log_level': self.log_level, |
|
196 |
'access_rights': [] |
|
197 |
} |
|
198 |
resource_type = ContentType.objects.get_for_model(self) |
|
199 |
for ar in AccessRight.objects.filter(resource_type=resource_type, |
|
200 |
resource_pk=self.pk).select_related(): |
|
201 |
d['access_rights'].append({ |
|
202 |
'codename': ar.codename, |
|
203 |
'apiuser': ar.apiuser.username, |
|
204 |
}) |
|
205 |
for field, model in self.__class__._meta.get_concrete_fields_with_model(): |
|
206 |
if field.name == 'id': |
|
207 |
continue |
|
208 |
value = getattr(self, field.attname) |
|
209 |
if isinstance(field, (models.TextField, models.CharField, models.SlugField, |
|
210 |
models.URLField, models.BooleanField, models.IntegerField, |
|
211 |
models.CommaSeparatedIntegerField, models.EmailField, |
|
212 |
models.IntegerField, models.PositiveIntegerField, JSONField)): |
|
213 |
d[field.name] = value |
|
214 |
elif isinstance(field, models.FileField): |
|
215 |
if value: |
|
216 |
d[field.name] = { |
|
217 |
'name': os.path.basename(value.name), |
|
218 |
'content': base64.b64encode(value.read()), |
|
219 |
} |
|
220 |
else: |
|
221 |
d[field.name] = None |
|
222 |
else: |
|
223 |
raise Exception('export_json: field %s of ressource class %s is unsupported' % ( |
|
224 |
field, self.__class__)) |
|
225 |
return d |
|
226 | ||
227 |
@staticmethod |
|
228 |
def import_json(d, import_users=False, overwrite=False): |
|
229 |
if d.get('@type') != 'passerelle-resource': |
|
230 |
raise ValueError('not a passerelle resource export') |
|
231 | ||
232 |
d = d.copy() |
|
233 |
d.pop('@type') |
|
234 |
app_label, model_name = d['resource_type'].split('.') |
|
235 |
model = apps.get_model(app_label, model_name) |
|
236 |
try: |
|
237 |
instance = model.objects.get(slug=d['slug']) |
|
238 |
if not overwrite: |
|
239 |
return |
|
240 |
except model.DoesNotExist: |
|
241 |
instance = None |
|
242 |
with transaction.atomic(): |
|
243 |
# prevent semi-creation of ressources |
|
244 |
instance = model.import_json_real(overwrite, instance, d) |
|
245 |
resource_type = ContentType.objects.get_for_model(instance) |
|
246 |
# We can only connect AccessRight objects to the new Resource after its creation |
|
247 |
if import_users: |
|
248 |
for ar in d['access_rights']: |
|
249 |
apiuser = ApiUser.objects.get(username=ar['apiuser']) |
|
250 |
AccessRight.objects.get_or_create( |
|
251 |
codename=ar['codename'], |
|
252 |
resource_type=resource_type, |
|
253 |
resource_pk=instance.pk, |
|
254 |
apiuser=apiuser) |
|
255 |
return instance |
|
256 | ||
257 |
@classmethod |
|
258 |
def import_json_real(cls, overwrite, instance, d, **kwargs): |
|
259 |
init_kwargs = { |
|
260 |
'title': d['title'], |
|
261 |
'slug': d['slug'], |
|
262 |
'description': d['description'], |
|
263 |
'log_level': d['log_level'], |
|
264 |
} |
|
265 |
init_kwargs.update(kwargs) |
|
266 |
if instance: |
|
267 |
for key in init_kwargs: |
|
268 |
setattr(instance, key, init_kwargs[key]) |
|
269 |
else: |
|
270 |
instance = cls(**init_kwargs) |
|
271 |
for field, model in cls._meta.get_concrete_fields_with_model(): |
|
272 |
if field.name == 'id': |
|
273 |
continue |
|
274 |
value = d[field.name] |
|
275 |
if isinstance(field, (models.TextField, models.CharField, models.SlugField, |
|
276 |
models.URLField, models.BooleanField, models.IntegerField, |
|
277 |
models.CommaSeparatedIntegerField, models.EmailField, |
|
278 |
models.IntegerField, models.PositiveIntegerField, JSONField)): |
|
279 |
setattr(instance, field.attname, value) |
|
280 |
elif isinstance(field, models.FileField): |
|
281 |
if value: |
|
282 |
getattr(instance, field.attname).save( |
|
283 |
value['name'], |
|
284 |
ContentFile(base64.b64decode(value['content'])), |
|
285 |
save=False) |
|
286 |
else: |
|
287 |
raise Exception('import_json_real: field %s of ressource class ' |
|
288 |
'%s is unsupported' % (field, cls)) |
|
289 | ||
290 |
instance.save() |
|
291 |
return instance |
|
292 | ||
158 | 293 | |
159 | 294 |
class AccessRight(models.Model): |
160 | 295 |
codename = models.CharField(max_length=100, verbose_name='codename') |
passerelle/repost/models.py | ||
---|---|---|
20 | 20 |
out_data = json.loads(p.read()) |
21 | 21 |
p.close() |
22 | 22 |
return out_data |
23 | ||
24 |
def export_json(self): |
|
25 |
raise NotImplementedError |
passerelle/utils/__init__.py | ||
---|---|---|
13 | 13 |
from django.utils.decorators import available_attrs |
14 | 14 |
from django.views.generic.detail import SingleObjectMixin |
15 | 15 |
from django.contrib.contenttypes.models import ContentType |
16 |
from django.db import transaction |
|
16 | 17 | |
17 | 18 |
from passerelle.base.context_processors import template_vars |
18 |
from passerelle.base.models import ApiUser, AccessRight |
|
19 |
from passerelle.base.models import ApiUser, AccessRight, BaseResource
|
|
19 | 20 |
from passerelle.base.signature import check_query, check_url |
20 | 21 | |
21 | 22 |
from .jsonresponse import to_json |
... | ... | |
173 | 174 |
extra={'requests_response_content': content}) |
174 | 175 | |
175 | 176 |
return response |
177 | ||
178 | ||
179 |
def export_site(): |
|
180 |
'''Dump passerelle configuration (users, resources and ACLs) to JSON dumpable dictionnary''' |
|
181 |
d = {} |
|
182 |
d['apiusers'] = [apiuser.export_json() for apiuser in ApiUser.objects.all()] |
|
183 |
d['resources'] = resources = [] |
|
184 |
for subclass in BaseResource.__subclasses__(): |
|
185 |
if subclass._meta.abstract: |
|
186 |
continue |
|
187 |
for resource in subclass.objects.all(): |
|
188 |
try: |
|
189 |
resources.append(resource.export_json()) |
|
190 |
except NotImplementedError: |
|
191 |
break |
|
192 |
return d |
|
193 | ||
194 | ||
195 |
def import_site(d, if_empty=False, clean=False, overwrite=False, import_users=False): |
|
196 |
'''Load passerelle configuration (users, resources and ACLs) from a dictionnary loaded from |
|
197 |
JSON |
|
198 |
''' |
|
199 |
d = d.copy() |
|
200 | ||
201 |
def is_empty(): |
|
202 |
if import_users: |
|
203 |
if ApiUser.objects.count(): |
|
204 |
return False |
|
205 | ||
206 |
for subclass in BaseResource.__subclasses__(): |
|
207 |
if subclass._meta.abstract: |
|
208 |
continue |
|
209 |
if subclass.objects.count(): |
|
210 |
return False |
|
211 |
return True |
|
212 | ||
213 |
if if_empty and not is_empty(): |
|
214 |
return |
|
215 | ||
216 |
if clean: |
|
217 |
for subclass in BaseResource.__subclasses__(): |
|
218 |
if subclass._meta.abstract: |
|
219 |
continue |
|
220 |
subclass.objects.all().delete() |
|
221 |
if import_users: |
|
222 |
ApiUser.objects.all().delete() |
|
223 | ||
224 |
with transaction.atomic(): |
|
225 |
if import_users: |
|
226 |
for apiuser in d['apiusers']: |
|
227 |
ApiUser.import_json(apiuser, overwrite=overwrite) |
|
228 | ||
229 |
for resource in d['resources']: |
|
230 |
BaseResource.import_json(resource, overwrite=overwrite, import_users=import_users) |
tests/test_import_export.py | ||
---|---|---|
1 |
# -*- coding: utf-8 -*- |
|
2 |
import sys |
|
3 |
import json |
|
4 |
import os |
|
5 |
import pytest |
|
6 |
from StringIO import StringIO |
|
7 |
import tempfile |
|
8 | ||
9 |
from django.core.files import File |
|
10 |
from django.core.urlresolvers import reverse |
|
11 |
from django.core.management import call_command |
|
12 |
from django.contrib.contenttypes.models import ContentType |
|
13 |
from django.test import Client |
|
14 | ||
15 |
from passerelle.base.models import ApiUser, AccessRight |
|
16 |
from passerelle.utils import import_site, export_site |
|
17 | ||
18 |
data = """121;69981;DELANOUE;Eliot;H |
|
19 |
525;6;DANIEL WILLIAMS;Shanone;F |
|
20 |
253;67742;MARTIN;Sandra;F |
|
21 |
511;38142;MARCHETTI;Lucie;F |
|
22 |
235;22;MARTIN;Sandra;F |
|
23 |
620;52156;ARNAUD;Mathis;H |
|
24 |
902;36;BRIGAND;Coline;F |
|
25 |
2179;48548;THEBAULT;Salima;F |
|
26 |
3420;46;WILSON-LUZAYADIO;Anaëlle;F |
|
27 |
1421;55486;WONE;Fadouma;F |
|
28 |
2841;51;FIDJI;Zakia;F |
|
29 |
2431;59;BELICARD;Sacha;H |
|
30 |
4273;60;GOUBERT;Adrien;H |
|
31 |
4049;64;MOVSESSIAN;Dimitri;H |
|
32 |
4605;67;ABDOU BACAR;Kyle;H |
|
33 |
4135;22231;SAVERIAS;Marius;H |
|
34 |
4809;75;COROLLER;Maelys;F |
|
35 |
5427;117;KANTE;Aliou;H |
|
36 |
116642;118;ZAHMOUM;Yaniss;H |
|
37 |
216352;38;Dupont;Benoît;H |
|
38 |
""" |
|
39 | ||
40 |
data_bom = data.decode('utf-8').encode('utf-8-sig') |
|
41 | ||
42 |
from csvdatasource.models import CsvDataSource, Query |
|
43 |
from bdp.models import Bdp |
|
44 | ||
45 |
pytestmark = pytest.mark.django_db |
|
46 | ||
47 |
TEST_BASE_DIR = os.path.join(os.path.dirname(__file__), 'data', 'csvdatasource') |
|
48 | ||
49 | ||
50 |
def get_file_content(filename): |
|
51 |
return file(os.path.join(TEST_BASE_DIR, filename)).read() |
|
52 | ||
53 | ||
54 |
@pytest.fixture |
|
55 |
def setup(): |
|
56 | ||
57 |
def maker(columns_keynames='fam,id,lname,fname,sex', filename='data.csv', sheet_name='Feuille2', |
|
58 |
data=''): |
|
59 |
api = ApiUser.objects.create(username='all', keytype='', key='') |
|
60 |
csv = CsvDataSource.objects.create(csv_file=File(StringIO(data), filename), |
|
61 |
sheet_name=sheet_name, columns_keynames=columns_keynames, |
|
62 |
slug='test', title='a title', |
|
63 |
description='a description') |
|
64 |
obj_type = ContentType.objects.get_for_model(csv) |
|
65 |
AccessRight.objects.create(codename='can_access', apiuser=api, resource_type=obj_type, |
|
66 |
resource_pk=csv.pk) |
|
67 |
url = reverse('csvdatasource-data', kwargs={'slug': csv.slug}) |
|
68 |
return csv, url |
|
69 | ||
70 |
return maker |
|
71 | ||
72 | ||
73 |
def parse_response(response): |
|
74 |
return json.loads(response.content)['data'] |
|
75 | ||
76 | ||
77 |
@pytest.fixture |
|
78 |
def client(): |
|
79 |
return Client() |
|
80 | ||
81 | ||
82 |
@pytest.fixture(params=['data.csv', 'data.ods', 'data.xls', 'data.xlsx']) |
|
83 |
def filetype(request): |
|
84 |
return request.param |
|
85 | ||
86 | ||
87 |
def get_output_of_command(command, *args, **kwargs): |
|
88 |
old_stdout = sys.stdout |
|
89 |
output = sys.stdout = StringIO() |
|
90 |
call_command(command, *args, **kwargs) |
|
91 |
sys.stdout = old_stdout |
|
92 |
return output.getvalue() |
|
93 | ||
94 | ||
95 |
def test_export_csvdatasource(app, setup, filetype): |
|
96 |
def clear(): |
|
97 |
Query.objects.all().delete() |
|
98 |
CsvDataSource.objects.all().delete() |
|
99 |
ApiUser.objects.all().delete() |
|
100 | ||
101 |
csvdata, url = setup('id,whatever,nom,prenom,sexe', filename=filetype, |
|
102 |
data=get_file_content(filetype)) |
|
103 |
query = Query(slug='query-1_', resource=csvdata, structure='array') |
|
104 |
query.projections = '\n'.join(['id:int(id)', 'prenom:prenom']) |
|
105 |
query.save() |
|
106 | ||
107 |
first = export_site() |
|
108 |
clear() |
|
109 |
import_site(first, import_users=True) |
|
110 |
second = export_site() |
|
111 | ||
112 |
# we don't care about file names |
|
113 |
first['resources'][0]['csv_file']['name'] = 'whocares' |
|
114 |
second['resources'][0]['csv_file']['name'] = 'whocares' |
|
115 |
assert first == second |
|
116 | ||
117 |
output = get_output_of_command('export_site') |
|
118 |
third = json.loads(output) |
|
119 |
third['resources'][0]['csv_file']['name'] = 'whocares' |
|
120 |
assert first == third |
|
121 | ||
122 |
clear() |
|
123 |
with tempfile.NamedTemporaryFile() as f: |
|
124 |
f.write(output) |
|
125 |
f.flush() |
|
126 |
call_command('import_site', f.name, import_users=True) |
|
127 |
fourth = export_site() |
|
128 |
fourth['resources'][0]['csv_file']['name'] = 'whocares' |
|
129 |
assert first == fourth |
|
130 | ||
131 |
Query.objects.all().delete() |
|
132 | ||
133 |
with tempfile.NamedTemporaryFile() as f: |
|
134 |
f.write(output) |
|
135 |
f.flush() |
|
136 |
call_command('import_site', f.name, import_users=True) |
|
137 |
assert Query.objects.count() == 0 |
|
138 | ||
139 |
with tempfile.NamedTemporaryFile() as f: |
|
140 |
f.write(output) |
|
141 |
f.flush() |
|
142 |
call_command('import_site', f.name, overwrite=True, import_users=True) |
|
143 |
assert Query.objects.count() == 1 |
|
144 | ||
145 |
query = Query(slug='query-2_', resource=CsvDataSource.objects.get(), structure='array') |
|
146 |
query.projections = '\n'.join(['id:int(id)', 'prenom:prenom']) |
|
147 |
query.save() |
|
148 | ||
149 |
assert Query.objects.count() == 2 |
|
150 | ||
151 |
with tempfile.NamedTemporaryFile() as f: |
|
152 |
f.write(output) |
|
153 |
f.flush() |
|
154 |
call_command('import_site', f.name, clean=True, overwrite=True, import_users=True) |
|
155 |
assert Query.objects.count() == 1 |
|
156 |
assert Query.objects.filter(slug='query-1_').exists() |
|
157 | ||
158 | ||
159 |
def test_import_export_empty_filefield(app, setup, filetype): |
|
160 |
Bdp.objects.create(service_url='https://bdp.example.com/') |
|
161 | ||
162 |
first = export_site() |
|
163 |
import_site(first, import_users=True) |
|
164 |
second = export_site() |
|
165 | ||
166 |
# we don't care about file names |
|
167 |
assert first['resources'][0]['keystore'] is None |
|
168 |
assert second['resources'][0]['keystore'] is None |
|
0 |
- |