diff --git a/README b/README new file mode 100644 index 000000000..18bdec71f --- /dev/null +++ b/README @@ -0,0 +1,25 @@ +This is the "facelift" datatracker branch that uses Twitter Bootstrap for +the UI. + +You need to install a few new django extensions: + https://pypi.python.org/pypi/django-widget-tweaks + https://pypi.python.org/pypi/django-bootstrap3 + https://pypi.python.org/pypi/django-typogrify + +The meta goal of this effort is: *** NO CHANGES TO THE PYTHON CODE *** + +Whenever changes to the python code are made, they can only fix HTML bugs, +or add comments (tagged with "FACELIFT") about functionality that can be +removed once the facelift templates become default. Or they need to add +functionality that is only called from the new facelift templates. + +Javascript that is only used on one template goes into that template. +Javascript that is used by more than one template goes into ietf.js. + +CSS that is only used on one template goes into that template. +CSS that is used by more than one template goes into ietf.css. No CSS in the +templates or - god forbid - style tags! (And no CSS or HTML styling in +python code!!) + +Templates that use jquery or bootstrap plugins include the css in the pagehead +block, and the js in the js block. diff --git a/TODO b/TODO new file mode 100644 index 000000000..994c859d2 --- /dev/null +++ b/TODO @@ -0,0 +1,11 @@ +Major pieces not facelifted: milestone editing, liaison editing, WG workflow customization + +Use affix for navigation on active_wgs.html + +Figure out why {% if debug %} does not work in the text templates under ietf/templates_facelift/community/public. + +Make django generate HTML5 date inputs or use a js-based datepicker. + +Deferring ballots does not work. (Seems to be an upstream bug.) + +Make tables that are too wide to usefully work on small screens responsive. See http://getbootstrap.com/css/#tables-responsive diff --git a/bootstrap3/__init__.py b/bootstrap3/__init__.py new file mode 100644 index 000000000..c2f508106 --- /dev/null +++ b/bootstrap3/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +__version__ = '5.1.1' diff --git a/bootstrap3/bootstrap.py b/bootstrap3/bootstrap.py new file mode 100644 index 000000000..cae8e496a --- /dev/null +++ b/bootstrap3/bootstrap.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.conf import settings +from django.utils.importlib import import_module + + +# Default settings +BOOTSTRAP3_DEFAULTS = { + 'jquery_url': '//code.jquery.com/jquery.min.js', + 'base_url': '//netdna.bootstrapcdn.com/bootstrap/3.3.2/', + 'css_url': None, + 'theme_url': None, + 'javascript_url': None, + 'javascript_in_head': False, + 'include_jquery': False, + 'horizontal_label_class': 'col-md-2', + 'horizontal_field_class': 'col-md-4', + 'set_required': True, + 'set_placeholder': True, + 'required_css_class': '', + 'error_css_class': 'has-error', + 'success_css_class': 'has-success', + 'formset_renderers': { + 'default': 'bootstrap3.renderers.FormsetRenderer', + }, + 'form_renderers': { + 'default': 'bootstrap3.renderers.FormRenderer', + }, + 'field_renderers': { + 'default': 'bootstrap3.renderers.FieldRenderer', + 'inline': 'bootstrap3.renderers.InlineFieldRenderer', + }, +} + +# Start with a copy of default settings +BOOTSTRAP3 = BOOTSTRAP3_DEFAULTS.copy() + +# Override with user settings from settings.py +BOOTSTRAP3.update(getattr(settings, 'BOOTSTRAP3', {})) + + +def get_bootstrap_setting(setting, default=None): + """ + Read a setting + """ + return BOOTSTRAP3.get(setting, default) + + +def bootstrap_url(postfix): + """ + Prefix a relative url with the bootstrap base url + """ + return get_bootstrap_setting('base_url') + postfix + + +def jquery_url(): + """ + Return the full url to jQuery file to use + """ + return get_bootstrap_setting('jquery_url') + + +def javascript_url(): + """ + Return the full url to the Bootstrap JavaScript file + """ + return get_bootstrap_setting('javascript_url') or \ + bootstrap_url('js/bootstrap.min.js') + + +def css_url(): + """ + Return the full url to the Bootstrap CSS file + """ + return get_bootstrap_setting('css_url') or \ + bootstrap_url('css/bootstrap.min.css') + + +def theme_url(): + """ + Return the full url to the theme CSS file + """ + return get_bootstrap_setting('theme_url') + + +def get_renderer(renderers, **kwargs): + layout = kwargs.get('layout', '') + path = renderers.get(layout, renderers['default']) + mod, cls = path.rsplit(".", 1) + return getattr(import_module(mod), cls) + + +def get_formset_renderer(**kwargs): + renderers = get_bootstrap_setting('formset_renderers') + return get_renderer(renderers, **kwargs) + + +def get_form_renderer(**kwargs): + renderers = get_bootstrap_setting('form_renderers') + return get_renderer(renderers, **kwargs) + + +def get_field_renderer(**kwargs): + renderers = get_bootstrap_setting('field_renderers') + return get_renderer(renderers, **kwargs) diff --git a/bootstrap3/components.py b/bootstrap3/components.py new file mode 100644 index 000000000..403203b0a --- /dev/null +++ b/bootstrap3/components.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.forms.widgets import flatatt + +from .text import text_value + + +def render_icon(icon, title=''): + """ + Render a Bootstrap glyphicon icon + """ + attrs = { + 'class': 'glyphicon glyphicon-{icon}'.format(icon=icon), + } + if title: + attrs['title'] = title + return ''.format(attrs=flatatt(attrs)) + + +def render_alert(content, alert_type=None, dismissable=True): + """ + Render a Bootstrap alert + """ + button = '' + if not alert_type: + alert_type = 'info' + css_classes = ['alert', 'alert-' + text_value(alert_type)] + if dismissable: + css_classes.append('alert-dismissable') + button = '' + return '
{button}{content}
'.format( + css_classes=' '.join(css_classes), + button=button, + content=text_value(content), + ) diff --git a/bootstrap3/exceptions.py b/bootstrap3/exceptions.py new file mode 100644 index 000000000..0dcf9acb6 --- /dev/null +++ b/bootstrap3/exceptions.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + + +class BootstrapException(Exception): + """ + Any exception from this package + """ + pass + + +class BootstrapError(BootstrapException): + """ + Any exception that is an error + """ + pass diff --git a/bootstrap3/forms.py b/bootstrap3/forms.py new file mode 100644 index 000000000..a9c78f95d --- /dev/null +++ b/bootstrap3/forms.py @@ -0,0 +1,173 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.contrib.admin.widgets import AdminFileWidget +from django.forms import ( + HiddenInput, FileInput, CheckboxSelectMultiple, Textarea, TextInput +) + +from .bootstrap import ( + get_bootstrap_setting, get_form_renderer, get_field_renderer, + get_formset_renderer +) +from .text import text_concat, text_value +from .exceptions import BootstrapError +from .utils import add_css_class, render_tag +from .components import render_icon + + +FORM_GROUP_CLASS = 'form-group' + + +def render_formset(formset, **kwargs): + """ + Render a formset to a Bootstrap layout + """ + renderer_cls = get_formset_renderer(**kwargs) + return renderer_cls(formset, **kwargs).render() + + +def render_formset_errors(form, **kwargs): + """ + Render formset errors to a Bootstrap layout + """ + renderer_cls = get_formset_renderer(**kwargs) + return renderer_cls(form, **kwargs).render_errors() + + +def render_form(form, **kwargs): + """ + Render a formset to a Bootstrap layout + """ + renderer_cls = get_form_renderer(**kwargs) + return renderer_cls(form, **kwargs).render() + + +def render_form_errors(form, type='all', **kwargs): + """ + Render form errors to a Bootstrap layout + """ + renderer_cls = get_form_renderer(**kwargs) + return renderer_cls(form, **kwargs).render_errors(type) + + +def render_field(field, **kwargs): + """ + Render a formset to a Bootstrap layout + """ + renderer_cls = get_field_renderer(**kwargs) + return renderer_cls(field, **kwargs).render() + + +def render_label(content, label_for=None, label_class=None, label_title=''): + """ + Render a label with content + """ + attrs = {} + if label_for: + attrs['for'] = label_for + if label_class: + attrs['class'] = label_class + if label_title: + attrs['title'] = label_title + return render_tag('label', attrs=attrs, content=content) + + +def render_button( + content, button_type=None, icon=None, button_class='', size='', + href=''): + """ + Render a button with content + """ + attrs = {} + classes = add_css_class('btn', button_class) + size = text_value(size).lower().strip() + if size == 'xs': + classes = add_css_class(classes, 'btn-xs') + elif size == 'sm' or size == 'small': + classes = add_css_class(classes, 'btn-sm') + elif size == 'lg' or size == 'large': + classes = add_css_class(classes, 'btn-lg') + elif size == 'md' or size == 'medium': + pass + elif size: + raise BootstrapError( + 'Parameter "size" should be "xs", "sm", "lg" or ' + + 'empty ("{}" given).'.format(size)) + if button_type: + if button_type == 'submit': + classes = add_css_class(classes, 'btn-primary') + elif button_type not in ('reset', 'button', 'link'): + raise BootstrapError( + 'Parameter "button_type" should be "submit", "reset", ' + + '"button", "link" or empty ("{}" given).'.format(button_type)) + attrs['type'] = button_type + attrs['class'] = classes + icon_content = render_icon(icon) if icon else '' + if href: + attrs['href'] = href + tag = 'a' + else: + tag = 'button' + return render_tag( + tag, attrs=attrs, content=text_concat( + icon_content, content, separator=' ')) + + +def render_field_and_label( + field, label, field_class='', label_for=None, label_class='', + layout='', **kwargs): + """ + Render a field with its label + """ + if layout == 'horizontal': + if not label_class: + label_class = get_bootstrap_setting('horizontal_label_class') + if not field_class: + field_class = get_bootstrap_setting('horizontal_field_class') + if not label: + label = ' ' + label_class = add_css_class(label_class, 'control-label') + html = field + if field_class: + html = '
{html}
'.format( + klass=field_class, html=html) + if label: + html = render_label( + label, label_for=label_for, label_class=label_class) + html + return html + + +def render_form_group(content, css_class=FORM_GROUP_CLASS): + """ + Render a Bootstrap form group + """ + return '
{content}
'.format( + klass=css_class, + content=content, + ) + + +def is_widget_required_attribute(widget): + """ + Is this widget required? + """ + if not get_bootstrap_setting('set_required'): + return False + if not widget.is_required: + return False + if isinstance( + widget, ( + AdminFileWidget, HiddenInput, FileInput, + CheckboxSelectMultiple)): + return False + return True + + +def is_widget_with_placeholder(widget): + """ + Is this a widget that should have a placeholder? + Only text, search, url, tel, e-mail, password, number have placeholders + These are all derived form TextInput, except for Textarea + """ + return isinstance(widget, (TextInput, Textarea)) diff --git a/bootstrap3/models.py b/bootstrap3/models.py new file mode 100644 index 000000000..60b94b00d --- /dev/null +++ b/bootstrap3/models.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +# Empty models.py, required file for Django tests diff --git a/bootstrap3/renderers.py b/bootstrap3/renderers.py new file mode 100644 index 000000000..f383844c9 --- /dev/null +++ b/bootstrap3/renderers.py @@ -0,0 +1,485 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals +from django.contrib.auth.forms import ReadOnlyPasswordHashWidget + +from django.forms import ( + TextInput, DateInput, FileInput, CheckboxInput, + ClearableFileInput, Select, RadioSelect, CheckboxSelectMultiple +) +from django.forms.extras import SelectDateWidget +from django.forms.forms import BaseForm, BoundField +from django.forms.formsets import BaseFormSet +from django.utils.html import conditional_escape, strip_tags +from django.template import Context +from django.template.loader import get_template +from django.utils.safestring import mark_safe + +from .bootstrap import get_bootstrap_setting +from .text import text_value +from .exceptions import BootstrapError +from .utils import add_css_class +from .forms import ( + render_form, render_field, render_label, render_form_group, + is_widget_with_placeholder, is_widget_required_attribute, FORM_GROUP_CLASS +) + + +class BaseRenderer(object): + def __init__(self, *args, **kwargs): + self.layout = kwargs.get('layout', '') + self.form_group_class = kwargs.get( + 'form_group_class', FORM_GROUP_CLASS) + self.field_class = kwargs.get('field_class', '') + self.label_class = kwargs.get('label_class', '') + self.show_help = kwargs.get('show_help', True) + self.show_label = kwargs.get('show_label', True) + self.exclude = kwargs.get('exclude', '') + self.set_required = kwargs.get('set_required', True) + self.size = self.parse_size(kwargs.get('size', '')) + self.horizontal_label_class = kwargs.get( + 'horizontal_label_class', + get_bootstrap_setting('horizontal_label_class') + ) + self.horizontal_field_class = kwargs.get( + 'horizontal_field_class', + get_bootstrap_setting('horizontal_field_class') + ) + + def parse_size(self, size): + size = text_value(size).lower().strip() + if size in ('sm', 'small'): + return 'small' + if size in ('lg', 'large'): + return 'large' + if size in ('md', 'medium', ''): + return 'medium' + raise BootstrapError('Invalid value "%s" for parameter "size" (expected "sm", "md", "lg" or "").' % size) + + def get_size_class(self, prefix='input'): + if self.size == 'small': + return prefix + '-sm' + if self.size == 'large': + return prefix + '-lg' + return '' + + +class FormsetRenderer(BaseRenderer): + """ + Default formset renderer + """ + + def __init__(self, formset, *args, **kwargs): + if not isinstance(formset, BaseFormSet): + raise BootstrapError( + 'Parameter "formset" should contain a valid Django Formset.') + self.formset = formset + super(FormsetRenderer, self).__init__(*args, **kwargs) + + def render_management_form(self): + return text_value(self.formset.management_form) + + def render_form(self, form, **kwargs): + return render_form(form, **kwargs) + + def render_forms(self): + rendered_forms = [] + for form in self.formset.forms: + rendered_forms.append(self.render_form( + form, + layout=self.layout, + form_group_class=self.form_group_class, + field_class=self.field_class, + label_class=self.label_class, + show_help=self.show_help, + exclude=self.exclude, + set_required=self.set_required, + size=self.size, + horizontal_label_class=self.horizontal_label_class, + horizontal_field_class=self.horizontal_field_class, + )) + return '\n'.join(rendered_forms) + + def get_formset_errors(self): + return self.formset.non_form_errors() + + def render_errors(self): + formset_errors = self.get_formset_errors() + if formset_errors: + return get_template( + 'bootstrap3/form_errors.html').render( + Context({ + 'errors': formset_errors, + 'form': self.formset, + 'layout': self.layout, + }) + ) + return '' + + def render(self): + return self.render_errors() + self.render_management_form() + \ + self.render_forms() + + +class FormRenderer(BaseRenderer): + """ + Default form renderer + """ + + def __init__(self, form, *args, **kwargs): + if not isinstance(form, BaseForm): + raise BootstrapError( + 'Parameter "form" should contain a valid Django Form.') + self.form = form + super(FormRenderer, self).__init__(*args, **kwargs) + # Handle form.empty_permitted + if self.form.empty_permitted: + self.set_required = False + + def render_fields(self): + rendered_fields = [] + for field in self.form: + rendered_fields.append(render_field( + field, + layout=self.layout, + form_group_class=self.form_group_class, + field_class=self.field_class, + label_class=self.label_class, + show_help=self.show_help, + exclude=self.exclude, + set_required=self.set_required, + size=self.size, + horizontal_label_class=self.horizontal_label_class, + horizontal_field_class=self.horizontal_field_class, + )) + return '\n'.join(rendered_fields) + + def get_fields_errors(self): + form_errors = [] + for field in self.form: + if field.is_hidden and field.errors: + form_errors += field.errors + return form_errors + + def render_errors(self, type='all'): + form_errors = None + if type == 'all': + form_errors = self.get_fields_errors() + \ + self.form.non_field_errors() + elif type == 'fields': + form_errors = self.get_fields_errors() + elif type == 'non_fields': + form_errors = self.form.non_field_errors() + + if form_errors: + return get_template( + 'bootstrap3/form_errors.html').render( + Context({ + 'errors': form_errors, + 'form': self.form, + 'layout': self.layout, + }) + ) + return '' + + def render(self): + return self.render_errors() + self.render_fields() + + +class FieldRenderer(BaseRenderer): + """ + Default field renderer + """ + + def __init__(self, field, *args, **kwargs): + if not isinstance(field, BoundField): + raise BootstrapError( + 'Parameter "field" should contain a valid Django BoundField.') + self.field = field + super(FieldRenderer, self).__init__(*args, **kwargs) + + self.widget = field.field.widget + self.initial_attrs = self.widget.attrs.copy() + self.field_help = text_value(mark_safe(field.help_text)) \ + if self.show_help and field.help_text else '' + self.field_errors = [conditional_escape(text_value(error)) + for error in field.errors] + + if get_bootstrap_setting('set_placeholder'): + self.placeholder = field.label + else: + self.placeholder = '' + + self.addon_before = kwargs.get('addon_before', self.initial_attrs.pop('addon_before', '')) + self.addon_after = kwargs.get('addon_after', self.initial_attrs.pop('addon_after', '')) + + # These are set in Django or in the global BOOTSTRAP3 settings, and + # they can be overwritten in the template + error_css_class = kwargs.get('error_css_class', '') + required_css_class = kwargs.get('required_css_class', '') + bound_css_class = kwargs.get('bound_css_class', '') + if error_css_class: + self.error_css_class = error_css_class + else: + self.error_css_class = getattr( + field.form, 'error_css_class', + get_bootstrap_setting('error_css_class')) + if required_css_class: + self.required_css_class = required_css_class + else: + self.required_css_class = getattr( + field.form, 'required_css_class', + get_bootstrap_setting('required_css_class')) + if bound_css_class: + self.success_css_class = bound_css_class + else: + self.success_css_class = getattr( + field.form, 'bound_css_class', + get_bootstrap_setting('success_css_class')) + + # Handle form.empty_permitted + if self.field.form.empty_permitted: + self.set_required = False + self.required_css_class = '' + + def restore_widget_attrs(self): + self.widget.attrs = self.initial_attrs + + def add_class_attrs(self): + classes = self.widget.attrs.get('class', '') + if isinstance(self.widget, ReadOnlyPasswordHashWidget): + classes = add_css_class( + classes, 'form-control-static', prepend=True) + elif not isinstance(self.widget, (CheckboxInput, + RadioSelect, + CheckboxSelectMultiple, + FileInput)): + classes = add_css_class(classes, 'form-control', prepend=True) + # For these widget types, add the size class here + classes = add_css_class(classes, self.get_size_class()) + self.widget.attrs['class'] = classes + + def add_placeholder_attrs(self): + placeholder = self.widget.attrs.get('placeholder', self.placeholder) + if placeholder and is_widget_with_placeholder(self.widget): + self.widget.attrs['placeholder'] = placeholder + + def add_help_attrs(self): + if not isinstance(self.widget, CheckboxInput): + self.widget.attrs['title'] = self.widget.attrs.get( + 'title', strip_tags(self.field_help)) + + def add_required_attrs(self): + if self.set_required and is_widget_required_attribute(self.widget): + self.widget.attrs['required'] = 'required' + + def add_widget_attrs(self): + self.add_class_attrs() + self.add_placeholder_attrs() + self.add_help_attrs() + self.add_required_attrs() + + def list_to_class(self, html, klass): + classes = add_css_class(klass, self.get_size_class()) + mapping = [ + ('', ''), + ('', ''), + ] + for k, v in mapping: + html = html.replace(k, v) + return html + + def put_inside_label(self, html): + content = '{field} {label}'.format(field=html, label=self.field.label) + return render_label( + content=content, label_for=self.field.id_for_label, + label_title=strip_tags(self.field_help)) + + def fix_date_select_input(self, html): + div1 = '
' + div2 = '
' + html = html.replace('', '' + div2) + return '
' + html + '
' + + def fix_clearable_file_input(self, html): + """ + Fix a clearable file input + TODO: This needs improvement + + Currently Django returns + Currently: + dummy.txt + +
+ Change: + + + + """ + # TODO This needs improvement + return '
' + \ + '
' + html + '
' + + def post_widget_render(self, html): + if isinstance(self.widget, RadioSelect): + html = self.list_to_class(html, 'radio') + elif isinstance(self.widget, CheckboxSelectMultiple): + html = self.list_to_class(html, 'checkbox') + elif isinstance(self.widget, SelectDateWidget): + html = self.fix_date_select_input(html) + elif isinstance(self.widget, ClearableFileInput): + html = self.fix_clearable_file_input(html) + elif isinstance(self.widget, CheckboxInput): + html = self.put_inside_label(html) + return html + + def wrap_widget(self, html): + if isinstance(self.widget, CheckboxInput): + checkbox_class = add_css_class('checkbox', self.get_size_class()) + html = \ + '
{content}
'.format( + klass=checkbox_class, content=html + ) + return html + + def make_input_group(self, html): + if ( + (self.addon_before or self.addon_after) and + isinstance(self.widget, (TextInput, DateInput, Select)) + ): + before = '{addon}'.format( + addon=self.addon_before) if self.addon_before else '' + after = '{addon}'.format( + addon=self.addon_after) if self.addon_after else '' + html = \ + '
' + \ + '{before}{html}{after}
'.format( + before=before, + after=after, + html=html + ) + return html + + def append_to_field(self, html): + help_text_and_errors = [self.field_help] + self.field_errors \ + if self.field_help else self.field_errors + if help_text_and_errors: + help_html = get_template( + 'bootstrap3/field_help_text_and_errors.html' + ).render(Context({ + 'field': self.field, + 'help_text_and_errors': help_text_and_errors, + 'layout': self.layout, + })) + html += '{help}'.format( + help=help_html) + return html + + def get_field_class(self): + field_class = self.field_class + if not field_class and self.layout == 'horizontal': + field_class = self.horizontal_field_class + return field_class + + def wrap_field(self, html): + field_class = self.get_field_class() + if field_class: + html = '
{html}
'.format( + klass=field_class, html=html) + return html + + def get_label_class(self): + label_class = self.label_class + if not label_class and self.layout == 'horizontal': + label_class = self.horizontal_label_class + label_class = text_value(label_class) + if not self.show_label: + label_class = add_css_class(label_class, 'sr-only') + return add_css_class(label_class, 'control-label') + + def get_label(self): + if isinstance(self.widget, CheckboxInput): + label = None + else: + label = self.field.label + if self.layout == 'horizontal' and not label: + return ' ' + return label + + def add_label(self, html): + label = self.get_label() + if label: + html = render_label( + label, + label_for=self.field.id_for_label, + label_class=self.get_label_class()) + html + return html + + def get_form_group_class(self): + form_group_class = self.form_group_class + if self.field.errors and self.error_css_class: + form_group_class = add_css_class( + form_group_class, self.error_css_class) + if self.field.field.required and self.required_css_class: + form_group_class = add_css_class( + form_group_class, self.required_css_class) + if self.field_errors: + form_group_class = add_css_class(form_group_class, 'has-error') + elif self.field.form.is_bound: + form_group_class = add_css_class( + form_group_class, self.success_css_class) + if self.layout == 'horizontal': + form_group_class = add_css_class( + form_group_class, self.get_size_class(prefix='form-group')) + return form_group_class + + def wrap_label_and_field(self, html): + return render_form_group(html, self.get_form_group_class()) + + def render(self): + # See if we're not excluded + if self.field.name in self.exclude.replace(' ', '').split(','): + return '' + # Hidden input requires no special treatment + if self.field.is_hidden: + return text_value(self.field) + # Render the widget + self.add_widget_attrs() + html = self.field.as_widget(attrs=self.widget.attrs) + self.restore_widget_attrs() + # Start post render + html = self.post_widget_render(html) + html = self.wrap_widget(html) + html = self.make_input_group(html) + html = self.append_to_field(html) + html = self.wrap_field(html) + html = self.add_label(html) + html = self.wrap_label_and_field(html) + return html + + +class InlineFieldRenderer(FieldRenderer): + """ + Inline field renderer + """ + + def add_error_attrs(self): + field_title = self.widget.attrs.get('title', '') + field_title += ' ' + ' '.join( + [strip_tags(e) for e in self.field_errors]) + self.widget.attrs['title'] = field_title.strip() + + def add_widget_attrs(self): + super(InlineFieldRenderer, self).add_widget_attrs() + self.add_error_attrs() + + def append_to_field(self, html): + return html + + def get_field_class(self): + return self.field_class + + def get_label_class(self): + return add_css_class(self.label_class, 'sr-only') diff --git a/bootstrap3/templates.py b/bootstrap3/templates.py new file mode 100644 index 000000000..a6485e7ba --- /dev/null +++ b/bootstrap3/templates.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import re + +from django.template import Variable, VariableDoesNotExist +from django.template.base import ( + FilterExpression, kwarg_re, TemplateSyntaxError +) + +# Extra features for template file handling + +QUOTED_STRING = re.compile(r'^["\'](?P.+)["\']$') + + +def handle_var(value, context): + # Resolve FilterExpression and Variable immediately + if isinstance(value, FilterExpression) or isinstance(value, Variable): + return value.resolve(context) + # Return quoted strings unquotes, from djangosnippets.org/snippets/886 + stringval = QUOTED_STRING.search(value) + if stringval: + return stringval.group('noquotes') + # Resolve variable or return string value + try: + return Variable(value).resolve(context) + except VariableDoesNotExist: + return value + + +def parse_token_contents(parser, token): + bits = token.split_contents() + tag = bits.pop(0) + args = [] + kwargs = {} + asvar = None + if len(bits) >= 2 and bits[-2] == 'as': + asvar = bits[-1] + bits = bits[:-2] + if len(bits): + for bit in bits: + match = kwarg_re.match(bit) + if not match: + raise TemplateSyntaxError( + 'Malformed arguments to tag "{}"'.format(tag)) + name, value = match.groups() + if name: + kwargs[name] = parser.compile_filter(value) + else: + args.append(parser.compile_filter(value)) + return { + 'tag': tag, + 'args': args, + 'kwargs': kwargs, + 'asvar': asvar, + } diff --git a/bootstrap3/templates/bootstrap3/bootstrap3.html b/bootstrap3/templates/bootstrap3/bootstrap3.html new file mode 100644 index 000000000..f68228ad2 --- /dev/null +++ b/bootstrap3/templates/bootstrap3/bootstrap3.html @@ -0,0 +1,27 @@ + +{% load bootstrap3 %} + + + + + + + {% block bootstrap3_title %}django-bootstrap3 template title{% endblock %} + {% bootstrap_css %} + + + + {% if 'javascript_in_head'|bootstrap_setting %}{% bootstrap_javascript jquery=True %}{% endif %} + {% block bootstrap3_extra_head %}{% endblock %} + + + +{% block bootstrap3_content %}django-bootstrap3 template content{% endblock %} +{% if not 'javascript_in_head'|bootstrap_setting %}{% bootstrap_javascript jquery=True %}{% endif %} +{% block bootstrap3_extra_script %}{% endblock %} + + + diff --git a/bootstrap3/templates/bootstrap3/field_help_text_and_errors.html b/bootstrap3/templates/bootstrap3/field_help_text_and_errors.html new file mode 100644 index 000000000..5330389ad --- /dev/null +++ b/bootstrap3/templates/bootstrap3/field_help_text_and_errors.html @@ -0,0 +1 @@ +{{ help_text_and_errors|join:' ' }} diff --git a/bootstrap3/templates/bootstrap3/form_errors.html b/bootstrap3/templates/bootstrap3/form_errors.html new file mode 100644 index 000000000..6d9e70025 --- /dev/null +++ b/bootstrap3/templates/bootstrap3/form_errors.html @@ -0,0 +1,6 @@ + diff --git a/bootstrap3/templates/bootstrap3/messages.html b/bootstrap3/templates/bootstrap3/messages.html new file mode 100644 index 000000000..6192177aa --- /dev/null +++ b/bootstrap3/templates/bootstrap3/messages.html @@ -0,0 +1,6 @@ +{% for message in messages %} +
+ + {{ message|safe }} +
+{% endfor %} diff --git a/bootstrap3/templates/bootstrap3/pagination.html b/bootstrap3/templates/bootstrap3/pagination.html new file mode 100644 index 000000000..8949d1d8e --- /dev/null +++ b/bootstrap3/templates/bootstrap3/pagination.html @@ -0,0 +1,33 @@ +{% with bootstrap_pagination_url=bootstrap_pagination_url|default:"?" %} + +
    + +
  • + « +
  • + + {% if pages_back %} +
  • + +
  • + {% endif %} + + {% for p in pages_shown %} + + {{ p }} + + {% endfor %} + + {% if pages_forward %} +
  • + +
  • + {% endif %} + +
  • + » +
  • + +
+ +{% endwith %} diff --git a/bootstrap3/templatetags/__init__.py b/bootstrap3/templatetags/__init__.py new file mode 100644 index 000000000..40a96afc6 --- /dev/null +++ b/bootstrap3/templatetags/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/bootstrap3/templatetags/bootstrap3.py b/bootstrap3/templatetags/bootstrap3.py new file mode 100644 index 000000000..2febb0233 --- /dev/null +++ b/bootstrap3/templatetags/bootstrap3.py @@ -0,0 +1,637 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import re + +from math import floor + +from django import template +from django.template.loader import get_template + +from ..bootstrap import ( + css_url, javascript_url, jquery_url, theme_url, get_bootstrap_setting +) +from ..utils import render_link_tag +from ..forms import ( + render_button, render_field, render_field_and_label, render_form, + render_form_group, render_formset, + render_label, render_form_errors, render_formset_errors +) +from ..components import render_icon, render_alert +from ..templates import handle_var, parse_token_contents +from ..text import force_text + + +register = template.Library() + + +@register.filter +def bootstrap_setting(value): + """ + A simple way to read bootstrap settings in a template. + Please consider this filter private for now, do not use it in your own + templates. + """ + return get_bootstrap_setting(value) + + +@register.simple_tag +def bootstrap_jquery_url(): + """ + **Tag name**:: + + bootstrap_jquery_url + + Return the full url to jQuery file to use + + Default value: ``//code.jquery.com/jquery.min.js`` + + This value is configurable, see Settings section + + **usage**:: + + {% bootstrap_jquery_url %} + + **example**:: + + {% bootstrap_jquery_url %} + """ + return jquery_url() + + +@register.simple_tag +def bootstrap_javascript_url(): + """ + Return the full url to the Bootstrap JavaScript library + + Default value: ``None`` + + This value is configurable, see Settings section + + **Tag name**:: + + bootstrap_javascript_url + + **usage**:: + + {% bootstrap_javascript_url %} + + **example**:: + + {% bootstrap_javascript_url %} + """ + return javascript_url() + + +@register.simple_tag +def bootstrap_css_url(): + """ + Return the full url to the Bootstrap CSS library + + Default value: ``None`` + + This value is configurable, see Settings section + + **Tag name**:: + + bootstrap_css_url + + **usage**:: + + {% bootstrap_css_url %} + + **example**:: + + {% bootstrap_css_url %} + """ + return css_url() + + +@register.simple_tag +def bootstrap_theme_url(): + """ + Return the full url to a Bootstrap theme CSS library + + Default value: ``None`` + + This value is configurable, see Settings section + + **Tag name**:: + + bootstrap_css_url + + **usage**:: + + {% bootstrap_css_url %} + + **example**:: + + {% bootstrap_css_url %} + """ + return theme_url() + + +@register.simple_tag +def bootstrap_css(): + """ + Return HTML for Bootstrap CSS + Adjust url in settings. If no url is returned, we don't want this statement + to return any HTML. + This is intended behavior. + + Default value: ``FIXTHIS`` + + This value is configurable, see Settings section + + **Tag name**:: + + bootstrap_css + + **usage**:: + + {% bootstrap_css %} + + **example**:: + + {% bootstrap_css %} + """ + urls = [url for url in [bootstrap_css_url(), bootstrap_theme_url()] if url] + return ''.join([render_link_tag(url) for url in urls]) + + +@register.simple_tag +def bootstrap_javascript(jquery=None): + """ + Return HTML for Bootstrap JavaScript. + + Adjust url in settings. If no url is returned, we don't want this + statement to return any HTML. + This is intended behavior. + + Default value: ``None`` + + This value is configurable, see Settings section + + **Tag name**:: + + bootstrap_javascript + + **Parameters**: + + :jquery: Truthy to include jQuery as well as Bootstrap + + **usage**:: + + {% bootstrap_javascript %} + + **example**:: + + {% bootstrap_javascript jquery=1 %} + """ + + javascript = '' + # See if we have to include jQuery + if jquery is None: + jquery = get_bootstrap_setting('include_jquery', False) + # NOTE: No async on scripts, not mature enough. See issue #52 and #56 + if jquery: + url = bootstrap_jquery_url() + if url: + javascript += ''.format(url=url) + url = bootstrap_javascript_url() + if url: + javascript += ''.format(url=url) + return javascript + + +@register.simple_tag +def bootstrap_formset(*args, **kwargs): + """ + Render a formset + + + **Tag name**:: + + bootstrap_formset + + **Parameters**: + + :args: + :kwargs: + + **usage**:: + + {% bootstrap_formset formset %} + + **example**:: + + {% bootstrap_formset formset layout='horizontal' %} + + """ + return render_formset(*args, **kwargs) + + +@register.simple_tag +def bootstrap_formset_errors(*args, **kwargs): + """ + Render form errors + + **Tag name**:: + + bootstrap_form_errors + + **Parameters**: + + :args: + :kwargs: + + **usage**:: + + {% bootstrap_form_errors form %} + + **example**:: + + {% bootstrap_form_errors form layout='inline' %} + """ + return render_formset_errors(*args, **kwargs) + + +@register.simple_tag +def bootstrap_form(*args, **kwargs): + """ + Render a form + + **Tag name**:: + + bootstrap_form + + **Parameters**: + + :args: + :kwargs: + + **usage**:: + + {% bootstrap_form form %} + + **example**:: + + {% bootstrap_form form layout='inline' %} + """ + return render_form(*args, **kwargs) + + +@register.simple_tag +def bootstrap_form_errors(*args, **kwargs): + """ + Render form errors + + **Tag name**:: + + bootstrap_form_errors + + **Parameters**: + + :args: + :kwargs: + + **usage**:: + + {% bootstrap_form_errors form %} + + **example**:: + + {% bootstrap_form_errors form layout='inline' %} + """ + return render_form_errors(*args, **kwargs) + + +@register.simple_tag +def bootstrap_field(*args, **kwargs): + """ + Render a field + + **Tag name**:: + + bootstrap_field + + **Parameters**: + + :args: + :kwargs: + + **usage**:: + + {% bootstrap_field form_field %} + + **example**:: + + {% bootstrap_field form_field %} + """ + return render_field(*args, **kwargs) + + +@register.simple_tag() +def bootstrap_label(*args, **kwargs): + """ + Render a label + + **Tag name**:: + + bootstrap_label + + **Parameters**: + + :args: + :kwargs: + + **usage**:: + + {% bootstrap_label FIXTHIS %} + + **example**:: + + {% bootstrap_label FIXTHIS %} + """ + return render_label(*args, **kwargs) + + +@register.simple_tag +def bootstrap_button(*args, **kwargs): + """ + Render a button + + **Tag name**:: + + bootstrap_button + + **Parameters**: + + :args: + :kwargs: + + **usage**:: + + {% bootstrap_button FIXTHIS %} + + **example**:: + + {% bootstrap_button FIXTHIS %} + """ + return render_button(*args, **kwargs) + + +@register.simple_tag +def bootstrap_icon(icon, **kwargs): + """ + Render an icon + + **Tag name**:: + + bootstrap_icon + + **Parameters**: + + :icon: icon name + + **usage**:: + + {% bootstrap_icon "icon_name" %} + + **example**:: + + {% bootstrap_icon "star" %} + + """ + return render_icon(icon, **kwargs) + + +@register.simple_tag +def bootstrap_alert(content, alert_type='info', dismissable=True): + """ + Render an alert + + **Tag name**:: + + bootstrap_alert + + **Parameters**: + + :content: HTML content of alert + :alert_type: one of 'info', 'warning', 'danger' or 'success' + :dismissable: boolean, is alert dismissable + + **usage**:: + + {% bootstrap_alert "my_content" %} + + **example**:: + + {% bootstrap_alert "Something went wrong" alert_type='error' %} + + """ + return render_alert(content, alert_type, dismissable) + + +@register.tag('buttons') +def bootstrap_buttons(parser, token): + """ + Render buttons for form + + **Tag name**:: + + bootstrap_buttons + + **Parameters**: + + :parser: + :token: + + **usage**:: + + {% bootstrap_buttons FIXTHIS %} + + **example**:: + + {% bootstrap_buttons FIXTHIS %} + """ + kwargs = parse_token_contents(parser, token) + kwargs['nodelist'] = parser.parse(('endbuttons', )) + parser.delete_first_token() + return ButtonsNode(**kwargs) + + +class ButtonsNode(template.Node): + def __init__(self, nodelist, args, kwargs, asvar, **kwargs2): + self.nodelist = nodelist + self.args = args + self.kwargs = kwargs + self.asvar = asvar + + def render(self, context): + output_kwargs = {} + for key in self.kwargs: + output_kwargs[key] = handle_var(self.kwargs[key], context) + buttons = [] + submit = output_kwargs.get('submit', None) + reset = output_kwargs.get('reset', None) + if submit: + buttons.append(bootstrap_button(submit, 'submit')) + if reset: + buttons.append(bootstrap_button(reset, 'reset')) + buttons = ' '.join(buttons) + self.nodelist.render(context) + output_kwargs.update({ + 'label': None, + 'field': buttons, + }) + output = render_form_group(render_field_and_label(**output_kwargs)) + if self.asvar: + context[self.asvar] = output + return '' + else: + return output + + +@register.simple_tag(takes_context=True) +def bootstrap_messages(context, *args, **kwargs): + """ + Show django.contrib.messages Messages in Bootstrap alert containers. + + In order to make the alerts dismissable (with the close button), + we have to set the jquery parameter too when using the + bootstrap_javascript tag. + + **Tag name**:: + + bootstrap_messages + + **Parameters**: + + :context: + :args: + :kwargs: + + **usage**:: + + {% bootstrap_messages FIXTHIS %} + + **example**:: + + {% bootstrap_javascript jquery=1 %} + {% bootstrap_messages FIXTHIS %} + + """ + return get_template('bootstrap3/messages.html').render(context) + + +@register.inclusion_tag('bootstrap3/pagination.html') +def bootstrap_pagination(page, **kwargs): + """ + Render pagination for a page + + **Tag name**:: + + bootstrap_pagination + + **Parameters**: + + :page: + :parameter_name: Name of paging URL parameter (default: "page") + :kwargs: + + **usage**:: + + {% bootstrap_pagination FIXTHIS %} + + **example**:: + + {% bootstrap_pagination FIXTHIS %} + """ + + pagination_kwargs = kwargs.copy() + pagination_kwargs['page'] = page + return get_pagination_context(**pagination_kwargs) + + +def get_pagination_context(page, pages_to_show=11, + url=None, size=None, extra=None, + parameter_name='page'): + """ + Generate Bootstrap pagination context from a page object + """ + pages_to_show = int(pages_to_show) + if pages_to_show < 1: + raise ValueError("Pagination pages_to_show should be a positive " + + "integer, you specified {pages}".format( + pages=pages_to_show)) + num_pages = page.paginator.num_pages + current_page = page.number + half_page_num = int(floor(pages_to_show / 2)) - 1 + if half_page_num < 0: + half_page_num = 0 + first_page = current_page - half_page_num + if first_page <= 1: + first_page = 1 + if first_page > 1: + pages_back = first_page - half_page_num + if pages_back < 1: + pages_back = 1 + else: + pages_back = None + last_page = first_page + pages_to_show - 1 + if pages_back is None: + last_page += 1 + if last_page > num_pages: + last_page = num_pages + if last_page < num_pages: + pages_forward = last_page + half_page_num + if pages_forward > num_pages: + pages_forward = num_pages + else: + pages_forward = None + if first_page > 1: + first_page -= 1 + if pages_back is not None and pages_back > 1: + pages_back -= 1 + else: + pages_back = None + pages_shown = [] + for i in range(first_page, last_page + 1): + pages_shown.append(i) + # Append proper character to url + if url: + # Remove existing page GET parameters + url = force_text(url) + url = re.sub(r'\?{0}\=[^\&]+'.format(parameter_name), '?', url) + url = re.sub(r'\&{0}\=[^\&]+'.format(parameter_name), '', url) + # Append proper separator + if '?' in url: + url += '&' + else: + url += '?' + # Append extra string to url + if extra: + if not url: + url = '?' + url += force_text(extra) + '&' + if url: + url = url.replace('?&', '?') + # Set CSS classes, see http://getbootstrap.com/components/#pagination + pagination_css_classes = ['pagination'] + if size == 'small': + pagination_css_classes.append('pagination-sm') + elif size == 'large': + pagination_css_classes.append('pagination-lg') + # Build context object + return { + 'bootstrap_pagination_url': url, + 'num_pages': num_pages, + 'current_page': current_page, + 'first_page': first_page, + 'last_page': last_page, + 'pages_shown': pages_shown, + 'pages_back': pages_back, + 'pages_forward': pages_forward, + 'pagination_css_classes': ' '.join(pagination_css_classes), + 'parameter_name': parameter_name, + } diff --git a/bootstrap3/tests.py b/bootstrap3/tests.py new file mode 100644 index 000000000..cf2345fd1 --- /dev/null +++ b/bootstrap3/tests.py @@ -0,0 +1,447 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import re + +from django.test import TestCase + +from django import forms +from django.template import Template, Context + +from .text import text_value, text_concat +from .exceptions import BootstrapError +from .utils import add_css_class + + +RADIO_CHOICES = ( + ('1', 'Radio 1'), + ('2', 'Radio 2'), +) + +MEDIA_CHOICES = ( + ('Audio', ( + ('vinyl', 'Vinyl'), + ('cd', 'CD'), + ) + ), + ('Video', ( + ('vhs', 'VHS Tape'), + ('dvd', 'DVD'), + ) + ), + ('unknown', 'Unknown'), +) + + +class TestForm(forms.Form): + """ + Form with a variety of widgets to test bootstrap3 rendering. + """ + date = forms.DateField(required=False) + subject = forms.CharField( + max_length=100, + help_text='my_help_text', + required=True, + widget=forms.TextInput(attrs={'placeholder': 'placeholdertest'}), + ) + message = forms.CharField(required=False, help_text='my_help_text') + sender = forms.EmailField(label='Sender © unicode') + secret = forms.CharField(initial=42, widget=forms.HiddenInput) + cc_myself = forms.BooleanField( + required=False, help_text='You will get a copy in your mailbox.') + select1 = forms.ChoiceField(choices=RADIO_CHOICES) + select2 = forms.MultipleChoiceField( + choices=RADIO_CHOICES, + help_text='Check as many as you like.', + ) + select3 = forms.ChoiceField(choices=MEDIA_CHOICES) + select4 = forms.MultipleChoiceField( + choices=MEDIA_CHOICES, + help_text='Check as many as you like.', + ) + category1 = forms.ChoiceField( + choices=RADIO_CHOICES, widget=forms.RadioSelect) + category2 = forms.MultipleChoiceField( + choices=RADIO_CHOICES, + widget=forms.CheckboxSelectMultiple, + help_text='Check as many as you like.', + ) + category3 = forms.ChoiceField( + widget=forms.RadioSelect, choices=MEDIA_CHOICES) + category4 = forms.MultipleChoiceField( + choices=MEDIA_CHOICES, + widget=forms.CheckboxSelectMultiple, + help_text='Check as many as you like.', + ) + addon = forms.CharField( + widget=forms.TextInput(attrs={'addon_before': 'before', 'addon_after': 'after'}), + ) + + required_css_class = 'bootstrap3-req' + + def clean(self): + cleaned_data = super(TestForm, self).clean() + raise forms.ValidationError( + "This error was added to show the non field errors styling.") + return cleaned_data + + +class TestFormWithoutRequiredClass(TestForm): + required_css_class = '' + + +def render_template(text, **context_args): + """ + Create a template ``text`` that first loads bootstrap3. + """ + template = Template("{% load bootstrap3 %}" + text) + if 'form' not in context_args: + context_args['form'] = TestForm() + return template.render(Context(context_args)) + + +def render_formset(formset=None, **context_args): + """ + Create a template that renders a formset + """ + context_args['formset'] = formset + return render_template('{% bootstrap_formset formset %}', **context_args) + + +def render_form(form=None, **context_args): + """ + Create a template that renders a form + """ + if form: + context_args['form'] = form + return render_template('{% bootstrap_form form %}', **context_args) + + +def render_form_field(field, **context_args): + """ + Create a template that renders a field + """ + form_field = 'form.%s' % field + return render_template( + '{% bootstrap_field ' + form_field + ' %}', **context_args) + + +def render_field(field, **context_args): + """ + Create a template that renders a field + """ + context_args['field'] = field + return render_template('{% bootstrap_field field %}', **context_args) + + +class SettingsTest(TestCase): + def test_settings(self): + from .bootstrap import BOOTSTRAP3 + self.assertTrue(BOOTSTRAP3) + + def test_settings_filter(self): + res = render_template( + '{% load bootstrap3 %}' + + '{{ "required_css_class"|bootstrap_setting }}') + self.assertEqual(res.strip(), 'bootstrap3-req') + res = render_template( + '{% load bootstrap3 %}' + + '{% if "javascript_in_head"|bootstrap_setting %}' + + 'head{% else %}body{% endif %}' + ) + self.assertEqual(res.strip(), 'head') + + def test_required_class(self): + form = TestForm() + res = render_template('{% bootstrap_form form %}', form=form) + self.assertIn('bootstrap3-req', res) + + def test_error_class(self): + form = TestForm({}) + res = render_template('{% bootstrap_form form %}', form=form) + self.assertIn('bootstrap3-err', res) + + def test_bound_class(self): + form = TestForm({'sender': 'sender'}) + res = render_template('{% bootstrap_form form %}', form=form) + self.assertIn('bootstrap3-bound', res) + + +class TemplateTest(TestCase): + def test_empty_template(self): + res = render_template('') + self.assertEqual(res.strip(), '') + + def test_text_template(self): + res = render_template('some text') + self.assertEqual(res.strip(), 'some text') + + def test_bootstrap_template(self): + template = Template(( + '{% extends "bootstrap3/bootstrap3.html" %}' + + '{% block bootstrap3_content %}' + + 'test_bootstrap3_content' + + '{% endblock %}' + )) + res = template.render(Context({})) + self.assertIn('test_bootstrap3_content', res) + + def test_javascript_without_jquery(self): + res = render_template('{% bootstrap_javascript jquery=0 %}') + self.assertIn('bootstrap', res) + self.assertNotIn('jquery', res) + + def test_javascript_with_jquery(self): + res = render_template('{% bootstrap_javascript jquery=1 %}') + self.assertIn('bootstrap', res) + self.assertIn('jquery', res) + + +class FormSetTest(TestCase): + def test_illegal_formset(self): + with self.assertRaises(BootstrapError): + render_formset(formset='illegal') + + +class FormTest(TestCase): + def test_illegal_form(self): + with self.assertRaises(BootstrapError): + render_form(form='illegal') + + def test_field_names(self): + form = TestForm() + res = render_form(form) + for field in form: + self.assertIn('name="%s"' % field.name, res) + + def test_field_addons(self): + form = TestForm() + res = render_form(form) + self.assertIn('
beforeafter
', res) + + def test_exclude(self): + form = TestForm() + res = render_template( + '{% bootstrap_form form exclude="cc_myself" %}', form=form) + self.assertNotIn('cc_myself', res) + + def test_layout_horizontal(self): + form = TestForm() + res = render_template( + '{% bootstrap_form form layout="horizontal" %}', form=form) + self.assertIn('col-md-2', res) + self.assertIn('col-md-4', res) + res = render_template( + '{% bootstrap_form form layout="horizontal" ' + + 'horizontal_label_class="hlabel" ' + + 'horizontal_field_class="hfield" %}', + form=form + ) + self.assertIn('hlabel', res) + self.assertIn('hfield', res) + + def test_buttons_tag(self): + form = TestForm() + res = render_template( + '{% buttons layout="horizontal" %}{% endbuttons %}', form=form) + self.assertIn('col-md-2', res) + self.assertIn('col-md-4', res) + + +class FieldTest(TestCase): + def test_illegal_field(self): + with self.assertRaises(BootstrapError): + render_field(field='illegal') + + def test_show_help(self): + res = render_form_field('subject') + self.assertIn('my_help_text', res) + self.assertNotIn('my_help_text', res) + res = render_template('{% bootstrap_field form.subject show_help=0 %}') + self.assertNotIn('my_help_text', res) + + def test_subject(self): + res = render_form_field('subject') + self.assertIn('type="text"', res) + self.assertIn('placeholder="placeholdertest"', res) + + def test_required_field(self): + required_field = render_form_field('subject') + self.assertIn('required', required_field) + self.assertIn('bootstrap3-req', required_field) + not_required_field = render_form_field('message') + self.assertNotIn('required', not_required_field) + # Required field with required=0 + form_field = 'form.subject' + rendered = render_template( + '{% bootstrap_field ' + form_field + ' set_required=0 %}') + self.assertNotIn('required', rendered) + # Required settings in field + form_field = 'form.subject' + rendered = render_template( + '{% bootstrap_field ' + + form_field + + ' required_css_class="test-required" %}') + self.assertIn('test-required', rendered) + + def test_empty_permitted(self): + form = TestForm() + res = render_form_field('subject', form=form) + self.assertIn('required', res) + form.empty_permitted = True + res = render_form_field('subject', form=form) + self.assertNotIn('required', res) + + def test_input_group(self): + res = render_template( + '{% bootstrap_field form.subject addon_before="$" ' + + 'addon_after=".00" %}' + ) + self.assertIn('class="input-group"', res) + self.assertIn('class="input-group-addon">$', res) + self.assertIn('class="input-group-addon">.00', res) + + def test_size(self): + def _test_size(param, klass): + res = render_template( + '{% bootstrap_field form.subject size="' + param + '" %}') + self.assertIn(klass, res) + + def _test_size_medium(param): + res = render_template( + '{% bootstrap_field form.subject size="' + param + '" %}') + self.assertNotIn('input-lg', res) + self.assertNotIn('input-sm', res) + self.assertNotIn('input-md', res) + _test_size('sm', 'input-sm') + _test_size('small', 'input-sm') + _test_size('lg', 'input-lg') + _test_size('large', 'input-lg') + _test_size_medium('md') + _test_size_medium('medium') + _test_size_medium('') + + +class ComponentsTest(TestCase): + def test_icon(self): + res = render_template('{% bootstrap_icon "star" %}') + self.assertEqual( + res.strip(), '') + res = render_template( + '{% bootstrap_icon "star" title="alpha centauri" %}') + self.assertEqual( + res.strip(), + '') + + def test_alert(self): + res = render_template( + '{% bootstrap_alert "content" alert_type="danger" %}') + self.assertEqual( + res.strip(), + '
' + + 'content
' + ) + + +class MessagesTest(TestCase): + def test_messages(self): + class FakeMessage(object): + """ + Follows the `django.contrib.messages.storage.base.Message` API. + """ + + def __init__(self, message, tags): + self.tags = tags + self.message = message + + def __str__(self): + return self.message + + pattern = re.compile(r'\s+') + messages = [FakeMessage("hello", "warning")] + res = render_template( + '{% bootstrap_messages messages %}', messages=messages) + expected = """ +
+ + hello +
+""" + self.assertEqual( + re.sub(pattern, '', res), + re.sub(pattern, '', expected) + ) + + messages = [FakeMessage("hello", "error")] + res = render_template( + '{% bootstrap_messages messages %}', messages=messages) + expected = """ +
+ + hello +
+ """ + self.assertEqual( + re.sub(pattern, '', res), + re.sub(pattern, '', expected) + ) + + messages = [FakeMessage("hello", None)] + res = render_template( + '{% bootstrap_messages messages %}', messages=messages) + expected = """ +
+ + hello +
+ """ + + self.assertEqual( + re.sub(pattern, '', res), + re.sub(pattern, '', expected) + ) + + +class TextTest(TestCase): + def test_add_css_class(self): + css_classes = "one two" + css_class = "three four" + classes = add_css_class(css_classes, css_class) + self.assertEqual(classes, "one two three four") + + classes = add_css_class(css_classes, css_class, prepend=True) + self.assertEqual(classes, "three four one two") + + +class HtmlTest(TestCase): + def test_text_value(self): + self.assertEqual(text_value(''), "") + self.assertEqual(text_value(' '), " ") + self.assertEqual(text_value(None), "") + self.assertEqual(text_value(1), "1") + + def test_text_concat(self): + self.assertEqual(text_concat(1, 2), "12") + self.assertEqual(text_concat(1, 2, separator='='), "1=2") + self.assertEqual(text_concat(None, 2, separator='='), "2") + + +class ButtonTest(TestCase): + def test_button(self): + res = render_template( + "{% bootstrap_button 'button' size='lg' %}") + self.assertEqual( + res.strip(), '') + res = render_template( + "{% bootstrap_button 'button' size='lg' href='#' %}") + self.assertIn( + res.strip(), + 'buttonbutton') diff --git a/bootstrap3/text.py b/bootstrap3/text.py new file mode 100644 index 000000000..d4fcd4dd5 --- /dev/null +++ b/bootstrap3/text.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + + +try: + from django.utils.encoding import force_text +except ImportError: + from django.utils.encoding import force_unicode as force_text + + +def text_value(value): + """ + Force a value to text, render None as an empty string + """ + if value is None: + return '' + return force_text(value) + + +def text_concat(*args, **kwargs): + """ + Concatenate several values as a text string with an optional separator + """ + separator = text_value(kwargs.get('separator', '')) + values = filter(None, [text_value(v) for v in args]) + return separator.join(values) diff --git a/bootstrap3/utils.py b/bootstrap3/utils.py new file mode 100644 index 000000000..8410d63fb --- /dev/null +++ b/bootstrap3/utils.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.forms.widgets import flatatt + +from .text import text_value + + +# Handle HTML and CSS manipulation + + +def split_css_classes(css_classes): + """ + Turn string into a list of CSS classes + """ + classes_list = text_value(css_classes).split(' ') + return [c for c in classes_list if c] + + +def add_css_class(css_classes, css_class, prepend=False): + """ + Add a CSS class to a string of CSS classes + """ + classes_list = split_css_classes(css_classes) + classes_to_add = [c for c in split_css_classes(css_class) + if c not in classes_list] + if prepend: + classes_list = classes_to_add + classes_list + else: + classes_list += classes_to_add + return ' '.join(classes_list) + + +def remove_css_class(css_classes, css_class): + """ + Remove a CSS class from a string of CSS classes + """ + remove = set(split_css_classes(css_class)) + classes_list = [c for c in split_css_classes(css_classes) + if c not in remove] + return ' '.join(classes_list) + + +def render_link_tag(url, rel='stylesheet', media='all'): + """ + Build a link tag + """ + return render_tag( + 'link', + attrs={'href': url, 'rel': rel, 'media': media}, + close=False) + + +def render_tag(tag, attrs=None, content=None, close=True): + """ + Render a HTML tag + """ + builder = '<{tag}{attrs}>{content}' + if content or close: + builder += '' + return builder.format( + tag=tag, + attrs=flatatt(attrs) if attrs else '', + content=text_value(content), + ) diff --git a/ietf/community/templatetags/community_tags.py b/ietf/community/templatetags/community_tags.py index 1797bb99b..74d5f1938 100644 --- a/ietf/community/templatetags/community_tags.py +++ b/ietf/community/templatetags/community_tags.py @@ -1,5 +1,6 @@ from django import template from django.template.loader import render_to_string +from django.conf import settings from ietf.community.models import CommunityList from ietf.group.models import Role @@ -7,47 +8,24 @@ from ietf.group.models import Role register = template.Library() - -class CommunityListNode(template.Node): - - def __init__(self, user, var_name): - self.user = user - self.var_name = var_name - - def render(self, context): - user = self.user.resolve(context) - if not (user and hasattr(user, "is_authenticated") and user.is_authenticated() ): - return '' - lists = {'personal': CommunityList.objects.get_or_create(user=user)[0]} - try: - person = user.person - groups = [] - managed_areas = [i.group for i in Role.objects.filter(name__slug='ad', email__in=person.email_set.all())] - for area in managed_areas: - groups.append(CommunityList.objects.get_or_create(group=area)[0]) - managed_wg = [i.group for i in Role.objects.filter(name__slug='chair', group__type__slug='wg', email__in=person.email_set.all())] - for wg in managed_wg: - groups.append(CommunityList.objects.get_or_create(group=wg)[0]) - lists['group'] = groups - except: - pass - context.update({self.var_name: lists}) +@register.assignment_tag +def get_user_managed_lists(user): + if not (user and hasattr(user, "is_authenticated") and user.is_authenticated()): return '' - - -@register.tag -def get_user_managed_lists(parser, token): - firstbits = token.contents.split(None, 2) - if len(firstbits) != 3: - raise template.TemplateSyntaxError("'get_user_managed_lists' tag takes three arguments") - user = parser.compile_filter(firstbits[1]) - lastbits_reversed = firstbits[2][::-1].split(None, 2) - if lastbits_reversed[1][::-1] != 'as': - raise template.TemplateSyntaxError("next-to-last argument to 'get_user_managed_lists' tag must" - " be 'as'") - var_name = lastbits_reversed[0][::-1] - return CommunityListNode(user, var_name) - + lists = {'personal': CommunityList.objects.get_or_create(user=user)[0]} + try: + person = user.person + groups = [] + managed_areas = [i.group for i in Role.objects.filter(name__slug='ad', email__in=person.email_set.all())] + for area in managed_areas: + groups.append(CommunityList.objects.get_or_create(group=area)[0]) + managed_wg = [i.group for i in Role.objects.filter(name__slug='chair', group__type__slug='wg', email__in=person.email_set.all())] + for wg in managed_wg: + groups.append(CommunityList.objects.get_or_create(group=wg)[0]) + lists['group'] = groups + except: + pass + return lists @register.inclusion_tag('community/display_field.html', takes_context=False) def show_field(field, doc): @@ -56,25 +34,11 @@ def show_field(field, doc): } -class CommunityListViewNode(template.Node): - - def __init__(self, clist): - self.clist = clist - - def render(self, context): - clist = self.clist.resolve(context) - if not clist.cached: +@register.simple_tag +def get_clist_view(clist): + if settings.DEBUG or not clist.cached: clist.cached = render_to_string('community/raw_view.html', {'cl': clist, 'dc': clist.get_display_config()}) clist.save() return clist.cached - - -@register.tag -def get_clist_view(parser, token): - firstbits = token.contents.split(None, 1) - if len(firstbits) != 2: - raise template.TemplateSyntaxError("'get_clist_view' tag takes one argument") - clist = parser.compile_filter(firstbits[1]) - return CommunityListViewNode(clist) diff --git a/ietf/dbtemplate/templates/dbtemplate/template_edit.html b/ietf/dbtemplate/templates/dbtemplate/template_edit.html index fb4da59a1..267130bff 100644 --- a/ietf/dbtemplate/templates/dbtemplate/template_edit.html +++ b/ietf/dbtemplate/templates/dbtemplate/template_edit.html @@ -1,4 +1,6 @@ -{% extends "base.html" %} +{% extends "ietf.html" %} + +{% load bootstrap3 %} {% block content %}

Template: {{ template }}

@@ -12,15 +14,15 @@
Template type
{{ template.type.name }} {% if template.type.slug == "rst" %} -

This template uses the syntax of reStructuredText. Get a quick reference at http://docutils.sourceforge.net/docs/user/rst/quickref.html.

-

You can do variable interpolation with $varialbe if the template allows any variable.

+

This template uses the syntax of reStructuredText. Get a quick reference at http://docutils.sourceforge.net/docs/user/rst/quickref.html.

+

You can do variable interpolation with $varialbe if the template allows any variable.

{% endif %} {% if template.type.slug == "django" %} -

This template uses the syntax of the default django template framework. Get more info at https://docs.djangoproject.com/en/dev/topics/templates/.

-

You can do variable interpolation with the current django markup {{variable}} if the template allows any variable.

+

This template uses the syntax of the default django template framework. Get more info at https://docs.djangoproject.com/en/dev/topics/templates/.

+

You can do variable interpolation with the current django markup {{variable}} if the template allows any variable.

{% endif %} {% if template.type.slug == "plain" %} -

This template uses plain text, so no markup is used. You can do variable interpolation with $variable if the template allows any variable.

+

This template uses plain text, so no markup is used. You can do variable interpolation with $variable if the template allows any variable.

{% endif %}
{% if template.variables %} @@ -30,8 +32,13 @@

Edit template content

-
{% csrf_token %} -{{ form.as_p }} - + + {% csrf_token %} + + {% bootstrap_form form %} + + {% buttons %} + + {% endbuttons %}
{% endblock content %} diff --git a/ietf/dbtemplate/templates/dbtemplate/template_list.html b/ietf/dbtemplate/templates/dbtemplate/template_list.html index dd2036bc6..8beb06c7b 100644 --- a/ietf/dbtemplate/templates/dbtemplate/template_list.html +++ b/ietf/dbtemplate/templates/dbtemplate/template_list.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "ietf.html" %} {% block content %}

Defined templates for group {{ group }}

@@ -6,7 +6,7 @@ {% if template_list %} {% else %} diff --git a/ietf/dbtemplate/views.py b/ietf/dbtemplate/views.py index 95529c378..6986fffdc 100644 --- a/ietf/dbtemplate/views.py +++ b/ietf/dbtemplate/views.py @@ -1,6 +1,5 @@ from django.http import HttpResponseForbidden, HttpResponseRedirect -from django.shortcuts import get_object_or_404, render_to_response -from django.template import RequestContext +from django.shortcuts import get_object_or_404, render from ietf.dbtemplate.models import DBTemplate from ietf.dbtemplate.forms import DBTemplateForm @@ -15,10 +14,10 @@ def template_list(request, acronym): return HttpResponseForbidden("You are not authorized to access this view") template_list = DBTemplate.objects.filter(group=group) - return render_to_response('dbtemplate/template_list.html', + return render(request, 'dbtemplate/template_list.html', {'template_list': template_list, 'group': group, - }, RequestContext(request)) + }) def template_edit(request, acronym, template_id, base_template='dbtemplate/template_edit.html', formclass=DBTemplateForm, extra_context=None): @@ -43,4 +42,4 @@ def template_edit(request, acronym, template_id, base_template='dbtemplate/templ 'form': form, } context.update(extra_context) - return render_to_response(base_template, context, RequestContext(request)) + return render(request, base_template, context) diff --git a/ietf/doc/fields.py b/ietf/doc/fields.py new file mode 100644 index 000000000..b8f482885 --- /dev/null +++ b/ietf/doc/fields.py @@ -0,0 +1,113 @@ +import json + +from django.utils.html import escape +from django import forms +from django.core.urlresolvers import reverse as urlreverse + +from ietf.doc.models import Document, DocAlias +from ietf.doc.utils import uppercase_std_abbreviated_name + +def select2_id_doc_name_json(objs): + return json.dumps([{ "id": o.pk, "text": escape(uppercase_std_abbreviated_name(o.name)) } for o in objs]) + +# FIXME: select2 version 4 uses a standard select for the AJAX case - +# switching to that would allow us to derive from the standard +# multi-select machinery in Django instead of the manual CharField +# stuff below + +class SearchableDocumentsField(forms.CharField): + """Server-based multi-select field for choosing documents using + select2.js. + + The field uses a comma-separated list of primary keys in a + CharField element as its API with some extra attributes used by + the Javascript part.""" + + def __init__(self, + max_entries=None, # max number of selected objs + model=Document, + hint_text="Type in name to search for document", + doc_type="draft", + *args, **kwargs): + kwargs["max_length"] = 10000 + self.max_entries = max_entries + self.doc_type = doc_type + self.model = model + + super(SearchableDocumentsField, self).__init__(*args, **kwargs) + + self.widget.attrs["class"] = "select2-field form-control" + self.widget.attrs["data-placeholder"] = hint_text + if self.max_entries != None: + self.widget.attrs["data-max-entries"] = self.max_entries + + def parse_select2_value(self, value): + return [x.strip() for x in value.split(",") if x.strip()] + + def prepare_value(self, value): + if not value: + value = "" + if isinstance(value, (int, long)): + value = str(value) + if isinstance(value, basestring): + pks = self.parse_select2_value(value) + value = self.model.objects.filter(pk__in=pks) + filter_args = {} + if self.model == DocAlias: + filter_args["document__type"] = self.doc_type + else: + filter_args["type"] = self.doc_type + value = value.filter(**filter_args) + if isinstance(value, self.model): + value = [value] + + self.widget.attrs["data-pre"] = select2_id_doc_name_json(value) + + # doing this in the constructor is difficult because the URL + # patterns may not have been fully constructed there yet + self.widget.attrs["data-ajax-url"] = urlreverse("ajax_select2_search_docs", kwargs={ + "doc_type": self.doc_type, + "model_name": self.model.__name__.lower() + }) + + return u",".join(unicode(o.pk) for o in value) + + def clean(self, value): + value = super(SearchableDocumentsField, self).clean(value) + pks = self.parse_select2_value(value) + + objs = self.model.objects.filter(pk__in=pks) + + found_pks = [str(o.pk) for o in objs] + failed_pks = [x for x in pks if x not in found_pks] + if failed_pks: + raise forms.ValidationError(u"Could not recognize the following documents: {pks}. You can only input documents already registered in the Datatracker.".format(pks=", ".join(failed_pks))) + + if self.max_entries != None and len(objs) > self.max_entries: + raise forms.ValidationError(u"You can select at most %s entries only." % self.max_entries) + + return objs + +class SearchableDocumentField(SearchableDocumentsField): + """Specialized to only return one Document.""" + def __init__(self, model=Document, *args, **kwargs): + kwargs["max_entries"] = 1 + super(SearchableDocumentField, self).__init__(model=model, *args, **kwargs) + + def clean(self, value): + return super(SearchableDocumentField, self).clean(value).first() + +class SearchableDocAliasesField(SearchableDocumentsField): + def __init__(self, model=DocAlias, *args, **kwargs): + super(SearchableDocAliasesField, self).__init__(model=model, *args, **kwargs) + +class SearchableDocAliasField(SearchableDocumentsField): + """Specialized to only return one DocAlias.""" + def __init__(self, model=DocAlias, *args, **kwargs): + kwargs["max_entries"] = 1 + super(SearchableDocAliasField, self).__init__(model=model, *args, **kwargs) + + def clean(self, value): + return super(SearchableDocAliasField, self).clean(value).first() + + diff --git a/ietf/doc/forms.py b/ietf/doc/forms.py index e48d1b40e..33a2b9150 100644 --- a/ietf/doc/forms.py +++ b/ietf/doc/forms.py @@ -34,7 +34,7 @@ class AdForm(forms.Form): self.fields['ad'].choices = list(choices) + [("", "-------"), (ad_pk, Person.objects.get(pk=ad_pk).plain_name())] class NotifyForm(forms.Form): - notify = forms.CharField(max_length=255, help_text="List of email addresses to receive state notifications, separated by comma", label="Notification list", required=False) + notify = forms.CharField(max_length=255, help_text="List of email addresses to receive state notifications, separated by comma.", label="Notification list", required=False) def clean_notify(self): addrspecs = [x.strip() for x in self.cleaned_data["notify"].split(',')] diff --git a/ietf/doc/templatetags/ballot_icon.py b/ietf/doc/templatetags/ballot_icon.py index 23b5f99e4..3b893fc90 100644 --- a/ietf/doc/templatetags/ballot_icon.py +++ b/ietf/doc/templatetags/ballot_icon.py @@ -87,35 +87,32 @@ def ballot_icon(context, doc): positions = list(doc.active_ballot().active_ad_positions().items()) positions.sort(key=sort_key) - edit_position_url = "" - if has_role(user, "Area Director"): - edit_position_url = urlreverse('ietf.doc.views_ballot.edit_position', kwargs=dict(name=doc.name, ballot_id=ballot.pk)) - - title = "IESG positions (click to show more%s)" % (", right-click to edit position" if edit_position_url else "") - - res = ['' % ( - urlreverse("doc_ballot", kwargs=dict(name=doc.name, ballot_id=ballot.pk)), + res = ['
' % ( urlreverse("ietf.doc.views_doc.ballot_popup", kwargs=dict(name=doc.name, ballot_id=ballot.pk)), - edit_position_url, - title - )] + ballot.pk)] res.append("") for i, (ad, pos) in enumerate(positions): if i > 0 and i % 5 == 0: - res.append("") - res.append("") + res.append("") c = "position-%s" % (pos.pos.slug if pos else "norecord") if user_is_person(user, ad): c += " my" - res.append('' % c) - res.append("") - res.append("
' % c) + res.append('
") + # add sufficient table calls to last row to avoid HTML validation warning + while (i + 1) % 5 != 0: + res.append('') + i = i + 1 + + res.append("") + # XXX FACELIFT: Loading via href will go away in bootstrap 4. + # See http://getbootstrap.com/javascript/#modals-usage + res.append('' % ballot.pk) return "".join(res) @@ -156,7 +153,7 @@ def state_age_colored(doc): except IndexError: state_date = datetime.date(1990,1,1) days = (datetime.date.today() - state_date).days - # loosely based on + # loosely based on # http://trac.tools.ietf.org/group/iesg/trac/wiki/PublishPath if iesg_state == "lc": goal1 = 30 @@ -180,16 +177,17 @@ def state_age_colored(doc): goal1 = 14 goal2 = 28 if days > goal2: - class_name = "ietf-small ietf-highlight-r" + class_name = "label label-danger" elif days > goal1: - class_name = "ietf-small ietf-highlight-y" + class_name = "label label-warning" else: class_name = "ietf-small" if days > goal1: title = ' title="Goal is <%d days"' % (goal1,) else: title = '' - return mark_safe('(for %d day%s)' % ( - class_name, title, days, 's' if days != 1 else '')) + return mark_safe('for %d day%s' % ( + class_name, title, days, + 's' if days != 1 else '')) else: return "" diff --git a/ietf/doc/templatetags/ietf_filters.py b/ietf/doc/templatetags/ietf_filters.py index d3e81df15..3ca7a4176 100644 --- a/ietf/doc/templatetags/ietf_filters.py +++ b/ietf/doc/templatetags/ietf_filters.py @@ -8,9 +8,9 @@ from email.utils import parseaddr from ietf.doc.models import ConsensusDocEvent from django import template +from django.conf import settings from django.utils.html import escape, fix_ampersands -from django.utils.text import wrap -from django.template.defaultfilters import truncatewords_html, linebreaksbr, stringfilter, urlize +from django.template.defaultfilters import truncatewords_html, linebreaksbr, stringfilter, striptags, urlize from django.template import resolve_variable from django.utils.safestring import mark_safe, SafeData from django.utils.html import strip_tags @@ -52,12 +52,12 @@ def parse_email_list(value): u'joe@example.org, fred@example.com' Parsing a non-string should return the input value, rather than fail: - + >>> parse_email_list(['joe@example.org', 'fred@example.com']) ['joe@example.org', 'fred@example.com'] - + Null input values should pass through silently: - + >>> parse_email_list('') '' @@ -91,7 +91,7 @@ def fix_angle_quotes(value): if "<" in value: value = re.sub("<([\w\-\.]+@[\w\-\.]+)>", "<\1>", value) return value - + # there's an "ahref -> a href" in GEN_UTIL # but let's wait until we understand what that's for. @register.filter(name='make_one_per_line') @@ -103,7 +103,7 @@ def make_one_per_line(value): 'a\\nb\\nc' Pass through non-strings: - + >>> make_one_per_line([1, 2]) [1, 2] @@ -114,7 +114,7 @@ def make_one_per_line(value): return re.sub(", ?", "\n", value) else: return value - + @register.filter(name='timesum') def timesum(value): """ @@ -203,7 +203,7 @@ def rfcspace(string): """ string = str(string) if string[:3].lower() == "rfc" and string[3] != " ": - return string[:3] + " " + string[3:] + return string[:3].upper() + " " + string[3:] else: return string @@ -226,7 +226,7 @@ def rfclink(string): URL for that RFC. """ string = str(string); - return "http://tools.ietf.org/html/rfc" + string; + return "//tools.ietf.org/html/rfc" + string; @register.filter(name='urlize_ietf_docs', is_safe=True, needs_autoescape=True) def urlize_ietf_docs(string, autoescape=None): @@ -278,7 +278,7 @@ def truncate_ellipsis(text, arg): return escape(text[:num-1])+"…" else: return escape(text) - + @register.filter def split(text, splitter=None): return text.split(splitter) @@ -379,7 +379,7 @@ def linebreaks_lf(text): @register.filter(name='clean_whitespace') def clean_whitespace(text): """ - Map all ASCII control characters (0x00-0x1F) to spaces, and + Map all ASCII control characters (0x00-0x1F) to spaces, and remove unnecessary spaces. """ text = re.sub("[\000-\040]+", " ", text) @@ -388,7 +388,7 @@ def clean_whitespace(text): @register.filter(name='unescape') def unescape(text): """ - Unescape  />/< + Unescape  />/< """ text = text.replace(">", ">") text = text.replace("<", "<") @@ -429,7 +429,7 @@ def has_role(user, role_names): @register.filter def stable_dictsort(value, arg): """ - Like dictsort, except it's stable (preserves the order of items + Like dictsort, except it's stable (preserves the order of items whose sort key is the same). See also bug report http://code.djangoproject.com/ticket/12110 """ @@ -461,21 +461,14 @@ def format_snippet(text, trunc_words=25): full = mark_safe(keep_spacing(collapsebr(linebreaksbr(urlize(sanitize_html(text)))))) snippet = truncatewords_html(full, trunc_words) if snippet != full: - return mark_safe(u'
%s[show all]
' % (snippet, full)) + return mark_safe(u'
%s
' % (snippet, full)) return full -@register.filter -def format_editable_snippet(text,link): - full = mark_safe(keep_spacing(collapsebr(linebreaksbr(urlize(sanitize_html(text)))))) - snippet = truncatewords_html(full, 25) - if snippet != full: - return mark_safe(u'
%s[show all]