0002-csvdatasource-add-upload-csv-file-endpoint-29713.patch
passerelle/apps/csvdatasource/models.py | ||
---|---|---|
11 | 11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
12 | 12 |
# GNU Affero 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 |
import csv |
18 | 18 |
import datetime |
19 |
import mimetypes |
|
19 | 20 |
import os |
20 | 21 |
import re |
21 | 22 |
import tempfile |
22 | 23 |
from collections import OrderedDict |
23 | 24 | |
24 | 25 |
import pytz |
25 | 26 |
import six |
26 | 27 |
from django.conf import settings |
27 | 28 |
from django.contrib.postgres.fields import JSONField |
28 | 29 |
from django.core.exceptions import ValidationError |
30 |
from django.core.files.base import ContentFile |
|
29 | 31 |
from django.db import models, transaction |
30 | 32 |
from django.urls import reverse |
31 | 33 |
from django.utils.encoding import force_str, force_text, smart_text |
32 | 34 |
from django.utils.timezone import make_aware, now |
33 | 35 |
from django.utils.translation import ugettext_lazy as _ |
34 | 36 |
from pyexcel_ods import get_data as get_data_ods |
35 | 37 |
from pyexcel_xls import get_data as get_data_xls |
36 | 38 | |
... | ... | |
139 | 141 |
_dialect_options = JSONField(editable=False, null=True) |
140 | 142 |
sheet_name = models.CharField(_('Sheet name'), blank=True, max_length=150) |
141 | 143 | |
142 | 144 |
category = _('Data Sources') |
143 | 145 |
documentation_url = ( |
144 | 146 |
'https://doc-publik.entrouvert.com/admin-fonctionnel/parametrage-avance/source-de-donnees-csv/' |
145 | 147 |
) |
146 | 148 | |
149 |
_can_update_file_description = _('Uploading spreadsheet file is limited to the following API users:') |
|
150 | ||
147 | 151 |
class Meta: |
148 | 152 |
verbose_name = _('Spreadsheet File') |
149 | 153 | |
150 | 154 |
def clean(self, *args, **kwargs): |
151 | 155 |
file_type = self.csv_file.name.split('.')[-1] |
152 | 156 |
if file_type in ('ods', 'xls', 'xlsx') and not self.sheet_name: |
153 | 157 |
raise ValidationError(_('You must specify a sheet name')) |
154 | 158 |
if file_type not in ('ods', 'xls', 'xlsx'): |
... | ... | |
513 | 517 |
# remove |
514 | 518 |
os.unlink(filepath) |
515 | 519 |
else: |
516 | 520 |
# move file in unused-files dir |
517 | 521 |
unused_dir = os.path.join(base_dir, 'unused-files') |
518 | 522 |
os.makedirs(unused_dir, exist_ok=True) |
519 | 523 |
os.rename(filepath, os.path.join(unused_dir, filename)) |
520 | 524 | |
525 |
@endpoint(perm='can_update_file', methods=['put']) |
|
526 |
def update(self, request): |
|
527 |
ext = mimetypes.guess_extension(request.content_type) |
|
528 |
if not ext: |
|
529 |
raise APIError( |
|
530 |
"can't guess filename extension for '%s' content type" % request.content_type, http_status=400 |
|
531 |
) |
|
532 |
name = self.csv_file.storage.get_available_name('api-uploaded-file' + ext) |
|
533 |
self.csv_file = ContentFile(content=request.body, name=name) |
|
534 |
try: |
|
535 |
self.clean() |
|
536 |
except ValidationError as e: |
|
537 |
raise APIError(e, http_status=400) |
|
538 |
self.save() |
|
539 |
return {'created': self.csv_file.name} |
|
540 | ||
521 | 541 | |
522 | 542 |
class TableRow(models.Model): |
523 | 543 |
resource = models.ForeignKey('CsvDataSource', on_delete=models.CASCADE) |
524 | 544 |
line_number = models.IntegerField(null=False) |
525 | 545 |
data = JSONField(blank=True) |
526 | 546 | |
527 | 547 |
class Meta: |
528 | 548 |
ordering = ('line_number',) |
passerelle/apps/csvdatasource/templates/csvdatasource/csvdatasource_detail.html | ||
---|---|---|
8 | 8 |
{% trans "File:" %} |
9 | 9 |
{% if object|can_edit:request.user %}<a href="{% url 'csv-download' connector_slug=object.slug %}">{{object.csv_file}}</a> |
10 | 10 |
— {% trans "added on" %} {{object.csv_file_datetime}} |
11 | 11 |
{% else %}{{object.csv_file}}{% endif %} |
12 | 12 |
</p> |
13 | 13 |
{% endblock %} |
14 | 14 | |
15 | 15 |
{% block endpoints %} |
16 |
<h4>{% trans "Access" %}</h4> |
|
16 | 17 |
<ul class="endpoints"> |
17 | 18 |
{% url 'generic-endpoint' connector='csvdatasource' slug=object.slug endpoint='data' as csvdatasource_data_url %} |
18 | 19 |
<li class="get-method"> |
19 | 20 |
<div class="description"><span class="description--label">{% trans "Returning all file lines:" %}</span> |
20 | 21 |
<a class="example-url" href="{{ csvdatasource_data_url }}">{{ csvdatasource_data_url }}</a></div> |
21 | 22 |
</li> |
22 | 23 |
<li class="get-method"> |
23 | 24 |
<div class="description"><span class="description--label">{% trans "Returning lines containing 'abc' in 'text' column (case insensitive):" %}</span> |
... | ... | |
25 | 26 |
</li> |
26 | 27 |
{% for query in object.queries.all %} |
27 | 28 |
<li class="get-method"><div class="description"><span class="description--label">{{ query.label }}:</span> |
28 | 29 |
<a class="example-url" href="{% url 'generic-endpoint' connector='csvdatasource' slug=object.slug endpoint='query' rest=query.slug %}/">{% url 'generic-endpoint' connector='csvdatasource' slug=object.slug endpoint='query' rest=query.slug %}/</a></div> |
29 | 30 |
{% if query.description %}— {{ query.description }}{% endif %} |
30 | 31 |
</li> |
31 | 32 |
{% endfor %} |
32 | 33 |
</ul> |
34 |
<h4>{% trans "Management" %}</h4> |
|
35 |
<ul class="endpoints"> |
|
36 |
<li class="put-method"> |
|
37 |
<div class="description"> |
|
38 |
<span class="description--label">{% trans "Modify spreadsheet file:" %}</span> |
|
39 |
<a href="/csvdatasource/test/update" class="example-url">/csvdatasource/test/update</a></div> |
|
40 |
</li> |
|
41 |
</ul> |
|
33 | 42 |
{% endblock %} |
tests/test_csv_datasource.py | ||
---|---|---|
24 | 24 |
from stat import ST_MTIME |
25 | 25 | |
26 | 26 |
import mock |
27 | 27 |
import pytest |
28 | 28 |
import six |
29 | 29 |
import webtest |
30 | 30 |
from django.contrib.contenttypes.models import ContentType |
31 | 31 |
from django.core.files import File |
32 |
from django.core.files.storage import default_storage |
|
32 | 33 |
from django.core.management import call_command |
33 | 34 |
from django.test import Client, override_settings |
34 | 35 |
from django.urls import reverse |
35 | 36 |
from django.utils.encoding import force_bytes, force_str, force_text |
36 | 37 |
from django.utils.six import StringIO |
37 | 38 |
from django.utils.six.moves.urllib.parse import urlencode |
38 | 39 |
from django.utils.timezone import now |
39 | 40 |
from test_manager import login |
40 | 41 | |
41 |
from passerelle.apps.csvdatasource.models import CsvDataSource, Query, TableRow |
|
42 |
from passerelle.apps.csvdatasource.models import CsvDataSource, Query, TableRow, upload_to
|
|
42 | 43 |
from passerelle.base.models import AccessRight, ApiUser |
43 | 44 |
from passerelle.compat import json_loads |
44 | 45 | |
45 | 46 |
data = """121;69981;DELANOUE;Eliot;H |
46 | 47 |
525;6;DANIEL WILLIAMS;Shanone;F |
47 | 48 |
253;67742;MARTIN;Sandra;F |
48 | 49 |
511;38142;MARCHETTI;Lucie;F |
49 | 50 |
235;22;MARTIN;Sandra;F |
... | ... | |
862 | 863 |
csv.refresh_from_db() |
863 | 864 |
assert list(csv.get_rows()) == [] |
864 | 865 | |
865 | 866 |
call_command('change-csv', 'test', os.path.join(TEST_BASE_DIR, 'data.ods'), sheet_name='Feuille2') |
866 | 867 |
csv.refresh_from_db() |
867 | 868 |
assert list(csv.get_rows()) != [] |
868 | 869 | |
869 | 870 | |
871 |
def test_update(admin_user, app, setup): |
|
872 |
csv, url = setup(data=StringIO(data), filename='api-uploaded-file.csv') |
|
873 |
upload_dir = default_storage.path(upload_to(csv, '')) |
|
874 |
url = reverse( |
|
875 |
'generic-endpoint', |
|
876 |
kwargs={ |
|
877 |
'connector': 'csvdatasource', |
|
878 |
'slug': csv.slug, |
|
879 |
'endpoint': 'update', |
|
880 |
}, |
|
881 |
) |
|
882 | ||
883 |
assert CsvDataSource.objects.get().get_rows()[0]['fam'] == '121' |
|
884 |
assert [files for root, dirs, files in os.walk(upload_dir)][0] == ['api-uploaded-file.csv'] |
|
885 | ||
886 |
# curl -H "Content-Type: text/csv" -T data.csv |
|
887 |
headers = {'CONTENT-TYPE': 'text/csv'} |
|
888 |
body = '212' + data[3:] |
|
889 | ||
890 |
# restricted access |
|
891 |
resp = app.put(url, params=body, headers=headers, status=403) |
|
892 |
assert resp.json['err'] |
|
893 |
assert 'PermissionDenied' in resp.json['err_class'] |
|
894 |
assert resp.json['err_class'] == "django.core.exceptions.PermissionDenied" |
|
895 | ||
896 |
# add can_update_file access |
|
897 |
api = ApiUser.objects.get() |
|
898 |
obj_type = ContentType.objects.get_for_model(csv) |
|
899 |
AccessRight.objects.create( |
|
900 |
codename='can_update_file', apiuser=api, resource_type=obj_type, resource_pk=csv.pk |
|
901 |
) |
|
902 | ||
903 |
resp = app.put(url, params=body, headers=headers) |
|
904 |
assert not resp.json['err'] |
|
905 |
assert CsvDataSource.objects.get().get_rows()[0]['fam'] == '212' |
|
906 |
assert len([files for root, dirs, files in os.walk(upload_dir)][0]) == 2 |
|
907 | ||
908 |
resp = app.put(url, params=body, headers={}, status=400) |
|
909 |
assert resp.json['err'] |
|
910 |
assert "can't guess filename extension" in resp.json['err_desc'] |
|
911 | ||
912 |
resp = app.put(url, params='\n', headers=headers, status=400) |
|
913 |
assert resp.json['err'] |
|
914 |
assert 'Could not detect CSV dialect' in resp.json['err_desc'] |
|
915 | ||
916 |
headers = {'CONTENT-TYPE': 'application/vnd.oasis.opendocument.spreadsheet'} |
|
917 |
resp = app.put(url, params=body, headers=headers, status=400) |
|
918 |
assert resp.json['err'] |
|
919 |
assert 'Invalid CSV file: File is not a zip file' in resp.json['err_desc'] |
|
920 | ||
921 |
csv.sheet_name = '' |
|
922 |
csv.save() |
|
923 |
resp = app.put(url, params=body, headers=headers, status=400) |
|
924 |
assert resp.json['err'] |
|
925 |
assert 'You must specify a sheet name' in resp.json['err_desc'] |
|
926 | ||
927 | ||
870 | 928 |
@pytest.mark.parametrize( |
871 | 929 |
'payload,expected', |
872 | 930 |
[ |
873 | 931 |
({}, 20), |
874 | 932 |
({'limit': 10}, 10), |
875 | 933 |
({'limit': 10, 'offset': 0}, 10), |
876 | 934 |
({'limit': 10, 'offset': 15}, 5), |
877 | 935 |
({'limit': 10, 'offset': 42}, 0), |
tests/test_manager.py | ||
---|---|---|
627 | 627 |
private = ApiUser.objects.create(username='private', fullname='private', keytype='', key='xxx') |
628 | 628 |
public = ApiUser.objects.create(username='public', fullname='private', keytype='', key='') |
629 | 629 |
assert AccessRight.objects.count() == 0 |
630 | 630 | |
631 | 631 |
# adding private api user works |
632 | 632 |
app = login(app) |
633 | 633 |
resp = app.get(csv.get_absolute_url()) |
634 | 634 |
assert 'Access is limited' in resp.html.find('div', {'id': 'security'}).div.text.strip() |
635 |
resp = resp.click('Add') |
|
635 |
resp = resp.click('Add', href='/can_access/')
|
|
636 | 636 |
resp.form['apiuser'] = private.pk |
637 | 637 |
resp = resp.form.submit().follow() |
638 | 638 |
assert AccessRight.objects.count() == 1 |
639 | 639 | |
640 | 640 |
# adding public user displays a warning |
641 |
resp = resp.click('Add') |
|
641 |
resp = resp.click('Add', href='/can_access/')
|
|
642 | 642 |
resp.form['apiuser'] = public.pk |
643 | 643 |
resp = resp.form.submit() |
644 | 644 |
assert AccessRight.objects.count() == 1 |
645 | 645 |
assert 'user has no security' in resp.text |
646 | 646 | |
647 | 647 |
resp = resp.form.submit() |
648 | 648 |
assert AccessRight.objects.count() == 1 |
649 | 649 |
assert 'user has no security' in resp.text |
650 |
- |