Browse Source

Editable user signatures.

Rafał Pitoń 11 years ago
parent
commit
da952d40b8
34 changed files with 903 additions and 42 deletions
  1. 8 1
      misago/conf/defaults.py
  2. 9 1
      misago/core/forms.py
  3. 3 1
      misago/markup/__init__.py
  4. 20 0
      misago/markup/editor.py
  5. 9 0
      misago/markup/flavours.py
  6. 0 0
      misago/markup/templatetags/__init__.py
  7. 23 0
      misago/markup/templatetags/misago_editor.py
  8. 1 0
      misago/static/misago/css/misago/alerts.less
  9. 100 0
      misago/static/misago/css/misago/editor.less
  10. 2 24
      misago/static/misago/css/misago/forms.less
  11. 1 0
      misago/static/misago/css/misago/misago.less
  12. 272 0
      misago/static/misago/js/jquery.autosize.js
  13. 133 0
      misago/static/misago/js/misago-editor.js
  14. 2 0
      misago/templates/bootstrap3/layout/radioselect.html
  15. 1 1
      misago/templates/misago/admin/permissions_table.html
  16. 9 0
      misago/templates/misago/admin/users/edit.html
  17. 1 1
      misago/templates/misago/auth_form_errors.html
  18. 73 0
      misago/templates/misago/editor/body.html
  19. 7 0
      misago/templates/misago/editor/js.html
  20. 2 2
      misago/templates/misago/errorpages/403.html
  21. 2 2
      misago/templates/misago/legal/privacy_policy.html
  22. 2 2
      misago/templates/misago/legal/terms_of_service.html
  23. 63 0
      misago/templates/misago/usercp/edit_signature.html
  24. 7 0
      misago/users/apps.py
  25. 38 2
      misago/users/forms/admin.py
  26. 23 1
      misago/users/forms/usercp.py
  27. 2 1
      misago/users/migrations/0001_initial.py
  28. 12 0
      misago/users/migrations/0002_users_settings.py
  29. 7 1
      misago/users/models/user.py
  30. 10 2
      misago/users/permissions/account.py
  31. 24 0
      misago/users/signatures.py
  32. 1 0
      misago/users/urls.py
  33. 3 0
      misago/users/views/admin/users.py
  34. 33 0
      misago/users/views/usercp.py

+ 8 - 1
misago/conf/defaults.py

@@ -62,6 +62,13 @@ PIPELINE_JS = {
         ),
         ),
         'output_filename': 'misago.js',
         'output_filename': 'misago.js',
     },
     },
+    'misago_editor': {
+        'source_filenames': (
+            'misago/js/jquery.autosize.js',
+            'misago/js/misago-editor.js',
+        ),
+        'output_filename': 'misago-editor.js',
+    },
     'misago_admin': {
     'misago_admin': {
         'source_filenames': (
         'source_filenames': (
             'misago/admin/js/jquery.js',
             'misago/admin/js/jquery.js',
@@ -71,7 +78,7 @@ PIPELINE_JS = {
             'misago/admin/js/misago-timestamps.js',
             'misago/admin/js/misago-timestamps.js',
             'misago/admin/js/misago-tooltips.js',
             'misago/admin/js/misago-tooltips.js',
             'misago/admin/js/misago-tables.js',
             'misago/admin/js/misago-tables.js',
-            #'misago/admin/js/misago-yesnoswitch.js',
+            'misago/admin/js/misago-yesnoswitch.js',
         ),
         ),
         'output_filename': 'misago_admin.js',
         'output_filename': 'misago_admin.js',
     },
     },

+ 9 - 1
misago/core/forms.py

@@ -9,11 +9,19 @@ TEXT_BASED_FIELDS = (
 )
 )
 
 
 
 
+class YesNoSwitchBase(TypedChoiceField):
+    def prepare_value(self, value):
+        """normalize bools to binary 1/0 so field works on them too"""
+        return 1 if value else 0
+
+
 def YesNoSwitch(**kwargs):
 def YesNoSwitch(**kwargs):
     if 'initial' not in kwargs:
     if 'initial' not in kwargs:
         kwargs['initial'] = 0
         kwargs['initial'] = 0
 
 
-    return TypedChoiceField(
+    kwargs['initial'] = 1 if kwargs['initial'] else 0
+
+    return YesNoSwitchBase(
         coerce=int,
         coerce=int,
         choices=((1, _("Yes")), (0, _("No"))),
         choices=((1, _("Yes")), (0, _("No"))),
         widget=RadioSelect(attrs={'class': 'yesno-switch'}),
         widget=RadioSelect(attrs={'class': 'yesno-switch'}),

+ 3 - 1
misago/markup/__init__.py

@@ -1,3 +1,5 @@
 from misago.markup.flavours import (common as common_flavour,
 from misago.markup.flavours import (common as common_flavour,
-                                    limited as limited_flavour)  # noqa
+                                    limited as limited_flavour,
+                                    signature as signature_flavour)  # noqa
 from misago.markup.parser import parse  # noqa
 from misago.markup.parser import parse  # noqa
+from misago.markup.editor import Editor  # noqa

+ 20 - 0
misago/markup/editor.py

@@ -0,0 +1,20 @@
+class Editor(object):
+    """
+    Misago editor class
+    """
+    def __init__(self, field, body_template='misago/editor/body.html',
+                 js_template='misago/editor/js.html', allow_mentions=True,
+                 allow_links=True, allow_images=True, allow_blocks=True,
+                 uploads_url=None):
+        self.field = field
+        self.auto_id = 'misago-editor-%s' % field.auto_id
+
+        self.body_template = body_template
+        self.js_template = js_template
+
+        self.uploads_url = uploads_url
+
+        self.allow_mentions = allow_mentions
+        self.allow_links = allow_links
+        self.allow_images = allow_images
+        self.allow_blocks = allow_blocks

+ 9 - 0
misago/markup/flavours.py

@@ -28,3 +28,12 @@ def limited(text):
                    allow_images=False, allow_blocks=False)
                    allow_images=False, allow_blocks=False)
 
 
     return result['parsed_text']
     return result['parsed_text']
+
+
+def signature(text, owner=None):
+    result = parse(text, allow_mentions=False,
+                   allow_blocks=owner.acl['allow_signature_blocks'],
+                   allow_links=owner.acl['allow_signature_links'],
+                   allow_images=owner.acl['allow_signature_images'])
+
+    return result['parsed_text']

+ 0 - 0
misago/markup/templatetags/__init__.py


