# copyright 2019 by Maikel Martens
#
# license GPLv3
"""
Layout and Components
=====================
Layouts are used to render a view for an object.
Layouts are defined and registered in layouts.py in an app.
**Example of a tab layout for the user profile:**
.. code-block:: python
@tabs.register('trionyx.profile')
def account_overview(obj):
return Container(
Row(
Column2(
Panel(
'Avatar',
Img(src="{}{}".format(settings.MEDIA_URL, obj.avatar)),
collapse=True,
),
),
Column10(
Panel(
'Account information',
DescriptionList(
'email',
'first_name',
'last_name',
),
)
),
)
)
"""
import re
import time
from django import template
from django.utils.functional import cached_property
from django.template.loader import render_to_string
from django.utils.safestring import mark_safe
from django.conf import settings
from django.db.models import QuerySet
from trionyx import utils
register = template.Library()
[docs]class Layout:
"""Layout object that holds components"""
def __init__(self, *components, **options):
"""Initialize Layout"""
self.object = False
self.components = list(components)
self.options = options
def __getitem__(self, slice):
"""Get component item"""
return self.components[slice]
def __setitem__(self, slice, value):
"""Set component"""
self.components[slice] = value
def __delitem__(self, slice):
"""Delete component"""
del self.components[slice]
def __len__(self):
"""Get component length"""
return len(self.components)
[docs] def get_paths(self):
"""Get all paths in layout for easy lookup"""
def generate_paths(component=None, path=None, index=None):
"""Generate all paths in layout"""
path = [
*path,
str(component.__class__.__name__ + '-' + str(index)).lower()
]
paths = {'.'.join(path): component}
for index, comp in enumerate(component.components):
paths.update(generate_paths(comp, path, index))
return paths
paths = {}
for index, comp in enumerate(self.components):
paths.update(generate_paths(comp, [], index))
# Can't cache paths because after one change the are not valid
return paths
[docs] def find_component_by_path(self, path):
"""Find component by path, gives back component and parent"""
path_re = re.compile(r'([\wd]+)(\[(\d+)\])?')
new_path = []
for p in path.lower().split('.'):
match = path_re.match(p)
new_path.append('{}-{}'.format(
match.group(1),
match.group(3) if match.group(3) else 0
))
paths = self.get_paths()
return (
paths.get('.'.join(new_path)),
paths.get('.'.join(new_path[:-1]))
)
[docs] def find_component_by_id(self, id=None, current_comp=None):
"""Find component by id, gives back component and parent"""
current_comp = current_comp if current_comp else self
for comp in current_comp.components:
if id and comp.id == id:
return (comp, current_comp)
for comp in current_comp.components:
r_comp, r_parent = self.find_component_by_id(id=id, current_comp=comp)
if r_comp:
return (r_comp, r_parent)
return (None, None)
[docs] def render(self, request=None):
"""Render layout for given request"""
return render_to_string('trionyx/layout.html', {
'layout': self,
'css_files': self.collect_css_files(),
'js_files': self.collect_js_files(),
}, request)
[docs] def collect_css_files(self, component=None):
"""Collect all css files"""
component = component if component else self
files = getattr(component, 'css_files', None)
files = files if files else []
for comp in component.components:
files.extend(self.collect_css_files(comp))
return list(set(files))
[docs] def collect_js_files(self, component=None):
"""Collect all js files"""
component = component if component else self
files = getattr(component, 'js_files', None)
files = files if files else []
for comp in component.components:
files.extend(self.collect_js_files(comp))
return list(set(files))
[docs] def set_object(self, object):
"""
Set object for rendering layout and set object to all components
:param object:
:return:
"""
if self.object is False:
self.object = object
# Pass object along to child components for rendering
for component in self.components:
component.set_object(self.object)
[docs] def add_component(self, component, id=None, path=None, before=False):
"""
Add component to existing layout can insert component before or after component
:param component:
:param id: component id
:param path: component path, example: container.row.column6[1].panel
:return:
"""
if not id and not path:
if before:
self.components.append(component)
else:
self.components.insert(0, component)
if id:
comp, parent = self.find_component_by_id(id)
else:
comp, parent = self.find_component_by_path(path)
if not comp:
raise Exception('Could not add component: Unknown path {} or id {}'.format(path, id))
if parent:
index = parent.components.index(comp) if before else parent.components.index(comp) + 1
parent.components.insert(index, component)
elif comp:
index = self.components.index(comp) if before else self.components.index(comp) + 1
self.components.insert(index, component)
[docs] def delete_component(self, id=None, path=None):
"""
Delete component for given path or id
:param id: component id
:param path: component path, example: container.row.column6[1].panel
:return:
"""
if not id and not path:
raise Exception('You must supply an id or path')
if id:
comp, parent = self.find_component_by_id(id)
else:
comp, parent = self.find_component_by_path(path)
if not comp:
raise Exception('Could not delete component: Unknown path {} or id {}'.format(path, id))
if parent:
parent.components.remove(comp)
elif comp:
self.components.remove(comp)
[docs]class Component:
"""Base component can be use as an holder for other components"""
template_name = None
"""Component template to be rendered, default template only renders child components"""
js_files = None
"""List of required javascript files"""
css_files = None
"""List of required css files"""
def __init__(self, *components, **options):
"""Initialize Component"""
self.id = options.get('id')
self.components = list(filter(None, components))
self.parent = None
self.object = options.get('object', False)
self.context = {}
self.request = None
# set options on object
for key, value in options.items():
setattr(self, key, value)
@cached_property
def css_id(self):
"""Generate random css id for component"""
return 'component-{}'.format(utils.random_string(6))
[docs] def set_object(self, object, force=False):
"""
Set object for rendering component and set object to all components
:param object:
:return:
"""
if self.object is False or force:
self.object = object
else:
object = self.object
# Pass object along to child components for rendering
for component in self.components:
component.set_object(object)
[docs] def render(self, context, request=None):
"""Render component"""
context['component'] = self
self.context = context
self.request = request
if settings.DEBUG:
path = [
*getattr(self, '_debug_path', []),
'{name}[{index}]'.format(
name=str(self.__class__.__name__).lower(),
index=getattr(self, '_debug_path_index', 0),
)
]
self._debug_full_path = '.'.join(path)
for index, comp in enumerate(self.components):
comp._debug_path = path
comp._debug_path_index = index
start_time = time.time()
if self.template_name:
output = render_to_string(self.template_name, context, request)
else:
output = ''.join(comp.render(context, request) for comp in self.components)
if settings.DEBUG:
output = """
<!--{class_name}: {template_name}-->
<!--Path: {path}-->
<!--Render time: {render_time}-->
{output}
""".format(
class_name=".".join([self.__class__.__module__, self.__class__.__name__]),
template_name=self.template_name,
path=self._debug_full_path,
render_time=round(time.time() - start_time, 4),
output=output,
)
return mark_safe(output)
[docs]class ComponentFieldsMixin:
"""Mixin for adding fields support and rendering of object(s) with fields."""
fields = []
"""
List of fields to be rendered. Item can be a string or dict, default options:
- **field**: Name of object attribute or dict key to be rendered
- **label**: Label of field
- **value**: Value to be rendered
- **format**: String format for rendering field, default is '{0}'
- **renderer**: Render function for rendering value, result will be given to format. (lambda value, **options: value)
- **component**: Render field with given component, row object will be set as the component object
Based on the order the fields are in the list a __index__ is set with the list index,
this is used for rendering a object that is a list.
.. code-block:: python
fields = [
'first_name',
'last_name'
]
fields = [
'first_name',
{
'label': 'Real last name',
'value': object.last_name
}
]
"""
fields_options = {}
"""
Options available for the field, this is not required to set options on field.
- **default**: Default option value when not set.
.. code-block:: python
fields_options = {
'width': {
'default': '150px',
}
}
"""
objects = []
"""
List of object to be rendered, this can be a QuerySet, list or string.
When its a string it will get the attribute of the object.
The items in the objects list can be a mix of Models, dicts or lists.
"""
[docs] def add_field(self, field, index=None):
"""Add field"""
self.fields = list(self.fields)
if index is not None:
self.fields.insert(index, field)
else:
self.fields.append(field)
[docs] def get_fields(self):
"""Get all fields"""
if not hasattr(self, '__fields'):
self.__fields = [
self.parse_field(field, index)
for index, field in enumerate(getattr(self, 'fields', []))
if not (field is False or field is None)
]
return self.__fields
[docs] def parse_field(self, field_data, index=0):
"""Parse field and add missing options"""
field = {
'__index__': index,
}
if isinstance(field_data, str):
field.update(self.parse_string_field(field_data))
elif isinstance(field_data, dict):
field.update(field_data)
else:
raise TypeError('Expected a str or dict get {}'.format(type(field_data)))
if 'field' not in field:
field['field'] = None
if 'label' not in field and field['field']:
model = self.objects.model if isinstance(self.objects, QuerySet) else self.object
try:
field['label'] = model._meta.get_field(field['field']).verbose_name.capitalize()
except Exception:
field['label'] = field['field'].replace('_', '').capitalize()
elif 'label' not in field:
field['label'] = ''
if 'format' not in field:
field['format'] = '{0}'
# Set default options
for name, options in self.fields_options.items():
if 'default' in options and name not in field:
field[name] = options['default']
return field
[docs] def parse_string_field(self, field_data):
"""
Parse a string field to dict with options
String value is used as field name. Options can be given after = symbol.
Where key value is separated by : and different options by ;, when no : is used then the value becomes True.
**Example 1:** `field_name`
.. code-block:: python
# Output
{
'field': 'field_name'
}
**Example 3** `field_name=option1:some value;option2: other value`
.. code-block:: python
# Output
{
'field': 'field_name',
'option1': 'some value',
'option2': 'other value',
}
**Example 3** `field_name=option1;option2: other value`
.. code-block:: python
# Output
{
'field': 'field_name',
'option1': True,
'option2': 'other value',
}
:param str field_data:
:return dict:
"""
field_name, *data = field_data.split('=', 1)
field = {
'field': field_name,
}
for option_string in ''.join(data).split(';'):
option, *value = option_string.split(':')
if option.strip():
field[option.strip()] = value[0].strip() if value else True
return field
[docs] def render_field(self, field, data):
"""Render field for given data"""
from trionyx.renderer import renderer
if 'component' in field:
component = field.get('component')
component.set_object(data, True)
return component.render(self.context)
if 'value' in field:
value = field['value']
elif isinstance(data, object) and field['field'] and hasattr(data, field['field']):
value = getattr(data, field['field'])
if 'renderer' not in field:
value = renderer.render_field(data, field['field'], **field)
elif isinstance(data, dict) and field['field'] in data:
value = data.get(field['field'])
elif isinstance(data, list) and field['__index__'] < len(data):
value = data[field['__index__']]
else:
return ''
options = {key: value for key, value in field.items() if key not in ['value', 'data_object']}
if 'renderer' in field:
value = field['renderer'](value, data_object=data, **options)
elif isinstance(value, Component):
value.set_object(data, True)
value = value.render(self.context.copy(), self.request)
else:
value = renderer.render_value(value, data_object=data, **options)
return field['format'].format(value)
[docs] def get_rendered_object(self, obj=None):
"""Render object"""
obj = obj if obj else self.object
return [
{
**field,
'value': self.render_field(field, obj)
}
for field in self.get_fields()
]
[docs] def get_rendered_objects(self):
"""Render objects"""
objects = self.objects
if isinstance(objects, str):
objects = getattr(self.object, objects).all()
return [
self.get_rendered_object(obj)
for obj in objects
]
# =============================================================================
# Simple HTML tags
# =============================================================================
[docs]class HtmlTemplate(Component):
"""HtmlTemplate render django html template"""
def __init__(self, template_name, context=None, css_files=None, js_files=None):
"""Initialize HtmlTemplate"""
super().__init__()
self.template_name = template_name
self.context = context
self.css_files = css_files if css_files else []
self.js_files = js_files if js_files else []
[docs] def render(self, context, request=None):
"""Render component"""
context['component'] = self
context.update(self.context)
self.context = context
self.request = request
return render_to_string(self.template_name, context, request)
[docs]class HtmlTagWrapper(Component):
"""HtmlTagWrapper wraps given component in given html tag"""
template_name = 'trionyx/components/html_tag.html'
tag = 'div'
"""Html tag nam"""
attr = None
"""Dict with html attributes"""
def __init__(self, *args, **kwargs):
"""Initialize HtmlTagWrapper"""
super().__init__(*args, **kwargs)
self.attr = self.attr.copy() if self.attr else {}
[docs] def get_attr_text(self):
"""Get html attr text to render in template"""
return ' '.join([
'{}="{}"'.format(key, value)
for key, value in self.attr.items()
])
[docs]class Html(HtmlTagWrapper):
"""Html single html tag"""
template_name = 'trionyx/components/html.html'
tag = None
valid_attr = []
"""Valid attributes that can be used"""
def __init__(self, html=None, **kwargs):
"""Init Html"""
super().__init__(**kwargs)
kwargs['class'] = kwargs.pop('css_class', self.attr.get('class', ''))
self.html = html
for key, value in kwargs.items():
if key in self.valid_attr:
self.attr[key] = value
[docs]class Img(Html):
"""Img tag"""
tag = 'img'
valid_attr = ['src', 'width']
attr = {
'width': '100%',
}
# =============================================================================
# Bootstrap grid system
# =============================================================================
[docs]class Container(HtmlTagWrapper):
"""Bootstrap container"""
attr = {
'class': 'container-fluid'
}
[docs]class Row(HtmlTagWrapper):
"""Bootstrap row"""
attr = {
'class': 'row'
}
[docs]class Column(HtmlTagWrapper):
"""Bootstrap Column"""
size = 'md'
columns = 1
def __init__(self, *args, **kwargs):
"""Initialize Column"""
super().__init__(*args, **kwargs)
self.attr['class'] = '-'.join(x for x in ['col', str(self.size), str(self.columns)] if x)
[docs]class Column2(Column):
"""Bootstrap Column 2"""
columns = 2
[docs]class Column3(Column):
"""Bootstrap Column 3"""
columns = 3
[docs]class Column4(Column):
"""Bootstrap Column 4"""
columns = 4
[docs]class Column5(Column):
"""Bootstrap Column 5"""
columns = 5
[docs]class Column6(Column):
"""Bootstrap Column 6"""
columns = 6
[docs]class Column7(Column):
"""Bootstrap Column 7"""
columns = 7
[docs]class Column8(Column):
"""Bootstrap Column 8"""
columns = 8
[docs]class Column9(Column):
"""Bootstrap Column 9"""
columns = 9
[docs]class Column10(Column):
"""Bootstrap Column 10"""
columns = 10
[docs]class Column11(Column):
"""Bootstrap Column 11"""
columns = 11
[docs]class Column12(Column):
"""Bootstrap Column 12"""
columns = 12
# =============================================================================
# Bootstrap elements
# =============================================================================
[docs]class Panel(Component):
"""
Bootstrap panel available options
- title
- footer_components
- collapse
- contextual: primary, success, info, warning, danger
"""
template_name = 'trionyx/components/panel.html'
collapse = True
def __init__(self, title, *components, **options):
"""Init Panel"""
super().__init__(*components, **options)
self.title = title
[docs]class DescriptionList(Component, ComponentFieldsMixin):
"""
Bootstrap description, fields are the params. available options
- horizontal
"""
template_name = 'trionyx/components/description_list.html'
horizontal = True
no_data_message = "There is no data"
def __init__(self, *fields, **options):
"""Init panel"""
super().__init__(**options)
self.fields = fields
[docs]class TableDescription(Component, ComponentFieldsMixin):
"""Bootstrap table description, fields are the params"""
template_name = 'trionyx/components/table_description.html'
fields_options = {
'width': {
'default': '150px',
}
}
def __init__(self, *fields, **options):
"""Init panel"""
super().__init__(**options)
self.fields = fields
[docs]class Table(Component, ComponentFieldsMixin):
"""
Bootstrap table
footer: array with first items array/queryset and other items are the fields,
Same way how the constructor works
"""
template_name = 'trionyx/components/table.html'
def __init__(self, objects, *fields, **options):
"""Init Table"""
footer = options.pop('footer', None)
super().__init__(**options)
self.objects = objects
"""Can be string with field name relation, Queryset or list"""
self.fields = fields
self.footer_objects = footer[0] if footer else None
"""Can be string with field name relation, Queryset or list"""
self.footer_fields = footer[1:] if footer else []