Rafał Pitoń 11 лет назад
Родитель
Сommit
829cb4cf73

+ 232 - 0
misago/static/misago/css/bootstrap-datetimepicker.less

@@ -0,0 +1,232 @@
+/*!
+ * Datetimepicker for Bootstrap v3
+ * https://github.com/Eonasdan/bootstrap-datetimepicker/
+ */
+.bootstrap-datetimepicker-widget {
+    top: 0;
+    left: 0;
+    width: 250px;
+    padding: 4px 8px;
+    margin-top: 1px;
+    z-index: 99999 !important;
+    border-radius: 4px;
+
+    &.timepicker-sbs {
+        width: 600px;
+    }
+
+    & .dow {
+        width: 14.2857%;
+    }
+
+    &.pull-right {
+        &:before {
+            left: auto;
+            right: 6px;
+        }
+
+        &:after {
+            left: auto;
+            right: 7px;
+        }
+    }
+
+    >ul {
+        list-style-type: none;
+        margin: 0;
+    }
+
+    a[data-action] {
+      padding: 6px 0;
+    }
+
+    .timepicker-hour, .timepicker-minute, .timepicker-second {
+        width: 54px;
+        font-weight: bold;
+        font-size: 1.2em;
+        margin: 0;
+    }
+
+    button[data-action] {
+      padding: 6px;
+    }
+
+    table[data-hour-format="12"] .separator {
+        width: 4px;
+        padding: 0;
+        margin: 0;
+    }
+
+    .datepicker > div {
+        display: none;
+    }
+
+    .picker-switch {
+        text-align: center;
+    }
+
+    table {
+        width: 100%;
+        margin: 0;
+    }
+
+    td,
+    th {
+        text-align: center;
+        border-radius: 4px;
+    }
+
+    td {
+        height: 54px;
+        line-height: 54px;
+        width: 54px;
+
+        &.day
+        {
+            height: 20px;
+            line-height: 20px;
+            width: 20px;
+        }
+
+        &.day:hover,
+        &.hour:hover,
+        &.minute:hover,
+        &.second:hover {
+            background: @gray-lighter;
+            cursor: pointer;
+        }
+
+        &.old,
+        &.new {
+            color: @gray-light;
+        }
+
+        &.today {
+            position: relative;
+
+            &:before {
+                content: '';
+                display: inline-block;
+                border-left: 7px solid transparent;
+                border-bottom: 7px solid @btn-primary-bg;
+                border-top-color: rgba(0, 0, 0, 0.2);
+                position: absolute;
+                bottom: 4px;
+                right: 4px;
+            }
+        }
+
+        &.active,
+        &.active:hover {
+            background-color: @btn-primary-bg;
+            color: #fff;
+            text-shadow: 0 -1px 0 rgba(0,0,0,.25);
+        }
+
+            &.active.today:before {
+                border-bottom-color: #fff;
+            }
+
+        &.disabled,
+        &.disabled:hover {
+            background: none;
+            color: @gray-light;
+            cursor: not-allowed;
+        }
+
+        span {
+            display: block;
+            width: 54px;
+            height: 54px;
+            line-height: 54px;
+            float: left;
+            margin: 2px 1.5px;
+            cursor: pointer;
+            border-radius: 4px;
+
+            &:hover {
+                background: @gray-lighter;
+            }
+
+            &.active {
+                background-color: @btn-primary-bg;
+                color: #fff;
+                text-shadow: 0 -1px 0 rgba(0,0,0,.25);
+            }
+
+            &.old {
+                color: @gray-light;
+            }
+
+            &.disabled,
+            &.disabled:hover {
+                background: none;
+                color: @gray-light;
+                cursor: not-allowed;
+            }
+        }
+    }
+
+    th {
+        height: 20px;
+        line-height: 20px;
+        width: 20px;
+
+        &.switch {
+            width: 145px;
+        }
+
+        &.next,
+        &.prev {
+            font-size: @font-size-base * 1.5;
+        }
+
+        &.disabled,
+        &.disabled:hover {
+            background: none;
+            color: @gray-light;
+            cursor: not-allowed;
+        }
+    }
+
+    thead tr:first-child th {
+        cursor: pointer;
+
+        &:hover {
+            background: @gray-lighter;
+        }
+    }
+}
+
+.input-group {
+    &.date {
+        .input-group-addon span {
+            display: block;
+            cursor: pointer;
+            width: 16px;
+            height: 16px;
+        }
+    }
+}
+
+.bootstrap-datetimepicker-widget.left-oriented {
+    &:before {
+        left: auto;
+        right: 6px;
+    }
+
+    &:after {
+        left: auto;
+        right: 7px;
+    }
+}
+
+.bootstrap-datetimepicker-widget ul.list-unstyled li div.timepicker div.timepicker-picker table.table-condensed tbody > tr > td {
+    padding: 0px !important;
+}
+
+@media screen and (max-width: 767px) {
+    .bootstrap-datetimepicker-widget.timepicker-sbs {
+        width: 283px;
+    }
+}

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

@@ -7,6 +7,7 @@
 @import "modals.less";
 @import "markup.less";
 @import "pager.less";
+@import "panels.less";
 @import "states.less";
 @import "typography.less";
 @import "yesnoswitch.less";

+ 11 - 0
misago/static/misago/css/misago/panels.less

@@ -0,0 +1,11 @@
+//
+// Panels
+// --------------------------------------------------
+
+
+// Shared styles
+//
+//==
+.panel {
+  box-shadow: none;
+}

+ 2 - 0
misago/static/misago/css/style.less

@@ -18,3 +18,5 @@
 @import "misago/misago.less";
 @import "flavor/flavor.less";
 
+// Bootstrap 3rd party libs
+@import "bootstrap-datetimepicker.less";

+ 105 - 0
misago/static/misago/js/bootstrap-datetimepicker.min.js

