Projet

Général

Profil

0002-csvdatasource-add-upload-csv-file-endpoint-29713.patch

Nicolas Roche, 28 juin 2021 16:48

Télécharger (11,1 ko)

Voir les différences:

Subject: [PATCH 2/2] csvdatasource: add upload-csv-file endpoint (#29713)

 passerelle/apps/csvdatasource/models.py       | 20 +++++++
 .../csvdatasource/csvdatasource_detail.html   |  9 +++
 tests/test_csv_datasource.py                  | 60 ++++++++++++++++++-
 tests/test_manager.py                         |  4 +-
 4 files changed, 90 insertions(+), 3 deletions(-)
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 %}&mdash; {{ 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
-