0002-add-toulouse_smart-connector-53834.patch
passerelle/contrib/toulouse_smart/migrations/0001_initial.py | ||
---|---|---|
1 |
# Generated by Django 2.2.19 on 2021-05-06 22:50 |
|
2 | ||
3 |
import django.contrib.postgres.fields.jsonb |
|
4 |
from django.db import migrations, models |
|
5 |
import django.db.models.deletion |
|
6 | ||
7 | ||
8 |
class Migration(migrations.Migration): |
|
9 | ||
10 |
initial = True |
|
11 | ||
12 |
dependencies = [ |
|
13 |
('base', '0029_auto_20210202_1627'), |
|
14 |
] |
|
15 | ||
16 |
operations = [ |
|
17 |
migrations.CreateModel( |
|
18 |
name='ToulouseSmartResource', |
|
19 |
fields=[ |
|
20 |
( |
|
21 |
'id', |
|
22 |
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), |
|
23 |
), |
|
24 |
('title', models.CharField(max_length=50, verbose_name='Title')), |
|
25 |
('slug', models.SlugField(unique=True, verbose_name='Identifier')), |
|
26 |
('description', models.TextField(verbose_name='Description')), |
|
27 |
( |
|
28 |
'basic_auth_username', |
|
29 |
models.CharField( |
|
30 |
blank=True, max_length=128, verbose_name='Basic authentication username' |
|
31 |
), |
|
32 |
), |
|
33 |
( |
|
34 |
'basic_auth_password', |
|
35 |
models.CharField( |
|
36 |
blank=True, max_length=128, verbose_name='Basic authentication password' |
|
37 |
), |
|
38 |
), |
|
39 |
( |
|
40 |
'client_certificate', |
|
41 |
models.FileField( |
|
42 |
blank=True, null=True, upload_to='', verbose_name='TLS client certificate' |
|
43 |
), |
|
44 |
), |
|
45 |
( |
|
46 |
'trusted_certificate_authorities', |
|
47 |
models.FileField(blank=True, null=True, upload_to='', verbose_name='TLS trusted CAs'), |
|
48 |
), |
|
49 |
( |
|
50 |
'verify_cert', |
|
51 |
models.BooleanField(blank=True, default=True, verbose_name='TLS verify certificates'), |
|
52 |
), |
|
53 |
( |
|
54 |
'http_proxy', |
|
55 |
models.CharField(blank=True, max_length=128, verbose_name='HTTP and HTTPS proxy'), |
|
56 |
), |
|
57 |
('webservice_base_url', models.URLField(verbose_name='Webservice Base URL')), |
|
58 |
( |
|
59 |
'users', |
|
60 |
models.ManyToManyField( |
|
61 |
blank=True, |
|
62 |
related_name='_toulousesmartresource_users_+', |
|
63 |
related_query_name='+', |
|
64 |
to='base.ApiUser', |
|
65 |
), |
|
66 |
), |
|
67 |
], |
|
68 |
options={ |
|
69 |
'verbose_name': 'Toulouse Smart', |
|
70 |
}, |
|
71 |
), |
|
72 |
migrations.CreateModel( |
|
73 |
name='Cache', |
|
74 |
fields=[ |
|
75 |
( |
|
76 |
'id', |
|
77 |
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), |
|
78 |
), |
|
79 |
('key', models.CharField(max_length=64, verbose_name='Key')), |
|
80 |
('timestamp', models.DateTimeField(auto_now=True, verbose_name='Timestamp')), |
|
81 |
('value', django.contrib.postgres.fields.jsonb.JSONField(default=dict, verbose_name='Value')), |
|
82 |
( |
|
83 |
'resource', |
|
84 |
models.ForeignKey( |
|
85 |
on_delete=django.db.models.deletion.CASCADE, |
|
86 |
to='toulouse_smart.ToulouseSmartResource', |
|
87 |
verbose_name='Resource', |
|
88 |
), |
|
89 |
), |
|
90 |
], |
|
91 |
), |
|
92 |
] |
passerelle/contrib/toulouse_smart/models.py | ||
---|---|---|
1 |
# passerelle - uniform access to multiple data sources and services |
|
2 |
# Copyright (C) 2021 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
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/>. |
|
16 | ||
17 |
import datetime |
|
18 | ||
19 |
from django.db import models |
|
20 | ||
21 |
from django.utils.timezone import now |
|
22 |
from django.utils.translation import ugettext_lazy as _ |
|
23 | ||
24 |
from django.contrib.postgres.fields import JSONField |
|
25 | ||
26 |
import lxml.etree as ET |
|
27 | ||
28 |
from passerelle.base.models import BaseResource, HTTPResource |
|
29 |
from passerelle.utils import xml |
|
30 |
from passerelle.utils.api import endpoint |
|
31 | ||
32 | ||
33 |
class ToulouseSmartResource(BaseResource, HTTPResource): |
|
34 |
category = _('Business Process Connectors') |
|
35 | ||
36 |
webservice_base_url = models.URLField(_('Webservice Base URL')) |
|
37 | ||
38 |
log_requests_errors = False |
|
39 | ||
40 |
class Meta: |
|
41 |
verbose_name = _('Toulouse Smart') |
|
42 | ||
43 |
def get_intervention_types(self): |
|
44 |
try: |
|
45 |
return self.get('intervention-types', max_age=120) |
|
46 |
except KeyError: |
|
47 |
pass |
|
48 | ||
49 |
try: |
|
50 |
url = self.webservice_base_url + 'v1/type-intervention' |
|
51 |
response = self.requests.get(url) |
|
52 |
doc = ET.fromstring(response.content) |
|
53 |
intervention_types = [] |
|
54 |
for xml_item in doc: |
|
55 |
item = xml.to_json(xml_item) |
|
56 |
for prop in item.get('properties', []): |
|
57 |
prop['required'] = prop.get('required') == 'true' |
|
58 |
intervention_types.append(item) |
|
59 |
intervention_types.sort(key=lambda x: x['name']) |
|
60 |
for i, intervention_type in enumerate(intervention_types): |
|
61 |
intervention_type['order'] = i + 1 |
|
62 |
except Exception: |
|
63 |
try: |
|
64 |
return self.get('intervention-types') |
|
65 |
except KeyError: |
|
66 |
raise |
|
67 |
self.set('intervention-types', intervention_types) |
|
68 |
return intervention_types |
|
69 | ||
70 |
def get(self, key, max_age=None): |
|
71 |
cache_entries = self.cache_entries |
|
72 |
if max_age: |
|
73 |
cache_entries = cache_entries.filter(timestamp__gt=now() - datetime.timedelta(seconds=max_age)) |
|
74 |
try: |
|
75 |
return cache_entries.get(key='intervention-types').value |
|
76 |
except Cache.DoesNotExist: |
|
77 |
raise KeyError(key) |
|
78 | ||
79 |
def set(self, key, value): |
|
80 |
self.cache_entries.update_or_create(key=key, defaults={'value': value}) |
|
81 | ||
82 |
@endpoint( |
|
83 |
name='type-intervention', |
|
84 |
description=_('Get intervention types'), |
|
85 |
perm='can_access', |
|
86 |
) |
|
87 |
def type_intervention(self, request): |
|
88 |
try: |
|
89 |
return { |
|
90 |
'data': [ |
|
91 |
{ |
|
92 |
'id': intervention_type['id'], |
|
93 |
'text': intervention_type['name'], |
|
94 |
} |
|
95 |
for intervention_type in self.get_intervention_types() |
|
96 |
] |
|
97 |
} |
|
98 |
except Exception: |
|
99 |
return { |
|
100 |
'data': [ |
|
101 |
{ |
|
102 |
'id': '', |
|
103 |
'text': _('Service is unavailable'), |
|
104 |
'disabled': True, |
|
105 |
} |
|
106 |
] |
|
107 |
} |
|
108 | ||
109 | ||
110 |
class Cache(models.Model): |
|
111 |
resource = models.ForeignKey( |
|
112 |
verbose_name=_('Resource'), |
|
113 |
to=ToulouseSmartResource, |
|
114 |
on_delete=models.CASCADE, |
|
115 |
related_name='cache_entries', |
|
116 |
) |
|
117 | ||
118 |
key = models.CharField(_('Key'), max_length=64) |
|
119 | ||
120 |
timestamp = models.DateTimeField(_('Timestamp'), auto_now=True) |
|
121 | ||
122 |
value = JSONField(_('Value'), default=dict) |
passerelle/contrib/toulouse_smart/templates/toulouse_smart/toulousesmartresource_detail.html | ||
---|---|---|
1 |
{% extends "passerelle/manage/service_view.html" %} |
|
2 |
{% load i18n passerelle %} |
|
3 | ||
4 |
{% block extra-sections %} |
|
5 |
<div class="section"> |
|
6 |
<h3>{% trans "Details" %}</h3> |
|
7 |
<ul> |
|
8 |
<li><a href="{% url "toulouse-smart-type-intervention" slug=object.slug %}">{% trans "Intervention types" %}</a></li> |
|
9 |
</ul> |
|
10 |
</div> |
|
11 |
{% endblock %} |
passerelle/contrib/toulouse_smart/templates/toulouse_smart/type-intervention.html | ||
---|---|---|
1 |
{% extends "passerelle/manage.html" %} |
|
2 |
{% load i18n gadjo %} |
|
3 | ||
4 |
{% block breadcrumb %} |
|
5 |
{{ block.super }} |
|
6 |
<a href="{{ toulousesmartresource.get_absolute_url }}">{{ toulousesmartresource.title }}</a> |
|
7 |
<a href="#">{% trans 'Intervention types' %}</a> |
|
8 |
{% endblock %} |
|
9 | ||
10 |
{% block appbar %} |
|
11 |
<h2>{% trans 'Intervention types' %}</h2> |
|
12 |
<span class="actions"> |
|
13 |
<a href="{% url "toulouse-smart-type-intervention-as-blocks" slug=toulousesmartresource.slug %}">{% trans "Export to blocks" %}</a> |
|
14 |
</span> |
|
15 |
{% endblock %} |
|
16 | ||
17 |
{% block content %} |
|
18 |
<table class="main"> |
|
19 |
<thead> |
|
20 |
<tr> |
|
21 |
<th>Nom du type d'intervention</th> |
|
22 |
<th>Nom</th> |
|
23 |
<th>Type</th> |
|
24 |
<th>Requis</th> |
|
25 |
<th>Valeur par défaut</th> |
|
26 |
</tr> |
|
27 |
</thead> |
|
28 |
<tbody> |
|
29 |
{% for intervention_type in toulousesmartresource.get_intervention_types %} |
|
30 |
<tr><td colspan="4">{{ intervention_type.order }} - {{ intervention_type.name }}</td></tr> |
|
31 |
{% for property in intervention_type.properties %} |
|
32 |
<tr> |
|
33 |
<td></td> |
|
34 |
<td>{{ property.name }}</td> |
|
35 |
<td>{{ property.type }}</td> |
|
36 |
<td title="{{ property.required|lower }}">{{ property.required|yesno:"✔,✘" }}</td> |
|
37 |
<td>{{ property.defaultValue }}</td> |
|
38 |
</tr> |
|
39 |
{% endfor %} |
|
40 |
{% endfor %} |
|
41 |
</tbody> |
|
42 |
</table> |
|
43 | ||
44 |
<!-- |
|
45 |
{{ toulousesmartresource.get_intervention_types|pprint }} |
|
46 |
--> |
|
47 | ||
48 |
{% endblock %} |
passerelle/contrib/toulouse_smart/templates/toulouse_smart/wcs_block.wcs | ||
---|---|---|
1 |
<?xml version="1.0"?> |
|
2 |
<block id="{{ id }}"> |
|
3 |
<name>{{ name }}</name> |
|
4 |
<slug>{{ name|slugify }}</slug> |
|
5 |
<fields> |
|
6 |
{% for property in properties %} |
|
7 |
<field> |
|
8 |
<id>{{ property.id }}</id> |
|
9 |
<label>{{ property.name }}</label> |
|
10 |
<type>{{ property.type }}</type> |
|
11 |
<required>{{ property.required }}</required> |
|
12 |
<varname>{{ property.name|slugify }}</varname> |
|
13 |
<display_locations> |
|
14 |
<display_location>validation</display_location> |
|
15 |
<display_location>summary</display_location> |
|
16 |
</display_locations>{% if property.validation %} |
|
17 |
<validation> |
|
18 |
<type>{{ property.validation }}</type> |
|
19 |
</validation>{% endif %} |
|
20 |
</field>{% endfor %} |
|
21 |
</fields> |
|
22 |
</block> |
passerelle/contrib/toulouse_smart/urls.py | ||
---|---|---|
1 |
# passerelle - uniform access to multiple data sources and services |
|
2 |
# Copyright (C) 2021 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
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/>. |
|
16 | ||
17 |
from django.conf.urls import url |
|
18 | ||
19 |
from .views import TypeIntervention, TypeInterventionAsBlocks |
|
20 | ||
21 |
management_urlpatterns = [ |
|
22 |
url( |
|
23 |
r'^(?P<slug>[\w,-]+)/type-intervention/as-blocks/$', |
|
24 |
TypeInterventionAsBlocks.as_view(), |
|
25 |
name='toulouse-smart-type-intervention-as-blocks', |
|
26 |
), |
|
27 |
url( |
|
28 |
r'^(?P<slug>[\w,-]+)/type-intervention/$', |
|
29 |
TypeIntervention.as_view(), |
|
30 |
name='toulouse-smart-type-intervention', |
|
31 |
), |
|
32 |
] |
passerelle/contrib/toulouse_smart/views.py | ||
---|---|---|
1 |
# passerelle - uniform access to multiple data sources and services |
|
2 |
# Copyright (C) 2021 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
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/>. |
|
16 | ||
17 |
import hashlib |
|
18 |
import uuid |
|
19 |
import zipfile |
|
20 | ||
21 |
from django.http import HttpResponse |
|
22 |
from django.template.loader import render_to_string |
|
23 |
from django.utils.text import slugify |
|
24 |
from django.views.generic import DetailView |
|
25 | ||
26 |
from .models import ToulouseSmartResource |
|
27 | ||
28 | ||
29 |
class TypeIntervention(DetailView): |
|
30 |
model = ToulouseSmartResource |
|
31 |
template_name = 'toulouse_smart/type-intervention.html' |
|
32 | ||
33 | ||
34 |
class TypeInterventionAsBlocks(DetailView): |
|
35 |
model = ToulouseSmartResource |
|
36 | ||
37 |
def get(self, request, *args, **kwargs): |
|
38 |
self.object = self.get_object() |
|
39 | ||
40 |
def make_id(s): |
|
41 |
return str(uuid.UUID(bytes=hashlib.md5(s.encode()).digest())) |
|
42 | ||
43 |
# generate file contents |
|
44 |
files = {} |
|
45 |
for intervention_type in self.object.get_intervention_types(): |
|
46 |
slug = slugify(intervention_type['name']) |
|
47 |
# only export intervention_type with properties |
|
48 |
if not intervention_type.get('properties'): |
|
49 |
continue |
|
50 |
for prop in intervention_type['properties']: |
|
51 |
# generate a natural id for fields |
|
52 |
prop['id'] = make_id(slug + slugify(prop['name'])) |
|
53 |
# adapt types |
|
54 |
prop.setdefault('type', 'string') |
|
55 |
if prop['type'] == 'boolean': |
|
56 |
prop['type'] = 'bool' |
|
57 |
if prop['type'] == 'int': |
|
58 |
prop['type'] = 'string' |
|
59 |
prop['validation'] = 'digits' |
|
60 |
filename = 'block-%s.wcs' % slug |
|
61 |
files[filename] = render_to_string('toulouse_smart/wcs_block.wcs', context=intervention_type) |
|
62 | ||
63 |
# zip it ! |
|
64 |
response = HttpResponse(content_type='application/zip') |
|
65 |
response['Content-Disposition'] = 'attachment; filename=blocks.zip' |
|
66 |
with zipfile.ZipFile(response, mode='w') as zip_file: |
|
67 |
for name in files: |
|
68 |
zip_file.writestr(name, files[name]) |
|
69 | ||
70 |
return response |
tests/settings.py | ||
---|---|---|
39 | 39 |
'passerelle.contrib.teamnet_axel', |
40 | 40 |
'passerelle.contrib.tcl', |
41 | 41 |
'passerelle.contrib.toulouse_axel', |
42 |
'passerelle.contrib.toulouse_smart', |
|
42 | 43 |
'passerelle.contrib.lille_kimoce', |
43 | 44 |
) |
44 | 45 |
tests/test_toulouse_smart.py | ||
---|---|---|
1 |
# passerelle - uniform access to multiple data sources and services |
|
2 |
# Copyright (C) 2021 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
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/>. |
|
16 | ||
17 |
import functools |
|
18 |
import io |
|
19 |
import zipfile |
|
20 | ||
21 |
import lxml.etree as ET |
|
22 |
import httmock |
|
23 |
import pytest |
|
24 | ||
25 |
import utils |
|
26 | ||
27 |
from passerelle.contrib.toulouse_smart.models import ToulouseSmartResource |
|
28 |
from passerelle.utils.xml import to_json |
|
29 | ||
30 |
from test_manager import login |
|
31 | ||
32 | ||
33 |
@pytest.fixture |
|
34 |
def smart(db): |
|
35 |
return utils.make_resource( |
|
36 |
ToulouseSmartResource, |
|
37 |
title='Test', |
|
38 |
slug='test', |
|
39 |
description='Test', |
|
40 |
webservice_base_url='https://smart.example.com/', |
|
41 |
basic_auth_username='username', |
|
42 |
basic_auth_password='password', |
|
43 |
) |
|
44 | ||
45 | ||
46 |
def mock_response(*path_contents): |
|
47 |
def decorator(func): |
|
48 |
@httmock.urlmatch() |
|
49 |
def error(url, request): |
|
50 |
assert False, 'request to %s' % (url,) |
|
51 | ||
52 |
@functools.wraps(func) |
|
53 |
def wrapper(*args, **kwargs): |
|
54 |
handlers = [] |
|
55 |
for row in path_contents: |
|
56 |
path, content = row |
|
57 | ||
58 |
@httmock.urlmatch(path=path) |
|
59 |
def handler(url, request): |
|
60 |
return content |
|
61 | ||
62 |
handlers.append(handler) |
|
63 |
handlers.append(error) |
|
64 | ||
65 |
with httmock.HTTMock(*handlers): |
|
66 |
return func(*args, **kwargs) |
|
67 | ||
68 |
return wrapper |
|
69 | ||
70 |
return decorator |
|
71 | ||
72 | ||
73 |
@mock_response(['/v1/type-intervention', b'<List></List>']) |
|
74 |
def test_empty_intervention_types(smart): |
|
75 |
assert smart.get_intervention_types() == [] |
|
76 | ||
77 | ||
78 |
INTERVENTION_TYPES = b'''<List> |
|
79 |
<item> |
|
80 |
<id>1234</id> |
|
81 |
<name>coin</name> |
|
82 |
<properties> |
|
83 |
<properties> |
|
84 |
<name>FIELD1</name> |
|
85 |
<type>string</type> |
|
86 |
<required>false</required> |
|
87 |
</properties> |
|
88 |
<properties> |
|
89 |
<name>FIELD2</name> |
|
90 |
<type>int</type> |
|
91 |
<required>true</required> |
|
92 |
</properties> |
|
93 |
</properties> |
|
94 |
</item> |
|
95 |
</List>''' |
|
96 | ||
97 | ||
98 |
@mock_response(['/v1/type-intervention', INTERVENTION_TYPES]) |
|
99 |
def test_model_intervention_types(smart): |
|
100 |
assert smart.get_intervention_types() == [ |
|
101 |
{ |
|
102 |
'id': '1234', |
|
103 |
'name': 'coin', |
|
104 |
'order': 1, |
|
105 |
'properties': [ |
|
106 |
{'name': 'FIELD1', 'required': False, 'type': 'string'}, |
|
107 |
{'name': 'FIELD2', 'required': True, 'type': 'int'}, |
|
108 |
], |
|
109 |
}, |
|
110 |
] |
|
111 | ||
112 | ||
113 |
URL = '/toulouse-smart/test/' |
|
114 | ||
115 | ||
116 |
@mock_response(['/v1/type-intervention', INTERVENTION_TYPES]) |
|
117 |
def test_endpoint_intervention_types(app, smart): |
|
118 |
resp = app.get(URL + 'type-intervention') |
|
119 |
assert resp.json == {'data': [{'id': '1234', 'text': 'coin'}], 'err': 0} |
|
120 | ||
121 | ||
122 |
@mock_response(['/v1/type-intervention', INTERVENTION_TYPES]) |
|
123 |
def test_manage_intervention_types(app, smart, admin_user): |
|
124 |
login(app) |
|
125 |
resp = app.get('/manage' + URL + 'type-intervention/') |
|
126 |
assert [[td.text for td in tr.cssselect('td,th')] for tr in resp.pyquery('tr')] == [ |
|
127 |
["Nom du type d'intervention", 'Nom', 'Type', 'Requis', 'Valeur par défaut'], |
|
128 |
['1 - coin'], |
|
129 |
[None, 'FIELD1', 'string', '✘', None], |
|
130 |
[None, 'FIELD2', 'int', '✔', None], |
|
131 |
] |
|
132 |
resp = resp.click('Export to blocks') |
|
133 |
with zipfile.ZipFile(io.BytesIO(resp.body)) as zip_file: |
|
134 |
assert zip_file.namelist() == ['block-coin.wcs'] |
|
135 |
with zip_file.open('block-coin.wcs') as fd: |
|
136 |
content = ET.tostring(ET.fromstring(fd.read()), pretty_print=True).decode() |
|
137 |
assert ( |
|
138 |
content |
|
139 |
== '''<block id="1234"> |
|
140 |
<name>coin</name> |
|
141 |
<slug>coin</slug> |
|
142 |
<fields> |
|
143 |
|
|
144 |
<field> |
|
145 |
<id>038a8c2e-14de-4d4f-752f-496eb7fe90d7</id> |
|
146 |
<label>FIELD1</label> |
|
147 |
<type>string</type> |
|
148 |
<required>False</required> |
|
149 |
<varname>field1</varname> |
|
150 |
<display_locations> |
|
151 |
<display_location>validation</display_location> |
|
152 |
<display_location>summary</display_location> |
|
153 |
</display_locations> |
|
154 |
</field> |
|
155 |
<field> |
|
156 |
<id>e72f251a-5eef-5b78-c35a-94b549510029</id> |
|
157 |
<label>FIELD2</label> |
|
158 |
<type>string</type> |
|
159 |
<required>True</required> |
|
160 |
<varname>field2</varname> |
|
161 |
<display_locations> |
|
162 |
<display_location>validation</display_location> |
|
163 |
<display_location>summary</display_location> |
|
164 |
</display_locations> |
|
165 |
<validation> |
|
166 |
<type>digits</type> |
|
167 |
</validation> |
|
168 |
</field> |
|
169 |
</fields> |
|
170 |
</block> |
|
171 |
''' |
|
172 |
) |
|
0 |
- |