Browse Source

Merge branch 'features/mko_01_forum_group_access' of https://github.com/maiiku/flaskbb into maiiku-features/mko_01_forum_group_access

sh4nks 10 years ago
parent
commit
f9d8e01e9f

+ 130 - 49
flaskbb/forum/models.py

@@ -11,8 +11,10 @@
 from datetime import datetime, timedelta
 from datetime import datetime, timedelta
 
 
 from flask import url_for, abort
 from flask import url_for, abort
+from sqlalchemy.orm import aliased
 
 
 from flaskbb.extensions import db
 from flaskbb.extensions import db
+from flaskbb.utils.decorators import can_access_forum, can_access_topic
 from flaskbb.utils.helpers import slugify, get_categories_and_forums, get_forums
 from flaskbb.utils.helpers import slugify, get_categories_and_forums, get_forums
 from flaskbb.utils.settings import flaskbb_config
 from flaskbb.utils.settings import flaskbb_config
 
 
@@ -35,6 +37,22 @@ topictracker = db.Table(
                             use_alter=True, name="fk_tracker_topic_id"),
                             use_alter=True, name="fk_tracker_topic_id"),
               nullable=False))
               nullable=False))
 
 
+# m2m table for group-forum permission mapping
+forumgroups = db.Table(
+    'forumgroups',
+    db.Column(
+        'group_id',
+        db.Integer(),
+        db.ForeignKey('groups.id'),
+        nullable=False
+    ),
+    db.Column(
+        'forum_id',
+        db.Integer(),
+        db.ForeignKey('forums.id', use_alter=True, name="fk_forum_id"),
+        nullable=False
+    )
+)
 
 
 class TopicsRead(db.Model):
 class TopicsRead(db.Model):
     __tablename__ = "topicsread"
     __tablename__ = "topicsread"
@@ -339,6 +357,12 @@ class Topic(db.Model):
         """
         """
         return "<{} {}>".format(self.__class__.__name__, self.id)
         return "<{} {}>".format(self.__class__.__name__, self.id)
 
 
+    @classmethod
+    @can_access_topic
+    def get_topic(cls, topic_id, user):
+        topic = Topic.query.filter_by(id=topic_id).first()
+        return topic
+
     def tracker_needs_update(self, forumsread, topicsread):
     def tracker_needs_update(self, forumsread, topicsread):
         """Returns True if the topicsread tracker needs an update.
         """Returns True if the topicsread tracker needs an update.
         Also, if the ``TRACKER_LENGTH`` is configured, it will just recognize
         Also, if the ``TRACKER_LENGTH`` is configured, it will just recognize
@@ -621,15 +645,28 @@ class Forum(db.Model):
     last_post_created = db.Column(db.DateTime, default=datetime.utcnow())
     last_post_created = db.Column(db.DateTime, default=datetime.utcnow())
 
 
     # One-to-many
     # One-to-many
-    topics = db.relationship("Topic", backref="forum", lazy="dynamic",
-                             cascade="all, delete-orphan")
+    topics = db.relationship(
+        "Topic",
+        backref="forum",
+        lazy="dynamic",
+        cascade="all, delete-orphan"
+    )
 
 
     # Many-to-many
     # Many-to-many
-    moderators = \
-        db.relationship("User", secondary=moderators,
-                        primaryjoin=(moderators.c.forum_id == id),
-                        backref=db.backref("forummoderator", lazy="dynamic"),
-                        lazy="joined")
+    moderators = db.relationship(
+        "User",
+        secondary=moderators,
+        primaryjoin=(moderators.c.forum_id == id),
+        backref=db.backref("forummoderator", lazy="dynamic"),
+        lazy="joined"
+    )
+    groups = db.relationship(
+        "Group",
+        secondary=forumgroups,
+        primaryjoin=(forumgroups.c.forum_id == id),
+        backref="forumgroups",
+        lazy="joined",
+    )
 
 
     # Properties
     # Properties
     @property
     @property
@@ -743,18 +780,12 @@ class Forum(db.Model):
         # Nothing updated, because there are still more than 0 unread topicsread
         # Nothing updated, because there are still more than 0 unread topicsread
         return False
         return False
 
 
-    def save(self, moderators=None):
+    def save(self):
         """Saves a forum"""
         """Saves a forum"""
-        if moderators is not None:
-            for moderator in self.moderators:
-                self.moderators.remove(moderator)
-            db.session.commit()
-
-            for moderator in moderators:
-                if moderator:
-                    self.moderators.append(moderator)
-
-        db.session.add(self)
+        if self.id:
+            db.session.merge(self)
+        else:
+            db.session.add(self)
         db.session.commit()
         db.session.commit()
         return self
         return self
 
 
@@ -786,6 +817,7 @@ class Forum(db.Model):
 
 
     # Classmethods
     # Classmethods
     @classmethod
     @classmethod
+    @can_access_forum
     def get_forum(cls, forum_id, user):
     def get_forum(cls, forum_id, user):
         """Returns the forum and forumsread object as a tuple for the user.
         """Returns the forum and forumsread object as a tuple for the user.
 
 
@@ -916,23 +948,48 @@ class Category(db.Model):
         :param user: The user object is needed to check if we also need their
         :param user: The user object is needed to check if we also need their
                      forumsread object.
                      forumsread object.
         """
         """
+        # import Group model locally to avoid cicular imports
+        from flaskbb.user.models import Group
         if user.is_authenticated():
         if user.is_authenticated():
-            forums = cls.query.\
-                join(Forum, cls.id == Forum.category_id).\
-                outerjoin(ForumsRead,
-                          db.and_(ForumsRead.forum_id == Forum.id,
-                                  ForumsRead.user_id == user.id)).\
-                add_entity(Forum).\
-                add_entity(ForumsRead).\
-                order_by(Category.position, Category.id,  Forum.position).\
-                all()
+            # get list of user group ids
+            user_groups = [gr.id for gr in user.groups]
+            # filter forums by user groups
+            user_forums = Forum.query.filter(Forum.groups.any(
+                Group.id.in_(user_groups))
+            ).subquery()
+            forum_alias = aliased(Forum, user_forums)
+            # get all
+            forums = cls.query.join(
+                forum_alias,
+                cls.id == forum_alias.category_id
+            ).outerjoin(
+                ForumsRead,
+                db.and_(
+                    ForumsRead.forum_id == forum_alias.id,
+                    ForumsRead.user_id == user.id
+                )
+            ).add_entity(
+                forum_alias
+            ).add_entity(
+                ForumsRead
+            ).order_by(
+                Category.position, Category.id,  forum_alias.position
+            ).all()
         else:
         else:
-            # Get all the forums
-            forums = cls.query.\
-                join(Forum, cls.id == Forum.category_id).\
-                add_entity(Forum).\
-                order_by(Category.position, Category.id, Forum.position).\
-                all()
+            guest_group = Group.get_guest_group()
+            # filter forums by guest groups
+            guest_forums = Forum.query.filter(
+                Forum.groups.any(Group.id==guest_group.id)
+            ).subquery()
+            forum_alias = aliased(Forum, guest_forums)
+            forums = cls.query.join(
+                forum_alias,
+                cls.id == forum_alias.category_id
+            ).add_entity(
+                forum_alias
+            ).order_by(
+                Category.position, Category.id, forum_alias.position
+            ).all()
 
 
         return get_categories_and_forums(forums, user)
         return get_categories_and_forums(forums, user)
 
 
@@ -951,24 +1008,48 @@ class Category(db.Model):
         :param user: The user object is needed to check if we also need their
         :param user: The user object is needed to check if we also need their
                      forumsread object.
                      forumsread object.
         """
         """
+        from flaskbb.user.models import Group
         if user.is_authenticated():
         if user.is_authenticated():
-            forums = cls.query.\
-                filter(cls.id == category_id).\
-                join(Forum, cls.id == Forum.category_id).\
-                outerjoin(ForumsRead,
-                          db.and_(ForumsRead.forum_id == Forum.id,
-                                  ForumsRead.user_id == user.id)).\
-                add_entity(Forum).\
-                add_entity(ForumsRead).\
-                order_by(Forum.position).\
-                all()
+            # get list of user group ids
+            user_groups = [gr.id for gr in user.groups]
+            # filter forums by user groups
+            user_forums = Forum.query.filter(Forum.groups.any(
+                Group.id.in_(user_groups))
+            ).subquery()
+            forum_alias = aliased(Forum, user_forums)
+            forums = cls.query.filter(
+                cls.id == category_id
+            ).join(
+                forum_alias,
+                cls.id == forum_alias.category_id
+            ).outerjoin(
+                ForumsRead,
+                db.and_(
+                    ForumsRead.forum_id == forum_alias.id,
+                    ForumsRead.user_id == user.id)
+            ).add_entity(
+                forum_alias
+            ).add_entity(
+                ForumsRead
+            ).order_by(
+                forum_alias.position
+            ).all()
         else:
         else:
-            forums = cls.query.\
-                filter(cls.id == category_id).\
-                join(Forum, cls.id == Forum.category_id).\
-                add_entity(Forum).\
-                order_by(Forum.position).\
-                all()
+            guest_group = Group.get_guest_group()
+            # filter forums by guest groups
+            guest_forums = Forum.query.filter(
+                Forum.groups.any(Group.id==guest_group.id)
+            ).subquery()
+            forum_alias = aliased(Forum, guest_forums)
+            forums = cls.query.filter(
+                cls.id == category_id
+            ).join(
+                forum_alias, cls.id == forum_alias.category_id
+            ).add_entity(
+                forum_alias
+            ).order_by(
+                forum_alias.position
+            ).all()
 
 
         if not forums:
         if not forums:
             abort(404)
             abort(404)

+ 1 - 1
flaskbb/forum/views.py

@@ -101,7 +101,7 @@ def view_topic(topic_id, slug=None):
     page = request.args.get('page', 1, type=int)
     page = request.args.get('page', 1, type=int)
 
 
     # Fetch some information about the topic
     # Fetch some information about the topic
-    topic = Topic.query.filter_by(id=topic_id).first()
+    topic = Topic.get_topic(topic_id=topic_id, user=current_user )
 
 
     # Count the topic views
     # Count the topic views
     topic.views += 1
     topic.views += 1

+ 43 - 14
flaskbb/management/forms.py

@@ -9,16 +9,25 @@
     :license: BSD, see LICENSE for more details.
     :license: BSD, see LICENSE for more details.
 """
 """
 from flask_wtf import Form
 from flask_wtf import Form
-from wtforms import (StringField, TextAreaField, PasswordField, IntegerField,
-                     BooleanField, SelectField, SubmitField)
+from wtforms import (
+    StringField,
+    TextAreaField,
+    PasswordField,
+    IntegerField,
+    BooleanField,
+    SelectField,
+    SubmitField,
+    HiddenField,
+)
 from wtforms.validators import (DataRequired, Optional, Email, regexp, Length,
 from wtforms.validators import (DataRequired, Optional, Email, regexp, Length,
                                 URL, ValidationError)
                                 URL, ValidationError)
 from wtforms.ext.sqlalchemy.fields import (QuerySelectField,
 from wtforms.ext.sqlalchemy.fields import (QuerySelectField,
                                            QuerySelectMultipleField)
                                            QuerySelectMultipleField)
+from sqlalchemy.orm.session import make_transient, make_transient_to_detached
 from flask_babelex import lazy_gettext as _
 from flask_babelex import lazy_gettext as _
 
 
 from flaskbb.utils.fields import BirthdayField
 from flaskbb.utils.fields import BirthdayField
-from flaskbb.utils.widgets import SelectBirthdayWidget
+from flaskbb.utils.widgets import SelectBirthdayWidget, MultiSelect
 from flaskbb.extensions import db
 from flaskbb.extensions import db
 from flaskbb.forum.models import Forum, Category
 from flaskbb.forum.models import Forum, Category
 from flaskbb.user.models import User, Group
 from flaskbb.user.models import User, Group
@@ -35,6 +44,8 @@ def selectable_forums():
 def selectable_categories():
 def selectable_categories():
     return Category.query.order_by(Category.position)
     return Category.query.order_by(Category.position)
 
 
+def selectable_groups():
+    return Group.query.order_by(Group.name.asc()).all()
 
 
 def select_primary_group():
 def select_primary_group():
     return Group.query.filter(Group.guest != True).order_by(Group.id)
     return Group.query.filter(Group.guest != True).order_by(Group.id)
@@ -306,6 +317,14 @@ class ForumForm(Form):
         description=_("Disable new posts and topics in this forum.")
         description=_("Disable new posts and topics in this forum.")
     )
     )
 
 
+    groups = QuerySelectMultipleField(
+        _("Group Access to Forum"),
+        query_factory=selectable_groups,
+        get_label="name",
+        widget=MultiSelect(),
+        description=_("Select user groups that can access this forum.")
+    )
+
     submit = SubmitField(_("Save"))
     submit = SubmitField(_("Save"))
 
 
     def validate_external(self, field):
     def validate_external(self, field):
@@ -350,24 +369,34 @@ class ForumForm(Form):
         else:
         else:
             field.data = approved_moderators
             field.data = approved_moderators
 
 