@@ -0,0 +1,105 @@
+/*
+Version 3.0.0
+=========================================================
+bootstrap-datetimepicker.js
+https://github.com/Eonasdan/bootstrap-datetimepicker
+=========================================================
+The MIT License (MIT)
+
+Copyright (c) 2014 Jonathan Peterson
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+*/
+!function(e){if("function"==typeof define&&define.amd)define(["jquery","moment"],e)
+else{if(!jQuery)throw"bootstrap-datetimepicker requires jQuery to be loaded first"
+if(!moment)throw"bootstrap-datetimepicker requires moment.js to be loaded first"
+e(jQuery,moment)}}(function(e,t){if(void 0===t)throw alert("momentjs is requried"),Error("momentjs is required")
+var a=0,o=t,n=function(t,n){var s={pickDate:!0,pickTime:!0,useMinutes:!0,useSeconds:!1,useCurrent:!0,minuteStepping:1,minDate:new o({y:1900}),maxDate:(new o).add(100,"y"),showToday:!0,collapse:!0,language:"en",defaultDate:"",disabledDates:!1,enabledDates:!1,icons:{},useStrict:!1,direction:"auto",sideBySide:!1,daysOfWeekDisabled:!1},d={time:"glyphicon glyphicon-time",date:"glyphicon glyphicon-calendar",up:"glyphicon glyphicon-chevron-up",down:"glyphicon glyphicon-chevron-down"},r=this,c=function(){var i,c=!1
+if(r.options=e.extend({},s,n),r.options.icons=e.extend({},d,r.options.icons),r.element=e(t),l(),!r.options.pickTime&&!r.options.pickDate)throw Error("Must choose at least one picker")
+if(r.id=a++,o.lang(r.options.language),r.date=o(),r.unset=!1,r.isInput=r.element.is("input"),r.component=!1,r.element.hasClass("input-group")&&(r.component=r.element.find(0==r.element.find(".datepickerbutton").size()?"[class^='input-group-']":".datepickerbutton")),r.format=r.options.format,i=o()._lang._longDateFormat,r.format||(r.format=r.options.pickDate?i.L:"",r.options.pickDate&&r.options.pickTime&&(r.format+=" "),r.format+=r.options.pickTime?i.LT:"",r.options.useSeconds&&(~i.LT.indexOf(" A")?r.format=r.format.split(" A")[0]+":ss A":r.format+=":ss")),r.use24hours=r.format.toLowerCase().indexOf("a")<1,r.component&&(c=r.component.find("span")),r.options.pickTime&&c&&c.addClass(r.options.icons.time),r.options.pickDate&&c&&(c.removeClass(r.options.icons.time),c.addClass(r.options.icons.date)),r.widget=e(W()).appendTo("body"),r.options.useSeconds&&!r.use24hours&&r.widget.width(300),r.minViewMode=r.options.minViewMode||0,"string"==typeof r.minViewMode)switch(r.minViewMode){case"months":r.minViewMode=1
+break
+case"years":r.minViewMode=2
+break
+default:r.minViewMode=0}if(r.viewMode=r.options.viewMode||0,"string"==typeof r.viewMode)switch(r.viewMode){case"months":r.viewMode=1
+break
+case"years":r.viewMode=2
+break
+default:r.viewMode=0}if(r.options.disabledDates=j(r.options.disabledDates),r.options.enabledDates=j(r.options.enabledDates),r.startViewMode=r.viewMode,r.setMinDate(r.options.minDate),r.setMaxDate(r.options.maxDate),g(),w(),k(),b(),y(),h(),P(),V(),""!==r.options.defaultDate&&""==p().val()&&r.setValue(r.options.defaultDate),1!==r.options.minuteStepping){var m=r.options.minuteStepping
+r.date.minutes(Math.round(r.date.minutes()/m)*m%60).seconds(0)}},p=function(){return r.isInput?r.element:dateStr=r.element.find("input")},l=function(){var e
+e=(r.element.is("input"),r.element.data()),void 0!==e.dateFormat&&(r.options.format=e.dateFormat),void 0!==e.datePickdate&&(r.options.pickDate=e.datePickdate),void 0!==e.datePicktime&&(r.options.pickTime=e.datePicktime),void 0!==e.dateUseminutes&&(r.options.useMinutes=e.dateUseminutes),void 0!==e.dateUseseconds&&(r.options.useSeconds=e.dateUseseconds),void 0!==e.dateUsecurrent&&(r.options.useCurrent=e.dateUsecurrent),void 0!==e.dateMinutestepping&&(r.options.minuteStepping=e.dateMinutestepping),void 0!==e.dateMindate&&(r.options.minDate=e.dateMindate),void 0!==e.dateMaxdate&&(r.options.maxDate=e.dateMaxdate),void 0!==e.dateShowtoday&&(r.options.showToday=e.dateShowtoday),void 0!==e.dateCollapse&&(r.options.collapse=e.dateCollapse),void 0!==e.dateLanguage&&(r.options.language=e.dateLanguage),void 0!==e.dateDefaultdate&&(r.options.defaultDate=e.dateDefaultdate),void 0!==e.dateDisableddates&&(r.options.disabledDates=e.dateDisableddates),void 0!==e.dateEnableddates&&(r.options.enabledDates=e.dateEnableddates),void 0!==e.dateIcons&&(r.options.icons=e.dateIcons),void 0!==e.dateUsestrict&&(r.options.useStrict=e.dateUsestrict),void 0!==e.dateDirection&&(r.options.direction=e.dateDirection),void 0!==e.dateSidebyside&&(r.options.sideBySide=e.dateSidebyside)},m=function(){var t="absolute",i=r.component?r.component.offset():r.element.offset(),a=e(window)
+r.width=r.component?r.component.outerWidth():r.element.outerWidth(),i.top=i.top+r.element.outerHeight()
+var o
+"up"===r.options.direction?o="top":"bottom"===r.options.direction?o="bottom":"auto"===r.options.direction&&(o=i.top+r.widget.height()>a.height()+a.scrollTop()&&r.widget.height()+r.element.outerHeight()<i.top?"top":"bottom"),"top"===o?(i.top-=r.widget.height()+r.element.outerHeight()+15,r.widget.addClass("top").removeClass("bottom")):(i.top+=1,r.widget.addClass("bottom").removeClass("top")),void 0!==r.options.width&&r.widget.width(r.options.width),"left"===r.options.orientation&&(r.widget.addClass("left-oriented"),i.left=i.left-r.widget.width()+20),Y()&&(t="fixed",i.top-=a.scrollTop(),i.left-=a.scrollLeft()),a.width()<i.left+r.widget.outerWidth()?(i.right=a.width()-i.left-r.width,i.left="auto",r.widget.addClass("pull-right")):(i.right="auto",r.widget.removeClass("pull-right")),r.widget.css({position:t,top:i.top,left:i.left,right:i.right})},u=function(e,t){o(r.date).isSame(o(e))||(r.element.trigger({type:"dp.change",date:o(r.date),oldDate:o(e)}),"change"!==t&&r.element.change())},f=function(e){r.element.trigger({type:"dp.error",date:o(e)})},h=function(e){o.lang(r.options.language)
+var t=e
+t||(t=p().val(),t&&(r.date=o(t,r.format,r.options.useStrict)),r.date||(r.date=o())),r.viewDate=o(r.date).startOf("month"),v(),D()},g=function(){o.lang(r.options.language)
+var t,i=e("<tr>"),a=o.weekdaysMin()
+if(0==o()._lang._week.dow)for(t=0;7>t;t++)i.append('<th class="dow">'+a[t]+"</th>")
+else for(t=1;8>t;t++)i.append(7==t?'<th class="dow">'+a[0]+"</th>":'<th class="dow">'+a[t]+"</th>")
+r.widget.find(".datepicker-days thead").append(i)},w=function(){o.lang(r.options.language)
+for(var e="",t=0,i=o.monthsShort();12>t;)e+='<span class="month">'+i[t++]+"</span>"
+r.widget.find(".datepicker-months td").append(e)},v=function(){o.lang(r.options.language)
+var t,i,a,n,s,d,c,p,l=r.viewDate.year(),m=r.viewDate.month(),u=r.options.minDate.year(),f=r.options.minDate.month(),h=r.options.maxDate.year(),g=r.options.maxDate.month(),w=[],v=o.months()
+for(r.widget.find(".datepicker-days").find(".disabled").removeClass("disabled"),r.widget.find(".datepicker-months").find(".disabled").removeClass("disabled"),r.widget.find(".datepicker-years").find(".disabled").removeClass("disabled"),r.widget.find(".datepicker-days th:eq(1)").text(v[m]+" "+l),t=o(r.viewDate).subtract("months",1),d=t.daysInMonth(),t.date(d).startOf("week"),(l==u&&f>=m||u>l)&&r.widget.find(".datepicker-days th:eq(0)").addClass("disabled"),(l==h&&m>=g||l>h)&&r.widget.find(".datepicker-days th:eq(2)").addClass("disabled"),i=o(t).add(42,"d");t.isBefore(i);){if(t.weekday()===o().startOf("week").weekday()&&(a=e("<tr>"),w.push(a)),n="",t.year()<l||t.year()==l&&t.month()<m?n+=" old":(t.year()>l||t.year()==l&&t.month()>m)&&(n+=" new"),t.isSame(o({y:r.date.year(),M:r.date.month(),d:r.date.date()}))&&(n+=" active"),(N(t)||!U(t))&&(n+=" disabled"),r.options.showToday===!0&&t.isSame(o(),"day")&&(n+=" today"),r.options.daysOfWeekDisabled)for(s in r.options.daysOfWeekDisabled)if(t.day()==r.options.daysOfWeekDisabled[s]){n+=" disabled"
+break}a.append('<td class="day'+n+'">'+t.date()+"</td>"),t.add(1,"d")}for(r.widget.find(".datepicker-days tbody").empty().append(w),p=r.date.year(),v=r.widget.find(".datepicker-months").find("th:eq(1)").text(l).end().find("span").removeClass("active"),p===l&&v.eq(r.date.month()).addClass("active"),u>p-1&&r.widget.find(".datepicker-months th:eq(0)").addClass("disabled"),p+1>h&&r.widget.find(".datepicker-months th:eq(2)").addClass("disabled"),s=0;12>s;s++)l==u&&f>s||u>l?e(v[s]).addClass("disabled"):(l==h&&s>g||l>h)&&e(v[s]).addClass("disabled")
+for(w="",l=10*parseInt(l/10,10),c=r.widget.find(".datepicker-years").find("th:eq(1)").text(l+"-"+(l+9)).end().find("td"),r.widget.find(".datepicker-years").find("th").removeClass("disabled"),u>l&&r.widget.find(".datepicker-years").find("th:eq(0)").addClass("disabled"),l+9>h&&r.widget.find(".datepicker-years").find("th:eq(2)").addClass("disabled"),l-=1,s=-1;11>s;s++)w+='<span class="year'+(-1===s||10===s?" old":"")+(p===l?" active":"")+(u>l||l>h?" disabled":"")+'">'+l+"</span>",l+=1
+c.html(w)},k=function(){o.lang(r.options.language)
+var e,t,i,a=r.widget.find(".timepicker .timepicker-hours table"),n=""
+if(a.parent().hide(),r.use24hours)for(e=0,t=0;6>t;t+=1){for(n+="<tr>",i=0;4>i;i+=1)n+='<td class="hour">'+F(""+e)+"</td>",e++
+n+="</tr>"}else for(e=1,t=0;3>t;t+=1){for(n+="<tr>",i=0;4>i;i+=1)n+='<td class="hour">'+F(""+e)+"</td>",e++
+n+="</tr>"}a.html(n)},b=function(){var e,t,i=r.widget.find(".timepicker .timepicker-minutes table"),a="",o=0,n=r.options.minuteStepping
+for(i.parent().hide(),1==n&&(n=5),e=0;e<Math.ceil(60/n/4);e++){for(a+="<tr>",t=0;4>t;t+=1)60>o?(a+='<td class="minute">'+F(""+o)+"</td>",o+=n):a+="<td></td>"
+a+="</tr>"}i.html(a)},y=function(){var e,t,i=r.widget.find(".timepicker .timepicker-seconds table"),a="",o=0
+for(i.parent().hide(),e=0;3>e;e++){for(a+="<tr>",t=0;4>t;t+=1)a+='<td class="second">'+F(""+o)+"</td>",o+=5
+a+="</tr>"}i.html(a)},D=function(){if(r.date){var e=r.widget.find(".timepicker span[data-time-component]"),t=r.date.hours(),i="AM"
+r.use24hours||(t>=12&&(i="PM"),0===t?t=12:12!=t&&(t%=12),r.widget.find(".timepicker [data-action=togglePeriod]").text(i)),e.filter("[data-time-component=hours]").text(F(t)),e.filter("[data-time-component=minutes]").text(F(r.date.minutes())),e.filter("[data-time-component=seconds]").text(F(r.date.second()))}},M=function(t){t.stopPropagation(),t.preventDefault(),r.unset=!1
+var i,a,n,s,d=e(t.target).closest("span, td, th"),c=o(r.date)
+if(1===d.length&&!d.is(".disabled"))switch(d[0].nodeName.toLowerCase()){case"th":switch(d[0].className){case"switch":P(1)
+break
+case"prev":case"next":n=B.modes[r.viewMode].navStep,"prev"===d[0].className&&(n=-1*n),r.viewDate.add(n,B.modes[r.viewMode].navFnc),v()}break
+case"span":d.is(".month")?(i=d.parent().find("span").index(d),r.viewDate.month(i)):(a=parseInt(d.text(),10)||0,r.viewDate.year(a)),r.viewMode===r.minViewMode&&(r.date=o({y:r.viewDate.year(),M:r.viewDate.month(),d:r.viewDate.date(),h:r.date.hours(),m:r.date.minutes(),s:r.date.seconds()}),u(c,t.type),O()),P(-1),v()
+break
+case"td":d.is(".day")&&(s=parseInt(d.text(),10)||1,i=r.viewDate.month(),a=r.viewDate.year(),d.is(".old")?0===i?(i=11,a-=1):i-=1:d.is(".new")&&(11==i?(i=0,a+=1):i+=1),r.date=o({y:a,M:i,d:s,h:r.date.hours(),m:r.date.minutes(),s:r.date.seconds()}),r.viewDate=o({y:a,M:i,d:Math.min(28,s)}),v(),O(),u(c,t.type))}},x={incrementHours:function(){L("add","hours",1)},incrementMinutes:function(){L("add","minutes",r.options.minuteStepping)},incrementSeconds:function(){L("add","seconds",1)},decrementHours:function(){L("subtract","hours",1)},decrementMinutes:function(){L("subtract","minutes",r.options.minuteStepping)},decrementSeconds:function(){L("subtract","seconds",1)},togglePeriod:function(){var e=r.date.hours()
+e>=12?e-=12:e+=12,r.date.hours(e)},showPicker:function(){r.widget.find(".timepicker > div:not(.timepicker-picker)").hide(),r.widget.find(".timepicker .timepicker-picker").show()},showHours:function(){r.widget.find(".timepicker .timepicker-picker").hide(),r.widget.find(".timepicker .timepicker-hours").show()},showMinutes:function(){r.widget.find(".timepicker .timepicker-picker").hide(),r.widget.find(".timepicker .timepicker-minutes").show()},showSeconds:function(){r.widget.find(".timepicker .timepicker-picker").hide(),r.widget.find(".timepicker .timepicker-seconds").show()},selectHour:function(t){var i=r.widget.find(".timepicker [data-action=togglePeriod]").text(),a=parseInt(e(t.target).text(),10)
+"PM"==i&&(a+=12),r.date.hours(a),x.showPicker.call(r)},selectMinute:function(t){r.date.minutes(parseInt(e(t.target).text(),10)),x.showPicker.call(r)},selectSecond:function(t){r.date.seconds(parseInt(e(t.target).text(),10)),x.showPicker.call(r)}},S=function(t){var i=o(r.date),a=e(t.currentTarget).data("action"),n=x[a].apply(r,arguments)
+return T(t),r.date||(r.date=o({y:1970})),O(),D(),u(i,t.type),n},T=function(e){e.stopPropagation(),e.preventDefault()},C=function(t){o.lang(r.options.language)
+var i=e(t.target),a=o(r.date),n=o(i.val(),r.format,r.options.useStrict)
+n.isValid()&&!N(n)&&U(n)?(h(),r.setValue(n),u(a,t.type),O()):(r.viewDate=a,u(a,t.type),f(n),r.unset=!0)},P=function(e){e&&(r.viewMode=Math.max(r.minViewMode,Math.min(2,r.viewMode+e)))
+B.modes[r.viewMode].clsName
+r.widget.find(".datepicker > div").hide().filter(".datepicker-"+B.modes[r.viewMode].clsName).show()},V=function(){var t,i,a,o,n
+r.widget.on("click",".datepicker *",e.proxy(M,this)),r.widget.on("click","[data-action]",e.proxy(S,this)),r.widget.on("mousedown",e.proxy(T,this)),r.options.pickDate&&r.options.pickTime&&r.widget.on("click.togglePicker",".accordion-toggle",function(s){if(s.stopPropagation(),t=e(this),i=t.closest("ul"),a=i.find(".in"),o=i.find(".collapse:not(.in)"),a&&a.length){if(n=a.data("collapse"),n&&n.date-transitioning)return
+a.collapse("hide"),o.collapse("show"),t.find("span").toggleClass(r.options.icons.time+" "+r.options.icons.date),r.element.find(".input-group-addon span").toggleClass(r.options.icons.time+" "+r.options.icons.date)}}),r.isInput?r.element.on({focus:e.proxy(r.show,this),change:e.proxy(C,this),blur:e.proxy(r.hide,this)}):(r.element.on({change:e.proxy(C,this)},"input"),r.component?r.component.on("click",e.proxy(r.show,this)):r.element.on("click",e.proxy(r.show,this)))},q=function(){e(window).on("resize.datetimepicker"+r.id,e.proxy(m,this)),r.isInput||e(document).on("mousedown.datetimepicker"+r.id,e.proxy(r.hide,this))},I=function(){r.widget.off("click",".datepicker *",r.click),r.widget.off("click","[data-action]"),r.widget.off("mousedown",r.stopEvent),r.options.pickDate&&r.options.pickTime&&r.widget.off("click.togglePicker"),r.isInput?r.element.off({focus:r.show,change:r.change}):(r.element.off({change:r.change},"input"),r.component?r.component.off("click",r.show):r.element.off("click",r.show))},H=function(){e(window).off("resize.datetimepicker"+r.id),r.isInput||e(document).off("mousedown.datetimepicker"+r.id)},Y=function(){if(r.element){var t,i=r.element.parents(),a=!1
+for(t=0;t<i.length;t++)if("fixed"==e(i[t]).css("position")){a=!0
+break}return a}return!1},O=function(){o.lang(r.options.language)
+var e=""
+r.unset||(e=o(r.date).format(r.format)),p().val(e),r.element.data("date",e),r.options.pickTime||r.hide()},L=function(e,t,i){o.lang(r.options.language)
+var a
+return"add"==e?(a=o(r.date),23==a.hours()&&a.add(i,t),a.add(i,t)):a=o(r.date).subtract(i,t),N(o(a.subtract(i,t)))||N(a)?void f(a.format(r.format)):("add"==e?r.date.add(i,t):r.date.subtract(i,t),void(r.unset=!1))},N=function(e){return o.lang(r.options.language),e.isAfter(r.options.maxDate)||e.isBefore(r.options.minDate)?!0:r.options.disabledDates===!1?!1:r.options.disabledDates[o(e).format("YYYY-MM-DD")]===!0},U=function(e){return o.lang(r.options.language),r.options.enabledDates===!1?!0:r.options.enabledDates[o(e).format("YYYY-MM-DD")]===!0},j=function(e){var t={},a=0
+for(i=0;i<e.length;i++)dDate=o(e[i]),dDate.isValid()&&(t[dDate.format("YYYY-MM-DD")]=!0,a++)
+return a>0?t:!1},F=function(e){return e=""+e,e.length>=2?e:"0"+e},W=function(){if(r.options.pickDate&&r.options.pickTime){var e=""
+return e='<div class="bootstrap-datetimepicker-widget'+(r.options.sideBySide?" timepicker-sbs":"")+' dropdown-menu" style="z-index:9999 !important;">',e+=r.options.sideBySide?'<div class="row"><div class="col-sm-6 datepicker">'+B.template+'</div><div class="col-sm-6 timepicker">'+E.getTemplate()+"</div></div>":'<ul class="list-unstyled"><li'+(r.options.collapse?' class="collapse in"':"")+'><div class="datepicker">'+B.template+'</div></li><li class="picker-switch accordion-toggle"><a class="btn" style="width:100%"><span class="'+r.options.icons.time+'"></span></a></li><li'+(r.options.collapse?' class="collapse"':"")+'><div class="timepicker">'+E.getTemplate()+"</div></li></ul>",e+="</div>"}return r.options.pickTime?'<div class="bootstrap-datetimepicker-widget dropdown-menu"><div class="timepicker">'+E.getTemplate()+"</div></div>":'<div class="bootstrap-datetimepicker-widget dropdown-menu"><div class="datepicker">'+B.template+"</div></div>"},B={modes:[{clsName:"days",navFnc:"month",navStep:1},{clsName:"months",navFnc:"year",navStep:1},{clsName:"years",navFnc:"year",navStep:10}],headTemplate:'<thead><tr><th class="prev">&lsaquo;</th><th colspan="5" class="switch"></th><th class="next">&rsaquo;</th></tr></thead>',contTemplate:'<tbody><tr><td colspan="7"></td></tr></tbody>'},E={hourTemplate:'<span data-action="showHours"   data-time-component="hours"   class="timepicker-hour"></span>',minuteTemplate:'<span data-action="showMinutes" data-time-component="minutes" class="timepicker-minute"></span>',secondTemplate:'<span data-action="showSeconds"  data-time-component="seconds" class="timepicker-second"></span>'}
+B.template='<div class="datepicker-days"><table class="table-condensed">'+B.headTemplate+'<tbody></tbody></table></div><div class="datepicker-months"><table class="table-condensed">'+B.headTemplate+B.contTemplate+'</table></div><div class="datepicker-years"><table class="table-condensed">'+B.headTemplate+B.contTemplate+"</table></div>",E.getTemplate=function(){return'<div class="timepicker-picker"><table class="table-condensed"><tr><td><a href="#" class="btn" data-action="incrementHours"><span class="'+r.options.icons.up+'"></span></a></td><td class="separator"></td><td>'+(r.options.useMinutes?'<a href="#" class="btn" data-action="incrementMinutes"><span class="'+r.options.icons.up+'"></span></a>':"")+"</td>"+(r.options.useSeconds?'<td class="separator"></td><td><a href="#" class="btn" data-action="incrementSeconds"><span class="'+r.options.icons.up+'"></span></a></td>':"")+(r.use24hours?"":'<td class="separator"></td>')+"</tr><tr><td>"+E.hourTemplate+'</td> <td class="separator">:</td><td>'+(r.options.useMinutes?E.minuteTemplate:'<span class="timepicker-minute">00</span>')+"</td> "+(r.options.useSeconds?'<td class="separator">:</td><td>'+E.secondTemplate+"</td>":"")+(r.use24hours?"":'<td class="separator"></td><td><button type="button" class="btn btn-primary" data-action="togglePeriod"></button></td>')+'</tr><tr><td><a href="#" class="btn" data-action="decrementHours"><span class="'+r.options.icons.down+'"></span></a></td><td class="separator"></td><td>'+(r.options.useMinutes?'<a href="#" class="btn" data-action="decrementMinutes"><span class="'+r.options.icons.down+'"></span></a>':"")+"</td>"+(r.options.useSeconds?'<td class="separator"></td><td><a href="#" class="btn" data-action="decrementSeconds"><span class="'+r.options.icons.down+'"></span></a></td>':"")+(r.use24hours?"":'<td class="separator"></td>')+'</tr></table></div><div class="timepicker-hours" data-action="selectHour"><table class="table-condensed"></table></div><div class="timepicker-minutes" data-action="selectMinute"><table class="table-condensed"></table></div>'+(r.options.useSeconds?'<div class="timepicker-seconds" data-action="selectSecond"><table class="table-condensed"></table></div>':"")},r.destroy=function(){I(),H(),r.widget.remove(),r.element.removeData("DateTimePicker"),r.component&&r.component.removeData("DateTimePicker")},r.show=function(e){if(r.options.useCurrent&&""==p().val())if(1!==r.options.minuteStepping){var t=o(),i=r.options.minuteStepping
+t.minutes(Math.round(t.minutes()/i)*i%60).seconds(0),r.setValue(t.format(r.format))}else r.setValue(o().format(r.format))
+r.widget.show(),r.height=r.component?r.component.outerHeight():r.element.outerHeight(),m(),r.element.trigger({type:"dp.show",date:o(r.date)}),q(),e&&T(e)},r.disable=function(){var e=r.element.find("input")
+e.prop("disabled")||(e.prop("disabled",!0),I())},r.enable=function(){var e=r.element.find("input")
+e.prop("disabled")&&(e.prop("disabled",!1),V())},r.hide=function(t){if(!t||!e(t.target).is(r.element.attr("id"))){var i,a,n=r.widget.find(".collapse")
+for(i=0;i<n.length;i++)if(a=n.eq(i).data("collapse"),a&&a.date-transitioning)return
+r.widget.hide(),r.viewMode=r.startViewMode,P(),r.element.trigger({type:"dp.hide",date:o(r.date)}),H()}},r.setValue=function(e){o.lang(r.options.language),e?r.unset=!1:(r.unset=!0,O()),o.isMoment(e)||(e=o(e,r.format)),e.isValid()?(r.date=e,O(),r.viewDate=o({y:r.date.year(),M:r.date.month()}),v(),D()):f(e)},r.getDate=function(){return r.unset?null:r.date},r.setDate=function(e){var t=o(r.date)
+r.setValue(e?e:null),u(t,"function")},r.setDisabledDates=function(e){r.options.disabledDates=j(e),r.viewDate&&h()},r.setEnabledDates=function(e){r.options.enabledDates=j(e),r.viewDate&&h()},r.setMaxDate=function(e){void 0!=e&&(r.options.maxDate=o(e),r.viewDate&&h())},r.setMinDate=function(e){void 0!=e&&(r.options.minDate=o(e),r.viewDate&&h())},c()}
+e.fn.datetimepicker=function(t){return this.each(function(){var i=e(this),a=i.data("DateTimePicker")
+a||i.data("DateTimePicker",new n(this,t))})}})

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

