Browse Source

Basic admin crud for bans.

Rafał Pitoń 11 years ago
parent
commit
4f3fcf9057

+ 1 - 0
misago/conf/defaults.py

@@ -62,6 +62,7 @@ PIPELINE_JS = {
             'misago/admin/js/jquery.js',
             'misago/admin/js/bootstrap.js',
             'misago/admin/js/moment.min.js',
+            'misago/admin/js/bootstrap-datetimepicker.min.js',
             'misago/admin/js/misago-timestamps.js',
             'misago/admin/js/misago-tooltips.js',
             'misago/admin/js/misago-tables.js',

+ 229 - 0
misago/static/misago/admin/css/bootstrap-datetimepicker.css

@@ -0,0 +1,229 @@
+/*!
+ * Datetimepicker for Bootstrap v3
+ * https://github.com/Eonasdan/bootstrap-datetimepicker/
+ */
+.bootstrap-datetimepicker-widget {
+  top: 0;
+  left: 0;
+  width: 250px;
+  padding: 4px;
+  margin-top: 1px;
+  z-index: 99999 !important;
+  border-radius: 4px;
+}
+.bootstrap-datetimepicker-widget.timepicker-sbs {
+  width: 600px;
+}
+.bootstrap-datetimepicker-widget.bottom:before {
+  content: '';
+  display: inline-block;
+  border-left: 7px solid transparent;
+  border-right: 7px solid transparent;
+  border-bottom: 7px solid #ccc;
+  border-bottom-color: rgba(0, 0, 0, 0.2);
+  position: absolute;
+  top: -7px;
+  left: 7px;
+}
+.bootstrap-datetimepicker-widget.bottom:after {
+  content: '';
+  display: inline-block;
+  border-left: 6px solid transparent;
+  border-right: 6px solid transparent;
+  border-bottom: 6px solid white;
+  position: absolute;
+  top: -6px;
+  left: 8px;
+}
+.bootstrap-datetimepicker-widget.top:before {
+  content: '';
+  display: inline-block;
+  border-left: 7px solid transparent;
+  border-right: 7px solid transparent;
+  border-top: 7px solid #ccc;
+  border-top-color: rgba(0, 0, 0, 0.2);
+  position: absolute;
+  bottom: -7px;
+  left: 6px;
+}
+.bootstrap-datetimepicker-widget.top:after {
+  content: '';
+  display: inline-block;
+  border-left: 6px solid transparent;
+  border-right: 6px solid transparent;
+  border-top: 6px solid white;
+  position: absolute;
+  bottom: -6px;
+  left: 7px;
+}
+.bootstrap-datetimepicker-widget .dow {
+  width: 14.2857%;
+}
+.bootstrap-datetimepicker-widget.pull-right:before {
+  left: auto;
+  right: 6px;
+}
+.bootstrap-datetimepicker-widget.pull-right:after {
+  left: auto;
+  right: 7px;
+}
+.bootstrap-datetimepicker-widget > ul {
+  list-style-type: none;
+  margin: 0;
+}
+.bootstrap-datetimepicker-widget a[data-action] {
+  padding: 6px 0;
+}
+.bootstrap-datetimepicker-widget .timepicker-hour,
+.bootstrap-datetimepicker-widget .timepicker-minute,
+.bootstrap-datetimepicker-widget .timepicker-second {
+  width: 54px;
+  font-weight: bold;
+  font-size: 1.2em;
+  margin: 0;
+}
+.bootstrap-datetimepicker-widget button[data-action] {
+  padding: 6px;
+}
+.bootstrap-datetimepicker-widget table[data-hour-format="12"] .separator {
+  width: 4px;
+  padding: 0;
+  margin: 0;
+}
+.bootstrap-datetimepicker-widget .datepicker > div {
+  display: none;
+}
+.bootstrap-datetimepicker-widget .picker-switch {
+  text-align: center;
+}
+.bootstrap-datetimepicker-widget table {
+  width: 100%;
+  margin: 0;
+}
+.bootstrap-datetimepicker-widget td,
+.bootstrap-datetimepicker-widget th {
+  text-align: center;
+  border-radius: 4px;
+}
+.bootstrap-datetimepicker-widget td {
+  height: 54px;
+  line-height: 54px;
+  width: 54px;
+}
+.bootstrap-datetimepicker-widget td.day {
+  height: 20px;
+  line-height: 20px;
+  width: 20px;
+}
+.bootstrap-datetimepicker-widget td.day:hover,
+.bootstrap-datetimepicker-widget td.hour:hover,
+.bootstrap-datetimepicker-widget td.minute:hover,
+.bootstrap-datetimepicker-widget td.second:hover {
+  background: #eeeeee;
+  cursor: pointer;
+}
+.bootstrap-datetimepicker-widget td.old,
+.bootstrap-datetimepicker-widget td.new {
+  color: #999999;
+}
+.bootstrap-datetimepicker-widget td.today {
+  position: relative;
+}
+.bootstrap-datetimepicker-widget td.today:before {
+  content: '';
+  display: inline-block;
+  border-left: 7px solid transparent;
+  border-bottom: 7px solid #428bca;
+  border-top-color: rgba(0, 0, 0, 0.2);
+  position: absolute;
+  bottom: 4px;
+  right: 4px;
+}
+.bootstrap-datetimepicker-widget td.active,
+.bootstrap-datetimepicker-widget td.active:hover {
+  background-color: #428bca;
+  color: #fff;
+  text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);
+}
+.bootstrap-datetimepicker-widget td.active.today:before {
+  border-bottom-color: #fff;
+}
+.bootstrap-datetimepicker-widget td.disabled,
+.bootstrap-datetimepicker-widget td.disabled:hover {
+  background: none;
+  color: #999999;
+  cursor: not-allowed;
+}
+.bootstrap-datetimepicker-widget td span {
+  display: block;
+  width: 54px;
+  height: 54px;
+  line-height: 54px;
+  float: left;
+  margin: 2px 1.5px;
+  cursor: pointer;
+  border-radius: 4px;
+}
+.bootstrap-datetimepicker-widget td span:hover {
+  background: #eeeeee;
+}
+.bootstrap-datetimepicker-widget td span.active {
+  background-color: #428bca;
+  color: #fff;
+  text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);
+}
+.bootstrap-datetimepicker-widget td span.old {
+  color: #999999;
+}
+.bootstrap-datetimepicker-widget td span.disabled,
+.bootstrap-datetimepicker-widget td span.disabled:hover {
+  background: none;
+  color: #999999;
+  cursor: not-allowed;
+}
+.bootstrap-datetimepicker-widget th {
+  height: 20px;
+  line-height: 20px;
+  width: 20px;
+}
+.bootstrap-datetimepicker-widget th.switch {
+  width: 145px;
+}
+.bootstrap-datetimepicker-widget th.next,
+.bootstrap-datetimepicker-widget th.prev {
+  font-size: 21px;
+}
+.bootstrap-datetimepicker-widget th.disabled,
+.bootstrap-datetimepicker-widget th.disabled:hover {
+  background: none;
+  color: #999999;
+  cursor: not-allowed;
+}
+.bootstrap-datetimepicker-widget thead tr:first-child th {
+  cursor: pointer;
+}
+.bootstrap-datetimepicker-widget thead tr:first-child th:hover {
+  background: #eeeeee;
+}
+.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;
+}
+.bootstrap-datetimepicker-widget.left-oriented: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/admin/css/style.css