-    def save(self):
-        forum = Forum(title=self.title.data,
-                      description=self.description.data,
-                      position=self.position.data,
-                      external=self.external.data,
-                      show_moderators=self.show_moderators.data,
-                      locked=self.locked.data)
+    def validate_groups(self, field):
 
 
-        if self.moderators.data:
-            # is already validated
-            forum.moderators = self.moderators.data
+        if field.data:
+            pass
+        elif field.raw_data:
+            ids = field.raw_data.pop().split(",")
+            groups  = Group.query.filter(Group.id.in_(ids)).all()
+            field.data = groups
+        else:
+            field.data = []
 
 
-        forum.category_id = self.category.data.id
+    def save(self, obj=None):
+
+        data = self.data
+        # remove the button
+        data.pop('submit', None)
+        forum = Forum(**data)
+        #flush SQLA info from created instabce so that it can be merged
+        make_transient(forum)
+        make_transient_to_detached(forum)
 
 
         return forum.save()
         return forum.save()
 
 
 
 
 class EditForumForm(ForumForm):
 class EditForumForm(ForumForm):
+
+    id = HiddenField()
+
     def __init__(self, forum, *args, **kwargs):
     def __init__(self, forum, *args, **kwargs):
         self.forum = forum
         self.forum = forum
         kwargs['obj'] = self.forum
         kwargs['obj'] = self.forum

+ 1 - 3
flaskbb/management/views.py

@@ -364,9 +364,7 @@ def edit_forum(forum_id):
 
 
     form = EditForumForm(forum)
     form = EditForumForm(forum)
     if form.validate_on_submit():
     if form.validate_on_submit():
-        form.populate_obj(forum)
-        forum.save(moderators=form.moderators.data)
-
+        form.save()
         flash(_("Forum successfully updated."), "success")
         flash(_("Forum successfully updated."), "success")
         return redirect(url_for("management.edit_forum", forum_id=forum.id))
         return redirect(url_for("management.edit_forum", forum_id=forum.id))
     else:
     else:

+ 4 - 0
flaskbb/static/css/flaskbb.css

@@ -358,3 +358,7 @@ margin-bottom: 0px;
 .conversation-list .conversation-date {
 .conversation-list .conversation-date {
     color: #999999;
     color: #999999;
 }
 }
+
+.spacer-megalist{
+    padding-top: 20px;
+}

+ 114 - 0
flaskbb/static/css/megalist-multiselect.css

@@ -0,0 +1,114 @@
+.megalist-mutliselect {
+  -webkit-user-select: none;
+  -moz-user-select: none;
+  -ms-user-select: none;
+  user-select: none;
+  overflow: hidden;
+}
+.megalist-mutliselect .megalist {
+  float: left;
+  width: 285px;
+}
+.megalist-mutliselect .megalist input[type=text] {
+  -webkit-box-sizing: border-box;
+  -moz-box-sizing: border-box;
+  -ms-box-sizing: border-box;
+  box-sizing: border-box;
+  width: 100% !important;
+  padding: 5px 4px;
+  margin-bottom: 5px;
+}
+.megalist-mutliselect .megalist .megalist-inner {
+  -webkit-box-sizing: border-box;
+  -moz-box-sizing: border-box;
+  -ms-box-sizing: border-box;
+  box-sizing: border-box;
+  position: relative;
+  height: 205px;
+  width: 100%;
+  overflow: hidden;
+  border: 1px solid silver;
+}
+.megalist-mutliselect .megalist .megalist-inner ul {
+  position: absolute;
+  padding: 0;
+  display: block;
+  margin-top: 0px;
+  width: 100%;
+  top: 0px;
+}
+.megalist-mutliselect .megalist .megalist-inner ul li {
+  margin: 0;
+  border: none;
+  white-space: nowrap;
+  overflow: hidden;
+  padding: 6px 0px 6px 10px !important;
+  display: block;
+  position: absolute;
+  width: 100%;
+  border-top: 1px solid #EDEDED;
+  line-height: 1em !important;
+  cursor: pointer;
+  color: #555;
+}
+.megalist-mutliselect .megalist .megalist-inner ul li:hover {
+  background-color: #08c;
+}
+.megalist-mutliselect .megalist .scrollbar {
+  -webkit-border-radius: 3px;
+  -moz-border-radius: 3px;
+  border-radius: 3px;
+  position: absolute;
+  right: 1px;
+  width: 11px;
+  height: 25px;
+  background-color: #bebebe;
+  z-index: 2;
+}
+.megalist-mutliselect .megalist .scrollbar:hover {
+  background-color: #afafaf;
+}
+.megalist-mutliselect .megalist .scrollbar-background {
+  position: absolute;
+  top: 0px;
+  right: 0px;
+  width: 14px;
+  height: 100%;
+  z-index: 1;
+  background-color: #ececec;
+}
+.megalist-mutliselect .move-buttons {
+  margin: 90px 0px;
+  float: left;
+}
+.megalist-mutliselect .move-buttons .move-button {
+  height: 30px;
+  padding: 0px 50px;
+  margin-bottom: 10px;
+  cursor: pointer;
+}
+.megalist-mutliselect .move-buttons .move-button svg {
+  fill: #0374bb;
+}
+.megalist-mutliselect .move-buttons .move-button:hover svg {
+  fill: #024570;
+}
+.megalist-mutliselect .move-buttons .move-button:hover.arrow-left.no-svg .svg {
+  background-position: 0% 100%;
+}
+.megalist-mutliselect .move-buttons .move-button:hover.arrow-right.no-svg .svg {
+  background-position: 100% 100%;
+}
+.megalist-mutliselect .move-buttons .move-button.no-svg .svg {
+  background-repeat: no-repeat;
+  background-position: center;
+  background: url('../icons/megalist-icons.png');
+  width: 32px;
+  height: 32px;
+}
+.megalist-mutliselect .move-buttons .move-button.arrow-left.no-svg .svg {
+  background-position: 0% 0%;
+}
+.megalist-mutliselect .move-buttons .move-button.arrow-right.no-svg .svg {
+  background-position: 100% 0%;
+}

BIN
flaskbb/static/img/megalist-icons.png


+ 1174 - 0
flaskbb/static/js/megalist-multiselect.js