@@ -18,7 +18,7 @@
     </div>
 
     {% if message %}
-    <h1>{{ message|urlize|linebreaksbr }}</h1>
+    <h1>{{ message|escape|urlize|linebreaksbr }}</h1>
     {% else %}
     <h1>{% trans "You don't have permission to access this page." %}</h1>
     {% endif %}

+ 69 - 0
misago/templates/misago/modusers/ban.html

@@ -0,0 +1,69 @@
+{% extends "misago/modusers/base.html" %}
+{% load i18n misago_forms staticfiles %}
+
+
+{% block title %}
+{{ profile.username }}: {% trans "Ban user" %} | {{ block.super }}
+{% endblock title %}
+
+
+{% block action-name %}
+{% trans "Ban user" %}
+{% endblock action-name %}
+
+
+{% block action-content %}
+<div class="row">
+  <div class="col-md-8 col-md-offset-2">
+
+    <div class="form-panel">
+      <form method="POST" role="form" class="form-horizontal">
+        {% csrf_token %}
+
+        <div class="form-header">
+          <h2>
+            {% blocktrans trimmed with username=profile.username %}
+            Ban {{ username }}
+            {% endblocktrans %}
+          </h2>
+        </div>
+
+        {% include "misago/form_errors.html" %}
+        <div class="form-body no-fieldsets">
+          {% form_row form.valid_until "col-md-3" "col-md-9" %}
+          {% form_row form.user_message "col-md-3" "col-md-9" %}
+          {% form_row form.staff_message "col-md-3" "col-md-9" %}
+        </div>
+        <div class="form-footer">
+          <div class="row">
+            <div class="col-md-9 col-md-offset-3">
+
+              <button class="btn btn-primary">{% trans "Ban user" %}</button>
+
+              <a href="{% url USER_PROFILE_URL user_slug=profile.slug user_id=profile.pk %}" class="btn btn-default">
+                {% trans "Cancel" %}
+              </a>
+
+            </div>
+          </div>
+        </div>
+
+      </form>
+    </div>
+
+  </div>
+</div>
+{% endblock action-content %}
+
+
+{% block javascripts %}
+<script type="text/javascript" src="{% static 'misago/js/bootstrap-datetimepicker.min.js' %}" charset="utf-8"></script>
+<script type="text/javascript">
+  $(function() {
+    $('#id_valid_until').datetimepicker({
+      language: $('html').attr('lang'),
+      pickTime: false
+    });
+  });
+</script>
+{% endblock javascripts %}