+ 23 - 0
misago/markup/templatetags/misago_editor.py

@@ -0,0 +1,23 @@
+from django import template
+from django.template import Context
+from django.template.loader import get_template
+
+
+register = template.Library()
+
+
+def _render_editor_template(context, editor, tpl):
+    c = Context(context)
+    c['editor'] =  editor
+
+    return get_template(tpl).render(c)
+
+
+def editor_body(context, editor):
+    return _render_editor_template(context, editor, editor.body_template)
+register.simple_tag(takes_context=True)(editor_body)
+
+
+def editor_js(context, editor):
+    return _render_editor_template(context, editor, editor.js_template)
+register.simple_tag(takes_context=True)(editor_js)

+ 1 - 0
misago/static/misago/css/misago/alerts.less

@@ -37,6 +37,7 @@
 
 
     &.affix {
     &.affix {
       top: 0;
       top: 0;
+      z-index: @zindex-navbar-fixed;
     }
     }
 
 
     .alert {
     .alert {

+ 100 - 0
misago/static/misago/css/misago/editor.less

@@ -0,0 +1,100 @@
+//
+// Misago Editor
+// --------------------------------------------------
+
+
+//== Toolbar
+//
+//**
+.misago-editor {
+  .editor-toolbar {
+    background: @gray-dark;
+    padding: @padding-small-vertical @padding-small-horizontal;
+
+    &>ul {
+      margin: 0px;
+      padding: 0px;
+      overflow: auto;
+
+      &>li {
+        padding: @padding-xs-vertical @padding-xs-horizontal;
+        float: left;
+
+        .btn-default {
+          background-color: transparent;
+          border-color: transparent;
+          .box-shadow(none);
+          padding: 4px 2px;
+
+          color: @gray-lighter;
+          text-shadow: 0px 1px 1px @gray-darker;
+
+          &:hover {
+            background-color: @gray-lighter;
+            border-color: @gray-lighter;
+            top: 0px;
+
+            color: @gray-darker;
+            outline: none;
+            text-shadow: none;
+          }
+
+          &.active, &:active {
+            background-color: @gray-darker;
+            border-color: @gray-darker;
+            top: 0px;
+
+            color: @gray-light;
+            outline: none;
+            text-shadow: none;
+          }
+
+          &:focus {
+            top: 0px;
+            outline: none;
+          }
+        }
+      }
+    }
+  }
+}
+
+
+//== Textarea
+//
+//**
+.misago-editor {
+  .editor-textarea {
+    textarea {
+      border: 0px;
+      width: 100%;
+      margin: 0px;
+      padding: @padding-large-vertical @padding-large-horizontal;
+
+      font-family: @font-family-monospace;
+      resize: none;
+      outline: none;
+    }
+  }
+}
+
+
+//== Footer
+//
+//**
+.misago-editor {
+  .editor-footer {
+
+  }
+}
+
+
+//== File uploads
+//
+//**
+.misago-editor {
+  .editor-upload {
+    position: fixed;
+    top: -1000px;
+  }
+}

+ 2 - 24
misago/static/misago/css/misago/forms.less

@@ -168,31 +168,9 @@
       border-top: 1px solid @form-panel-border;
       border-top: 1px solid @form-panel-border;
       padding: @form-panel-padding;
       padding: @form-panel-padding;
       padding-top: @line-height-computed;
       padding-top: @line-height-computed;
-      padding-bottom: 0px;
-    }
-  }
-}
-
-
-// Form permissions
-//
-//**
-.form-panel {
-  .table-permissions {
-    margin: 0px;
-
-    tr {
-      label {
-        margin: 0px;
-      }
-
-      p, .form-group {
-        margin-bottom: 0px;
-      }
 
 
-      td:first-child, th:first-child {
-        padding-left: @form-panel-padding-vertical;
-        vertical-align: middle;
+      .misago-markdown {
+        margin-bottom: @line-height-computed;
       }
       }
     }
     }
   }
   }

+ 1 - 0
misago/static/misago/css/misago/misago.less

@@ -2,6 +2,7 @@
 @import "alerts.less";
 @import "alerts.less";
 @import "buttons.less";
 @import "buttons.less";
 @import "dropdowns.less";
 @import "dropdowns.less";
+@import "editor.less";
 @import "navs.less";
 @import "navs.less";
 @import "modals.less";
 @import "modals.less";
 @import "markup.less";
 @import "markup.less";

+ 272 - 0
misago/static/misago/js/jquery.autosize.js