@@ -1,4 +1,5 @@
 @import "font-awesome.css";
+@import "bootstrap-datetimepicker.css";
 /*! normalize.css v3.0.0 | MIT License | git.io/normalize */
 html {
   font-family: sans-serif;

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

@@ -10,6 +10,7 @@
 
 // Extras
 @import "font-awesome.css";
+@import "bootstrap-datetimepicker.css";
 
 // Import other files
 @import "bootstrap/bootstrap.less";

+ 105 - 0
misago/static/misago/admin/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))})}})

+ 64 - 0
misago/templates/misago/admin/bans/form.html

@@ -0,0 +1,64 @@
+{% extends "misago/admin/generic/form.html" %}
+{% load crispy_forms_filters i18n %}
+
+
+{% block title %}
+{% if target.pk %}
+{% trans target.banned_value %}
+{% else %}
+{% trans "New ban" %}
+{% endif %} | {{ active_link.name }} | {{ block.super }}
+{% endblock title %}
+
+
+{% block page-target %}
+{% if target.pk %}
+{% trans target.banned_value %}
+{% else %}
+{% trans "New ban" %}
+{% endif %}
+{% endblock page-target %}
+
+
+{% block form-header %}
+<h1>
+  {% if target.pk %}
+  {% trans target.banned_value %}
+  {% else %}
+  {% trans "New ban" %}
+  {% endif %}
+</h1>
+{% endblock %}
+
+
+{% block form-body %}
+<div class="form-body">
+  <fieldset>
+    <legend>{% trans "Ban settings" %}</legend>
+
+    {{ form.test|as_crispy_field }}
+    {{ form.banned_value|as_crispy_field }}
+    {{ form.valid_until|as_crispy_field }}
+
+  </fieldset>
+  <fieldset>
+    <legend>{% trans "Messages" %}</legend>
+
+    {{ form.user_message|as_crispy_field }}
+    {{ form.staff_message|as_crispy_field }}
+
+  </fieldset>
+</div>
+{% endblock form-body %}
+
+
+{% block javascripts %}
+<script type="text/javascript">
+  $(function() {
+    $('#id_valid_until').datetimepicker({
+      language: $('html').attr('lang'),
+      pickTime: false
+    });
+  });
+</script>
+{% endblock %}