+ 6 - 14
misago/templates/misago/modusers/mod_options.html

@@ -17,35 +17,27 @@
     <li>
       <a href="{% url 'misago:rename_user' user_slug=profile.slug user_id=profile.pk %}">
         <span class="fa fa-credit-card"></span>
-        {% trans "Change username" %}
+        {% trans "Rename user" %}
       </a>
     </li>
     {% endif %}
     <li>
       <a href="#">
         <span class="fa fa-image"></span>
-        {% trans "Ban avatar" %}
+        {% trans "Avatar ban" %}
       </a>
     </li>
     <li>
       <a href="#">
         <span class="fa fa-pencil"></span>
-        {% trans "Edit and ban signature" %}
+        {% trans "Signature ban" %}
       </a>
     </li>
-    {% if profile.acl_.can_ban_username %}
+    {% if profile.acl_.can_ban %}
     <li>
-      <a href="{% url 'misago:ban_user_name' user_slug=profile.slug user_id=profile.pk %}">
+      <a href="{% url 'misago:ban_user' user_slug=profile.slug user_id=profile.pk %}">
         <span class="fa fa-lock"></span>
-        {% trans "Ban username" %}
-      </a>
-    </li>
-    {% endif %}
-    {% if profile.acl_.can_ban_email %}
-    <li>
-      <a href="{% url 'misago:ban_user_email' user_slug=profile.slug user_id=profile.pk %}">
-        <span class="fa fa-lock"></span>
-        {% trans "Ban e-mail address" %}
+        {% trans "Ban user" %}
       </a>
     </li>
     {% endif %}