@@ -0,0 +1,272 @@
+/*!
+	Autosize v1.18.9 - 2014-05-27
+	Automatically adjust textarea height based on user input.
+	(c) 2014 Jack Moore - http://www.jacklmoore.com/autosize
+	license: http://www.opensource.org/licenses/mit-license.php
+*/
+(function ($) {
+	var
+	defaults = {
+		className: 'autosizejs',
+		id: 'autosizejs',
+		append: '\n',
+		callback: false,
+		resizeDelay: 10,
+		placeholder: true
+	},
+
+	// border:0 is unnecessary, but avoids a bug in Firefox on OSX
+	copy = '<textarea tabindex="-1" style="position:absolute; top:-999px; left:0; right:auto; bottom:auto; border:0; padding: 0; -moz-box-sizing:content-box; -webkit-box-sizing:content-box; box-sizing:content-box; word-wrap:break-word; height:0 !important; min-height:0 !important; overflow:hidden; transition:none; -webkit-transition:none; -moz-transition:none;"/>',
+
+	// line-height is conditionally included because IE7/IE8/old Opera do not return the correct value.
+	typographyStyles = [
+		'fontFamily',
+		'fontSize',
+		'fontWeight',
+		'fontStyle',
+		'letterSpacing',
+		'textTransform',
+		'wordSpacing',
+		'textIndent'
+	],
+
+	// to keep track which textarea is being mirrored when adjust() is called.
+	mirrored,
+
+	// the mirror element, which is used to calculate what size the mirrored element should be.
+	mirror = $(copy).data('autosize', true)[0];
+
+	// test that line-height can be accurately copied.
+	mirror.style.lineHeight = '99px';
+	if ($(mirror).css('lineHeight') === '99px') {
+		typographyStyles.push('lineHeight');
+	}
+	mirror.style.lineHeight = '';
+
+	$.fn.autosize = function (options) {
+		if (!this.length) {
+			return this;
+		}
+
+		options = $.extend({}, defaults, options || {});
+
+		if (mirror.parentNode !== document.body) {
+			$(document.body).append(mirror);
+		}
+
+		return this.each(function () {
+			var
+			ta = this,
+			$ta = $(ta),
+			maxHeight,
+			minHeight,
+			boxOffset = 0,
+			callback = $.isFunction(options.callback),
+			originalStyles = {
+				height: ta.style.height,
+				overflow: ta.style.overflow,
+				overflowY: ta.style.overflowY,
+				wordWrap: ta.style.wordWrap,
+				resize: ta.style.resize
+			},
+			timeout,
+			width = $ta.width(),
+			taResize = $ta.css('resize');
+
+			if ($ta.data('autosize')) {
+				// exit if autosize has already been applied, or if the textarea is the mirror element.
+				return;
+			}
+			$ta.data('autosize', true);
+
+			if ($ta.css('box-sizing') === 'border-box' || $ta.css('-moz-box-sizing') === 'border-box' || $ta.css('-webkit-box-sizing') === 'border-box'){
+				boxOffset = $ta.outerHeight() - $ta.height();
+			}
+
+			// IE8 and lower return 'auto', which parses to NaN, if no min-height is set.
+			minHeight = Math.max(parseInt($ta.css('minHeight'), 10) - boxOffset || 0, $ta.height());
+
+			$ta.css({
+				overflow: 'hidden',
+				overflowY: 'hidden',
+				wordWrap: 'break-word' // horizontal overflow is hidden, so break-word is necessary for handling words longer than the textarea width
+			});
+
+			if (taResize === 'vertical') {
+				$ta.css('resize','none');
+			} else if (taResize === 'both') {
+				$ta.css('resize', 'horizontal');
+			}
+
+			// The mirror width must exactly match the textarea width, so using getBoundingClientRect because it doesn't round the sub-pixel value.
+			// window.getComputedStyle, getBoundingClientRect returning a width are unsupported, but also unneeded in IE8 and lower.
+			function setWidth() {
+				var width;
+				var style = window.getComputedStyle ? window.getComputedStyle(ta, null) : false;
+				
+				if (style) {
+
+					width = ta.getBoundingClientRect().width;
+
+					if (width === 0 || typeof width !== 'number') {
+						width = parseInt(style.width,10);
+					}
+
+					$.each(['paddingLeft', 'paddingRight', 'borderLeftWidth', 'borderRightWidth'], function(i,val){
+						width -= parseInt(style[val],10);
+					});
+				} else {
+					width = $ta.width();
+				}
+
+				mirror.style.width = Math.max(width,0) + 'px';
+			}
+
+			function initMirror() {
+				var styles = {};
+
+				mirrored = ta;
+				mirror.className = options.className;
+				mirror.id = options.id;
+				maxHeight = parseInt($ta.css('maxHeight'), 10);
+
+				// mirror is a duplicate textarea located off-screen that
+				// is automatically updated to contain the same text as the
+				// original textarea.  mirror always has a height of 0.
+				// This gives a cross-browser supported way getting the actual
+				// height of the text, through the scrollTop property.
+				$.each(typographyStyles, function(i,val){
+					styles[val] = $ta.css(val);
+				});
+				
+				$(mirror).css(styles).attr('wrap', $ta.attr('wrap'));
+
+				setWidth();
+
+				// Chrome-specific fix:
+				// When the textarea y-overflow is hidden, Chrome doesn't reflow the text to account for the space
+				// made available by removing the scrollbar. This workaround triggers the reflow for Chrome.
+				if (window.chrome) {
+					var width = ta.style.width;
+					ta.style.width = '0px';
+					var ignore = ta.offsetWidth;
+					ta.style.width = width;
+				}
+			}
+
+			// Using mainly bare JS in this function because it is going
+			// to fire very often while typing, and needs to very efficient.
+			function adjust() {
+				var height, original;
+
+				if (mirrored !== ta) {
+					initMirror();
+				} else {
+					setWidth();
+				}
+
+				if (!ta.value && options.placeholder) {
+					// If the textarea is empty, copy the placeholder text into 
+					// the mirror control and use that for sizing so that we 
+					// don't end up with placeholder getting trimmed.
+					mirror.value = ($ta.attr("placeholder") || '') + options.append;
+				} else {
+					mirror.value = ta.value + options.append;
+				}
+
+				mirror.style.overflowY = ta.style.overflowY;
+				original = parseInt(ta.style.height,10);
+
+				// Setting scrollTop to zero is needed in IE8 and lower for the next step to be accurately applied
+				mirror.scrollTop = 0;
+
+				mirror.scrollTop = 9e4;
+
+				// Using scrollTop rather than scrollHeight because scrollHeight is non-standard and includes padding.
+				height = mirror.scrollTop;
+
+				if (maxHeight && height > maxHeight) {
+					ta.style.overflowY = 'scroll';
+					height = maxHeight;
+				} else {
+					ta.style.overflowY = 'hidden';
+					if (height < minHeight) {
+						height = minHeight;
+					}
+				}
+
+				height += boxOffset;
+
+				if (original !== height) {
+					ta.style.height = height + 'px';
+					if (callback) {
+						options.callback.call(ta,ta);
+					}
+				}
+			}
+
+			function resize () {
+				clearTimeout(timeout);
+				timeout = setTimeout(function(){
+					var newWidth = $ta.width();
+
+					if (newWidth !== width) {
+						width = newWidth;
+						adjust();
+					}
+				}, parseInt(options.resizeDelay,10));
+			}
+
+			if ('onpropertychange' in ta) {
+				if ('oninput' in ta) {
+					// Detects IE9.  IE9 does not fire onpropertychange or oninput for deletions,
+					// so binding to onkeyup to catch most of those occasions.  There is no way that I
+					// know of to detect something like 'cut' in IE9.
+					$ta.on('input.autosize keyup.autosize', adjust);
+				} else {
+					// IE7 / IE8
+					$ta.on('propertychange.autosize', function(){
+						if(event.propertyName === 'value'){
+							adjust();
+						}
+					});
+				}
+			} else {
+				// Modern Browsers
+				$ta.on('input.autosize', adjust);
+			}
+
+			// Set options.resizeDelay to false if using fixed-width textarea elements.
+			// Uses a timeout and width check to reduce the amount of times adjust needs to be called after window resize.
+
+			if (options.resizeDelay !== false) {
+				$(window).on('resize.autosize', resize);
+			}
+
+			// Event for manual triggering if needed.
+			// Should only be needed when the value of the textarea is changed through JavaScript rather than user input.
+			$ta.on('autosize.resize', adjust);
+
+			// Event for manual triggering that also forces the styles to update as well.
+			// Should only be needed if one of typography styles of the textarea change, and the textarea is already the target of the adjust method.
+			$ta.on('autosize.resizeIncludeStyle', function() {
+				mirrored = null;
+				adjust();
+			});
+
+			$ta.on('autosize.destroy', function(){
+				mirrored = null;
+				clearTimeout(timeout);
+				$(window).off('resize', resize);
+				$ta
+					.off('autosize')
+					.off('.autosize')
+					.css(originalStyles)
+					.removeData('autosize');
+			});
+
+			// Call adjust in case the textarea already contains text.
+			adjust();
+		});
+	};
+}(window.jQuery || window.$)); // jQuery or jQuery-like library, such as Zepto