+ 87 - 0
misago/templates/misago/admin/bans/list.html

@@ -0,0 +1,87 @@
+{% extends "misago/admin/generic/list.html" %}
+{% load i18n %}
+
+
+{% block page-actions %}
+<div class="page-actions">
+  <a href="{% url 'misago:admin:users:bans:new' %}" class="btn btn-success">
+    <span class="fa fa-plus-circle"></span>
+    {% trans "New ban" %}
+  </a>
+</div>
+{% endblock %}
+
+
+{% block table-header %}
+<th style="width: 25%;">{% trans "Ban" %}</th>
+<th style="width: 160px;">{% trans "Type" %}</th>
+<th>{% trans "Valid until" %}</th>
+{% for action in extra_actions %}
+<th style="width: 1%;">&nbsp;</th>
+{% endfor %}
+<th style="width: 1%;">&nbsp;</th>
+<th style="width: 1%;">&nbsp;</th>
+{% endblock table-header %}
+
+
+{% block table-row %}
+<td class="lead">
+  {{ item.banned_value }}
+</td>
+<td>
+  {{ item.test_name }}
+</td>
+<td>
+  {% if item.valid_until %}
+    {% if item.is_expired %}
+      <span class="text-muted tooltip-top" title="{% trans "This ban has expired." %}">
+        {{ item.valid_until|date }}
+        <span class="fa fa-exclamation text-danger"></span>
+      </span>
+    {% else %}
+      {{ item.valid_until|date }}
+    {% endif %}
+  {% else %}
+  <em>{% trans "permanent" %}</em>
+  {% endif %}
+</td>
+{% for action in extra_actions %}
+<td class="row-action">
+  <a href="{% url action.link ban_id=item.id %}" class="btn btn-{% if action.style %}{{ action.style }}{% else %}default{% endif %} tooltip-top" title="{{ action.name }}">
+    <span class="{{ action.icon }}"></span>
+  </a>
+</td>
+{% endfor %}
+<td class="row-action">
+  <a href="{% url 'misago:admin:users:bans:edit' ban_id=item.id %}" class="btn btn-primary tooltip-top" title="{% trans "Edit" %}">
+    <span class="fa fa-pencil"></span>
+  </a>
+</td>
+<td class="row-action">
+  <form action="{% url 'misago:admin:users:bans:delete' ban_id=item.id %}" method="post" class="delete-prompt">
+    <button class="btn btn-danger tooltip-top" title="{% trans "Remove" %}">
+      {% csrf_token %}
+      <span class="fa fa-times"></span>
+    </button>
+  </form>
+</td>
+{% endblock %}
+
+
+{% block emptylist %}
+<td colspan="{{ 4|add:extra_actions_len }}">
+  <p>{% trans "No bans are currently set." %}</p>
+</td>
+{% endblock emptylist %}
+
+
+{% block javascripts %}
+<script type="text/javascript">
+  $(function() {
+    $('.delete-prompt').submit(function() {
+      var decision = confirm("{% trans "Are you sure you want to remove this ban?" %}");
+      return decision;
+    });
+  });
+</script>
+{% endblock %}