@@ -0,0 +1,1174 @@
+!function($){
+  'use strict';
+
+ /* LIST CLASS DEFINITION
+  * ========================= */
+
+  var Megalist = function(element, $parent) {
+    var html;
+
+    if ($parent === undefined){
+    //if there's no $parent then we are creating one
+        this.$el = element;
+        this.setOptions(this.$el.options);
+        // build HTML
+        html = this.buildParentDOM();
+        //source list - data to choose from
+        this.$el.sourceList = new Megalist(html.srcElement, this.$el);
+        //destination list - data chosen by user
+        this.$el.destinationList = new Megalist(html.dstElement, this.$el);
+
+    } else {
+    //else just init one of the megalistSide children
+        this.init(element, $parent);
+    }
+    return this;
+  };
+
+  Megalist.prototype = {
+
+    constructor: Megalist,
+
+    /**
+     * megalistSide constructor - initializes one side of megalist
+     *
+     * @param {object} element - jQuery object on witch megalist is initialized
+     * @param {object} $parent - optional jQuery object with parent for
+     *                           megalistSide initialization only
+     * @return {object} - returns self
+     */
+    init: function(element, $parent) {
+        this.$el = element;
+        this.$parent = $parent;
+
+        //defaults
+        this.processedItems = {};
+        this.totalItems = [];
+        this.itemHeight = -1;
+        this.listItems = $();
+        this.suffix = undefined;
+        this.yPosition = 0;
+        this.filteredData = [];
+        this.pageHeight = 0;
+        this.scrollingActive = false;
+
+        //init widget
+        this.setOptions(this.$parent.options);
+        this.getSuffix();
+        this.buildDOM();
+        this.bindEvents();
+        this.bindData();
+        this.updateLayout();
+        this.generatePOST(this.conf.BUILD_FULL_POST);
+
+        return this;
+    },
+
+    /**
+     * Sets default options and extends them if configuration was provided on
+     * megalist initialization
+     *
+     * @param {object} options - object containing options for megalist
+     */
+    setOptions: function(options){
+        var conf = {};
+
+        // mimimum scrollbar height in pixels
+        conf.SCROLLBAR_MIN_SIZE = 12;
+        // inertial delay for megalist ui update after resize event occurs
+        conf.RESIZE_TIMEOUT_DELAY = 100;
+        // minimum characters to trigger quicksearch filtering
+        conf.MINIMUM_SEARCH_QUERY_SIZE = 3;
+        // build full or simple (comma separated ids) post
+        conf.BUILD_FULL_POST = false;
+        // move action event name to trigger
+        conf.MOVE_ACTION_NAME = 'move';
+        //functional suffixes for multiselect: destination list suffix
+        conf.DESTINATION_SUFFIX = 'dst';
+        // functional suffixes for multiselect: source list suffix
+        conf.SOURCE_SUFFIX = 'src';
+        // text to display as search input placeholder
+        conf.PLACEHOLDER_TEXT = 'Search';
+        // time to wait for first continous scrolling
+        conf.CONTINOUS_SCROLLING_FIRST_INTERVAL = 500;
+        // time to wait for every next continous scrolling
+        conf.CONTINOUS_SCROLLING_INTERVAL = 60;
+
+        if (typeof options === 'object'){
+            conf = $.extend(conf, options);
+        }
+        this.conf = conf;
+    },
+
+    /**
+     * Builds required html elements for both source and destination
+     * megalistSide and append them to parent element
+     */
+    buildParentDOM: function() {
+        var srcElement, dstElement;
+
+        this.$el.html('');
+        this.$el.addClass('megalist-mutliselect');
+
+        //create 2 containers for megalists and buttons between then append
+        srcElement = $( '<div/>', {
+            'id': this.$el.attr('id') + '_' + this.conf.SOURCE_SUFFIX,
+            'class': 'megalist-inner'
+        });
+        dstElement = $( '<div/>', {
+            'id': this.$el.attr('id') + '_' + this.conf.DESTINATION_SUFFIX,
+            'class': 'megalist-inner'
+        });
+        this.$el.$moveButtons = $( '<div/>', {
+            'class': 'move-buttons'
+        });
+
+        this.$el.append(srcElement, this.$el.$moveButtons, dstElement);
+
+        return {srcElement:srcElement, dstElement:dstElement};
+    },
+
+    /**
+     * Builds required html elements for megalistSide:
+     * searchbox, scrollbar, move button and hidden result input
+     */
+    buildDOM: function() {
+        var arrowIcon = 'arrow-left';
+
+        if (this.suffix === this.conf.SOURCE_SUFFIX) {
+            arrowIcon = 'arrow-right';
+        }
+
+        this.$el.wrap('<div class="megalist"></div>"');
+
+        this.$search = $('<input/>', {
+            'id': this.$el.attr('id') + '_search',
+            'placeholder': this.conf.PLACEHOLDER_TEXT,
+            'type': 'text'
+        });
+        this.$scrollbar = $('<div/>', {
+            'id': this.$el.attr('id') + '_scrollbar',
+            'class': 'scrollbar'
+        });
+        this.$scrollbarBackground = $('<div/>', {
+            'class': 'scrollbar-background'
+        });
+        this.$moveall = $('<div/>', {
+            'class': 'move-button ' + arrowIcon
+        });
+
+        if (Modernizr.svg) {
+            this.$moveall.append($(
+                '<svg width="32" height="32" viewBox="0 0 64 64">' +
+                '<use xlink:href="#' + arrowIcon + '"></svg>'
+            ));
+        } else {
+            this.$moveall.addClass('no-svg');
+            this.$moveall.append($('<div class="svg" />'));
+        }
+
+        //attach to container in parent
+        this.$parent.$moveButtons.append(this.$moveall);
+
+        this.$input = $('<input/>', {
+            'name': this.name,
+            'type': 'hidden'
+        });
+        this.$ul = $('<ul />');
+
+        this.$el.before(this.$search);
+
+        // Set tabindex, so the element can be in focus
+        this.$el.attr('tabindex', '-1');
+    },
+
+    /**
+     * Resolves suffix for megalistSide so that it know if it's source or
+     * destination side. Resolving is based on id of the container
+     */
+    getSuffix: function() {
+        var id_tokens, lastToken;
+
+        id_tokens = this.$el.attr('id').split('_');
+        lastToken = id_tokens[id_tokens.length - 1];
+        this.name = id_tokens.splice(id_tokens, id_tokens.length-1).join('_');
+
+        if (lastToken === this.conf.SOURCE_SUFFIX) {
+            this.suffix = this.conf.SOURCE_SUFFIX;
+        } else if (lastToken === this.conf.DESTINATION_SUFFIX) {
+            this.suffix = this.conf.DESTINATION_SUFFIX;
+        }
+    },
+
+    /**
+     * Returns targetList for current megalistSide. In not defined, gets proper
+     * one from $parent first and stores it for later use
+     *
+     * @return {object} - returns target list
+     */
+    getTargetList: function() {
+        if (!(this.targetList instanceof Object)){
+            if ( this.suffix === this.conf.SOURCE_SUFFIX) {
+                this.targetList = this.$parent.destinationList;
+            } else if ( this.suffix === this.conf.DESTINATION_SUFFIX) {
+                this.targetList = this.$parent.sourceList;
+            }
+        }
+        return this.targetList;
+    },
+
+    /**
+     * Binds all events need by the widget
+     */
+    bindEvents: function() {
+        var self = this,
+            filterEvent;
+
+       $(window).resize(function(event){
+            return self.onResize(event);
+        });
+
+        $(window).bind('keydown', function(event) {
+            return self.onKeydown(event);
+        });
+
+        this.$el.mousedown(function() {
+            setTimeout(function(){
+                this.focus();
+            }, 1);
+        });
+
+        this.$el.bind('mousewheel DOMMouseScroll', function(event) {
+            event.preventDefault();
+            return self.onMouseWheel(event);
+        });
+
+        this.$el.click(function(event) {
+            self.processListClick(event);
+        });
+
+        this.$scrollbar.bind('mousedown', function(event) {
+             self.onScrollbarStart(event);
+        });
+
+        this.$scrollbarBackground.mousedown(function(event) {
+            self.scrollingActive = true;
+            self.scrollDir = undefined;
+            self.onScrollbarBackgroundClick(event);
+        });
+
+        this.$scrollbarBackground.mouseleave(function(event) {
+            self.scrollingActive = false;
+        });
+
+        this.$scrollbarBackground.mouseup(function(event) {
+            self.scrollingActive = false;
+        });
+
+        this.$moveall.click(function(event) {
+            self.onMoveAll(event);
+        });
+
+        if (Modernizr.hasEvent('input', this.$search)) {
+            filterEvent = 'input';
+        } else {
+            filterEvent = 'keyup';
+        }
+
+        this.$search.on(filterEvent, function() {
+            self.yPosition = 0;
+            self.filterList();
+        });
+    },
+
+    /**
+     * Extracts the supplied data for megalistSide from data-provider-src or
+     * data-provider-dst attributes depending which side is being loaded.
+     * The attributes must be set on megalist container.
+     */
+    bindData: function() {
+        this.origData = this.$parent.attr('data-provider-' + this.suffix);
+        if (this.origData.length){
+            this.dataProviderOrig =  this.parseData(this.origData);
+            this.$parent.attr('data-provider-' + this.suffix, '');
+        } else {
+            this.dataProviderOrig = {};
+        }
+
+        this.dataProvider = this.dataProviderOrig;
+
+        this.clearSelectedIndex();
+
+        this.$ul.find('li').each(function() {
+            $(this).remove();
+        });
+
+        this.yPosition = 0;
+    },
+
+    /**
+     * Parses the data extracted from container attribues. Currently two
+     * formats are supported: JSON and passing old <select> element that
+     * is being replaced by this widget
+     *
+     * @param {string} origData - string extracted from attribute
+     *                            (JSON or old select html)
+     * @return {Array} parsed - parsed data array
+     */
+    parseData: function(origData){
+        var parsed = [], item = {};
+        var selected = ':not(:selected)';
+
+        //first see if it's JSON
+        try {
+          parsed = $.parseJSON(origData);
+        } catch(e) {
+          //not JSON
+        }
+        //ok, maybe it's being fed <option>s from an old select?
+        if (origData.substr(0, 7) == '<select'){
+          if (this.suffix === this.conf.DESTINATION_SUFFIX) {
+              selected = ':selected';
+          }
+           $.map($('option', origData).filter(selected), function(opt){
+               item.listValue = opt.value;
+               item.label = opt.text;
+               parsed.push(item);
+               item = {};
+           });
+        } else if ((origData.indexOf('<select') > -1)){
+            console.log('ERROR: the supplied string MUST start with <select');
+        }
+
+        return parsed;
+    },
+
+    /**
+     * Updates responsive mutliselect on window resize by recalculating new
+     * sizing and redrawing megalistSide widgets. Updating has some inertia
+     * added resizing only after RESIZE_TIMEOUT_DELAY is reached
+     */
+    onResize: function() {
+        clearTimeout(this.reizeTimeout);
+        var self = this,
+            totalHeight = this.dataProvider.length * this.itemHeight,
+            maxPosition = totalHeight - this.$el.height();
+
+        maxPosition = Math.max(0, maxPosition);
+        this.yPosition = Math.min(this.yPosition, maxPosition);
+        this.reizeTimeout = setTimeout(function() {
+            self.updateLayout();
+        }, this.conf.RESIZE_TIMEOUT_DELAY);
+    },
+
+    /**
+    * @TODO - @FIXME
+    * @param {event} event - user key press event
+    */
+    onKeydown: function (event) {
+        var delta = 0,
+            action = this.conf.MOVE_ACTION_NAME,
+            self = this,
+            oldindex = this.getSelectedIndex(),
+            index = oldindex + delta;
+
+        if (!this.$el.is(':focus')) {
+            return;
+        }
+
+        switch (event.which) {
+            case 33:  // Page up
+                delta = -1 * Math.floor(this.$el.height() / this.itemHeight);
+                break;
+
+            case 34:  // Page down
+                delta = Math.floor(this.$el.height() / this.itemHeight);
+                break;
+
+            case 38:  // Up
+                delta = -1;
+                break;
+
+            case 40:  // Down
+                delta = 1;
+                break;
+
+            default:
+                return;
+        }
+
+        if (index > this.dataProvider.length -1) {
+            index = this.dataProvider.length;
+        }
+        if (index < 0) {
+            index = 0;
+        }
+
+        if (index === oldindex) {
+            return false;
+        }
+
+        this.setSelectedIndex(index);
+
+        if (this.yPosition > (index * this.itemHeight)) {
+            this.yPosition = (index*this.itemHeight);
+        }
+        if (this.yPosition < ((index+1) * this.itemHeight) - this.$el.height()) {
+            this.yPosition = ((index+1)*this.itemHeight) - this.$el.height();
+        }
+
+        this.updateLayout();
+        this.cleanupTimeout = setTimeout(function() {
+            self.cleanupListItems();
+        }, 100);
+
+        var target = this.$ul.find('.megalistSelected');
+
+        setTimeout(function() {
+            var event = $.Event(action, data),
+                data = {
+                    selectedIndex: index,
+                    srcElement: $(target),
+                    item: self.dataProvider[index],
+                    destination: self.$el.attr('id')
+                };
+            self.$el.trigger(event);
+        }, 150);
+
+        return false;
+    },
+
+    /**
+     * Updates megalistSide widget on mouse scroll event
+     * only concerned about vertical scroll
+     *
+     * @param {event} event - mouse wheel event
+     */
+    onMouseWheel: function (event) {
+        clearTimeout(this.cleanupTimeout);
+
+        var self = this,
+            orgEvent = event.originalEvent,
+            delta = 0,
+            totalHeight = this.dataProvider.length * this.itemHeight,
+            maxPosition = totalHeight - this.$el.height();
+
+        // Old school scrollwheel delta
+        if (orgEvent.wheelDelta) {
+            delta = orgEvent.wheelDelta / 120;
+        }
+        if (orgEvent.detail) {
+            delta = -orgEvent.detail / 3;
+        }
+
+        // Webkit
+        if ( orgEvent.wheelDeltaY !== undefined ) {
+            delta = orgEvent.wheelDeltaY / 120;
+        }
+
+        this.yPosition -= delta * this.itemHeight;
+
+        //limit the mouse wheel scroll area
+        if (this.yPosition > maxPosition) {
+            this.yPosition = maxPosition;
+        }
+        if (this.yPosition < 0) {
+            this.yPosition = 0;
+        }
+
+        this.updateLayout();
+        this.cleanupTimeout = setTimeout(function() {
+            self.cleanupListItems();
+        }, 100);
+
+        return false;
+    },
+
+    /**
+     * Handles click event on megalist element
+     *
+     * @param {event} event - mouse wheel event
+     */
+    processListClick: function(event) {
+        var self = this,
+            target = event.target,
+            index = $(target).attr('list-index'),
+            out_data = this.dataProvider[index],
+            clicked_value = this.dataProvider[index];
+
+        while (target.parentNode !== null) {
+            if (target.nodeName === 'LI') {
+                break;
+            }
+            target = target.parentNode;
+        }
+
+        if (target.nodeName !== 'LI') {
+            return false;
+        }
+
+        if (index === this.selectedIndex) {
+            return false;
+        }
+
+        this.setSelectedIndex(index);
+
+        this.getTargetList().updateDataProvider(out_data);
+
+        self.clearSelectedIndex();
+
+        self.dataProviderOrig.splice(
+            self.dataProviderOrig.indexOf(clicked_value), 1
+        );
+
+        if (this.yPosition > this.getMaxPosition()) {
+            this.yPosition -= this.itemHeight;
+        }
+
+        self.filterList();
+        this.$parent.destinationList.generatePOST(this.conf.BUILD_FULL_POST);
+
+        return true;
+    },
+
+    /**
+     * Handles click on "move all" button, move all items from one
+     * megalistSide to the other, renders POST result into hidden input field
+     * after the action is performed
+     *
+     */
+    onMoveAll: function(){
+        var out_data = this.dataProvider,
+            i;
+
+        this.getTargetList().updateDataProvider(out_data);
+
+        this.clearSelectedIndex();
+        this.dataProvider = [];
+        if (this.filteredData.length > 0) {
+            for (i = this.filteredData.length - 1; i >= 0; i--) {
+                this.dataProviderOrig.splice(this.filteredData[i], 1);
+            }
+        } else if (!this.searchingIsActive()) {
+            this.dataProviderOrig = [];
+        }
+        this.$parent.destinationList.generatePOST(this.conf.BUILD_FULL_POST);
+        this.updateLayout();
+    },
+
+    /**
+     * Handles drag event on scrollbar - binds events appropriate to user
+     * action and delgates event to correct function
+     *
+     * @param {event} event - mouse event on scrollbar
+     */
+    onScrollbarStart: function(event) {
+        var self = this;
+
+        this.unbindScrollbarEvents();
+        this.scrollbarInputCoordinates = this.getInputCoordinates(event);
+
+        $(document).bind('mousemove', function(event) {
+             self.onScrollbarMove(event);
+        });
+
+        $(document).bind('mouseup', function() {
+             self.unbindScrollbarEvents();
+        });
+
+        event.preventDefault();
+        return false;
+    },
+
+    /**
+     * Handles drag event on scroll bar and recalculates what items should be
+     * rendered in the viewport
+     *
+     * @param {event} event - scrollbar drag event to get coordinates from
+     */
+    onScrollbarMove: function(event) {
+        var newCoordinates = this.getInputCoordinates(event),
+            height = this.$el.height(),
+            totalHeight = this.dataProvider.length * this.itemHeight,
+            scrollbarHeight = this.$scrollbar.height(),
+            yDelta = this.scrollbarInputCoordinates.y - newCoordinates.y,
+            yPosition = parseInt(this.$scrollbar.css('top'), 10),
+            usingMinSize = scrollbarHeight === this.conf.SCROLLBAR_MIN_SIZE,
+            heightOffset = usingMinSize ? scrollbarHeight : 0,
+            newYPosition;
+
+        // valid move occurs only when pressing left mouse button
+        if (event.which !== 1) {
+            this.unbindScrollbarEvents();
+            return;
+        }
+
+        yPosition -= yDelta;
+
+        yPosition = Math.max(yPosition, 0);
+        yPosition = Math.min(yPosition, height - scrollbarHeight);
+        yPosition = Math.min(yPosition, height);
+
+        this.$scrollbar.css('top', yPosition);
+        this.scrollbarInputCoordinates = newCoordinates;
+
+        newYPosition = (
+            yPosition / (height - heightOffset) *
+            (this.itemHeight * this.dataProvider.length - 1)
+        );
+        newYPosition = Math.max(0, newYPosition);
+        newYPosition = Math.min(
+            newYPosition, totalHeight - height
+        );
+
+        this.yPosition = newYPosition;
+        this.updateLayout(true);
+
+        event.preventDefault();
+        return false;
+    },
+
+    /**
+     * Utility function to remove events bound to the scrollbar
+     *
+     */
+    unbindScrollbarEvents: function() {
+        $(document).unbind('mousemove');
+        $(document).unbind('mouseup');
+    },
+
+    /**
+     * Handles click event on scrollbar background - a click on scrollbar
+     * background should cause pageUp/PageDown action on the viewport
+     *
+     * @param {event} event - scrollbar click event to get coordinates from
+     */
+    onScrollbarBackgroundClick: function(event, repeatTimeout) {
+        var self = this,
+            // firefox uses originalEvent.layerY instead of offsetY
+            yOffset = event.offsetY !== undefined ? event.offsetY : event.originalEvent.layerY,
+            scrollbarBackgroundHeight = $(event.target).height(),
+            clickPos = yOffset / scrollbarBackgroundHeight,
+            listTotalHeight = this.dataProvider.length * this.itemHeight,
+            scrollbarHeightFraction = this.$scrollbar.height() / scrollbarBackgroundHeight,
+            currentPos = this.yPosition / listTotalHeight,
+            offsetToMove = this.pageHeight,
+            shouldMoveUp = clickPos > currentPos + scrollbarHeightFraction,
+            shouldMoveDown = clickPos < currentPos;
+
+        if (!this.scrollingActive) {
+            return;
+        }
+
+        if (this.scrollDir == undefined) {
+            if (shouldMoveUp) {
+                this.scrollDir = 1;
+            } else if (shouldMoveDown) {
+                this.scrollDir = -1;
+            } else {
+                return;
+            }
+        }
+
+        if (shouldMoveUp && this.scrollDir === 1) {
+            this.yPosition += offsetToMove;
+        } else if (shouldMoveDown && this.scrollDir === -1) {
+            this.yPosition -= offsetToMove;
+        } else {
+            return;
+        }
+
+        if (this.yPosition > listTotalHeight - this.pageHeight) {
+            this.yPosition = listTotalHeight - this.pageHeight;
+        } else if (this.yPosition < 0) {
+            this.yPosition = 0;
+        }
+
+        this.updateLayout();
+
+        if (this.scrollingActive) {
+            if (repeatTimeout === undefined) {
+                repeatTimeout = this.conf.CONTINOUS_SCROLLING_FIRST_INTERVAL;
+            }
+            setTimeout(function() {
+                self.onScrollbarBackgroundClick(
+                    event, self.conf.CONTINOUS_SCROLLING_INTERVAL
+                );
+            }, repeatTimeout);
+        }
+    },
+
+    /**
+     * Removes items rendered in megalist that no longer fit into the viewport
+     * and removes them from processed items cache
+     */
+    cleanupListItems: function() {
+        //remove any remaining LI elements hanging out on the dom
+        var temp = [],
+            item, index, x;
+
+        for (x = 0; x < this.totalItems.length; x++ ) {
+            item = this.totalItems[x];
+            index = item.attr('list-index');
+            if (this.processedItems[index] === undefined) {
+                item.remove();
+            }
+        }
+        //cleanup processedItems array
+        if (this.processedItems) {
+            for (index in this.processedItems) {
+                temp.push(this.processedItems[index]);
+            }
+        }
+        this.totalItems = temp;
+    },
+
+    /**
+     * Extracts input coordinates from the event
+     *
+     * @param {event} event - event to get coordinates from
+     * @return {object} result - object containge x and y coordinates
+     */
+    getInputCoordinates: function (event) {
+        var targetEvent = event,
+            result = {
+                x: Math.round(targetEvent.pageX),
+                y: Math.round(targetEvent.pageY)
+            };
+        return result;
+    },
+
+    /**
+     * Main rendering function for megalist: redraws the list based on data
+     * fed to megalist and scrollbar position. Iterates over visible items
+     * and renders them then calls update on the scrollbar if ignoreScrollbar
+     * not set to true
+     *
+     * @param {boolean} ignoreScrollbar - a flag allowing scrollbar to not be
+     * redrawn if not necessary
+     */
+    updateLayout: function(ignoreScrollbar) {
+        var height = this.$el.height(),
+            i = -1,
+            startPosition = Math.ceil(this.yPosition / this.itemHeight),
+            maxHeight = 2 * (height + (2 * this.itemHeight)),
+            index, item, currentPosition, parentLength;
+
+        if (this.dataProvider.length > 0) {
+            this.$ul.detach();
+            this.processedItems = {};
+
+            while (i * this.itemHeight < maxHeight) {
+                index = Math.min(
+                    Math.max(startPosition + i, 0),
+                    this.dataProvider.length
+                );
+
+                item = this.getItemAtIndex(index);
+                this.totalItems.push(item);
+
+                this.processedItems[index.toString()] = item;
+                currentPosition = i * this.itemHeight;
+                this.setItemPosition(item, 0, currentPosition);
+
+                if (item.parent().length <= 0) {
+                    this.$ul.append(item);
+
+                    if (this.itemHeight <= 0) {
+                        this.prepareLayout(item);
+                        this.updateLayout();
+                        return;
+                    }
+                }
+                i++;
+            }
+
+            this.cleanupListItems();
+            if (ignoreScrollbar !== true) {
+                this.updateScrollBar();
+            }
+            if (this.$scrollbar.parent().length > 0){
+                this.$scrollbar.before(this.$ul);
+            } else {
+                 this.$el.append(this.$ul);
+            }
+        } else {
+            if (this.$ul.children().length > 0) {
+                this.$ul.empty();
+                this.cleanupListItems();
+                parentLength = this.$scrollbar.parent().length > 0;
+                if (ignoreScrollbar !== true && parentLength > 0) {
+                    this.updateScrollBar();
+                }
+            } else {
+                this.hideScrollbar();
+            }
+        }
+    },
+
+    /**
+     * Prepares layout by appending list to DOM, and calculating site of
+     * element and size of single page
+     *
+     * @param  {object} item    jQuery object representing single item
+     */
+    prepareLayout: function(item) {
+        var itemsPerPage;
+
+        // make sure item have proper height by filling it with content
+        item.html('&nsbp;');
+        this.$el.append(this.$ul);
+
+        // calculate height of item and height of single page
+        this.itemHeight = item.outerHeight();
+        itemsPerPage = Math.floor(
+            this.$ul.parent().height() / this.itemHeight
+        );
+        this.pageHeight = this.itemHeight * itemsPerPage;
+    },
+
+    /**
+     * Shows scrollbar
+     */
+    showScrollbar: function() {
+        this.$el.append(this.$scrollbar, this.$scrollbarBackground);
+    },
+
+    /**
+     * Hides scrollbar
+     */
+    hideScrollbar: function() {
+        this.$scrollbar.detach();
+        this.$scrollbarBackground.detach();
+    },
+
+    /**
+     * Renders the scrollbar as a part of UI update when list is scrolled or
+     * modified
+     */
+    updateScrollBar: function() {
+        var height = this.$el.height(),
+            maxScrollbarHeight = height,
+            maxItemsHeight = this.dataProvider.length * this.itemHeight,
+            targetHeight = maxScrollbarHeight * Math.min(
+                maxScrollbarHeight / maxItemsHeight, 1
+            ),
+            actualHeight = Math.floor(
+                Math.max(targetHeight, this.conf.SCROLLBAR_MIN_SIZE)
+            ),
+            scrollPosition = (
+                this.yPosition / (maxItemsHeight - height) *
+                (maxScrollbarHeight - actualHeight)
+            ),
+            parent = this.$scrollbar.parent();
+
+        if (scrollPosition < 0) {
+            actualHeight = Math.max(actualHeight + scrollPosition, 0);
+            scrollPosition = 0;
+        } else if (scrollPosition > (height - actualHeight)) {
+            actualHeight = Math.min(actualHeight, height - scrollPosition);
+        }
+
+        this.$scrollbar.height(actualHeight);
+
+        if ((this.dataProvider.length * this.itemHeight) <= height) {
+            if (parent.length > 0) {
+                this.hideScrollbar();
+            }
+        } else {
+            if (parent.length <= 0) {
+                this.showScrollbar();
+            }
+            this.$scrollbar.css('top', scrollPosition);
+        }
+    },
+
+    /**
+     * Utility function to set offset css on an item
+     *
+     * @param {object} item - megalist element
+     * @param {int} x - x offset in pixels
+     * @param {int} y - y offset in pixels
+     */
+    setItemPosition: function(item, x, y) {
+        item.css('left', x);
+        item.css('top', y);
+    },
+
+    /**
+     * Gets megalist item at given index. Parses it to <li> item if necessary
+     *
+     * @param {int} i - object index
+     * @return {object} - jQuery object containing selected <li> element
+     */
+    getItemAtIndex: function(i) {
+        var item, iString, data;
+        if (this.dataProvider === this.listItems) {
+            item = $(this.listItems[i]);
+        }
+        else if (i !== undefined){
+            iString = i.toString();
+
+            if (this.listItems[iString] === null ||
+                this.listItems[iString] === undefined
+            ) {
+                item = $('<li />');
+                this.listItems[iString] = item;
+            } else {
+                item = $(this.listItems[i]);
+            }
+
+            if (i >= 0 && i < this.dataProvider.length){
+                data = this.dataProvider[i];
+                item.html(data.label);
+                item.attr('list-value', data.listValue);
+            }
+        }
+        if (item !== null && item !== undefined) {
+            item.attr('list-index', i);
+        }
+        return item;
+    },
+
+    /**
+     * Returns index of currently selected item
+     *
+     * @return {int} - index of item that was selected
+     */
+    getSelectedIndex: function() {
+        return parseInt(this.selectedIndex, 10);
+    },
+
+    /**
+     * Sets item at given index as selected and adds appropriate styling to it
+     *
+     * @param {int} index = index of item that was selected
+     */
+    setSelectedIndex: function(index) {
+        var item = this.getItemAtIndex(this.selectedIndex);
+
+        if (item !== undefined) {
+            item.removeClass('megalistSelected');
+        }
+
+        this.selectedIndex = index;
+        this.getItemAtIndex(index).addClass('megalistSelected');
+    },
+
+    /**
+     * Clears currently selected object by removing styling and setting
+     * internal variable pointing to currently selected item to -1
+     *
+     */
+    clearSelectedIndex: function() {
+        var item = this.getItemAtIndex(this.selectedIndex);
+
+        if (item !== undefined) {
+            item.removeClass('megalistSelected');
+        }
+        this.selectedIndex = -1;
+    },
+
+    /**
+     * Sets initial data for megalist and updates layout with it
+     *
+     * @param {Array} dataProvider - object array to initially feed megalist
+     */
+    setDataProvider: function(dataProvider) {
+        this.clearSelectedIndex();
+        this.dataProviderOrig = dataProvider;
+        this.dataProvider = dataProvider;
+
+        this.$ul.find('li').each(function() {
+            $(this).remove();
+        });
+
+        this.yPosition = 0;
+        this.updateLayout();
+    },
+
+    /**
+     * Updates megalist with new data. Accepts either a single object or
+     * an Array of objects and updates layout with new data
+     *
+     * @param {object|Array} newElement - new object / array of objects
+     *                                    to be inserted into the list
+     */
+    updateDataProvider: function(newElement) {
+        this.clearSelectedIndex();
+
+        if ($.isArray(newElement)) {
+            $.merge(this.dataProviderOrig, newElement);
+        } else {
+            this.dataProviderOrig.push(newElement);
+        }
+        this.filterList();
+
+        this.$ul.find('li').each(function() {
+            $(this).remove();
+        });
+
+        this.yPosition = this.getMaxPosition();
+        this.updateLayout();
+    },
+
+    /**
+     * Returns current objects in megalist
+     *
+     * @return {Array} - list of objects in megalist
+     *
+     */
+    getDataProvider: function() {
+        return this.dataProvider;
+    },
+
+    /**
+     * Get maximum value of yPosition
+     *
+     * @return {int} maximum value of yPosition
+     */
+    getMaxPosition: function() {
+        var height = this.$el.height(),
+            totalHeight = this.dataProvider.length * this.itemHeight;
+
+        return totalHeight > height ? totalHeight - height : 0;
+    },
+
+    /**
+     * Checks if search input has minimal length and therefor searching  is
+     * active.
+     * @return {bool} true when searching is active, false otherwise
+     */
+    searchingIsActive: function() {
+        var querySize = $.trim(this.$search.val()).length;
+        return querySize >= this.conf.MINIMUM_SEARCH_QUERY_SIZE;
+    },
+
+    /**
+     * Parses search input and performs filtering of list. The algorithm
+     * splits the search query to tokens and seeks for all subsequent
+     * tokens in the data. If not all tokens are found in the data then this
+     * record is excluded from the results.
+     *
+     */
+    filterList: function() {
+        var self = this,
+            searchQuery = $.trim(this.$search.val().toLowerCase()),
+            searchTokens = searchQuery.split(' '),
+            i;
+
+        this.filteredData = [];
+
+        for (i = searchTokens.length - 1; i >= 0; i--) {
+            searchTokens[i] = $.trim(searchTokens[i]);
+        }
+
+        if (!this.searchingIsActive()) {
+            this.dataProvider = this.dataProviderOrig;
+
+        } else {
+            this.dataProvider = $.grep(
+                this.dataProviderOrig,
+                function(val, index) {
+                    return self.testListElement(val, searchTokens, index);
+                }
+            );
+        }
+
+        this.updateLayout();
+    },
+
+    /**
+     * Tests if element of list data meets the query search criterias.
+     *
+     * @param  {string} val          value of the element to test
+     * @param  {array}  searchTokens query search tokens
+     * @param  {int}    index        the index of element in original data
+     *
+     * @return {boolean}             whether element meets the criteria on not
+     */
+    testListElement: function(val, searchTokens, index) {
+        var tokenIndex = 0,
+            valI = 0,
+            tokenDetected = true,
+            i;
+        val = val.label.toLowerCase();
+        while (valI < val.length) {
+            if (val[valI++] === searchTokens[tokenIndex][0]) {
+                tokenDetected = true;
+                for (i = 1; i < searchTokens[tokenIndex].length; i++) {
+                    if (val[valI] === searchTokens[tokenIndex][i]) {
+                        valI++;
+                    } else {
+                        tokenDetected = false;
+                        break;
+                    }
+                }
+                if (tokenDetected && ++tokenIndex === searchTokens.length) {
+                    this.filteredData[this.filteredData.length] = index;
+                    return true;
+                }
+            }
+        }
+        return false;
+    },
+
+    /**
+     * Generates string result of what is currently selected and populates
+     * this.$input value, adding it to DOM id necessary. Only does it for
+     * destination list. Result can be in 2 formats: POST-like (full) or comma
+     * separated
+     *
+     * @param {boolean} full - wherever to generate full POST-like data
+     * @return {string} result - string result of what is currently selected
+     */
+    generatePOST: function(full) {
+      var i,
+          postData = [],
+          result = {},
+          name = this.name;
+
+      if (this.suffix === this.conf.DESTINATION_SUFFIX){
+          for (i = 0; i < this.dataProviderOrig.length; i++) {
+              postData[i] = this.dataProviderOrig[i].listValue;
+          }
+          if (full === true){
+              result[name] = postData;
+              result = decodeURIComponent($.param(result, true ));
+              //cut out first name so that the post will not contain repetition
+              result = result.slice(this.name.length + 1, result.length);
+              this.$input.val(result);
+          } else {
+              result = postData.join(',');
+              this.$input.val(result);
+          }
+
+          if (this.$el.has(this.$input).length < 1) {
+              this.$el.append(this.$input);
+          }
+          return result;
+      } else {
+          return '';
+      }
+    }
+
+  };
+
+  /* LIST PLUGIN DEFINITION
+   * ========================== */
+
+  $.fn.megalist = function (option, params) {
+    if (typeof option === 'object') { this.options = option;}
+    var multiselect = new Megalist(this);
+    if (typeof option === 'string') { this.result = multiselect[option](params); }
+    return this;
+  };
+
+  // injects svg arrow icons into dom
+  $(document).ready(function(){
+    $('body').append('<svg style="display: none" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"> <path id="arrow-left" d="M48 10.667q1.104 0 1.885 0.781t0.781 1.885-0.792 1.896l-16.771 16.771 16.771 16.771q0.792 0.792 0.792 1.896t-0.781 1.885-1.885 0.781q-1.125 0-1.896-0.771l-18.667-18.667q-0.771-0.771-0.771-1.896t0.771-1.896l18.667-18.667q0.771-0.771 1.896-0.771zM32 10.667q1.104 0 1.885 0.781t0.781 1.885-0.792 1.896l-16.771 16.771 16.771 16.771q0.792 0.792 0.792 1.896t-0.781 1.885-1.885 0.781q-1.125 0-1.896-0.771l-18.667-18.667q-0.771-0.771-0.771-1.896t0.771-1.896l18.667-18.667q0.771-0.771 1.896-0.771z"></path> <path id="arrow-right" d="M29.333 10.667q1.104 0 1.875 0.771l18.667 18.667q0.792 0.792 0.792 1.896t-0.792 1.896l-18.667 18.667q-0.771 0.771-1.875 0.771t-1.885-0.781-0.781-1.885q0-1.125 0.771-1.896l16.771-16.771-16.771-16.771q-0.771-0.771-0.771-1.896 0-1.146 0.76-1.906t1.906-0.76zM13.333 10.667q1.104 0 1.875 0.771l18.667 18.667q0.792 0.792 0.792 1.896t-0.792 1.896l-18.667 18.667q-0.771 0.771-1.875 0.771t-1.885-0.781-0.781-1.885q0-1.125 0.771-1.896l16.771-16.771-16.771-16.771q-0.771-0.771-0.771-1.896 0-1.146 0.76-1.906t1.906-0.76z"></path></svg>');});
+
+  //adds indexOf to arry prototype for ie8
+  if(!Array.prototype.indexOf){Array.prototype.indexOf=function(e){var t=this.length>>>0;var n=Number(arguments[1])||0;n=n<0?Math.ceil(n):Math.floor(n);if(n<0)n+=t;for(;n<t;n++){if(n in this&&this[n]===e)return n}return-1}}
+
+} (window.jQuery);