+ 133 - 0
misago/static/misago/js/misago-editor.js

@@ -0,0 +1,133 @@
+// Basic editor API
+function storeCaret(ftext) {
+  if (ftext.createTextRange) {
+    ftext.caretPos = document.selection.createRange().duplicate();
+  }
+}
+
+function SelectionRange(start, end) {
+  this.start = start;
+  this.end = end;
+}
+
+function getSelection(textId) {
+  ctrl = document.getElementById(textId);
+  if (document.selection) {
+    ctrl.focus();
+    var range = document.selection.createRange();
+    var length = range.text.length;
+    range.moveStart('character', -ctrl.value.length);
+    return new SelectionRange(range.text.length - length, range.text.length);
+  } else if (ctrl.selectionStart || ctrl.selectionStart == '0') {
+    return new SelectionRange(ctrl.selectionStart, ctrl.selectionEnd);
+  }
+}
+
+function getSelectionText(textId) {
+  var ctrl = document.getElementById(textId);
+  var text = ctrl.value;
+  myRange = getSelection(textId);
+  return $.trim(text.substring(myRange.start, myRange.end));
+}
+
+function setSelection(textId, SelectionRange) {
+  ctrl = document.getElementById(textId);
+  if (ctrl.setSelectionRange) {
+    ctrl.focus();
+    ctrl.setSelectionRange(SelectionRange.start, SelectionRange.end);
+  } else if (ctrl.createTextRange) {
+    var range = ctrl.createTextRange();
+    range.collapse(true);
+    range.moveStart('character', SelectionRange.start);
+    range.moveEnd('character', SelectionRange.end);
+    range.select();
+  }
+}
+
+function _makeWrap(textId, myRange, wrap_start, wrap_end) {
+  var ctrl = document.getElementById(textId);
+  var text = ctrl.value;
+  var startText = text.substring(0, myRange.start) + wrap_start;
+  var middleText = text.substring(myRange.start, myRange.end);
+  var endText = wrap_end + text.substring(myRange.end);
+  ctrl.value = startText + middleText + endText;
+  setSelection(textId, new SelectionRange(startText.length, startText.length + middleText.length));
+}
+
+function makeWrap(textId, wrap_start, wrap_end) {
+  _makeWrap(textId, getSelection(textId), wrap_start, wrap_end);
+}
+
+function _makeReplace(textId, myRange, replacement) {
+  var ctrl = document.getElementById(textId);
+  var text = ctrl.value;
+  var startText = text.substring(0, myRange.start);
+  var middleText = text.substring(myRange.start, myRange.end);
+  var endText = text.substring(myRange.end);
+  ctrl.value = text.substring(0, myRange.start) + replacement + text.substring(myRange.end);
+  setSelection(textId, new SelectionRange(startText.length + middleText.length, startText.length + middleText.length));
+}
+
+function makeReplace(textId, replacement) {
+  _makeReplace(textId, getSelection(textId), replacement);
+}
+
+var url_pattern = new RegExp('^(https?:\\/\\/)?((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|((\\d{1,3}\\.){3}\\d{1,3}))(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*(\\?[;&a-z\\d%_.~+=-]*)?(\\#[-a-z\\d_]*)?$','i');
+function is_url(str) {
+  return url_pattern.test($.trim(str));
+}
+
+function extractor(query) {
+    var result = /([^\s]+)$/.exec(query);
+    if(result && result[1])
+        return result[1].trim();
+    return '';
+}
+
+
+// Enable editor
+function enable_editor(name) {
+  var $editor = $(name);
+  var $textarea = $editor.find('textarea');
+  var $upload = $editor.find('.editor-upload');
+
+  $textarea.autosize();
+
+  var textarea_id = $textarea.attr('id');
+  $editor.find('.btn-bold').click(function() {
+    makeWrap(textarea_id, '[b]', '[/b]');
+    return false;
+  });
+
+  $editor.find('.btn-italic').click(function() {
+    makeWrap(textarea_id, '[i]', '[/i]');
+    return false;
+  });
+
+  $editor.find('.btn-underline').click(function() {
+    makeWrap(textarea_id, '[u]', '[/u]');
+    return false;
+  });
+
+  $editor.find('.btn-insert-hr').click(function() {
+    makeReplace(textarea_id, '\r\n\r\n- - - - -\r\n\r\n');
+    return false;
+  });
+
+  // File upload handler WIP
+  $editor.find('.btn-upload-file').click(function() {
+    $upload.click();
+  });
+
+  $upload.on("change", function() {
+    if (this.files[0]) {
+      var uploaded_file = this.files[0];
+
+      var reader = new FileReader();
+      reader.onloadend = function() {
+        $editor.append('<img src="' + reader.result + '">');
+      }
+      reader.readAsDataURL(uploaded_file)
+    }
+  });
+};

+ 2 - 0
misago/templates/bootstrap3/layout/radioselect.html

@@ -10,5 +10,7 @@
         </label>
         </label>
     {% endfor %}
     {% endfor %}
 
 
+    {% if not hide_help_text %}
     {% include 'bootstrap3/layout/help_text.html' %}
     {% include 'bootstrap3/layout/help_text.html' %}
+    {% endif %}
 </div>
 </div>

+ 1 - 1
misago/templates/misago/admin/permissions_table.html

@@ -18,7 +18,7 @@
           {% endif %}
           {% endif %}
 
 
           {% if field|is_radioselect %}
           {% if field|is_radioselect %}
-            {% include 'bootstrap3/layout/radioselect.html' %}
+            {% include 'bootstrap3/layout/radioselect.html' with hide_help_text=1 %}
           {% endif %}
           {% endif %}
 
 
           {% if not field|is_checkboxselectmultiple and not field|is_radioselect %}
           {% if not field|is_checkboxselectmultiple and not field|is_radioselect %}