+ 18 - 1
misago/users/admin.py

@@ -1,9 +1,10 @@
 from django.conf.urls import url
 from django.utils.translation import ugettext_lazy as _
-from misago.users.views.useradmin import UsersList, NewUser, EditUser
+from misago.users.views.bansadmin import BansList, NewBan, EditBan, DeleteBan
 from misago.users.views.rankadmin import (RanksList, NewRank, EditRank,
                                           DeleteRank, MoveUpRank, MoveDownRank,
                                           DefaultRank)
+from misago.users.views.useradmin import UsersList, NewUser, EditUser
 
 
 class MisagoAdminExtension(object):
@@ -32,6 +33,15 @@ class MisagoAdminExtension(object):
             url(r'^delete/(?P<rank_id>\d+)/$', DeleteRank.as_view(), name='delete'),
         )
 
+        # Bans
+        urlpatterns.namespace(r'^bans/', 'bans', 'users')
+        urlpatterns.patterns('users:bans',
+            url(r'^$', BansList.as_view(), name='index'),
+            url(r'^new/$', NewBan.as_view(), name='new'),
+            url(r'^edit/(?P<ban_id>\d+)/$', EditBan.as_view(), name='edit'),
+            url(r'^delete/(?P<ban_id>\d+)/$', DeleteBan.as_view(), name='delete'),
+        )
+
     def register_navigation_nodes(self, site):
         site.add_node(name=_("Users"),
                       icon='fa fa-users',
@@ -52,3 +62,10 @@ class MisagoAdminExtension(object):
                       after='misago:admin:users:accounts:index',
                       namespace='misago:admin:users:ranks',
                       link='misago:admin:users:ranks:index')
+
+        site.add_node(name=_("Bans"),
+                      icon='fa fa-lock',
+                      parent='misago:admin:users',
+                      after='misago:admin:users:ranks:index',
+                      namespace='misago:admin:users:bans',
+                      link='misago:admin:users:bans:index')

+ 11 - 0
misago/users/decorators.py

@@ -20,3 +20,14 @@ def deny_guests(f):
         else:
             return f(request, *args, **kwargs)
     return decorator
+
+
+def deny_banned_ips(f):
+    def decorator(request, *args, **kwargs):
+        if request.user.is_anonymous():
+            raise PermissionDenied(
+                _("This page is not available to guests."))
+        else:
+            return f(request, *args, **kwargs)
+    return decorator
+

+ 52 - 1
misago/users/forms/admin.py

@@ -3,7 +3,7 @@ from django.utils.translation import ugettext_lazy as _
 from misago.core import forms, threadstore
 from misago.core.validators import validate_sluggable
 from misago.acl.models import Role
-from misago.users.models import Rank
+from misago.users.models import BANS_CHOICES, Ban, Rank
 from misago.users.validators import (validate_username, validate_email,
                                      validate_password)
 
@@ -264,3 +264,54 @@ class RankForm(forms.ModelForm):
 
         self.instance.set_name(data.get('name'))
         return data
+
+
+"""
+Bans Form
+"""
+
+class BanForm(forms.ModelForm):
+    test = forms.TypedChoiceField(
+        label=_("Ban type"),
+        coerce=int,
+        choices=BANS_CHOICES)
+    banned_value = forms.CharField(
+        label=_("Banned value"), max_length=250,
+        help_text=_('This value is case-insensitive and accepts asterisk (*) '
+                    'for rought matches. For example, making IP ban for value '
+                    '"83.*" will ban all IP addresses beginning with "83.".'),
+        error_messages={
+            'max_length': _("Banned value can't be longer than 250 characters.")
+        })
+    user_message = forms.CharField(
+        label=_("Optional message for user"), required=False, max_length=1000,
+        widget=forms.Textarea(attrs={'rows': 3}),
+        error_messages={
+            'max_length': _("Message can't be longer than 1000 characters.")
+        })
+    staff_message = forms.CharField(
+        label=_("Optional message for team"), required=False, max_length=1000,
+        widget=forms.Textarea(attrs={'rows': 3}),
+        error_messages={
+            'max_length': _("Message can't be longer than 1000 characters.")
+        })
+    valid_until = forms.DateField(
+        label=_("Optional expiration date for this ban"),
+        required=False, input_formats=['%m-%d-%Y'],
+        widget=forms.DateInput(
+            format='%m-%d-%Y', attrs={'data-date-format': 'MM-DD-YYYY'}),
+        help_text=_('Leave this field empty for this ban to never expire.'))
+
+    class Meta:
+        model = Ban
+        fields = [
+            'test',
+            'banned_value',
+            'user_message',
+            'staff_message',
+            'valid_until',
+        ]
+
+
+class SearchBansForm(object):
+    pass

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