+ 10 - 4
misago/templates/misago/modusers/rename.html

@@ -3,12 +3,12 @@
 
 
 {% block title %}
-{{ profile.username }}: {% trans "Change username" %} | {{ block.super }}
+{{ profile.username }}: {% trans "Rename user" %} | {{ block.super }}
 {% endblock title %}
 
 
 {% block action-name %}
-{% trans "Change username" %}
+{% trans "Rename user" %}
 {% endblock action-name %}
 
 
@@ -22,7 +22,9 @@
 
         <div class="form-header">
           <h2>
-            {% trans "Change username" %}
+            {% blocktrans trimmed with username=profile.username %}
+            Rename {{ username }}
+            {% endblocktrans %}
           </h2>
         </div>
 
@@ -34,7 +36,11 @@
           <div class="row">
             <div class="col-md-8 col-md-offset-4">
 
-              <button class="btn btn-primary">{% trans "Change username" %}</button>
+              <button class="btn btn-primary">{% trans "Rename user" %}</button>
+
+              <a href="{% url USER_PROFILE_URL user_slug=profile.slug user_id=profile.pk %}" class="btn btn-default">
+                {% trans "Cancel" %}
+              </a>
 
             </div>
           </div>

+ 48 - 0
misago/templates/misago/profile/ban_details.html