+ 9 - 0
misago/templates/misago/admin/users/edit.html

@@ -51,6 +51,15 @@ class="form-horizontal"
     {% form_row form.new_password label_class field_class %}
     {% form_row form.new_password label_class field_class %}
 
 
   </fieldset>
   </fieldset>
+  <fieldset>
+    <legend>{% trans "Signature" %}</legend>
+
+    {% form_row form.signature label_class field_class %}
+    {% form_row form.is_signature_banned label_class field_class %}
+    {% form_row form.signature_ban_user_message label_class field_class %}
+    {% form_row form.signature_ban_staff_message label_class field_class %}
+
+  </fieldset>
   {% endwith %}
   {% endwith %}
 </div>
 </div>
 {% endblock form-body %}
 {% endblock form-body %}

+ 1 - 1
misago/templates/misago/auth_form_errors.html

@@ -8,7 +8,7 @@
     {% if form.user_ban and form.user_ban.user_message %}
     {% if form.user_ban and form.user_ban.user_message %}
     <li>
     <li>
       <small>
       <small>
-        {{ form.user_ban.user_message|escape|linebreaksbr|urlize }}
+        {{ form.user_ban.user_message|escape|urlize|linebreaksbr }}
       </small>
       </small>
     </li>
     </li>
     {% elif form.user_cache.activation_by_user %}
     {% elif form.user_cache.activation_by_user %}

+ 73 - 0
misago/templates/misago/editor/body.html

@@ -0,0 +1,73 @@
+{% load i18n %}
+<div id="{{ editor.auto_id }}" class="misago-editor">
+  <div class="editor-toolbar">
+    <ul class="list-unstyled">
+      <li>
+        <button type="button" class="btn btn-default tooltip-top btn-bold" title="{% trans "Bold" %}">
+          <span class="fa fa-bold fa-fw fa-lg"></span>
+        </button>
+      </li>
+      <li>
+        <button type="button" class="btn btn-default tooltip-top btn-italic" title="{% trans "Italic" %}">
+          <span class="fa fa-italic fa-fw fa-lg"></span>
+        </button>
+      </li>
+      <li>
+        <button type="button" class="btn btn-default tooltip-top btn-underline" title="{% trans "Underline" %}">
+          <span class="fa fa-underline fa-fw fa-lg"></span>
+        </button>
+      </li>
+      {% if editor.allow_links or editor.allow_links or editor.allow_links %}
+      <li class="separator"></li>
+      {% endif %}
+      {% if editor.allow_links %}
+      <li>
+        <button type="button" class="btn btn-default tooltip-top btn-insert-link" title="{% trans "Insert link" %}">
+          <span class="fa fa-link fa-fw fa-lg"></span>
+        </button>
+      </li>
+      {% endif %}
+      {% if editor.allow_images %}
+      <li>
+        <button type="button" class="btn btn-default tooltip-top btn-insert-image" title="{% trans "Insert image" %}">
+          <span class="fa fa-image fa-fw fa-lg"></span>
+        </button>
+      </li>
+      {% endif %}
+      {% if editor.uploads_url %}
+      <li>
+        <button type="button" class="btn btn-default tooltip-top btn-upload-file" title="{% trans "Insert file" %}">
+          <span class="fa fa-paperclip fa-fw fa-lg"></span>
+        </button>
+      </li>
+      {% endif %}
+      {% if editor.allow_blocks %}
+      <li class="separator"></li>
+      <li>
+        <button type="button" class="btn btn-default tooltip-top btn-insert-quote" title="{% trans "Quote" %}">
+          <span class="fa fa-quote-left fa-fw fa-lg"></span>
+        </button>
+      </li>
+      <li>
+        <button type="button" class="btn btn-default tooltip-top btn-insert-code" title="{% trans "Code" %}">
+          <span class="fa fa-code fa-fw fa-lg"></span>
+        </button>
+      </li>
+      <li class="separator"></li>
+      <li>
+        <button type="button" class="btn btn-default tooltip-top btn-insert-hr" title="{% trans "Horizontal line" %}">
+          <span class="fa fa-minus fa-fw fa-lg"></span>
+        </button>
+      </li>
+      {% endif %}
+    </ul>
+  </div>
+
+  <div class="editor-textarea">
+    <textarea id="{{ editor.field.auto_id }}" name="{{ editor.field.html_name }}" rows="5">{% if editor.field.value %}{{ editor.field.value }}{% endif %}</textarea>
+  </div>
+
+  <div class="editor-footer">
+  </div>
+  <input type="file" class="editor-upload">
+</div>

+ 7 - 0
misago/templates/misago/editor/js.html

@@ -0,0 +1,7 @@
+{% load compressed i18n staticfiles %}
+{% compressed_js 'misago_editor' %}
+<script lang="JavaScript">
+$(function() {
+  enable_editor("#{{ editor.auto_id }}");
+});
+</script>

+ 2 - 2
misago/templates/misago/errorpages/403.html

@@ -1,5 +1,5 @@
 {% extends "misago/base.html" %}
 {% extends "misago/base.html" %}
-{% load i18n misago_stringutils %}
+{% load i18n %}
 
 
 
 
 {% block title %}{% trans "Page not available" %} | {{ block.super }}{% endblock %}
 {% block title %}{% trans "Page not available" %} | {{ block.super }}{% endblock %}
@@ -18,7 +18,7 @@
     </div>
     </div>
 
 
     {% if message %}
     {% if message %}
-    <h1>{{ message|linebreaksbr|linkify }}</h1>
+    <h1>{{ message|urlize|linebreaksbr }}</h1>
     {% else %}
     {% else %}
     <h1>{% trans "You don't have permission to access this page." %}</h1>
     <h1>{% trans "You don't have permission to access this page." %}</h1>
     {% endif %}
     {% endif %}

+ 2 - 2
misago/templates/misago/legal/privacy_policy.html

@@ -16,10 +16,10 @@
 </div>
 </div>
 
 
 <div class="container">
 <div class="container">
-  <div class="misago-markup">
+  <article class="misago-markup">
 
 
     {{ content|safe }}
     {{ content|safe }}
 
 
-  </div>
+  </article>
 </div>
 </div>
 {% endblock content %}
 {% endblock content %}

+ 2 - 2
misago/templates/misago/legal/terms_of_service.html

@@ -16,10 +16,10 @@
 </div>
 </div>
 
 
 <div class="container">
 <div class="container">
-  <div class="misago-markup">
+  <article class="misago-markup">
 
 
     {{ content|safe }}
     {{ content|safe }}
 
 