@@ -94,8 +94,8 @@ class Migration(migrations.Migration):
                 ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
                 ('test', models.PositiveIntegerField(default=0, db_index=True)),
                 ('banned_value', models.CharField(max_length=255, db_index=True)),
-                ('reason_user', models.TextField(null=True, blank=True)),
-                ('reason_admin', models.TextField(null=True, blank=True)),
+                ('user_message', models.TextField(null=True, blank=True)),
+                ('staff_message', models.TextField(null=True, blank=True)),
                 ('valid_until', models.DateField(null=True, blank=True, db_index=True)),
                 ('is_valid', models.BooleanField(default=False, db_index=True)),
             ],

+ 24 - 2
misago/users/models.py

@@ -281,6 +281,13 @@ BAN_EMAIL = 1
 BAN_IP = 2
 
 
+BANS_CHOICES = (
+    (BAN_NAME, _('Username')),
+    (BAN_EMAIL, _('E-mail address')),
+    (BAN_IP, _('IP Address')),
+)
+
+
 class BansManager(models.Manager):
     def is_ip_banned(self, ip):
         return self.check_ban(ip=ip)
@@ -320,13 +327,28 @@ class BansManager(models.Manager):
 class Ban(models.Model):
     test = models.PositiveIntegerField(default=BAN_NAME, db_index=True)
     banned_value = models.CharField(max_length=255, db_index=True)
-    reason_user = models.TextField(null=True, blank=True)
-    reason_admin = models.TextField(null=True, blank=True)
+    user_message = models.TextField(null=True, blank=True)
+    staff_message = models.TextField(null=True, blank=True)
     valid_until = models.DateField(null=True, blank=True, db_index=True)
     is_valid = models.BooleanField(default=False, db_index=True)
 
     objects = BansManager()
 
+    @property
+    def test_name(self):
+        return BANS_CHOICES[self.test][1]
+
+    @property
+    def name(self):
+        return self.banned_value
+
+    @property
+    def is_expired(self):
+        if self.valid_until:
+            return self.valid_until <= timezone.now().date
+        else:
+            return False
+
     def test_value(self, value):
         regex = '^' + re.escape(self.banned_value).replace('\*', '(.*?)') + '$'
         return re.search(regex, value, flags=re.IGNORECASE)

+ 43 - 0
misago/users/views/bansadmin.py

@@ -0,0 +1,43 @@
+from django.contrib import messages
+from django.utils.translation import ugettext_lazy as _
+from misago.admin.views import generic
+from misago.core import cachebuster
+from misago.users.models import Ban
+from misago.users.forms.admin import SearchBansForm, BanForm
+
+
+class BanAdmin(generic.AdminBaseMixin):
+    root_link = 'misago:admin:users:bans:index'
+    Model = Ban
+    Form = BanForm
+    templates_dir = 'misago/admin/bans'
+    message_404 = _("Requested ban does not exist.")
+
+    def update_roles(self, target, roles):
+        target.roles.clear()
+        if roles:
+            target.roles.add(*roles)
+
+    def handle_form(self, form, request, target):
+        super(BanAdmin, self).handle_form(form, request, target)
+        cachebuster.invalidate('misago_bans')
+
+
+class BansList(BanAdmin, generic.ListView):
+    ordering = (('-id', None),)
+
+
+class NewBan(BanAdmin, generic.ModelFormView):
+    message_submit = _('New ban "%s" has been saved.')
+
+
+class EditBan(BanAdmin, generic.ModelFormView):
+    message_submit = _('Ban "%s" has been edited.')
+
+
+class DeleteBan(BanAdmin, generic.ButtonView):
+    def button_action(self, request, target):
+        target.delete()
+        cachebuster.invalidate('misago_bans')
+        message = _('Ban "%s" has been removed.') % unicode(target.name)
+        messages.success(request, message)