Source code for univention.admin.rest.html_ui
#!/usr/bin/python3
#
# Univention Directory Manager
# HTML
#
# SPDX-FileCopyrightText: 2017-2025 Univention GmbH
# SPDX-License-Identifier: AGPL-3.0-only
import json
import re
import xml.etree.ElementTree as ET # noqa: S405
import defusedxml.minidom
from genshi import XML
from jinja2 import Environment, FileSystemLoader, StrictUndefined
from univention.lib.i18n import Translation
_ = Translation('univention-directory-manager-rest').translate
[docs]
class HTML:
@property
def head_template(self):
return self.get_template
[docs]
def content_negotiation_html(self, response, data):
self.set_header('Content-Type', 'text/html; charset=UTF-8')
target = self.request.headers.get('HX-Target')
hx_request = self.request.headers.get('HX-Request') == 'true'
ajax = (hx_request or self.request.headers.get('X-Requested-With', '').lower() == 'xmlhttprequest') and target
head = ET.Element("head")
title = 'FIXME: fallback title' # FIXME: set title
nav = ET.Element('nav')
links = ET.SubElement(nav, 'ul')
main = ET.Element('main')
_links = {}
navigation_relations = self.bread_crumbs_navigation()
hal_links = [
dict(_hlink.copy(), rel=rel)
for rel, _hlinks in self.get_links(data).items()
for _hlink in _hlinks
if 'href' in _hlink
]
for params in hal_links:
if params.pop('templated', None):
continue
link = params.pop('href')
if params.get('rel') not in ('udm:tab-switch'):
ET.SubElement(head, "link", href=link, **params)
_links[params.get('rel')] = dict(params, href=link)
if params.get('rel') == 'self':
title = params.get('title') or link or 'FIXME:notitle'
if params.get('rel') in ('stylesheet', 'icon', 'self', 'up', 'udm:object/remove', 'udm:object/edit', 'udm:report'):
continue
if not self.debug_mode_enabled and params.get('rel') in ('udm:report', 'udm:tree', 'udm:layout', 'udm:properties', 'describedby'):
continue
if params.get('rel') in navigation_relations:
continue
if params.get('rel') in ('udm:user-photo',):
ET.SubElement(nav, 'img', src=link, style='max-height: 250px; max-width: 100%; padding: 1em;')
continue
elif params.get('rel') in ('create-form', 'edit-form'): # TODO: move into main grid header
ET.SubElement(ET.SubElement(main, 'form', **{'hx-boost': 'true'}), 'button', formaction=link, **params).text = params.get('title', link)
continue
li = ET.SubElement(links, "li")
params.setdefault('hx-boost', 'true')
params.setdefault('hx-push-url', 'true')
ET.SubElement(li, "a", href=link, **params).text = params.get('title', link) or link
if isinstance(response, list | tuple):
main.extend(response)
elif response is not None:
main.append(response)
def get_inner_html(node):
return '\n'.join(ET.tostring(child, encoding='unicode', method='xml') for child in node)
tpldata = {
'language': self.locale.code,
'response': response,
'data': data,
'title': title,
'ajax': ajax,
'hx_request': hx_request,
'target': target,
'head_links': get_inner_html(head),
'nav': get_inner_html(nav),
'main': get_inner_html(main),
'display_nav': True,
'bread_crumbs': [_links.get(name) for name in navigation_relations if _links.get(name)],
}
tpldata.update(self.template_vars())
tpl = getattr(self, f'{self.request.method.lower()}_template', 'template.html')
stream = self.render_template(tpl, tpldata)
stream = defusedxml.minidom.parseString(stream).toprettyxml()
stream = XML(stream).render('xhtml')
self.write(stream)
[docs]
def get_html(self, response: dict):
root = []
# TODO: nav-layout?!
# main layout
main_layout = self.get_resource(response, 'udm:layout', name='main-layout')
if main_layout:
main = ET.Element('div')
self.get_html_layout(main, response, main_layout['layout'], [])
root.extend(main.getchildren())
else: # leftover forms
buttons = self.get_resources(response, 'udm:button')
for _button in buttons:
root.insert(0, self.get_html_button(_button, response))
forms = self.get_resources(response, 'udm:form')
for _form in forms:
root.insert(0, self.get_html_form(_form, response))
# root[0].append(ET.Element('hr'))
root.extend(self.get_error_html(response))
# print any leftover elements
if self.debug_mode_enabled:
r = response.copy()
r.pop('_links', None)
r.pop('_embedded', None)
if r:
pre = ET.Element("script", type='application/json')
pre.text = json.dumps(r, indent=4)
root.append(pre)
return root
[docs]
def get_error_html(self, response: dict):
root = []
# errors
if isinstance(response.get('error'), dict) and response['error'].get('code', 0) >= 400:
error_response = response['error']
error = ET.Element('div', **{'class': 'error'})
root.append(error)
ET.SubElement(error, 'h1').text = _('HTTP-Error %d: %s') % (error_response['code'], error_response['title'])
ET.SubElement(error, 'p', style='white-space: pre').text = error_response['message']
for error_detail in self.get_resources(response, 'udm:error'):
ET.SubElement(error, 'p', style='white-space: pre').text = '%s(%s): %s' % ('.'.join(error_detail['location']), error_detail['type'], error_detail['message'])
if error_response.get('traceback'):
ET.SubElement(error, 'pre').text = error_response['traceback']
response = {}
# redirections
if 400 > self._status_code >= 300 and self._status_code != 304:
warning = ET.Element('div', **{'class': 'warning'})
root.append(warning)
href = self._headers.get("Location")
ET.SubElement(warning, 'h1').text = _('HTTP redirection')
ET.SubElement(warning, 'p', style='white-space: pre').text = 'You are being redirected to:'
ET.SubElement(warning, 'a', href=href).text = href
return root
[docs]
def get_html_layout(self, root, response, layout, properties):
for sec in layout:
section = ET.SubElement(root, 'section', id=self.sanitize_html_id(sec['label']))
if sec.get('help'):
ET.SubElement(section, 'span').text = sec['help']
fieldset = ET.SubElement(section, 'fieldset')
ET.SubElement(fieldset, 'legend').text = sec['label']
ET.SubElement(fieldset, 'h1').text = sec['description']
if sec['layout']:
self.render_layout(sec['layout'], fieldset, properties, response)
return root
[docs]
def render_layout(self, layout, fieldset, properties, response):
for elem in layout:
if isinstance(elem, dict) and isinstance(elem.get('$form-ref'), list):
for _form in elem['$form-ref']:
form = self.get_resource(response, 'udm:form', name=_form)
if form:
fieldset.append(self.get_html_form(form, response))
continue
elif isinstance(elem, dict) and isinstance(elem.get('$button-ref'), list):
for _button in elem['$button-ref']:
button = self.get_resource(response, 'udm:button', name=_button)
if button:
fieldset.append(self.get_html_button(button, response))
continue
elif isinstance(elem, dict):
if not elem.get('label') and not elem.get('description'):
ET.SubElement(fieldset, 'br')
sub_fieldset = ET.SubElement(fieldset, 'div', style='display: flex')
else:
opened = {'open': 'open'} if elem.get('opened', True) else {}
sub_fieldset = ET.SubElement(fieldset, 'details', **opened)
ET.SubElement(sub_fieldset, 'summary').text = elem['label']
if elem['description']:
ET.SubElement(sub_fieldset, 'h2').text = elem['description']
self.render_layout(elem['layout'], sub_fieldset, properties, response)
continue
elements = [elem] if isinstance(elem, str) else elem
row = ET.SubElement(fieldset, 'div', **{'class': 'row'})
for elem in elements:
for field in properties:
if field['name'] in (elem, 'properties.%s' % elem):
self.render_form_field(row, field)
[docs]
def get_html_form(self, _form, response):
formattrs = {p: _form[p] for p in ('id', 'class', 'name', 'method', 'action', 'rel', 'enctype', 'accept-charset', 'novalidate', 'hx-confirm', 'hx-ext') if _form.get(p)}
formattrs.setdefault('hx-boost', 'true')
form = ET.Element('form', **formattrs)
if _form.get('layout'):
layout = self.get_resource(response, 'udm:layout', name=_form['layout'])
self.get_html_layout(form, response, layout['layout'], _form.get('fields'))
return form
for field in _form.get('fields', []):
self.render_form_field(form, field)
form.append(ET.Element('br'))
return form
[docs]
def render_form_field(self, parent_element, field):
datalist = None
name = field['name']
if field.get('type') == 'submit' and field.get('add_noscript_warning'):
ET.SubElement(ET.SubElement(parent_element, 'noscript'), 'p').text = _('This form requires JavaScript enabled!')
label = None
if name:
label = ET.Element('label', **{'for': name})
label.text = field.get('label', name)
cls = 'udmSize-%s' % (field.get('data-size', 'One'),)
wrapper = ET.Element('div', **{'class': 'label-wrapper %s' % cls})
multivalue = field.get('data-multivalue') == '1'
values = field['value'] or [''] if multivalue else [field['value']]
for value in values:
elemattrs = {p: field[p] for p in ('id', 'disabled', 'form', 'multiple', 'required', 'size', 'type', 'placeholder', 'accept', 'alt', 'autocomplete', 'checked', 'max', 'min', 'minlength', 'pattern', 'readonly', 'src', 'step', 'style', 'alt', 'autofocus', 'class', 'cols', 'href', 'rel', 'title', 'list') if field.get(p)}
elemattrs.setdefault('type', 'text')
elemattrs.setdefault('placeholder', name)
if field.get('type') == 'checkbox' and field.get('checked'):
elemattrs['checked'] = 'checked'
if field.get('data-dynamic'):
field['element'] = 'select'
elemattrs.update({
'hx-push-url': 'false',
# 'hx-trigger': 'revealed', # faster but more traffic
'hx-trigger': 'intersect once',
# 'hx-select': 'select > option', # bug: no re-rendering in browser, wrong value is displayed
# 'hx-swap': 'innerHTML',
'hx-select': 'select', # bug: no attributes like required are taken
'hx-swap': 'outerHTML',
# 'hx-get': self._append_query(field['data-dynamic'], f'selected={quote(value)}'),
'hx-get': field['data-dynamic'],
'hx-vals': json.dumps({'selected': value, 'required': field.get('required', ''), 'name': field.get('name', '')}), # caution: allowing freely added values like name=javascript: is a security risk
})
element = ET.Element(field.get('element', 'input'), name=name, value=str(value), **elemattrs)
if field['element'] == 'select':
if field.get('data-dynamic'):
ET.SubElement(wrapper, 'img', **{'class': 'htmx-indicator spinner', 'src': '/univention/udm/img/spinning-circles.svg'})
ET.SubElement(element, 'option', selected='selected', value=value).text = value # fallback during loading
else:
for option in field.get('options', []):
kwargs = {}
if field['value'] == option['value'] or (isinstance(field['value'], list) and option['value'] in field['value']):
kwargs['selected'] = 'selected'
ET.SubElement(element, 'option', value=option['value'], **kwargs).text = option.get('label', option['value'])
elif field.get('element') == 'a':
element.text = field['label']
label = None
elif field.get('type') == 'hidden':
label = None
elif field.get('list') and field.get('datalist'):
datalist = ET.Element('datalist', id=field['list'])
for option in field.get('datalist', []):
kwargs = {}
if field['value'] == option['value'] or (isinstance(field['value'], list) and option['value'] in field['value']):
kwargs['selected'] = 'selected'
ET.SubElement(datalist, 'option', value=option['value'], **kwargs).text = option.get('label', option['value'])
if label is not None:
wrapper.append(label)
label = None
if datalist is not None:
wrapper.append(datalist)
wrapper.append(element)
if multivalue:
btn = ET.Element('button')
btn.text = '-'
wrapper.append(btn)
if multivalue:
btn = ET.Element('button')
btn.text = '+'
wrapper.append(btn)
parent_element.append(wrapper)
[docs]
def get_html_button(self, _button, response):
# buttonattrs = {p: _button[p] for p in ('id', 'class', 'name', 'method', 'action', 'rel', 'enctype', 'accept-charset', 'novalidate') if _button.get(p)}
label = _button.pop('label', '')
buttonattrs = _button
buttonattrs.setdefault('hx-boost', 'true')
button = ET.Element('button', **buttonattrs)
button.text = label
return button
[docs]
def add_form(self, obj, action, method, **kwargs):
form = {
'action': action,
'method': method,
}
form.setdefault('enctype', 'application/x-www-form-urlencoded')
form.update(kwargs)
if form.get('name'):
self.add_link(form, 'self', href='', name=form['name'], dont_set_http_header=True)
self.add_resource(obj, 'udm:form', form)
return form
[docs]
def add_form_element(self, form, name, value, type='text', element='input', **kwargs):
field = {
'name': name,
'value': value,
'type': type,
'element': element,
}
field.update(kwargs)
form.setdefault('fields', []).append(field)
if field['type'] == 'submit':
field['add_noscript_warning'] = form.get('method', '').upper() not in ('GET', 'POST', '')
return field
[docs]
def add_button(self, obj, action, method, **kwargs):
button = {'hx-%s' % method.lower(): action, 'formaction': action, 'hx-boost': 'true'}
button.update(kwargs)
# title = kwargs.pop('title', '')
# form = self.add_form(obj, action, method, **kwargs)
# self.add_form_element(form, '', title, type='submit')
self.add_resource(obj, 'udm:button', button)
[docs]
def add_layout(self, obj, layout, name=None, href=None):
layout = {'layout': layout}
if name:
self.add_link(layout, 'self', href='', name=name, dont_set_http_header=True)
self.add_resource(obj, 'udm:layout', layout)
if href:
self.add_link(obj, 'udm:layout', href=href, name=name)
[docs]
@classmethod
def sanitize_html_id(cls, label):
label = re.sub(r'[^a-z0-9_:-]', '_', label.lower())
return re.sub(r'^[^a-z]+', 'id_', label)
[docs]
def render_template(self, template_path, data):
env = Environment(loader=FileSystemLoader('/usr/share/univention-directory-manager-rest/templates/'), autoescape=True, undefined=StrictUndefined)
template = env.get_template(template_path)
env.filters['translate'] = self.locale.translate
env.globals['self'] = self
return template.render(**data)