-  </div>
+  </article>
 </div>
 </div>
 {% endblock content %}
 {% endblock content %}

+ 63 - 0
misago/templates/misago/usercp/edit_signature.html

@@ -0,0 +1,63 @@
+{% extends "misago/usercp/base.html" %}
+{% load i18n misago_editor %}
+
+
+{% block page %}
+<div class="form-panel">
+  <form method="POST" role="form" class="form-horizontal">
+    {% csrf_token %}
+
+    <div class="form-header">
+      <h2>
+        <span class="{{ active_page.icon }}"></span>
+        {{ active_page.name }}
+      </h2>
+    </div>
+
+    {% include "misago/form_errors.html" %}
+
+    {% if user.signature %}
+    <div class="form-body message">
+      {% if user.has_valid_signature %}
+      <article class="misago-markup">
+        {{ user.signature_parsed|safe }}
+      </article>
+      {% else %}
+      <p class="lead text-danger">
+        <span class="fa fa-exclamation-triangle"></span>
+        {% trans "Signature is corrupted and can't be displayed." %}
+      </p>
+      {% endif %}
+    </div>
+    {% endif %}
+
+    {% if user.is_signature_banned %}
+    <div class="form-body message">
+      <div class="lead">
+        {% if user.signature_ban_user_message %}
+        {{ user.signature_ban_user_message|escape|urlize|linebreaks }}
+        {% else %}
+        <p>{% trans "You have been banned from editing your signature." %}</p>
+        {% endif %}
+      </div>
+    </div>
+    {% else %}
+    <div class="form-body">
+
+      {% editor_body editor %}
+
+    </div>
+
+    <div class="form-footer">
+      <button class="btn btn-primary">{% trans "Save changes" %}</button>
+    </div>
+    {% endif %}
+
+  </form>
+</div>
+{% endblock page %}
+
+
+{% block javascripts %}
+{% editor_js editor %}
+{% endblock javascripts %}

+ 7 - 0
misago/users/apps.py

@@ -14,9 +14,16 @@ class MisagoUsersConfig(AppConfig):
         self.register_default_user_profile_pages()
         self.register_default_user_profile_pages()
 
 
     def register_default_usercp_pages(self):
     def register_default_usercp_pages(self):
+        def show_signature_cp(request):
+            return request.user.acl['can_have_signature']
+
         usercp.add_page(link='misago:usercp_change_forum_options',
         usercp.add_page(link='misago:usercp_change_forum_options',
                         name=_('Change forum options'),
                         name=_('Change forum options'),
                         icon='fa fa-check-square-o')
                         icon='fa fa-check-square-o')
+        usercp.add_page(link='misago:usercp_edit_signature',
+                        name=_('Edit your signature'),
+                        icon='fa fa-pencil',
+                        visibility_condition=show_signature_cp)
         usercp.add_page(link='misago:usercp_change_username',
         usercp.add_page(link='misago:usercp_change_username',
                         name=_('Change username'),
                         name=_('Change username'),
                         icon='fa fa-credit-card')
                         icon='fa fa-credit-card')

+ 38 - 2
misago/users/forms/admin.py

@@ -1,6 +1,7 @@
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
-from django.utils.translation import ugettext_lazy as _
+from django.utils.translation import ugettext_lazy as _, ungettext
 
 
+from misago.conf import settings
 from misago.core import forms, threadstore
 from misago.core import forms, threadstore
 from misago.core.validators import validate_sluggable
 from misago.core.validators import validate_sluggable
 from misago.acl.models import Role
 from misago.acl.models import Role
@@ -70,9 +71,44 @@ class EditUserForm(UserBaseForm):
         widget=forms.PasswordInput,
         widget=forms.PasswordInput,
         required=False)
         required=False)
 
 
+    signature = forms.CharField(
+        label=_("Signature contents"),
+        widget=forms.Textarea(attrs={'rows': 3}),
+        required=False)
+    is_signature_banned = forms.YesNoSwitch(
+        label=_("Ban editing signature"),
+        help_text=_("Changing this to yes will ban user from "
+                    "making changes to his/her signature."))
+    signature_ban_user_message = forms.CharField(
+        label=_("User ban message"),
+        help_text=_("Optional message for user explaining "
+                    "why he/she is banned form editing signature."),
+        widget=forms.Textarea(attrs={'rows': 3}),
+        required=False)
+    signature_ban_staff_message = forms.CharField(
+        label=_("Staff ban message"),
+        help_text=_("Optional message for forum team members explaining "
+                    "why user is banned form editing signature."),
+        widget=forms.Textarea(attrs={'rows': 3}),
+        required=False)
+
     class Meta:
     class Meta:
         model = get_user_model()
         model = get_user_model()
-        fields = ['username', 'email', 'title']
+        fields = ['username', 'email', 'title', 'signature',
+                  'is_signature_banned', 'signature_ban_user_message',
+                  'signature_ban_staff_message']
+
+    def clean_signature(self):
+        data = self.cleaned_data['signature']
+
+        length_limit = settings.signature_length_max
+        if len(data) > length_limit:
+            raise forms.ValidationError(ungettext(
+                "Signature can't be longer than %(limit)s character.",
+                "Signature can't be longer than %(limit)s characters.",
+                length_limit) % {'limit': length_limit})
+
+        return data
 
 
 
 
 def UserFormFactory(FormType, instance):
 def UserFormFactory(FormType, instance):

+ 23 - 1
misago/users/forms/usercp.py

@@ -1,7 +1,9 @@
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
-from django.utils.translation import ugettext_lazy as _
+from django.utils.translation import ugettext_lazy as _, ungettext
 
 
+from misago.conf import settings
 from misago.core import forms, timezones
 from misago.core import forms, timezones
+
 from misago.users.models import AUTO_SUBSCRIBE_CHOICES
 from misago.users.models import AUTO_SUBSCRIBE_CHOICES
 from misago.users.validators import (validate_email, validate_password,
 from misago.users.validators import (validate_email, validate_password,
                                      validate_username)
                                      validate_username)
@@ -44,6 +46,26 @@ def ChangeForumOptionsForm(*args, **kwargs):
     return FinalFormType(*args, **kwargs)
     return FinalFormType(*args, **kwargs)
 
 
 
 