@@ -0,0 +1,48 @@
+{% extends "misago/profile/base.html" %}
+{% load i18n misago_avatars misago_capture %}
+
+
+{% block page %}
+<p class="lead">
+  <span class="fa fa-lock"></span>
+  {% if ban.valid_until %}
+  {% blocktrans trimmed with username=profile.username banned_until=ban.valid_until %}
+  {{ username }} is banned until after {{ banned_until }}.
+  {% endblocktrans %}
+  {% else %}
+  {% blocktrans trimmed with username=profile.username %}
+  {{ username }} is banned permanently.
+  {% endblocktrans %}
+  {% endif %}
+</p>
+
+{% if ban.user_message %}
+<div class="panel panel-default">
+  <div class="panel-heading">
+    <h3 class="panel-title">
+      {% trans "User message" %}
+    </h3>
+  </div>
+  <div class="panel-body">
+
+    {{ ban.user_message|escape|urlize|linebreaksbr }}
+
+  </div>
+</div>
+{% endif %}
+
+{% if ban.staff_message %}
+<div class="panel panel-default">
+  <div class="panel-heading">
+    <h3 class="panel-title">
+      {% trans "Team message" %}
+    </h3>
+  </div>
+  <div class="panel-body">
+
+    {{ ban.staff_message|escape|urlize|linebreaksbr }}
+
+  </div>
+</div>
+{% endif %}
+{% endblock page %}

+ 12 - 0
misago/users/apps.py

@@ -51,6 +51,15 @@ class MisagoUsersConfig(AppConfig):
                 return is_account_owner or has_permission
             else:
                 return False
