Browse Source

Merge branch 'maiiku-features/mko_01_forum_group_access'

sh4nks 10 years ago
parent
commit
9f528ad128

+ 130 - 49
flaskbb/forum/models.py

@@ -11,8 +11,10 @@
 from datetime import datetime, timedelta
 
 from flask import url_for, abort
+from sqlalchemy.orm import aliased
 
 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.settings import flaskbb_config
 
@@ -35,6 +37,22 @@ topictracker = db.Table(
                             use_alter=True, name="fk_tracker_topic_id"),
               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):
     __tablename__ = "topicsread"
@@ -339,6 +357,12 @@ class Topic(db.Model):
         """
         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):
         """Returns True if the topicsread tracker needs an update.
         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())
 
     # 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
-    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
     @property
@@ -743,18 +780,12 @@ class Forum(db.Model):
         # Nothing updated, because there are still more than 0 unread topicsread
         return False
 
-    def save(self, moderators=None):
+    def save(self):
         """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()
         return self
 
@@ -786,6 +817,7 @@ class Forum(db.Model):
 
     # Classmethods
     @classmethod
+    @can_access_forum
     def get_forum(cls, forum_id, 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
                      forumsread object.
         """
+        # import Group model locally to avoid cicular imports
+        from flaskbb.user.models import Group
         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:
-            # 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)
 
@@ -951,24 +1008,48 @@ class Category(db.Model):
         :param user: The user object is needed to check if we also need their
                      forumsread object.
         """
+        from flaskbb.user.models import Group
         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:
-            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:
             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)
 
     # 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
     topic.views += 1

+ 43 - 14
flaskbb/management/forms.py

@@ -9,16 +9,25 @@
     :license: BSD, see LICENSE for more details.
 """
 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,
                                 URL, ValidationError)
 from wtforms.ext.sqlalchemy.fields import (QuerySelectField,
                                            QuerySelectMultipleField)
+from sqlalchemy.orm.session import make_transient, make_transient_to_detached
 from flask_babelex import lazy_gettext as _
 
 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.forum.models import Forum, Category
 from flaskbb.user.models import User, Group
@@ -35,6 +44,8 @@ def selectable_forums():
 def selectable_categories():
     return Category.query.order_by(Category.position)
 
+def selectable_groups():
+    return Group.query.order_by(Group.name.asc()).all()
 
 def select_primary_group():
     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.")
     )
 
+    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"))
 
     def validate_external(self, field):
@@ -350,24 +369,34 @@ class ForumForm(Form):
         else:
             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()
 
 
 class EditForumForm(ForumForm):
+
+    id = HiddenField()
+
     def __init__(self, forum, *args, **kwargs):
         self.forum = forum
         kwargs['obj'] = self.forum

+ 4 - 3
flaskbb/management/views.py

@@ -364,9 +364,7 @@ def edit_forum(forum_id):
 
     form = EditForumForm(forum)
     if form.validate_on_submit():
-        form.populate_obj(forum)
-        forum.save(moderators=form.moderators.data)
-
+        form.save()
         flash(_("Forum successfully updated."), "success")
         return redirect(url_for("management.edit_forum", forum_id=forum.id))
     else:
@@ -406,6 +404,9 @@ def add_forum(category_id=None):
         flash(_("Forum successfully added."), "success")
         return redirect(url_for("management.forums"))
     else:
+        # by default all groups have access to a forum
+        form.groups.data = Group.query.order_by(Group.name.asc()).all()
+
         if category_id:
             category = Category.query.filter_by(id=category_id).first()
             form.category.data = category

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

@@ -358,3 +358,7 @@ margin-bottom: 0px;
 .conversation-list .conversation-date {
     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>
 {% 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='') %}
 <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") %}
 {% 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">
     <ul class="nav nav-pills nav-stacked">
@@ -13,24 +13,62 @@
     </ul>
 </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") }}
-    </form>
+        </div>
+
+    </div>
+        </form>
 </div>
 {% 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 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>
 {% endblock %}

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

@@ -1,4 +1,8 @@
 {% extends theme("layout.html") %}
+{% block css %}
+    {{ super() }}
+    <link rel="stylesheet" href="{{ url_for('static', filename='css/megalist-multiselect.css') }}" >
+{% endblock %}
 {% block content %}
 {%- from theme('macros.html') import navlink with context -%}
 
@@ -20,3 +24,8 @@
 {% block management_content %}{% 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()
         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):
     __tablename__ = "users"
@@ -162,6 +172,11 @@ class User(db.Model, UserMixin):
         """Returns the topics per day count"""
         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
     def __repr__(self):
         """Set to a unique key specific to the object in the database.

+ 48 - 0
flaskbb/utils/decorators.py

@@ -38,3 +38,51 @@ def moderator_required(f):
 
         return f(*args, **kwargs)
     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.
     :license: BSD, see LICENSE for more details.
 """
+import simplejson as json
 from datetime import datetime
 from wtforms.widgets.core import Select, HTMLString, html_params
 
@@ -92,3 +93,33 @@ class SelectBirthdayWidget(object):
             html.append(' ')
 
         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))

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

@@ -0,0 +1,31 @@
+"""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'], )
+    )
+    ### end Alembic commands ###
+
+
+def downgrade():
+    ### commands auto generated by Alembic - please adjust! ###
+    op.drop_table('forumgroups')
+    ### end Alembic commands ###

+ 2 - 1
tests/fixtures/forum.py

@@ -15,9 +15,10 @@ def category(database):
 
 
 @pytest.fixture
-def forum(category, default_settings):
+def forum(category, default_settings, default_groups):
     """A single forum in a category."""
     forum = Forum(title="Test Forum", category_id=category.id)
+    forum.groups  = default_groups
     forum.save()
     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):
     """Test the save forum method"""
     forum = Forum(title="Test Forum", category_id=category.id)
+    forum.moderators = [moderator_user]
     forum.save()
 
     assert forum.title == "Test Forum"
-
-    # Test with adding a moderator
-    forum.save([moderator_user])
-
     assert forum.moderators == [moderator_user]