+class EditSignatureForm(forms.ModelForm):
+    signature = forms.CharField(label=_("Signature"), required=False)
+
+    class Meta:
+        model = get_user_model()
+        fields = ['signature']
+
+    def clean(self):
+        data = super(EditSignatureForm, self).clean()
+
+        length_limit = settings.signature_length_max
+        if len(data) > length_limit:
+            raise forms.ValidationError(ungettext(
+                "Signature can't be longer than %(limit)s character.",
+                "Signature can't be longer than %(limit)s characters.",
+                length_limit) % {'limit': length_limit})
+
+        return data
+
+
 class ChangeUsernameForm(forms.Form):
 class ChangeUsernameForm(forms.Form):
     new_username = forms.CharField(label=_("New username"), max_length=200,
     new_username = forms.CharField(label=_("New username"), max_length=200,
                                    required=False)
                                    required=False)

+ 2 - 1
misago/users/migrations/0001_initial.py

@@ -50,7 +50,8 @@ class Migration(migrations.Migration):
                 ('avatar_ban_staff_message', models.TextField(null=True, blank=True)),
                 ('avatar_ban_staff_message', models.TextField(null=True, blank=True)),
                 ('is_signature_banned', models.BooleanField(default=False)),
                 ('is_signature_banned', models.BooleanField(default=False)),
                 ('signature', models.TextField(null=True, blank=True)),
                 ('signature', models.TextField(null=True, blank=True)),
-                ('signature_preparsed', models.TextField(null=True, blank=True)),
+                ('signature_parsed', models.TextField(null=True, blank=True)),
+                ('signature_checksum', models.CharField(max_length=64, null=True, blank=True)),
                 ('signature_ban_user_message', models.TextField(null=True, blank=True)),
                 ('signature_ban_user_message', models.TextField(null=True, blank=True)),
                 ('signature_ban_staff_message', models.TextField(null=True, blank=True)),
                 ('signature_ban_staff_message', models.TextField(null=True, blank=True)),
                 ('warning_level', models.PositiveIntegerField(default=0)),
                 ('warning_level', models.PositiveIntegerField(default=0)),

+ 12 - 0
misago/users/migrations/0002_users_settings.py

@@ -117,6 +117,18 @@ def create_users_settings_group(apps, schema_editor):
                     },
                     },
                 },
                 },
                 {
                 {
+                    'setting': 'signature_length_max',
+                    'name': _("Maximum length"),
+                    'legend': _("Signatures"),
+                    'description': _("Maximum allowed signature length."),
+                    'python_type': 'int',
+                    'value': 1048,
+                    'field_extra': {
+                        'min_value': 256,
+                        'max_value': 10000,
+                    },
+                },
+                {
                     'setting': 'subscribe_start',
                     'setting': 'subscribe_start',
                     'name': _("Started threads"),
                     'name': _("Started threads"),
                     'legend': _("Default subscriptions settings"),
                     'legend': _("Default subscriptions settings"),

+ 7 - 1
misago/users/models/user.py

@@ -15,6 +15,7 @@ from misago.core.utils import slugify
 
 
 from misago.users.models.rank import Rank
 from misago.users.models.rank import Rank
 from misago.users.signals import username_changed
 from misago.users.signals import username_changed
+from misago.users.signatures import is_user_signature_valid
 from misago.users.utils import hash_email
 from misago.users.utils import hash_email
 
 
 
 
@@ -175,7 +176,8 @@ class User(AbstractBaseUser, PermissionsMixin):
 
 
     is_signature_banned = models.BooleanField(default=False)
     is_signature_banned = models.BooleanField(default=False)
     signature = models.TextField(null=True, blank=True)
     signature = models.TextField(null=True, blank=True)
-    signature_preparsed = models.TextField(null=True, blank=True)
+    signature_parsed = models.TextField(null=True, blank=True)
+    signature_checksum = models.CharField(max_length=64, null=True, blank=True)
     signature_ban_user_message = models.TextField(null=True, blank=True)
     signature_ban_user_message = models.TextField(null=True, blank=True)
     signature_ban_staff_message = models.TextField(null=True, blank=True)
     signature_ban_staff_message = models.TextField(null=True, blank=True)
 
 
@@ -238,6 +240,10 @@ class User(AbstractBaseUser, PermissionsMixin):
         else:
         else:
             return 0
             return 0
 
 
+    @property
+    def has_valid_signature(self):
+        return is_user_signature_valid(self)
+
     @staff_level.setter
     @staff_level.setter
     def staff_level(self, new_level):
     def staff_level(self, new_level):
         if new_level == 2:
         if new_level == 2:

+ 10 - 2
misago/users/permissions/account.py

@@ -17,7 +17,9 @@ class PermissionsForm(forms.Form):
         initial=1)
         initial=1)
     name_changes_expire = forms.IntegerField(
     name_changes_expire = forms.IntegerField(
         label=_("Don't count username changes older than"),
         label=_("Don't count username changes older than"),
-        help_text=_("Number of days since name change that makes that change no longer count to limit. Enter zero to make all changes count."),
+        help_text=_("Number of days since name change that makes "
+                    "that change no longer count to limit. Enter "
+                    "zero to make all changes count."),
         min_value=0,
         min_value=0,
         initial=0)
         initial=0)
     can_have_signature = forms.YesNoSwitch(
     can_have_signature = forms.YesNoSwitch(
@@ -26,6 +28,10 @@ class PermissionsForm(forms.Form):
         label=_("Can put links in signature"))
         label=_("Can put links in signature"))
     allow_signature_images = forms.YesNoSwitch(
     allow_signature_images = forms.YesNoSwitch(
         label=_("Can put images in signature"))
         label=_("Can put images in signature"))
+    allow_signature_blocks = forms.YesNoSwitch(
+        label=_("Can use text blocks in signature"),
+        help_text=_("Controls whether or not users can put quote, code, "
+                    "spoiler blocks and horizontal lines in signatures."))
 
 
 
 
 def change_permissions_form(role):
 def change_permissions_form(role):
@@ -45,6 +51,7 @@ def build_acl(acl, roles, key_name):
         'can_have_signature': 0,
         'can_have_signature': 0,
         'allow_signature_links': 0,
         'allow_signature_links': 0,
         'allow_signature_images': 0,
         'allow_signature_images': 0,
+        'allow_signature_blocks': 0,
     }
     }
     new_acl.update(acl)
     new_acl.update(acl)
 
 
@@ -54,5 +61,6 @@ def build_acl(acl, roles, key_name):
             name_changes_expire=algebra.lower,
             name_changes_expire=algebra.lower,
             can_have_signature=algebra.greater,
             can_have_signature=algebra.greater,
             allow_signature_links=algebra.greater,
             allow_signature_links=algebra.greater,