+        def can_see_ban_details(request, profile):
+            if request.user.is_authenticated():
+                if request.user.acl['can_see_ban_details']:
+                    from misago.users.bans import get_user_ban
+                    return bool(get_user_ban(profile))
+                else:
+                    return False
+            else:
+                return False
 
         user_profile.add_page(link='misago:user_posts',
                               name=_("Posts"),
@@ -61,3 +70,6 @@ class MisagoUsersConfig(AppConfig):
         user_profile.add_page(link='misago:user_name_history',
                               name=_("Name history"),
                               visible_if=can_see_names_history)
+        user_profile.add_page(link='misago:user_ban',
+                              name=_("Ban"),
+                              visible_if=can_see_ban_details)

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

@@ -253,7 +253,8 @@ def SearchUsersForm(*args, **kwargs):
 class BanUsersForm(forms.Form):
     user_message = forms.CharField(
         label=_("User message"), required=False, max_length=1000,
-        help_text=_("Optional message displayed instead of default one."),
+        help_text=_("Optional message displayed to user "
+                    "instead of default one."),
         widget=forms.Textarea(attrs={'rows': 3}),
         error_messages={
             'max_length': _("Message can't be longer than 1000 characters.")
@@ -266,7 +267,7 @@ class BanUsersForm(forms.Form):
             'max_length': _("Message can't be longer than 1000 characters.")
         })
     valid_until = forms.DateField(
-        label=_("Expiration date"),
+        label=_("Expires after"),
         required=False, input_formats=['%m-%d-%Y'],
         widget=forms.DateInput(
             format='%m-%d-%Y', attrs={'data-date-format': 'MM-DD-YYYY'}),

+ 49 - 0
misago/users/forms/modusers.py

@@ -0,0 +1,49 @@
+from datetime import timedelta
+
+from django.utils.translation import ugettext_lazy as _, ungettext
+from django.utils import timezone
+
+from misago.core import forms
+
+from misago.users.forms.admin import BanUsersForm
+from misago.users.models import Ban, BAN_EMAIL, BAN_USERNAME
+
+
+class BanForm(BanUsersForm):
+    def __init__(self, *args, **kwargs):
+        self.user = kwargs.pop('user')
+        super(BanForm, self).__init__(*args, **kwargs)
+
+        if self.user.acl_['max_ban_length']:
+            message = ungettext(
+                "Required. Can't be longer than %(days)s day.",
+                "Required. Can't be longer than %(days)s days.",
+                self.user.acl_['max_ban_length'])
+            message = message % {'days': self.user.acl_['max_ban_length']}
+            self['valid_until'].field.help_text = message
+
+    def clean_valid_until(self):
+        data = self.cleaned_data['valid_until']
+
+        if self.user.acl_['max_ban_length']:
+            max_ban_length = timedelta(days=self.user.acl_['max_ban_length'])
+            if not data or data > (timezone.now() + max_ban_length).date():
+                message = ungettext(
+                    "You can't set bans longer than %(days)s day.",
+                    "You can't set bans longer than %(days)s days.",
+                    self.user.acl_['max_ban_length'])
+                message = message % {'days': self.user.acl_['max_ban_length']}
+                raise forms.ValidationError(message)
+        elif data and data < timezone.now().date():
+            raise forms.ValidationError(_("Expiration date is in past."))
+
+        return data
+
+    def ban_user(self):
+        new_ban = Ban(banned_value=self.user.username,
+                      user_message=self.cleaned_data['user_message'],
+                      staff_message=self.cleaned_data['staff_message'],
+                      valid_until=self.cleaned_data['valid_until'])
+        new_ban.save()
+
+        Ban.objects.invalidate_cache()

+ 0 - 7
misago/users/forms/usermod.py

@@ -1,7 +0,0 @@
-from misago.users.forms.admin import BanUsersForm
-
-
-class BanForm(BanUsersForm):
-    def __init__(self *args, **kwargs):
-        self.user = kwargs.pop('user')
-        super(BanForm, self).__init__(*args, **kwargs)

+ 10 - 21
misago/users/permissions/moderation.py

@@ -15,8 +15,7 @@ class PermissionsForm(forms.Form):
     legend = _("Users moderation")
 
     can_rename_users = forms.YesNoSwitch(label=_("Can rename users"))
-    can_ban_usernames = forms.YesNoSwitch(label=_("Can ban usernames"))
-    can_ban_emails = forms.YesNoSwitch(label=_("Can ban e-mails"))
+    can_ban_users = forms.YesNoSwitch(label=_("Can ban users"))
     max_ban_length = forms.IntegerField(
         label=_("Max length, in days, of imposed ban"),
         help_text=_("Enter zero to let moderators impose permanent bans."),
@@ -37,8 +36,7 @@ ACL Builder
 def build_acl(acl, roles, key_name):
     new_acl = {
         'can_rename_users': 0,
-        'can_ban_usernames': 0,
-        'can_ban_emails': 0,
+        'can_ban_users': 0,
         'max_ban_length': 2,
     }
     new_acl.update(acl)
@@ -46,8 +44,7 @@ def build_acl(acl, roles, key_name):
     return algebra.sum_acls(
             new_acl, roles=roles, key=key_name,
             can_rename_users=algebra.greater,
-            can_ban_usernames=algebra.greater,
-            can_ban_emails=algebra.greater,
+            can_ban_users=algebra.greater,
             max_ban_length=algebra.greater_or_zero
             )
 
@@ -58,10 +55,10 @@ ACL's for targets
 @require_target_type(get_user_model())
 def add_acl_to_target(user, acl, target):
     target.acl_['can_rename'] = can_rename_user(user, target)
-    target.acl_['can_ban_username'] = can_ban_username(user, target)
-    target.acl_['can_ban_email'] = can_ban_email(user, target)
+    target.acl_['can_ban'] = can_ban_user(user, target)
+    target.acl_['max_ban_length'] = user.acl['max_ban_length']
 
-    for permission in ('can_rename', 'can_ban_username', 'can_ban_email'):
+    for permission in ('can_rename', 'can_ban'):
         if target.acl_[permission]:
             target.acl_['can_moderate'] = True
             break
@@ -78,17 +75,9 @@ def allow_rename_user(user, target):
 can_rename_user = return_boolean(allow_rename_user)
 
 
-def allow_ban_username(user, target):
-    if not user.acl['can_ban_usernames']:
-        raise PermissionDenied(_("You can't ban usernames."))
+def allow_ban_user(user, target):
+    if not user.acl['can_ban_users']:
+        raise PermissionDenied(_("You can't ban users."))
     if target.is_staff or target.is_superuser:
         raise PermissionDenied(_("You can't ban administrators."))
-can_ban_username = return_boolean(allow_ban_username)
-
-
-def allow_ban_email(user, target):
-    if not user.acl['can_ban_emails']:
-        raise PermissionDenied(_("You can't ban e-mails."))
-    if target.is_staff or target.is_superuser:
-        raise PermissionDenied(_("You can't ban administrators."))
-can_ban_email = return_boolean(allow_ban_email)
+can_ban_user = return_boolean(allow_ban_user)

+ 6 - 0
misago/users/permissions/profiles.py

@@ -16,6 +16,10 @@ class PermissionsForm(forms.Form):
         initial=1)
     can_see_users_name_history = forms.YesNoSwitch(
         label=_("Can see other members name history"))
+    can_see_ban_details = forms.YesNoSwitch(
+        label=_("Can see members bans details"),
+        help_text=_("Allows users with this permission to see user and "
+                    "staff ban messages."))
     can_see_users_emails = forms.YesNoSwitch(
         label=_("Can see members e-mails"))
     can_see_users_ips = forms.YesNoSwitch(
@@ -38,6 +42,7 @@ def build_acl(acl, roles, key_name):
     new_acl = {
         'can_search_users': 0,
         'can_see_users_name_history': 0,
+        'can_see_ban_details': 0,
         'can_see_users_emails': 0,
         'can_see_users_ips': 0,
         'can_see_hidden_users': 0,
@@ -47,6 +52,7 @@ def build_acl(acl, roles, key_name):
     return algebra.sum_acls(
             new_acl, roles=roles, key=key_name,
             can_see_users_name_history=algebra.greater,
+            can_see_ban_details=algebra.greater,
             can_search_users=algebra.greater,
             can_see_users_emails=algebra.greater,
             can_see_users_ips=algebra.greater,

+ 41 - 0
misago/users/tests/test_moderation_views.py

@@ -4,6 +4,8 @@ from django.core.urlresolvers import reverse
 from misago.acl.testutils import override_acl
 from misago.admin.testutils import AdminTestCase
 
+from misago.users.models import Ban
+
 
 class UserModerationTestCase(AdminTestCase):
     def setUp(self):
@@ -51,6 +53,45 @@ class RenameUserTests(UserModerationTestCase):
         self.assertIn('Bob&#39;s username has been changed.', response.content)
 
 
+class BanUserTests(UserModerationTestCase):
+    def test_no_ban_permission(self):
+        """user with no permission fails to ban other user"""
+        override_acl(self.test_admin, {
+            'misago.users.permissions.moderation': {
+                'can_ban_users': 0,
+            },
+        })
+
+        response = self.client.get(
+            reverse('misago:ban_user', kwargs=self.link_kwargs))
+
+        self.assertEqual(response.status_code, 403)
+        self.assertIn("You can&#39;t ban users.", response.content)
+
+    def test_ban_user(self):
+        """user with permission bans other user"""
+        override_acl(self.test_admin, {
+            'misago.users.permissions.moderation': {
+                'can_ban_users': 1,
+                'max_ban_length': 0,
+            }
+        })
+
+        response = self.client.get(
+            reverse('misago:ban_user', kwargs=self.link_kwargs))
+        self.assertEqual(response.status_code, 200)
+
+        response = self.client.post(
+            reverse('misago:ban_user', kwargs=self.link_kwargs))
+        self.assertEqual(response.status_code, 302)
+
+        response = self.client.post(reverse('misago:index'))
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('Bob has been banned.', response.content)
+
+        Ban.objects.get(banned_value=self.test_user.username.lower())
+
+
 class DeleteUserTests(UserModerationTestCase):
     def test_no_delete_permission(self):
         """user with no permission fails to delete other user"""

+ 36 - 0
misago/users/tests/test_profile_views.py

@@ -1,7 +1,11 @@
+from django.contrib.auth import get_user_model
 from django.core.urlresolvers import reverse
 
+from misago.acl.testutils import override_acl
 from misago.admin.testutils import AdminTestCase
 
+from misago.users.models import Ban
+
 
 class UserProfileViewsTests(AdminTestCase):
     def setUp(self):
@@ -52,3 +56,35 @@ class UserProfileViewsTests(AdminTestCase):
         self.assertEqual(response.status_code, 200)
         self.assertIn("TestAdmin</strong> changed name to <strong>Renamed",
                       response.content)
+
+    def test_user_ban(self):
+        """user ban details page has no showstoppers"""
+        override_acl(self.test_admin, {
+            'misago.users.permissions.profiles': {
+                'can_see_ban_details': 0,
+            },
+        })
+
+        User = get_user_model()
+        test_user = User.objects.create_user("Bob", "bob@bob.com", 'pass.123')
+        link_kwargs = {'user_slug': test_user.slug, 'user_id': test_user.pk}
+
+        response = self.client.get(reverse('misago:user_ban',
+                                           kwargs=link_kwargs))
+        self.assertEqual(response.status_code, 404)
+
+        override_acl(self.test_admin, {
+            'misago.users.permissions.profiles': {
+                'can_see_ban_details': 1,
+            },
+        })
+
+        test_ban = Ban.objects.create(banned_value=test_user.username,
+                                      user_message="User m3ss4ge.",
+                                      staff_message="Staff m3ss4ge.")
+
+        response = self.client.get(reverse('misago:user_ban',
+                                           kwargs=link_kwargs))
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('User m3ss4ge', response.content)
+        self.assertIn('Staff m3ss4ge', response.content)

+ 2 - 2
misago/users/urls.py

@@ -58,6 +58,7 @@ urlpatterns += patterns('',
         url(r'^threads/$', 'user_threads', name="user_threads"),
         url(r'^name-history/$', 'name_history', name="user_name_history"),
         url(r'^name-history/(?P<page>\d+)/$', 'name_history', name="user_name_history"),
+        url(r'^ban-details/$', 'user_ban', name="user_ban"),
     )))
 )
 
@@ -65,8 +66,7 @@ urlpatterns += patterns('',
 urlpatterns += patterns('',
     url(r'^mod-user/(?P<user_slug>[a-zA-Z0-9]+)-(?P<user_id>\d+)/', include(patterns('misago.users.views.moderation',
         url(r'^rename/$', 'rename', name='rename_user'),
-        url(r'^ban-username/$', 'ban_username', name='ban_user_name'),
-        url(r'^ban-email/$', 'ban_email', name='ban_user_email'),
+        url(r'^ban-user/$', 'ban_user', name='ban_user'),
         url(r'^delete/$', 'delete', name='delete_user'),
     ))),
 )

+ 10 - 35
misago/users/views/moderation.py

@@ -9,10 +9,10 @@ from misago.core.decorators import require_POST
 from misago.core.shortcuts import get_object_or_404, validate_slug
 
 from misago.users.forms.rename import ChangeUsernameForm
+from misago.users.forms.modusers import BanForm
 from misago.users.decorators import deny_guests
 from misago.users.permissions.moderation import (allow_rename_user,
-                                                 allow_ban_username,
-                                                 allow_ban_email)
+                                                 allow_ban_user)
 from misago.users.permissions.delete import allow_delete_user
 from misago.users.sites import user_profile
 
@@ -60,48 +60,23 @@ def rename(request, user):
                   {'profile': user, 'form': form})
 
 
-@user_moderation_view(allow_ban_username)
-def ban_username(request, user):
-    form = ChangeUsernameForm(user=user)
+@user_moderation_view(allow_ban_user)
+def ban_user(request, user):
+    form = BanForm(user=user)
     if request.method == 'POST':
-        old_username = user.username
-        form = ChangeUsernameForm(request.POST, user=user)
+        form = BanForm(request.POST, user=user)
         if form.is_valid():
-            user.set_username(form.cleaned_data['new_username'],
-                              changed_by=request.user)
-            user.save(update_fields=['username', 'slug'])
+            form.ban_user()
 
-            message = _("%(old_username)s's username has been changed.")
-            messages.success(request, message % {'old_username': old_username})
+            message = _("%(username)s has been banned.")
+            messages.success(request, message % {'username': user.username})
 
             return redirect(user_profile.get_default_link(),
                             **{'user_slug': user.slug, 'user_id': user.pk})
 
-    return render(request, 'misago/modusers/rename.html',
+    return render(request, 'misago/modusers/ban.html',
                   {'profile': user, 'form': form})
 
-
-@user_moderation_view(allow_ban_email)
-def ban_email(request, user):
-    form = ChangeUsernameForm(user=user)
-    if request.method == 'POST':
-        old_username = user.username
-        form = ChangeUsernameForm(request.POST, user=user)
-        if form.is_valid():
-            user.set_username(form.cleaned_data['new_username'],
-                              changed_by=request.user)
-            user.save(update_fields=['username', 'slug'])
-
-            message = _("%(old_username)s's username has been changed.")
-            messages.success(request, message % {'old_username': old_username})
-
-            return redirect(user_profile.get_default_link(),
-                            **{'user_slug': user.slug, 'user_id': user.pk})
-
-    return render(request, 'misago/modusers/rename.html',
-                  {'profile': user, 'form': form})
-
-
 @require_POST
 @user_moderation_view(allow_delete_user)
 def delete(request, user):

+ 13 - 0
misago/users/views/profile.py

@@ -6,6 +6,7 @@ from misago.acl import add_acl
 from misago.core.shortcuts import get_object_or_404, paginate, validate_slug
 
 from misago.users import online
+from misago.users.bans import get_user_ban
 from misago.users.sites import user_profile
 
 
@@ -86,3 +87,15 @@ def name_history(request, profile=None, page=0):
         'page_number': name_changes.number,
         'items_left': items_left
     })
+
+
+@profile_view_restricted_visibility
+def user_ban(request, profile=None):
+    ban = get_user_ban(profile)
+    if not ban:
+        raise Http404()
+
+    return render(request, 'misago/profile/ban_details.html', {
+        'profile': profile,
+        'ban': ban
+    })