0001-admin-manage-api-access-keys-48751.patch
tests/admin_pages/test_api_access.py | ||
---|---|---|
1 |
# w.c.s. - web application for online forms |
|
2 |
# Copyright (C) 2005-2020 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software; you can redistribute it and/or modify |
|
5 |
# it under the terms of the GNU General Public License as published by |
|
6 |
# the Free Software Foundation; either version 2 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 General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU General Public License |
|
15 |
# along with this program; if not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
import pytest |
|
18 | ||
19 |
from wcs.qommon.http_request import HTTPRequest |
|
20 |
from wcs.api_access import ApiAccess |
|
21 | ||
22 |
from utilities import get_app, login, create_temporary_pub, clean_temporary_pub |
|
23 |
from .test_all import create_superuser |
|
24 | ||
25 | ||
26 |
def pytest_generate_tests(metafunc): |
|
27 |
if 'pub' in metafunc.fixturenames: |
|
28 |
metafunc.parametrize('pub', ['pickle', 'sql', 'pickle-templates'], indirect=True) |
|
29 | ||
30 | ||
31 |
@pytest.fixture |
|
32 |
def pub(request): |
|
33 |
pub = create_temporary_pub( |
|
34 |
sql_mode=bool('sql' in request.param), |
|
35 |
templates_mode=bool('templates' in request.param) |
|
36 |
) |
|
37 | ||
38 |
req = HTTPRequest(None, {'SCRIPT_NAME': '/', 'SERVER_NAME': 'example.net'}) |
|
39 |
pub.set_app_dir(req) |
|
40 |
pub.cfg['identification'] = {'methods': ['password']} |
|
41 |
pub.cfg['language'] = {'language': 'en'} |
|
42 |
pub.write_cfg() |
|
43 | ||
44 |
return pub |
|
45 | ||
46 | ||
47 |
def teardown_module(module): |
|
48 |
clean_temporary_pub() |
|
49 | ||
50 | ||
51 |
@pytest.fixture |
|
52 |
def api_access(): |
|
53 |
ApiAccess.wipe() |
|
54 |
obj = ApiAccess() |
|
55 |
obj.name = 'Jhon' |
|
56 |
obj.description = 'API key for Jhon' |
|
57 |
obj.access_identifier = 'jhon' |
|
58 |
obj.access_key = '12345' |
|
59 |
obj.store() |
|
60 |
return obj |
|
61 | ||
62 | ||
63 |
def test_api_access_new(pub): |
|
64 |
create_superuser(pub) |
|
65 |
ApiAccess.wipe() |
|
66 |
app = login(get_app(pub)) |
|
67 | ||
68 |
# go to the page and cancel |
|
69 |
resp = app.get('/backoffice/settings/api-access/') |
|
70 |
resp = resp.click('New API access') |
|
71 |
resp = resp.forms[0].submit('cancel') |
|
72 |
assert resp.location == 'http://example.net/backoffice/settings/api-access/' |
|
73 | ||
74 |
# go to the page and add an API access |
|
75 |
resp = app.get('/backoffice/settings/api-access/') |
|
76 |
resp = resp.click('New API access') |
|
77 |
resp.form['name'] = 'a new API access' |
|
78 |
resp.form['description'] = 'description' |
|
79 |
resp.form['access_identifier'] = 'new_access' |
|
80 |
resp.form['access_key'] = '1234' |
|
81 |
resp = resp.form.submit('submit') |
|
82 |
assert resp.location == 'http://example.net/backoffice/settings/api-access/' |
|
83 |
resp = resp.follow() |
|
84 |
assert 'a new API access' in resp.text |
|
85 |
resp = resp.click('a new API access') |
|
86 |
assert 'API access - a new API access' in resp.text |
|
87 | ||
88 |
# check name unicity |
|
89 |
resp = app.get('/backoffice/settings/api-access/new') |
|
90 |
resp.form['name'] = 'a new API access' |
|
91 |
resp.form['access_identifier'] = 'changed' |
|
92 |
resp.form['access_key'] = '1234' |
|
93 |
resp = resp.form.submit('submit') |
|
94 |
assert resp.html.find('div', {'class': 'error'}).text == 'This name is already used.' |
|
95 | ||
96 |
# check access_identifier unicity |
|
97 |
resp.form['name'] = 'new one' |
|
98 |
resp.form['access_identifier'] = 'new_access' |
|
99 |
resp = resp.form.submit('submit') |
|
100 |
assert resp.html.find('div', {'class': 'error'}).text == 'This value is already used.' |
|
101 | ||
102 | ||
103 |
def test_api_access_view(pub, api_access): |
|
104 |
create_superuser(pub) |
|
105 | ||
106 |
app = login(get_app(pub)) |
|
107 |
resp = app.get('/backoffice/settings/api-access/%s/' % api_access.id) |
|
108 |
assert '12345' in resp.text |
|
109 | ||
110 |
resp = app.get('/backoffice/settings/api-access/wrong-id/', status=404) |
|
111 | ||
112 | ||
113 |
def test_api_access_edit(pub, api_access): |
|
114 |
create_superuser(pub) |
|
115 | ||
116 |
app = login(get_app(pub)) |
|
117 | ||
118 |
resp = app.get('/backoffice/settings/api-access/1/') |
|
119 |
resp = resp.click(href='edit') |
|
120 |
assert resp.form['name'].value == 'Jhon' |
|
121 |
resp = resp.form.submit('cancel') |
|
122 |
assert resp.location == 'http://example.net/backoffice/settings/api-access/1/' |
|
123 |
resp = resp.follow() |
|
124 |
resp = resp.click(href='edit') |
|
125 |
resp.form['name'] = 'Smith Robert' |
|
126 |
resp.form['description'] = 'bla bla bla' |
|
127 |
resp.form['access_identifier'] = 'smith2' |
|
128 |
resp.form['access_key'] = '5678' |
|
129 |
resp = resp.form.submit('submit') |
|
130 |
assert resp.location == 'http://example.net/backoffice/settings/api-access/1/' |
|
131 |
resp = resp.follow() |
|
132 | ||
133 |
api_access = ApiAccess.get('1') |
|
134 |
assert api_access.name == 'Smith Robert' |
|
135 |
assert api_access.description == 'bla bla bla' |
|
136 |
assert api_access.access_identifier == 'smith2' |
|
137 |
assert api_access.access_key == '5678' |
|
138 | ||
139 |
# check name unicity |
|
140 |
resp = app.get('/backoffice/settings/api-access/new') |
|
141 |
resp.form['name'] = 'Jhon' |
|
142 |
resp.form['access_identifier'] = 'jhon' |
|
143 |
resp.form['access_key'] = '1234' |
|
144 |
resp = resp.form.submit('submit') |
|
145 |
resp = app.get('/backoffice/settings/api-access/1/') |
|
146 |
resp = resp.click(href='edit') |
|
147 |
resp.form['name'] = 'Jhon' |
|
148 |
resp = resp.form.submit('submit') |
|
149 |
assert resp.html.find('div', {'class': 'error'}).text == 'This name is already used.' |
|
150 | ||
151 | ||
152 |
def test_api_access_delete(pub, api_access): |
|
153 |
create_superuser(pub) |
|
154 | ||
155 |
app = login(get_app(pub)) |
|
156 | ||
157 |
resp = app.get('/backoffice/settings/api-access/1/') |
|
158 |
resp = resp.click(href='delete') |
|
159 |
resp = resp.form.submit('cancel') |
|
160 |
assert resp.location == 'http://example.net/backoffice/settings/api-access/' |
|
161 | ||
162 |
resp = app.get('/backoffice/settings/api-access/1/') |
|
163 |
resp = resp.click(href='delete') |
|
164 |
resp = resp.form.submit('submit') |
|
165 |
assert resp.location == 'http://example.net/backoffice/settings/api-access/' |
|
166 |
assert ApiAccess.count() == 0 |
wcs/admin/api_access.py | ||
---|---|---|
1 |
# w.c.s. - web application for online forms |
|
2 |
# Copyright (C) 2005-2020 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software; you can redistribute it and/or modify |
|
5 |
# it under the terms of the GNU General Public License as published by |
|
6 |
# the Free Software Foundation; either version 2 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 General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU General Public License |
|
15 |
# along with this program; if not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
from quixote import get_response, redirect |
|
18 |
from quixote.directory import Directory |
|
19 |
from quixote.html import TemplateIO, htmltext |
|
20 | ||
21 |
from wcs.qommon import _, errors, template |
|
22 |
from wcs.qommon.form import Form, StringWidget, TextWidget, HtmlWidget |
|
23 |
from wcs.qommon.backoffice.menu import html_top |
|
24 |
from wcs.api_access import ApiAccess |
|
25 | ||
26 | ||
27 |
class ApiAccessUI(object): |
|
28 |
def __init__(self, api_access): |
|
29 |
self.api_access = api_access |
|
30 |
if self.api_access is None: |
|
31 |
self.api_access = ApiAccess() |
|
32 | ||
33 |
def get_form(self): |
|
34 |
form = Form(enctype='multipart/form-data', |
|
35 |
advanced_label=_('Additional options')) |
|
36 |
form.add(StringWidget, 'name', title=_('Name'), required=True, size=30, |
|
37 |
value=self.api_access.name) |
|
38 |
form.add(TextWidget, 'description', title=_('Description'), |
|
39 |
cols=40, rows=5, |
|
40 |
value=self.api_access.description) |
|
41 |
form.add(StringWidget, 'access_identifier', title=_('Access identifier'), |
|
42 |
required=True, size=30, |
|
43 |
value=self.api_access.access_identifier) |
|
44 |
form.add(StringWidget, 'access_key', title=_('Access key'), required=True, size=30, |
|
45 |
value=self.api_access.access_key) |
|
46 |
if not self.api_access.is_readonly(): |
|
47 |
form.add_submit('submit', _('Submit')) |
|
48 |
form.add_submit('cancel', _('Cancel')) |
|
49 |
return form |
|
50 | ||
51 |
def submit_form(self, form): |
|
52 |
name = form.get_widget('name').parse() |
|
53 |
access_identifier = form.get_widget('access_identifier').parse() |
|
54 | ||
55 |
for api_access in ApiAccess.select(): |
|
56 |
if api_access.id == self.api_access.id: |
|
57 |
continue |
|
58 |
if name == api_access.name: |
|
59 |
form.get_widget('name').set_error(_('This name is already used.')) |
|
60 |
if access_identifier and access_identifier == api_access.access_identifier: |
|
61 |
form.get_widget('access_identifier').set_error(_('This value is already used.')) |
|
62 |
if form.has_errors(): |
|
63 |
raise ValueError() |
|
64 | ||
65 |
self.api_access.name = name |
|
66 |
self.api_access.description = form.get_widget('description').parse() |
|
67 |
self.api_access.access_identifier = access_identifier |
|
68 |
self.api_access.access_key = form.get_widget('access_key').parse() |
|
69 |
self.api_access.store() |
|
70 | ||
71 | ||
72 |
class ApiAccessPage(Directory): |
|
73 |
_q_exports = ['', 'edit', 'delete',] |
|
74 | ||
75 |
def __init__(self, component, instance=None): |
|
76 |
try: |
|
77 |
self.api_access = instance or ApiAccess.get(component) |
|
78 |
except KeyError: |
|
79 |
raise errors.TraversalError() |
|
80 |
self.api_access_ui = ApiAccessUI(self.api_access) |
|
81 |
get_response().breadcrumb.append((component + '/', self.api_access.name)) |
|
82 | ||
83 |
def get_sidebar(self): |
|
84 |
r = TemplateIO(html=True) |
|
85 |
r += htmltext('<ul id="sidebar-actions">') |
|
86 |
r += htmltext('<li><a href="delete" rel="popup">%s</a></li>') % _('Delete') |
|
87 |
r += htmltext('</ul>') |
|
88 |
return r.getvalue() |
|
89 | ||
90 |
def _q_index(self): |
|
91 |
html_top('api-accesss', title=self.api_access.name) |
|
92 |
get_response().filter['sidebar'] = self.get_sidebar() |
|
93 |
return template.QommonTemplateResponse( |
|
94 |
templates=['wcs/backoffice/api_access.html'], |
|
95 |
context={'view': self, 'api_access': self.api_access}) |
|
96 | ||
97 |
def edit(self): |
|
98 |
form = self.api_access_ui.get_form() |
|
99 |
if form.get_submit() == 'cancel': |
|
100 |
return redirect('.') |
|
101 | ||
102 |
if form.get_submit() == 'submit' and not form.has_errors(): |
|
103 |
try: |
|
104 |
self.api_access_ui.submit_form(form) |
|
105 |
except ValueError: |
|
106 |
pass |
|
107 |
else: |
|
108 |
return redirect('../%s/' % self.api_access.id) |
|
109 | ||
110 |
get_response().breadcrumb.append(('edit', _('Edit'))) |
|
111 |
html_top('api-access', title=_('Edit API access')) |
|
112 |
r = TemplateIO(html=True) |
|
113 |
r += htmltext('<h2>%s</h2>') % _('Edit API access') |
|
114 |
r += form.render() |
|
115 |
return r.getvalue() |
|
116 | ||
117 |
def delete(self): |
|
118 |
form = Form(enctype='multipart/form-data') |
|
119 |
form.widgets.append(HtmlWidget('<p>%s</p>' % _( |
|
120 |
'You are about to irrevocably delete this API access.'))) |
|
121 |
form.add_submit('delete', _('Delete')) |
|
122 |
form.add_submit('cancel', _('Cancel')) |
|
123 |
if form.get_widget('cancel').parse(): |
|
124 |
return redirect('..') |
|
125 |
if not form.is_submitted() or form.has_errors(): |
|
126 |
get_response().breadcrumb.append(('delete', _('Delete'))) |
|
127 |
html_top('api_accesss', title=_('Delete API access')) |
|
128 |
r = TemplateIO(html=True) |
|
129 |
r += htmltext('<h2>%s %s</h2>') % ( |
|
130 |
_('Deleting API access:'), self.api_access.name) |
|
131 |
r += form.render() |
|
132 |
return r.getvalue() |
|
133 |
else: |
|
134 |
self.api_access.remove_self() |
|
135 |
return redirect('..') |
|
136 | ||
137 | ||
138 |
class ApiAccessDirectory(Directory): |
|
139 |
_q_exports = ['', 'new'] |
|
140 | ||
141 |
def _q_traverse(self, path): |
|
142 |
get_response().breadcrumb.append(('api-access/', _('API access'))) |
|
143 |
return super()._q_traverse(path) |
|
144 | ||
145 |
def _q_index(self): |
|
146 |
html_top('api-access', title=_('API access')) |
|
147 |
return template.QommonTemplateResponse( |
|
148 |
templates=['wcs/backoffice/api_accesses.html'], |
|
149 |
context={'view': self, 'api_accesses': ApiAccess.select(order_by='name')}) |
|
150 | ||
151 |
def new(self): |
|
152 |
get_response().breadcrumb.append(('new', _('New'))) |
|
153 |
api_access_ui = ApiAccessUI(None) |
|
154 |
form = api_access_ui.get_form() |
|
155 |
if form.get_widget('cancel').parse(): |
|
156 |
return redirect('.') |
|
157 | ||
158 |
if form.get_submit() == 'submit' and not form.has_errors(): |
|
159 |
try: |
|
160 |
api_access_ui.submit_form(form) |
|
161 |
except ValueError: |
|
162 |
pass |
|
163 |
else: |
|
164 |
return redirect('.') |
|
165 | ||
166 |
html_top('api-access', title=_('New API access')) |
|
167 |
r = TemplateIO(html=True) |
|
168 |
r += htmltext('<h2>%s</h2>') % _('New API access') |
|
169 |
r += form.render() |
|
170 |
return r.getvalue() |
|
171 | ||
172 |
def _q_lookup(self, component): |
|
173 |
return ApiAccessPage(component) |
wcs/admin/settings.py | ||
---|---|---|
57 | 57 |
from wcs.carddef import CardDef |
58 | 58 |
from wcs.workflows import Workflow, WorkflowImportError |
59 | 59 |
from wcs.roles import Role |
60 | 60 | |
61 | 61 |
from wcs.backoffice.studio import StudioDirectory |
62 | 62 |
from .fields import FieldDefPage, FieldsDirectory |
63 | 63 |
from .data_sources import NamedDataSourcesDirectory |
64 | 64 |
from .wscalls import NamedWsCallsDirectory |
65 |
from .api_access import ApiAccessDirectory |
|
65 | 66 | |
66 | 67 | |
67 | 68 |
class UserFormDirectory(Directory): |
68 | 69 |
_q_exports = [''] |
69 | 70 | |
70 | 71 | |
71 | 72 |
class IdentificationDirectory(Directory): |
72 | 73 |
_q_exports = [''] |
... | ... | |
426 | 427 |
_q_exports = ['', 'themes', 'users', |
427 | 428 |
'template', 'emails', 'debug_options', 'language', |
428 | 429 |
('import', 'p_import'), 'export', 'identification', 'sitename', |
429 | 430 |
'sms', 'certificates', 'texts', 'install_theme', |
430 | 431 |
'session', 'download_theme', 'smstest', 'postgresql', |
431 | 432 |
('admin-permissions', 'admin_permissions'), 'geolocation', |
432 | 433 |
'theme_preview', 'filetypes', |
433 | 434 |
('user-template', 'user_template'), |
434 |
('data-sources', 'data_sources'), 'wscalls', 'logs'] |
|
435 |
('data-sources', 'data_sources'), 'wscalls', 'logs', |
|
436 |
('api-access', 'api_access')] |
|
435 | 437 | |
436 | 438 |
emails = EmailsDirectory() |
437 | 439 |
identification = IdentificationDirectory() |
438 | 440 |
users = UsersDirectory() |
439 | 441 |
texts = TextsDirectory() |
440 | 442 |
theme_preview = ThemePreviewDirectory() |
441 | 443 |
filetypes = FileTypesDirectory() |
442 | 444 |
data_sources = NamedDataSourcesDirectory() |
443 | 445 |
wscalls = NamedWsCallsDirectory() |
444 | 446 |
logs = LoggerDirectory() |
447 |
api_access = ApiAccessDirectory() |
|
445 | 448 | |
446 | 449 |
def _q_index(self): |
447 | 450 |
html_top('settings', title = _('Settings')) |
448 | 451 |
r = TemplateIO(html=True) |
449 | 452 | |
450 | 453 |
disabled_screens_option = get_publisher().get_site_option('settings-disabled-screens') or '' |
451 | 454 |
disabled_screens = [x.strip() for x in disabled_screens_option.split(',')] |
452 | 455 | |
... | ... | |
486 | 489 |
_('Session'), _('Configure session management')) |
487 | 490 | |
488 | 491 |
if enabled('permissions'): |
489 | 492 |
roles = list(Role.select()) |
490 | 493 |
if roles: |
491 | 494 |
r += htmltext('<dt><a href="admin-permissions">%s</a></dt> <dd>%s</dd>') % ( |
492 | 495 |
_('Admin Permissions'), _('Configure access to the administration interface')) |
493 | 496 | |
497 |
if enabled('api-access'): |
|
498 |
r += htmltext('<dt><a href="api-access">%s</a></dt> <dd>%s</dd>') % ( |
|
499 |
_('API access'), _('Configure access to the API endpoints')) |
|
500 | ||
494 | 501 |
r += htmltext('</dl></div>') |
495 | 502 | |
496 | 503 |
if enabled('import-export'): |
497 | 504 |
r += htmltext('<div class="section">') |
498 | 505 |
r += htmltext('<h2>%s</h2>') % _('Import / Export') |
499 | 506 | |
500 | 507 |
r += htmltext('<dl>') |
501 | 508 |
r += htmltext('<dt><a href="import">%s</a></dt> <dd>%s</dd>') % ( |
wcs/api_access.py | ||
---|---|---|
1 |
# w.c.s. - web application for online forms |
|
2 |
# Copyright (C) 2005-2020 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software; you can redistribute it and/or modify |
|
5 |
# it under the terms of the GNU General Public License as published by |
|
6 |
# the Free Software Foundation; either version 2 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 General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU General Public License |
|
15 |
# along with this program; if not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
from wcs.qommon.xml_storage import XmlStorableObject |
|
18 | ||
19 | ||
20 |
class ApiAccess(XmlStorableObject): |
|
21 |
_names = 'apiaccess' |
|
22 |
xml_root_node = 'apiaccess' |
|
23 |
name = None |
|
24 |
access_identifier = None |
|
25 |
access_key = None |
|
26 |
description = None |
|
27 | ||
28 |
# declarations for serialization |
|
29 |
XML_NODES = [ |
|
30 |
('name', 'str'), |
|
31 |
('description', 'str'), |
|
32 |
('access_identifier', 'str'), |
|
33 |
('access_key', 'str'), |
|
34 |
] |
|
35 | ||
36 |
@classmethod |
|
37 |
def get_access_key(cls, access_identifier): |
|
38 |
for api_access in cls.select(): |
|
39 |
if api_access.access_identifier == access_identifier: |
|
40 |
return api_access.access_key |
|
41 |
return None |
wcs/templates/wcs/backoffice/api_access.html | ||
---|---|---|
1 |
{% load i18n %} |
|
2 | ||
3 |
{% block body %} |
|
4 |
<div id="appbar"> |
|
5 |
<h2>{% trans "API access" %} - {{ api_access.name }}</h2> |
|
6 |
<span class="actions"> |
|
7 |
<a href="edit">{% trans "Edit" %}</a> |
|
8 |
</span> |
|
9 |
</div> |
|
10 | ||
11 |
{% if api_access.description %} |
|
12 |
<div class="bo-block">{{ api_access.description }}</div> |
|
13 |
{% endif %} |
|
14 | ||
15 |
<div class="bo-block"> |
|
16 |
<h3>{% trans "Parameters" %}</h3> |
|
17 |
<ul> |
|
18 |
<li>{% trans "Access identifier:" %} {{ api_access.access_identifier }}</li> |
|
19 |
<li>{% trans "Access key:" %} {{ api_access.access_key }}</li> |
|
20 |
</ul> |
|
21 |
</div> |
|
22 |
{% endblock %} |
wcs/templates/wcs/backoffice/api_accesses.html | ||
---|---|---|
1 |
{% extends "wcs/backoffice/base.html" %} |
|
2 |
{% load i18n %} |
|
3 | ||
4 |
{% block appbar-title %}{% trans "API access" %}{% endblock %} |
|
5 | ||
6 |
{% block appbar-actions %} |
|
7 |
<a rel="popup" href="new">{% trans "New API access" %}</a> |
|
8 |
{% endblock %} |
|
9 | ||
10 |
{% block content %} |
|
11 |
{% if api_accesses %} |
|
12 |
<ul class="objects-list single-links"> |
|
13 |
{% for api_access in api_accesses %} |
|
14 |
<li><a href="{{ api_access.id }}/">{{ api_access.name }} ({{ api_access.access_identifier }})</a></li> |
|
15 |
{% endfor %} |
|
16 |
</ul> |
|
17 |
{% else %} |
|
18 |
<div class="infonotice"> |
|
19 |
{% trans "There are no API access defined." %} |
|
20 |
</div> |
|
21 |
{% endif %} |
|
22 |
{% endblock %} |
|
0 |
- |