-            allow_signature_images=algebra.greater
+            allow_signature_images=algebra.greater,
+            allow_signature_blocks=algebra.greater
             )
             )

+ 24 - 0
misago/users/signatures.py

@@ -0,0 +1,24 @@
+from misago.markup import checksums, signature_flavour
+
+
+def set_user_signature(user, signature):
+    user.signature = signature
+
+    if signature:
+        user.signature_parsed = signature_flavour(signature, user)
+        user.signature_checksum = _make_checksum(user.signature_parsed, user)
+    else:
+        user.signature_parsed = ''
+        user.signature_checksum = ''
+
+
+def is_user_signature_valid(user):
+    if user.signature:
+        valid_checksum = _make_checksum(user.signature_parsed, user)
+        return user.signature_checksum == valid_checksum
+    else:
+        return False
+
+
+def _make_checksum(parsed_signature, user):
+    return checksums.make_checksum(parsed_signature, [user.pk])

+ 1 - 0
misago/users/urls.py

@@ -38,6 +38,7 @@ urlpatterns += patterns('misago.users.views.api',
 
 
 urlpatterns += patterns('misago.users.views.usercp',
 urlpatterns += patterns('misago.users.views.usercp',
     url(r'^usercp/forum-options/$', 'change_forum_options', name="usercp_change_forum_options"),
     url(r'^usercp/forum-options/$', 'change_forum_options', name="usercp_change_forum_options"),
+    url(r'^usercp/edit-signature/$', 'edit_signature', name="usercp_edit_signature"),
     url(r'^usercp/change-username/$', 'change_username', name="usercp_change_username"),
     url(r'^usercp/change-username/$', 'change_username', name="usercp_change_username"),
     url(r'^usercp/change-email-password/$', 'change_email_password', name="usercp_change_email_password"),
     url(r'^usercp/change-email-password/$', 'change_email_password', name="usercp_change_email_password"),
     url(r'^usercp/change-email-password/(?P<token>[a-zA-Z0-9]+)/$', 'confirm_email_password_change', name='usercp_confirm_email_password_change'),
     url(r'^usercp/change-email-password/(?P<token>[a-zA-Z0-9]+)/$', 'confirm_email_password_change', name='usercp_confirm_email_password_change'),

+ 3 - 0
misago/users/views/admin/users.py

@@ -11,6 +11,7 @@ from misago.core.mail import mail_users
 from misago.users.forms.admin import (StaffFlagUserFormFactory, NewUserForm,
 from misago.users.forms.admin import (StaffFlagUserFormFactory, NewUserForm,
                                       EditUserForm, SearchUsersForm)
                                       EditUserForm, SearchUsersForm)
 from misago.users.models import ACTIVATION_REQUIRED_NONE, User
 from misago.users.models import ACTIVATION_REQUIRED_NONE, User
+from misago.users.signatures import set_user_signature
 
 
 
 
 class UserAdmin(generic.AdminBaseMixin):
 class UserAdmin(generic.AdminBaseMixin):
@@ -140,6 +141,8 @@ class EditUser(UserAdmin, generic.ModelFormView):
         if form.cleaned_data.get('roles'):
         if form.cleaned_data.get('roles'):
             form.instance.roles.add(*form.cleaned_data['roles'])
             form.instance.roles.add(*form.cleaned_data['roles'])
 
 
+        set_user_signature(target, form.cleaned_data.get('signature'))
+
         form.instance.update_acl_key()
         form.instance.update_acl_key()
         form.instance.save()
         form.instance.save()
 
 

+ 33 - 0
misago/users/views/usercp.py

@@ -1,17 +1,21 @@
 from django.contrib import messages
 from django.contrib import messages
 from django.contrib.auth import update_session_auth_hash
 from django.contrib.auth import update_session_auth_hash
 from django.db import IntegrityError, transaction
 from django.db import IntegrityError, transaction
+from django.http import Http404
 from django.shortcuts import redirect, render as django_render
 from django.shortcuts import redirect, render as django_render
 from django.utils.translation import ugettext as _
 from django.utils.translation import ugettext as _
 from django.views.decorators.debug import sensitive_post_parameters
 from django.views.decorators.debug import sensitive_post_parameters
 
 
 from misago.conf import settings
 from misago.conf import settings
 from misago.core.mail import mail_user
 from misago.core.mail import mail_user
+from misago.markup import Editor
 
 
 from misago.users.decorators import deny_guests
 from misago.users.decorators import deny_guests
 from misago.users.forms.usercp import (ChangeForumOptionsForm,
 from misago.users.forms.usercp import (ChangeForumOptionsForm,
+                                       EditSignatureForm,
                                        ChangeUsernameForm,
                                        ChangeUsernameForm,
                                        ChangeEmailPasswordForm)
                                        ChangeEmailPasswordForm)
+from misago.users.signatures import set_user_signature
 from misago.users.sites import usercp
 from misago.users.sites import usercp
 from misago.users.changedcredentials import (cache_new_credentials,
 from misago.users.changedcredentials import (cache_new_credentials,
                                              get_new_credentials)
                                              get_new_credentials)
@@ -48,6 +52,35 @@ def change_forum_options(request):
 
 
 
 
 @deny_guests
 @deny_guests
+def edit_signature(request):
+    if not request.user.acl['can_have_signature']:
+        raise Http404()
+
+    form = EditSignatureForm(instance=request.user)
+    if not request.user.is_signature_banned and request.method == 'POST':
+        form = EditSignatureForm(request.POST, instance=request.user)
+        if form.is_valid():
+            set_user_signature(request.user, form.cleaned_data['signature'])
+            request.user.save(update_fields=['signature', 'signature_parsed',
+                                             'signature_checksum'])
+
+            if form.cleaned_data['signature']:
+                messages.success(request, _("Your signature has been edited."))
+            else:
+                message = _("Your signature has been cleared.")
+                messages.success(request, message)
+            return redirect('misago:usercp_edit_signature')
+
+    acl = request.user.acl
+    editor = Editor(form['signature'],
+                    allow_blocks=acl['allow_signature_blocks'],
+                    allow_links=acl['allow_signature_links'],
+                    allow_images=acl['allow_signature_images'])
+    return render(request, 'misago/usercp/edit_signature.html',
+                  {'form': form, 'editor': editor})
+
+
+@deny_guests
 @transaction.atomic()
 @transaction.atomic()
 def change_username(request):
 def change_username(request):
     namechanges = UsernameChanges(request.user)
     namechanges = UsernameChanges(request.user)