+ 828 - 0
flaskbb/static/js/modernizr.custom.js

@@ -0,0 +1,828 @@
+/* Modernizr 2.8.3 (Custom Build) | MIT & BSD
+ * Build: http://modernizr.com/download/#-fontface-backgroundsize-borderimage-borderradius-boxshadow-flexbox-hsla-multiplebgs-opacity-rgba-textshadow-cssanimations-csscolumns-generatedcontent-cssgradients-cssreflections-csstransforms-csstransforms3d-csstransitions-applicationcache-canvas-canvastext-draganddrop-hashchange-history-audio-video-indexeddb-input-inputtypes-localstorage-postmessage-sessionstorage-websockets-websqldatabase-webworkers-geolocation-inlinesvg-smil-svg-svgclippaths-touch-webgl-shiv-cssclasses-addtest-prefixed-teststyles-testprop-testallprops-hasevent-prefixes-domprefixes-load
+ */
+;
+
+
+
+window.Modernizr = (function( window, document, undefined ) {
+
+    var version = '2.8.3',
+
+    Modernizr = {},
+
+    enableClasses = true,
+
+    docElement = document.documentElement,
+
+    mod = 'modernizr',
+    modElem = document.createElement(mod),
+    mStyle = modElem.style,
+
+    inputElem  = document.createElement('input')  ,
+
+    smile = ':)',
+
+    toString = {}.toString,
+
+    prefixes = ' -webkit- -moz- -o- -ms- '.split(' '),
+
+
+
+    omPrefixes = 'Webkit Moz O ms',
+
+    cssomPrefixes = omPrefixes.split(' '),
+
+    domPrefixes = omPrefixes.toLowerCase().split(' '),
+
+    ns = {'svg': 'http://www.w3.org/2000/svg'},
+
+    tests = {},
+    inputs = {},
+    attrs = {},
+
+    classes = [],
+
+    slice = classes.slice,
+
+    featureName, 
+
+
+    injectElementWithStyles = function( rule, callback, nodes, testnames ) {
+
+      var style, ret, node, docOverflow,
+          div = document.createElement('div'),
+                body = document.body,
+                fakeBody = body || document.createElement('body');
+
+      if ( parseInt(nodes, 10) ) {
+                      while ( nodes-- ) {
+              node = document.createElement('div');
+              node.id = testnames ? testnames[nodes] : mod + (nodes + 1);
+              div.appendChild(node);
+          }
+      }
+
+                style = ['&#173;','<style id="s', mod, '">', rule, '</style>'].join('');
+      div.id = mod;
+          (body ? div : fakeBody).innerHTML += style;
+      fakeBody.appendChild(div);
+      if ( !body ) {
+                fakeBody.style.background = '';
+                fakeBody.style.overflow = 'hidden';
+          docOverflow = docElement.style.overflow;
+          docElement.style.overflow = 'hidden';
+          docElement.appendChild(fakeBody);
+      }
+
+      ret = callback(div, rule);
+        if ( !body ) {
+          fakeBody.parentNode.removeChild(fakeBody);
+          docElement.style.overflow = docOverflow;
+      } else {
+          div.parentNode.removeChild(div);
+      }
+
+      return !!ret;
+
+    },
+
+
+
+    isEventSupported = (function() {
+
+      var TAGNAMES = {
+        'select': 'input', 'change': 'input',
+        'submit': 'form', 'reset': 'form',
+        'error': 'img', 'load': 'img', 'abort': 'img'
+      };
+
+      function isEventSupported( eventName, element ) {
+
+        element = element || document.createElement(TAGNAMES[eventName] || 'div');
+        eventName = 'on' + eventName;
+
+            var isSupported = eventName in element;
+
+        if ( !isSupported ) {
+                if ( !element.setAttribute ) {
+            element = document.createElement('div');
+          }
+          if ( element.setAttribute && element.removeAttribute ) {
+            element.setAttribute(eventName, '');
+            isSupported = is(element[eventName], 'function');
+
+                    if ( !is(element[eventName], 'undefined') ) {
+              element[eventName] = undefined;
+            }
+            element.removeAttribute(eventName);
+          }
+        }
+
+        element = null;
+        return isSupported;
+      }
+      return isEventSupported;
+    })(),
+
+
+    _hasOwnProperty = ({}).hasOwnProperty, hasOwnProp;
+
+    if ( !is(_hasOwnProperty, 'undefined') && !is(_hasOwnProperty.call, 'undefined') ) {
+      hasOwnProp = function (object, property) {
+        return _hasOwnProperty.call(object, property);
+      };
+    }
+    else {
+      hasOwnProp = function (object, property) { 
+        return ((property in object) && is(object.constructor.prototype[property], 'undefined'));
+      };
+    }
+
+
+    if (!Function.prototype.bind) {
+      Function.prototype.bind = function bind(that) {
+
+        var target = this;
+
+        if (typeof target != "function") {
+            throw new TypeError();
+        }
+
+        var args = slice.call(arguments, 1),
+            bound = function () {
+
+            if (this instanceof bound) {
+
+              var F = function(){};
+              F.prototype = target.prototype;
+              var self = new F();
+
+              var result = target.apply(
+                  self,
+                  args.concat(slice.call(arguments))
+              );
+              if (Object(result) === result) {
+                  return result;
+              }
+              return self;
+
+            } else {
+
+              return target.apply(
+                  that,
+                  args.concat(slice.call(arguments))
+              );
+
+            }
+
+        };
+
+        return bound;
+      };
+    }
+
+    function setCss( str ) {
+        mStyle.cssText = str;
+    }
+
+    function setCssAll( str1, str2 ) {
+        return setCss(prefixes.join(str1 + ';') + ( str2 || '' ));
+    }
+
+    function is( obj, type ) {
+        return typeof obj === type;
+    }
+
+    function contains( str, substr ) {
+        return !!~('' + str).indexOf(substr);
+    }
+
+    function testProps( props, prefixed ) {
+        for ( var i in props ) {
+            var prop = props[i];
+            if ( !contains(prop, "-") && mStyle[prop] !== undefined ) {
+                return prefixed == 'pfx' ? prop : true;
+            }
+        }
+        return false;
+    }
+
+    function testDOMProps( props, obj, elem ) {
+        for ( var i in props ) {
+            var item = obj[props[i]];
+            if ( item !== undefined) {
+
+                            if (elem === false) return props[i];
+
+                            if (is(item, 'function')){
+                                return item.bind(elem || obj);
+                }
+
+                            return item;
+            }
+        }
+        return false;
+    }
+
+    function testPropsAll( prop, prefixed, elem ) {
+
+        var ucProp  = prop.charAt(0).toUpperCase() + prop.slice(1),
+            props   = (prop + ' ' + cssomPrefixes.join(ucProp + ' ') + ucProp).split(' ');
+
+            if(is(prefixed, "string") || is(prefixed, "undefined")) {
+          return testProps(props, prefixed);
+
+            } else {
+          props = (prop + ' ' + (domPrefixes).join(ucProp + ' ') + ucProp).split(' ');
+          return testDOMProps(props, prefixed, elem);
+        }
+    }    tests['flexbox'] = function() {
+      return testPropsAll('flexWrap');
+    };    tests['canvas'] = function() {
+        var elem = document.createElement('canvas');
+        return !!(elem.getContext && elem.getContext('2d'));
+    };
+
+    tests['canvastext'] = function() {
+        return !!(Modernizr['canvas'] && is(document.createElement('canvas').getContext('2d').fillText, 'function'));
+    };
+
+
+
+    tests['webgl'] = function() {
+        return !!window.WebGLRenderingContext;
+    };
+
+
+    tests['touch'] = function() {
+        var bool;
+
+        if(('ontouchstart' in window) || window.DocumentTouch && document instanceof DocumentTouch) {
+          bool = true;
+        } else {
+          injectElementWithStyles(['@media (',prefixes.join('touch-enabled),('),mod,')','{#modernizr{top:9px;position:absolute}}'].join(''), function( node ) {
+            bool = node.offsetTop === 9;
+          });
+        }
+
+        return bool;
+    };
+
+
+
+    tests['geolocation'] = function() {
+        return 'geolocation' in navigator;
+    };
+
+
+    tests['postmessage'] = function() {
+      return !!window.postMessage;
+    };
+
+
+    tests['websqldatabase'] = function() {
+      return !!window.openDatabase;
+    };
+
+    tests['indexedDB'] = function() {
+      return !!testPropsAll("indexedDB", window);
+    };
+
+    tests['hashchange'] = function() {
+      return isEventSupported('hashchange', window) && (document.documentMode === undefined || document.documentMode > 7);
+    };
+
+    tests['history'] = function() {
+      return !!(window.history && history.pushState);
+    };
+
+    tests['draganddrop'] = function() {
+        var div = document.createElement('div');
+        return ('draggable' in div) || ('ondragstart' in div && 'ondrop' in div);
+    };
+
+    tests['websockets'] = function() {
+        return 'WebSocket' in window || 'MozWebSocket' in window;
+    };
+
+
+    tests['rgba'] = function() {
+        setCss('background-color:rgba(150,255,150,.5)');
+
+        return contains(mStyle.backgroundColor, 'rgba');
+    };
+
+    tests['hsla'] = function() {
+            setCss('background-color:hsla(120,40%,100%,.5)');
+
+        return contains(mStyle.backgroundColor, 'rgba') || contains(mStyle.backgroundColor, 'hsla');
+    };
+
+    tests['multiplebgs'] = function() {
+                setCss('background:url(https://),url(https://),red url(https://)');
+
+            return (/(url\s*\(.*?){3}/).test(mStyle.background);
+    };    tests['backgroundsize'] = function() {
+        return testPropsAll('backgroundSize');
+    };
+
+    tests['borderimage'] = function() {
+        return testPropsAll('borderImage');
+    };
+
+
+
+    tests['borderradius'] = function() {
+        return testPropsAll('borderRadius');
+    };
+
+    tests['boxshadow'] = function() {
+        return testPropsAll('boxShadow');
+    };
+
+    tests['textshadow'] = function() {
+        return document.createElement('div').style.textShadow === '';
+    };
+
+
+    tests['opacity'] = function() {
+                setCssAll('opacity:.55');
+
+                    return (/^0.55$/).test(mStyle.opacity);
+    };
+
+
+    tests['cssanimations'] = function() {
+        return testPropsAll('animationName');
+    };
+
+
+    tests['csscolumns'] = function() {
+        return testPropsAll('columnCount');
+    };
+
+
+    tests['cssgradients'] = function() {
+        var str1 = 'background-image:',
+            str2 = 'gradient(linear,left top,right bottom,from(#9f9),to(white));',
+            str3 = 'linear-gradient(left top,#9f9, white);';
+
+        setCss(
+                       (str1 + '-webkit- '.split(' ').join(str2 + str1) +
+                       prefixes.join(str3 + str1)).slice(0, -str1.length)
+        );
+
+        return contains(mStyle.backgroundImage, 'gradient');
+    };
+
+
+    tests['cssreflections'] = function() {
+        return testPropsAll('boxReflect');
+    };
+
+
+    tests['csstransforms'] = function() {
+        return !!testPropsAll('transform');
+    };
+
+
+    tests['csstransforms3d'] = function() {
+
+        var ret = !!testPropsAll('perspective');
+
+                        if ( ret && 'webkitPerspective' in docElement.style ) {
+
+                      injectElementWithStyles('@media (transform-3d),(-webkit-transform-3d){#modernizr{left:9px;position:absolute;height:3px;}}', function( node, rule ) {
+            ret = node.offsetLeft === 9 && node.offsetHeight === 3;
+          });
+        }
+        return ret;
+    };
+
+
+    tests['csstransitions'] = function() {
+        return testPropsAll('transition');
+    };
+
+
+
+    tests['fontface'] = function() {
+        var bool;
+
+        injectElementWithStyles('@font-face {font-family:"font";src:url("https://")}', function( node, rule ) {
+          var style = document.getElementById('smodernizr'),
+              sheet = style.sheet || style.styleSheet,
+              cssText = sheet ? (sheet.cssRules && sheet.cssRules[0] ? sheet.cssRules[0].cssText : sheet.cssText || '') : '';
+
+          bool = /src/i.test(cssText) && cssText.indexOf(rule.split(' ')[0]) === 0;
+        });
+
+        return bool;
+    };
+
+    tests['generatedcontent'] = function() {
+        var bool;
+
+        injectElementWithStyles(['#',mod,'{font:0/0 a}#',mod,':after{content:"',smile,'";visibility:hidden;font:3px/1 a}'].join(''), function( node ) {
+          bool = node.offsetHeight >= 3;
+        });
+
+        return bool;
+    };
+    tests['video'] = function() {
+        var elem = document.createElement('video'),
+            bool = false;
+
+            try {
+            if ( bool = !!elem.canPlayType ) {
+                bool      = new Boolean(bool);
+                bool.ogg  = elem.canPlayType('video/ogg; codecs="theora"')      .replace(/^no$/,'');
+
+                            bool.h264 = elem.canPlayType('video/mp4; codecs="avc1.42E01E"') .replace(/^no$/,'');
+
+                bool.webm = elem.canPlayType('video/webm; codecs="vp8, vorbis"').replace(/^no$/,'');
+            }
+
+        } catch(e) { }
+
+        return bool;
+    };
+
+    tests['audio'] = function() {
+        var elem = document.createElement('audio'),
+            bool = false;
+
+        try {
+            if ( bool = !!elem.canPlayType ) {
+                bool      = new Boolean(bool);
+                bool.ogg  = elem.canPlayType('audio/ogg; codecs="vorbis"').replace(/^no$/,'');
+                bool.mp3  = elem.canPlayType('audio/mpeg;')               .replace(/^no$/,'');
+
+                                                    bool.wav  = elem.canPlayType('audio/wav; codecs="1"')     .replace(/^no$/,'');
+                bool.m4a  = ( elem.canPlayType('audio/x-m4a;')            ||
+                              elem.canPlayType('audio/aac;'))             .replace(/^no$/,'');
+            }
+        } catch(e) { }
+
+        return bool;
+    };
+
+
+    tests['localstorage'] = function() {
+        try {
+            localStorage.setItem(mod, mod);
+            localStorage.removeItem(mod);
+            return true;
+        } catch(e) {
+            return false;
+        }
+    };
+
+    tests['sessionstorage'] = function() {
+        try {
+            sessionStorage.setItem(mod, mod);
+            sessionStorage.removeItem(mod);
+            return true;
+        } catch(e) {
+            return false;
+        }
+    };
+
+
+    tests['webworkers'] = function() {
+        return !!window.Worker;
+    };
+
+
+    tests['applicationcache'] = function() {
+        return !!window.applicationCache;
+    };
+
+
+    tests['svg'] = function() {
+        return !!document.createElementNS && !!document.createElementNS(ns.svg, 'svg').createSVGRect;
+    };
+
+    tests['inlinesvg'] = function() {
+      var div = document.createElement('div');
+      div.innerHTML = '<svg/>';
+      return (div.firstChild && div.firstChild.namespaceURI) == ns.svg;
+    };
+
+    tests['smil'] = function() {
+        return !!document.createElementNS && /SVGAnimate/.test(toString.call(document.createElementNS(ns.svg, 'animate')));
+    };
+
+
+    tests['svgclippaths'] = function() {
+        return !!document.createElementNS && /SVGClipPath/.test(toString.call(document.createElementNS(ns.svg, 'clipPath')));
+    };
+
+    function webforms() {
+                                            Modernizr['input'] = (function( props ) {
+            for ( var i = 0, len = props.length; i < len; i++ ) {
+                attrs[ props[i] ] = !!(props[i] in inputElem);
+            }
+            if (attrs.list){
+                                  attrs.list = !!(document.createElement('datalist') && window.HTMLDataListElement);
+            }
+            return attrs;
+        })('autocomplete autofocus list placeholder max min multiple pattern required step'.split(' '));
+                            Modernizr['inputtypes'] = (function(props) {
+
+            for ( var i = 0, bool, inputElemType, defaultView, len = props.length; i < len; i++ ) {
+
+                inputElem.setAttribute('type', inputElemType = props[i]);
+                bool = inputElem.type !== 'text';
+
+                                                    if ( bool ) {
+
+                    inputElem.value         = smile;
+                    inputElem.style.cssText = 'position:absolute;visibility:hidden;';
+
+                    if ( /^range$/.test(inputElemType) && inputElem.style.WebkitAppearance !== undefined ) {
+
+                      docElement.appendChild(inputElem);
+                      defaultView = document.defaultView;
+
+                                        bool =  defaultView.getComputedStyle &&
+                              defaultView.getComputedStyle(inputElem, null).WebkitAppearance !== 'textfield' &&
+                                                                                  (inputElem.offsetHeight !== 0);
+
+                      docElement.removeChild(inputElem);
+
+                    } else if ( /^(search|tel)$/.test(inputElemType) ){
+                                                                                    } else if ( /^(url|email)$/.test(inputElemType) ) {
+                                        bool = inputElem.checkValidity && inputElem.checkValidity() === false;
+
+                    } else {
+                                        bool = inputElem.value != smile;
+                    }
+                }
+
+                inputs[ props[i] ] = !!bool;
+            }
+            return inputs;
+        })('search tel url email datetime date month week time datetime-local number range color'.split(' '));
+        }
+    for ( var feature in tests ) {
+        if ( hasOwnProp(tests, feature) ) {
+                                    featureName  = feature.toLowerCase();
+            Modernizr[featureName] = tests[feature]();
+
+            classes.push((Modernizr[featureName] ? '' : 'no-') + featureName);
+        }
+    }
+
+    Modernizr.input || webforms();
+
+
+     Modernizr.addTest = function ( feature, test ) {
+       if ( typeof feature == 'object' ) {
+         for ( var key in feature ) {
+           if ( hasOwnProp( feature, key ) ) {
+             Modernizr.addTest( key, feature[ key ] );
+           }
+         }
+       } else {
+
+         feature = feature.toLowerCase();
+
+         if ( Modernizr[feature] !== undefined ) {
+                                              return Modernizr;
+         }
+
+         test = typeof test == 'function' ? test() : test;
+
+         if (typeof enableClasses !== "undefined" && enableClasses) {
+           docElement.className += ' ' + (test ? '' : 'no-') + feature;
+         }
+         Modernizr[feature] = test;
+
+       }
+
+       return Modernizr; 
+     };
+
+
+    setCss('');
+    modElem = inputElem = null;
+
+    ;(function(window, document) {
+                var version = '3.7.0';
+
+            var options = window.html5 || {};
+
+            var reSkip = /^<|^(?:button|map|select|textarea|object|iframe|option|optgroup)$/i;
+
+            var saveClones = /^(?:a|b|code|div|fieldset|h1|h2|h3|h4|h5|h6|i|label|li|ol|p|q|span|strong|style|table|tbody|td|th|tr|ul)$/i;
+
+            var supportsHtml5Styles;
+
+            var expando = '_html5shiv';
+
+            var expanID = 0;
+
+            var expandoData = {};
+
+            var supportsUnknownElements;
+
+        (function() {
+          try {
+            var a = document.createElement('a');
+            a.innerHTML = '<xyz></xyz>';
+                    supportsHtml5Styles = ('hidden' in a);
+
+            supportsUnknownElements = a.childNodes.length == 1 || (function() {
+                        (document.createElement)('a');
+              var frag = document.createDocumentFragment();
+              return (
+                typeof frag.cloneNode == 'undefined' ||
+                typeof frag.createDocumentFragment == 'undefined' ||
+                typeof frag.createElement == 'undefined'
+              );
+            }());
+          } catch(e) {
+                    supportsHtml5Styles = true;
+            supportsUnknownElements = true;
+          }
+
+        }());
+
+            function addStyleSheet(ownerDocument, cssText) {
+          var p = ownerDocument.createElement('p'),
+          parent = ownerDocument.getElementsByTagName('head')[0] || ownerDocument.documentElement;
+
+          p.innerHTML = 'x<style>' + cssText + '</style>';
+          return parent.insertBefore(p.lastChild, parent.firstChild);
+        }
+
+            function getElements() {
+          var elements = html5.elements;
+          return typeof elements == 'string' ? elements.split(' ') : elements;
+        }
+
+            function getExpandoData(ownerDocument) {
+          var data = expandoData[ownerDocument[expando]];
+          if (!data) {
+            data = {};
+            expanID++;
+            ownerDocument[expando] = expanID;
+            expandoData[expanID] = data;
+          }
+          return data;
+        }
+
+            function createElement(nodeName, ownerDocument, data){
+          if (!ownerDocument) {
+            ownerDocument = document;
+          }
+          if(supportsUnknownElements){
+            return ownerDocument.createElement(nodeName);
+          }
+          if (!data) {
+            data = getExpandoData(ownerDocument);
+          }
+          var node;
+
+          if (data.cache[nodeName]) {
+            node = data.cache[nodeName].cloneNode();
+          } else if (saveClones.test(nodeName)) {
+            node = (data.cache[nodeName] = data.createElem(nodeName)).cloneNode();
+          } else {
+            node = data.createElem(nodeName);
+          }
+
+                                                    return node.canHaveChildren && !reSkip.test(nodeName) && !node.tagUrn ? data.frag.appendChild(node) : node;
+        }
+
+            function createDocumentFragment(ownerDocument, data){
+          if (!ownerDocument) {
+            ownerDocument = document;
+          }
+          if(supportsUnknownElements){
+            return ownerDocument.createDocumentFragment();
+          }
+          data = data || getExpandoData(ownerDocument);
+          var clone = data.frag.cloneNode(),
+          i = 0,
+          elems = getElements(),
+          l = elems.length;
+          for(;i<l;i++){
+            clone.createElement(elems[i]);
+          }
+          return clone;
+        }
+
+            function shivMethods(ownerDocument, data) {
+          if (!data.cache) {
+            data.cache = {};
+            data.createElem = ownerDocument.createElement;
+            data.createFrag = ownerDocument.createDocumentFragment;
+            data.frag = data.createFrag();
+          }
+
+
+          ownerDocument.createElement = function(nodeName) {
+                    if (!html5.shivMethods) {
+              return data.createElem(nodeName);
+            }
+            return createElement(nodeName, ownerDocument, data);
+          };
+
+          ownerDocument.createDocumentFragment = Function('h,f', 'return function(){' +
+                                                          'var n=f.cloneNode(),c=n.createElement;' +
+                                                          'h.shivMethods&&(' +
+                                                                                                                getElements().join().replace(/[\w\-]+/g, function(nodeName) {
+            data.createElem(nodeName);
+            data.frag.createElement(nodeName);
+            return 'c("' + nodeName + '")';
+          }) +
+            ');return n}'
+                                                         )(html5, data.frag);
+        }
+
+            function shivDocument(ownerDocument) {
+          if (!ownerDocument) {
+            ownerDocument = document;
+          }
+          var data = getExpandoData(ownerDocument);
+
+          if (html5.shivCSS && !supportsHtml5Styles && !data.hasCSS) {
+            data.hasCSS = !!addStyleSheet(ownerDocument,
+                                                                                'article,aside,dialog,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}' +
+                                                                                    'mark{background:#FF0;color:#000}' +
+                                                                                    'template{display:none}'
+                                         );
+          }
+          if (!supportsUnknownElements) {
+            shivMethods(ownerDocument, data);
+          }
+          return ownerDocument;
+        }
+
+            var html5 = {
+
+                'elements': options.elements || 'abbr article aside audio bdi canvas data datalist details dialog figcaption figure footer header hgroup main mark meter nav output progress section summary template time video',
+
+                'version': version,
+
+                'shivCSS': (options.shivCSS !== false),
+
+                'supportsUnknownElements': supportsUnknownElements,
+
+                'shivMethods': (options.shivMethods !== false),
+
+                'type': 'default',
+
+                'shivDocument': shivDocument,
+
+                createElement: createElement,
+
+                createDocumentFragment: createDocumentFragment
+        };
+
+            window.html5 = html5;
+
+            shivDocument(document);
+
+    }(this, document));
+
+    Modernizr._version      = version;
+
+    Modernizr._prefixes     = prefixes;
+    Modernizr._domPrefixes  = domPrefixes;
+    Modernizr._cssomPrefixes  = cssomPrefixes;
+
+
+    Modernizr.hasEvent      = isEventSupported;
+
+    Modernizr.testProp      = function(prop){
+        return testProps([prop]);
+    };
+
+    Modernizr.testAllProps  = testPropsAll;
+
+
+    Modernizr.testStyles    = injectElementWithStyles;
+    Modernizr.prefixed      = function(prop, obj, elem){
+      if(!obj) {
+        return testPropsAll(prop, 'pfx');
+      } else {
+            return testPropsAll(prop, obj, elem);
+      }
+    };
+
+
+    docElement.className = docElement.className.replace(/(^|\s)no-js(\s|$)/, '$1$2') +
+
+                                                    (enableClasses ? ' js ' + classes.join(' ') : '');
+
+    return Modernizr;
+
+})(this, this.document);
+/*yepnope1.5.4|WTFPL*/
+(function(a,b,c){function d(a){return"[object Function]"==o.call(a)}function e(a){return"string"==typeof a}function f(){}function g(a){return!a||"loaded"==a||"complete"==a||"uninitialized"==a}function h(){var a=p.shift();q=1,a?a.t?m(function(){("c"==a.t?B.injectCss:B.injectJs)(a.s,0,a.a,a.x,a.e,1)},0):(a(),h()):q=0}function i(a,c,d,e,f,i,j){function k(b){if(!o&&g(l.readyState)&&(u.r=o=1,!q&&h(),l.onload=l.onreadystatechange=null,b)){"img"!=a&&m(function(){t.removeChild(l)},50);for(var d in y[c])y[c].hasOwnProperty(d)&&y[c][d].onload()}}var j=j||B.errorTimeout,l=b.createElement(a),o=0,r=0,u={t:d,s:c,e:f,a:i,x:j};1===y[c]&&(r=1,y[c]=[]),"object"==a?l.data=c:(l.src=c,l.type=a),l.width=l.height="0",l.onerror=l.onload=l.onreadystatechange=function(){k.call(this,r)},p.splice(e,0,u),"img"!=a&&(r||2===y[c]?(t.insertBefore(l,s?null:n),m(k,j)):y[c].push(l))}function j(a,b,c,d,f){return q=0,b=b||"j",e(a)?i("c"==b?v:u,a,b,this.i++,c,d,f):(p.splice(this.i++,0,a),1==p.length&&h()),this}function k(){var a=B;return a.loader={load:j,i:0},a}var l=b.documentElement,m=a.setTimeout,n=b.getElementsByTagName("script")[0],o={}.toString,p=[],q=0,r="MozAppearance"in l.style,s=r&&!!b.createRange().compareNode,t=s?l:n.parentNode,l=a.opera&&"[object Opera]"==o.call(a.opera),l=!!b.attachEvent&&!l,u=r?"object":l?"script":"img",v=l?"script":u,w=Array.isArray||function(a){return"[object Array]"==o.call(a)},x=[],y={},z={timeout:function(a,b){return b.length&&(a.timeout=b[0]),a}},A,B;B=function(a){function b(a){var a=a.split("!"),b=x.length,c=a.pop(),d=a.length,c={url:c,origUrl:c,prefixes:a},e,f,g;for(f=0;f<d;f++)g=a[f].split("="),(e=z[g.shift()])&&(c=e(c,g));for(f=0;f<b;f++)c=x[f](c);return c}function g(a,e,f,g,h){var i=b(a),j=i.autoCallback;i.url.split(".").pop().split("?").shift(),i.bypass||(e&&(e=d(e)?e:e[a]||e[g]||e[a.split("/").pop().split("?")[0]]),i.instead?i.instead(a,e,f,g,h):(y[i.url]?i.noexec=!0:y[i.url]=1,f.load(i.url,i.forceCSS||!i.forceJS&&"css"==i.url.split(".").pop().split("?").shift()?"c":c,i.noexec,i.attrs,i.timeout),(d(e)||d(j))&&f.load(function(){k(),e&&e(i.origUrl,h,g),j&&j(i.origUrl,h,g),y[i.url]=2})))}function h(a,b){function c(a,c){if(a){if(e(a))c||(j=function(){var a=[].slice.call(arguments);k.apply(this,a),l()}),g(a,j,b,0,h);else if(Object(a)===a)for(n in m=function(){var b=0,c;for(c in a)a.hasOwnProperty(c)&&b++;return b}(),a)a.hasOwnProperty(n)&&(!c&&!--m&&(d(j)?j=function(){var a=[].slice.call(arguments);k.apply(this,a),l()}:j[n]=function(a){return function(){var b=[].slice.call(arguments);a&&a.apply(this,b),l()}}(k[n])),g(a[n],j,b,n,h))}else!c&&l()}var h=!!a.test,i=a.load||a.both,j=a.callback||f,k=j,l=a.complete||f,m,n;c(h?a.yep:a.nope,!!i),i&&c(i)}var i,j,l=this.yepnope.loader;if(e(a))g(a,0,l,0);else if(w(a))for(i=0;i<a.length;i++)j=a[i],e(j)?g(j,0,l,0):w(j)?B(j):Object(j)===j&&h(j,l);else Object(a)===a&&h(a,l)},B.addPrefix=function(a,b){z[a]=b},B.addFilter=function(a){x.push(a)},B.errorTimeout=1e4,null==b.readyState&&b.addEventListener&&(b.readyState="loading",b.addEventListener("DOMContentLoaded",A=function(){b.removeEventListener("DOMContentLoaded",A,0),b.readyState="complete"},0)),a.yepnope=k(),a.yepnope.executeStack=h,a.yepnope.injectJs=function(a,c,d,e,i,j){var k=b.createElement("script"),l,o,e=e||B.errorTimeout;k.src=a;for(o in d)k.setAttribute(o,d[o]);c=j?h:c||f,k.onreadystatechange=k.onload=function(){!l&&g(k.readyState)&&(l=1,c(),k.onload=k.onreadystatechange=null)},m(function(){l||(l=1,c(1))},e),i?k.onload():n.parentNode.insertBefore(k,n)},a.yepnope.injectCss=function(a,c,d,e,g,i){var e=b.createElement("link"),j,c=i?h:c||f;e.href=a,e.rel="stylesheet",e.type="text/css";for(j in d)e.setAttribute(j,d[j]);g||(n.parentNode.insertBefore(e,n),m(c,0))}})(this,document);
+Modernizr.load=function(){yepnope.apply(window,[].slice.call(arguments,0));};
+;

+ 125 - 0
flaskbb/static/less/megalist-multiselect.less

@@ -0,0 +1,125 @@
+.box-sizing(@boxmodel) {
+  -webkit-box-sizing: @boxmodel;
+     -moz-box-sizing: @boxmodel;
+      -ms-box-sizing: @boxmodel;
+          box-sizing: @boxmodel;
+}
+.border-radius(@radius) {
+    -webkit-border-radius: @radius;
+       -moz-border-radius: @radius;
+            border-radius: @radius;
+}
+.user-select(@select) {
+    -webkit-user-select: @select;
+       -moz-user-select: @select;
+        -ms-user-select: @select;
+            user-select: @select;
+}
+
+@icon-move-color: rgb(3, 116, 187);
+.megalist-mutliselect {
+    .user-select(none);
+    overflow: hidden;
+    .megalist {
+        float: left;
+        width: 285px;
+        input[type=text] {
+            .box-sizing(border-box);
+            width: 100% !important;
+            padding: 5px 4px;
+            margin-bottom: 5px;
+        }
+        .megalist-inner {
+            .box-sizing(border-box);
+            overflow: hidden;
+            position: relative;
+            height: 205px;
+            width: 100%;
+            overflow: hidden;
+            border: 1px solid silver;
+            ul {
+                position: absolute;
+                padding: 0;
+                display: block;
+                margin-top: 0px;
+                width: 100%;
+                top: 0px;
+                li {
+                    margin:0;
+                    border:none;
+                    white-space: nowrap;
+                    overflow: hidden;
+                    padding: 6px 0px 6px 10px !important;
+                    display: block;
+                    position: absolute;
+                    width: 100%;
+                    border-top: 1px solid #EDEDED;
+                    line-height: 1em !important;
+                    cursor: pointer;
+                    color: #555;
+                    &:hover {
+                        background-color: #08c;
+                    }
+                }
+            }
+        }
+        .scrollbar {
+            .border-radius(3px);
+            position: absolute;
+            right: 1px;
+            width: 11px;
+            height: 25px;
+            background-color: rgb(190, 190, 190);
+            z-index: 2;
+            &:hover {
+                background-color: rgb(175, 175, 175);
+            }
+        }
+        .scrollbar-background {
+            position: absolute;
+            top: 0px;
+            right: 0px;
+            width: 14px;
+            height: 100%;
+            z-index: 1;
+            background-color: rgb(236, 236, 236);
+        }
+    }
+    .move-buttons {
+        margin: 90px 0px;
+        float: left;
+        .move-button {
+            height: 30px;
+            padding: 0px 50px;
+            margin-bottom: 10px;
+            cursor: pointer;
+            svg {
+                fill: @icon-move-color;
+            }
+            &:hover {
+                svg {
+                    fill: darken(@icon-move-color, 15%);
+                }
+                &.arrow-left.no-svg .svg {
+                    background-position: 0% 100%;
+                }
+                &.arrow-right.no-svg .svg {
+                    background-position: 100% 100%;
+                }
+            }
+            &.no-svg .svg {
+                background-repeat: no-repeat;
+                background-position: center;
+                background: url('../icons/megalist-icons.png');
+                width: 32px;
+                height: 32px;
+            }
+            &.arrow-left.no-svg .svg {
+                background-position: 0% 0%;
+            }
+            &.arrow-right.no-svg .svg {
+                background-position: 100% 0%;
+            }
+        }
+    }
+}

+ 5 - 0
flaskbb/templates/macros.html

@@ -302,6 +302,11 @@
 </li>
 </li>
 {% endmacro %}
 {% endmacro %}
 
 
+{% macro tablink_href(endpoint, name, active=False) %}
+<li {% if endpoint == request.endpoint or active %}class="active"{% endif %} >
+    <a href={{ endpoint }} role="tab" data-toggle="tab">{{ name }}</a>
+</li>
+{% endmacro %}
 
 
 {% macro render_pagination(page_obj, url, ul_class='') %}
 {% macro render_pagination(page_obj, url, ul_class='') %}
 <ul class='{%- if ul_class -%}{{ ul_class }}{%- else -%}pagination{%- endif -%}'>
 <ul class='{%- if ul_class -%}{{ ul_class }}{%- else -%}pagination{%- endif -%}'>

+ 52 - 14
flaskbb/templates/management/forum_form.html

@@ -3,7 +3,7 @@
 
 
 {% extends theme("management/management_layout.html") %}
 {% extends theme("management/management_layout.html") %}
 {% block management_content %}
 {% block management_content %}
-{% from theme("macros.html") import horizontal_field, render_boolean_field, navlink with context %}
+{% from theme("macros.html") import horizontal_field, render_boolean_field, navlink, tablink_href  with context %}
 
 
 <div class="col-md-3">
 <div class="col-md-3">
     <ul class="nav nav-pills nav-stacked">
     <ul class="nav nav-pills nav-stacked">
@@ -13,24 +13,62 @@
     </ul>
     </ul>
 </div>
 </div>
 
 
-<div class="col-md-9">
-    <form class="form-horizontal" role="form" method="post">
-        {{ form.hidden_tag() }}
-        <legend class="">{{ title }}</legend>
-            {{ horizontal_field(form.title) }}
-            {{ horizontal_field(form.description, rows=5, div_class="col-lg-9") }}
 
 
-            {{ horizontal_field(form.category) }}
-            {{ horizontal_field(form.position) }}
+<div class="col-md-9" role="tabpanel">
+            <form class="form-horizontal" role="form" method="post">
+    <ul class="nav nav-tabs nav-justified">
+        {{ tablink_href('#basic', _('General Settings'), true) }}
+        {{ tablink_href('#perms', _('Group Access'))}}
+    </ul>
+    <div class="tab-content">
+        <div role="tabpanel" class="tab-pane active" id="basic">
+                {{ form.hidden_tag() }}
+                <legend class="">{{ title }}</legend>
+                    {{ form.id }}
+                    {{ horizontal_field(form.title) }}
+                    {{ horizontal_field(form.description, rows=5, div_class="col-lg-9") }}
+
+                    {{ horizontal_field(form.category) }}
+                    {{ horizontal_field(form.position) }}
+
+                    {{ horizontal_field(form.external) }}
 
 
-            {{ horizontal_field(form.external) }}
+                    {{ horizontal_field(form.moderators) }}
+                    {{ render_boolean_field(form.show_moderators) }}
 
 
-            {{ horizontal_field(form.moderators) }}
-            {{ render_boolean_field(form.show_moderators) }}
+                    {{ render_boolean_field(form.locked) }}
 
 
-            {{ render_boolean_field(form.locked) }}
+                    {{ horizontal_field(form.submit, div_class="col-lg-offset-0 col-lg-9") }}
 
 
+        </div>
+
+        <div role="tabpanel" class="tab-pane" id="perms">
+            <legend class="">{{ _("Group access to the forum") }}</legend>
+            {{ form.groups }}
             {{ horizontal_field(form.submit, div_class="col-lg-offset-0 col-lg-9") }}
             {{ horizontal_field(form.submit, div_class="col-lg-offset-0 col-lg-9") }}
-    </form>
+        </div>
+
+    </div>
+        </form>
 </div>
 </div>
 {% endblock %}
 {% endblock %}
+{% block scripts %}
+{{ super() }}
+<script type="application/javascript" >
+    $(document).ready(function() {
+        var options = {
+            PLACEHOLDER_TEXT: "Search Groups",
+            BUILD_FULL_POST: false
+        };
+{#        $('#my_megalist').attr('data-provider-src', '{{ group_list|safe }}');#}
+{#        $('#my_megalist').attr('data-provider-dst', "[]");#}
+        $('a[data-toggle="tab"]').on('shown.bs.tab', function (e) {
+            if ($(e.target).attr('href') == '#perms' && $('#groups').attr('inited') === undefined) { // newly activated tab
+                $('#groups').megalist(options);
+                $('#groups').attr('inited', true)
+            }
+        })
+
+    });
+</script>
+{% endblock %}

+ 19 - 19
flaskbb/templates/management/group_form.html

@@ -13,30 +13,30 @@
 </div>
 </div>
 
 
 <div class="col-md-9">
 <div class="col-md-9">
-    <form class="form-horizontal" role="form" method="post">
-        {{ form.hidden_tag() }}
-        <legend class="">{{ title }}</legend>
-            {{ horizontal_field(form.name) }}
-            {{ horizontal_field(form.description) }}
+        <form class="form-horizontal" role="form" method="post">
+            {{ form.hidden_tag() }}
+            <legend class="">{{ title }}</legend>
+                {{ horizontal_field(form.name) }}
+                {{ horizontal_field(form.description) }}
 
 
-            {{ render_boolean_field(form.admin) }}
-            {{ render_boolean_field(form.super_mod) }}
+                {{ render_boolean_field(form.admin) }}
+                {{ render_boolean_field(form.super_mod) }}
 
 
 
 
-            {{ render_boolean_field(form.mod) }}
-            {{ render_boolean_field(form.banned) }}
-            {{ render_boolean_field(form.guest) }}
+                {{ render_boolean_field(form.mod) }}
+                {{ render_boolean_field(form.banned) }}
+                {{ render_boolean_field(form.guest) }}
 
 
-            {{ render_boolean_field(form.mod_edituser) }}
-            {{ render_boolean_field(form.mod_banuser) }}
+                {{ render_boolean_field(form.mod_edituser) }}
+                {{ render_boolean_field(form.mod_banuser) }}
 
 
-            {{ render_boolean_field(form.editpost) }}
-            {{ render_boolean_field(form.deletepost) }}
-            {{ render_boolean_field(form.deletetopic) }}
-            {{ render_boolean_field(form.posttopic) }}
-            {{ render_boolean_field(form.postreply) }}
+                {{ render_boolean_field(form.editpost) }}
+                {{ render_boolean_field(form.deletepost) }}
+                {{ render_boolean_field(form.deletetopic) }}
+                {{ render_boolean_field(form.posttopic) }}
+                {{ render_boolean_field(form.postreply) }}
 
 
-            {{ horizontal_field(form.submit, div_class="col-lg-offset-0 col-lg-9") }}
-    </form>
+                {{ horizontal_field(form.submit, div_class="col-lg-offset-0 col-lg-9") }}
+        </form>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 9 - 0
flaskbb/templates/management/management_layout.html

@@ -1,4 +1,8 @@
 {% extends theme("layout.html") %}
 {% extends theme("layout.html") %}
+{% block css %}
+    {{ super() }}
+    <link rel="stylesheet" href="{{ url_for('static', filename='css/megalist-multiselect.css') }}" >
+{% endblock %}
 {% block content %}
 {% block content %}
 {%- from theme('macros.html') import navlink with context -%}
 {%- from theme('macros.html') import navlink with context -%}
 
 
@@ -20,3 +24,8 @@
 {% block management_content %}{% endblock %}
 {% block management_content %}{% endblock %}
 
 
 {% endblock %}
 {% endblock %}
+{% block javascript %}
+    {{ super() }}
+    <script src="{{ url_for('static', filename='js/modernizr.custom.js') }}"></script>
+    <script src="{{ url_for('static', filename='js/megalist-multiselect.js') }}"></script>
+{% endblock %}

+ 15 - 0
flaskbb/user/models.py

@@ -74,6 +74,16 @@ class Group(db.Model):
         db.session.commit()
         db.session.commit()
         return self
         return self
 
 
+    @classmethod
+    def selectable_groups_choices(cls):
+        return Group.query.order_by(Group.name.asc()).with_entities(
+            Group.id, Group.name
+        ).all()
+
+    @classmethod
+    def get_guest_group(cls):
+        return Group.query.filter(cls.guest==True).first()
+
 
 
 class User(db.Model, UserMixin):
 class User(db.Model, UserMixin):
     __tablename__ = "users"
     __tablename__ = "users"
@@ -162,6 +172,11 @@ class User(db.Model, UserMixin):
         """Returns the topics per day count"""
         """Returns the topics per day count"""
         return round((float(self.topic_count) / float(self.days_registered)), 1)
         return round((float(self.topic_count) / float(self.days_registered)), 1)
 
 
+    @property
+    def groups(self):
+        """Returns user groups"""
+        return [self.primary_group] + list(self.secondary_groups)
+
     # Methods
     # Methods
     def __repr__(self):
     def __repr__(self):
         """Set to a unique key specific to the object in the database.
         """Set to a unique key specific to the object in the database.

+ 41 - 1
flaskbb/utils/decorators.py

@@ -13,7 +13,6 @@ from functools import wraps
 from flask import abort
 from flask import abort
 from flask_login import current_user
 from flask_login import current_user
 
 
-
 def admin_required(f):
 def admin_required(f):
     @wraps(f)
     @wraps(f)
     def decorated(*args, **kwargs):
     def decorated(*args, **kwargs):
@@ -38,3 +37,44 @@ def moderator_required(f):
 
 
         return f(*args, **kwargs)
         return f(*args, **kwargs)
     return decorated
     return decorated
+
+
+def can_access_forum(func):
+    def decorated(*args, **kwargs):
+        forum_id = kwargs['forum_id'] if 'forum_id' in kwargs else args[1]
+        from flaskbb.forum.models import Forum
+        from flaskbb.user.models import Group
+        # get list of user group ids
+        if current_user.is_authenticated():
+            user_groups = [gr.id for gr in current_user.groups]
+        else:
+            user_groups = [Group.get_guest_group().id]
+        user_forums = Forum.query.filter(
+            Forum.id==forum_id, Forum.groups.any(Group.id.in_(user_groups))
+        ).all()
+        if len(user_forums) < 1:
+            abort(403)
+
+        return func(*args, **kwargs)
+    return decorated
+
+
+def can_access_topic(func):
+    def decorated(*args, **kwargs):
+        topic_id = kwargs['topic_id'] if 'topic_id' in kwargs else args[1]
+        from flaskbb.forum.models import Forum, Topic
+        from flaskbb.user.models import Group
+        topic = Topic.query.get(topic_id==topic_id)
+        # get list of user group ids
+        if current_user.is_authenticated():
+            user_groups = [gr.id for gr in current_user.groups]
+        else:
+            user_groups = [Group.get_guest_group().id]
+        user_forums = Forum.query.filter(
+            Forum.id==topic.forum.id, Forum.groups.any(Group.id.in_(user_groups))
+        ).all()
+        if len(user_forums) < 1:
+            abort(403)
+
+        return func(*args, **kwargs)
+    return decorated

+ 31 - 0
flaskbb/utils/widgets.py

@@ -8,6 +8,7 @@
     :copyright: (c) 2014 by the FlaskBB Team.
     :copyright: (c) 2014 by the FlaskBB Team.
     :license: BSD, see LICENSE for more details.
     :license: BSD, see LICENSE for more details.
 """
 """
+import simplejson as json
 from datetime import datetime
 from datetime import datetime
 from wtforms.widgets.core import Select, HTMLString, html_params
 from wtforms.widgets.core import Select, HTMLString, html_params
 
 
@@ -92,3 +93,33 @@ class SelectBirthdayWidget(object):
             html.append(' ')
             html.append(' ')
 
 
         return HTMLString(''.join(html))
         return HTMLString(''.join(html))
+
+
+class MultiSelect(object):
+    """
+    Renders a megalist-multiselect widget.
+
+
+    The field must provide an `iter_choices()` method which the widget will
+    call on rendering; this method must yield tuples of
+    `(value, label, selected)`.
+    """
+
+    def __call__(self, field, **kwargs):
+        kwargs.setdefault('id', field.id)
+        src_list, dst_list = [], []
+
+        for val, label, selected in field.iter_choices():
+            if selected:
+                dst_list.append({'label':label, 'listValue':val})
+            else:
+                src_list.append({'label':label, 'listValue':val})
+        kwargs.update(
+            {
+                'data-provider-src':json.dumps(src_list),
+                'data-provider-dst':json.dumps(dst_list)
+            }
+        )
+        html = ['<div %s>' % html_params(name=field.name, **kwargs)]
+        html.append('</div>')
+        return HTMLString(''.join(html))

+ 47 - 0
migrations/versions/127be3fb000_added_m2m_forumgroups_table.py

@@ -0,0 +1,47 @@
+"""Added m2m forumgroups table
+
+Revision ID: 127be3fb000
+Revises: 514ca0a3282c
+Create Date: 2015-04-08 22:25:52.809557
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = '127be3fb000'
+down_revision = '514ca0a3282c'
+
+from alembic import op
+import sqlalchemy as sa
+
+
+def upgrade():
+    ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('forumgroups',
+    sa.Column('group_id', sa.Integer(), nullable=False),
+    sa.Column('forum_id', sa.Integer(), nullable=False),
+    sa.ForeignKeyConstraint(['forum_id'], ['forums.id'], name='fk_forum_id', use_alter=True),
+    sa.ForeignKeyConstraint(['group_id'], ['groups.id'], )
+    )
+    op.create_foreign_key('fk_fr_forum_id', 'forumsread', 'forums', ['forum_id'], ['id'])
+    op.create_foreign_key('fk_forum_id', 'moderators', 'forums', ['forum_id'], ['id'])
+    op.create_foreign_key('fk_post_topic_id', 'posts', 'topics', ['topic_id'], ['id'])
+    op.create_foreign_key('fk_settingsgroup', 'settings', 'settingsgroup', ['settingsgroup'], ['key'])
+    op.create_foreign_key('fk_topic_forum_id', 'topics', 'forums', ['forum_id'], ['id'])
+    op.create_foreign_key('fk_tr_topic_id', 'topicsread', 'topics', ['topic_id'], ['id'])
+    op.create_foreign_key('fk_tr_forum_id', 'topicsread', 'forums', ['forum_id'], ['id'])
+    op.create_foreign_key('fk_tracker_topic_id', 'topictracker', 'topics', ['topic_id'], ['id'])
+    ### end Alembic commands ###
+
+
+def downgrade():
+    ### commands auto generated by Alembic - please adjust! ###
+    op.drop_constraint('fk_tracker_topic_id', 'topictracker', type_='foreignkey')
+    op.drop_constraint('fk_tr_forum_id', 'topicsread', type_='foreignkey')
+    op.drop_constraint('fk_tr_topic_id', 'topicsread', type_='foreignkey')
+    op.drop_constraint('fk_topic_forum_id', 'topics', type_='foreignkey')
+    op.drop_constraint('fk_settingsgroup', 'settings', type_='foreignkey')
+    op.drop_constraint('fk_post_topic_id', 'posts', type_='foreignkey')
+    op.drop_constraint('fk_forum_id', 'moderators', type_='foreignkey')
+    op.drop_constraint('fk_fr_forum_id', 'forumsread', type_='foreignkey')
+    op.drop_table('forumgroups')
+    ### end Alembic commands ###

+ 2 - 1
tests/fixtures/forum.py

@@ -15,9 +15,10 @@ def category(database):
 
 
 
 
 @pytest.fixture
 @pytest.fixture
-def forum(category, default_settings):
+def forum(category, default_settings, default_groups):
     """A single forum in a category."""
     """A single forum in a category."""
     forum = Forum(title="Test Forum", category_id=category.id)
     forum = Forum(title="Test Forum", category_id=category.id)
+    forum.groups  = default_groups
     forum.save()
     forum.save()
     return forum
     return forum
 
 

+ 1 - 4
tests/unit/test_forum_models.py

@@ -123,13 +123,10 @@ def test_category_get_all(forum, user):
 def test_forum_save(category, moderator_user):
 def test_forum_save(category, moderator_user):
     """Test the save forum method"""
     """Test the save forum method"""
     forum = Forum(title="Test Forum", category_id=category.id)
     forum = Forum(title="Test Forum", category_id=category.id)
+    forum.moderators = [moderator_user]
     forum.save()
     forum.save()
 
 
     assert forum.title == "Test Forum"
     assert forum.title == "Test Forum"
-
-    # Test with adding a moderator
-    forum.save([moderator_user])
-
     assert forum.moderators == [moderator_user]
     assert forum.moderators == [moderator_user]