Browse Source

Merge remote-tracking branch 'upstream/master'

Conflicts:
	flaskbb/templates/forum/topic.html
Casper Van Gheluwe 10 years ago
parent
commit
7fbb8187ed

+ 1 - 0
.travis.yml

@@ -1,6 +1,7 @@
 language: python
 python:
   - "2.7"
+  - "3.3"
 # command to install dependencies
 install:
   - "pip install -r requirements.txt"

+ 2 - 1
AUTHORS

@@ -9,4 +9,5 @@ contributors:
 
 # Contributors
 
-* None! Be the first :)
+* CasperVg
+* Feel free to join! :)

+ 1 - 1
README.md

@@ -7,7 +7,7 @@
 using the micro framework Flask.
 
 
-**_Note:_** Do not expect too much activity during the summer holidays - I hope you can understand this :)
+**_Note:_** I'm doing my exchange semester and most of the time I'm busy with traveling and such :). The project is not dead in any way - I'm just really busy.
 
 
 ## FEATURES

+ 29 - 0
flaskbb/_compat.py

@@ -0,0 +1,29 @@
+"""
+Look here for more information:
+https://github.com/mitsuhiko/flask/blob/master/flask/_compat.py
+"""
+
+import sys
+
+PY2 = sys.version_info[0] == 2
+
+if not PY2:     # pragma: no cover
+    text_type = str
+    string_types = (str,)
+    integer_types = (int, )
+    intern_method = sys.intern
+    range_method = range
+    iterkeys = lambda d: iter(d.keys())
+    itervalues = lambda d: iter(d.values())
+    iteritems = lambda d: iter(d.items())
+    max_integer = sys.maxsize
+else:           # pragma: no cover
+    text_type = unicode
+    string_types = (str, unicode)
+    integer_types = (int, long)
+    intern_method = intern
+    range_method = xrange
+    iterkeys = lambda d: d.iterkeys()
+    itervalues = lambda d: d.itervalues()
+    iteritems = lambda d: d.iteritems()
+    max_integer = sys.maxint

+ 25 - 4
flaskbb/app.py

@@ -11,6 +11,10 @@
 import os
 import logging
 import datetime
+import time
+
+from sqlalchemy import event
+from sqlalchemy.engine import Engine
 
 from flask import Flask, request
 from flask.ext.login import current_user
@@ -26,7 +30,7 @@ from flaskbb.management.views import management
 from flaskbb.forum.views import forum
 from flaskbb.forum.models import Post, Topic, Category, Forum
 # extensions
-from flaskbb.extensions import db, login_manager, mail, cache, redis, \
+from flaskbb.extensions import db, login_manager, mail, cache, redis_store, \
     debugtoolbar, migrate, themes, plugin_manager
 from flask.ext.whooshalchemy import whoosh_index
 # various helpers
@@ -70,7 +74,9 @@ def configure_blueprints(app):
     app.register_blueprint(forum, url_prefix=app.config["FORUM_URL_PREFIX"])
     app.register_blueprint(user, url_prefix=app.config["USER_URL_PREFIX"])
     app.register_blueprint(auth, url_prefix=app.config["AUTH_URL_PREFIX"])
-    app.register_blueprint(management, url_prefix=app.config["ADMIN_URL_PREFIX"])
+    app.register_blueprint(
+        management, url_prefix=app.config["ADMIN_URL_PREFIX"]
+    )
 
 
 def configure_extensions(app):
@@ -100,7 +106,7 @@ def configure_extensions(app):
     themes.init_themes(app, app_identifier="flaskbb")
 
     # Flask-And-Redis
-    redis.init_app(app)
+    redis_store.init_app(app)
 
     # Flask-WhooshAlchemy
     with app.app_context():
@@ -121,7 +127,7 @@ def configure_extensions(app):
         Loads the user. Required by the `login` extension
         """
         unread_count = db.session.query(db.func.count(PrivateMessage.id)).\
-            filter(PrivateMessage.unread == True,
+            filter(PrivateMessage.unread,
                    PrivateMessage.user_id == id).subquery()
         u = db.session.query(User, unread_count).filter(User.id == id).first()
 
@@ -264,3 +270,18 @@ def configure_logging(app):
         mail_handler.setLevel(logging.ERROR)
         mail_handler.setFormatter(formatter)
         app.logger.addHandler(mail_handler)
+
+    if app.config["SQLALCHEMY_ECHO"]:
+        # Ref: http://stackoverflow.com/a/8428546
+        @event.listens_for(Engine, "before_cursor_execute")
+        def before_cursor_execute(conn, cursor, statement,
+                                  parameters, context, executemany):
+            context._query_start_time = time.time()
+
+        @event.listens_for(Engine, "after_cursor_execute")
+        def after_cursor_execute(conn, cursor, statement,
+                                 parameters, context, executemany):
+            total = time.time() - context._query_start_time
+
+            # Modification for StackOverflow: times in milliseconds
+            app.logger.debug("Total Time: %.02fms" % (total*1000))

+ 20 - 18
flaskbb/auth/forms.py

@@ -11,8 +11,9 @@
 from datetime import datetime
 
 from flask.ext.wtf import Form, RecaptchaField
-from wtforms import TextField, PasswordField, BooleanField, HiddenField
-from wtforms.validators import Required, Email, EqualTo, regexp, ValidationError
+from wtforms import StringField, PasswordField, BooleanField, HiddenField
+from wtforms.validators import (DataRequired, Email, EqualTo, regexp,
+                                ValidationError)
 
 from flaskbb.user.models import User
 
@@ -22,29 +23,29 @@ is_username = regexp(USERNAME_RE,
 
 
 class LoginForm(Form):
-    login = TextField("Username or E-Mail", validators=[
-        Required(message="You must provide an email adress or username")])
+    login = StringField("Username or E-Mail", validators=[
+        DataRequired(message="You must provide an email adress or username")])
 
     password = PasswordField("Password", validators=[
-        Required(message="Password required")])
+        DataRequired(message="Password required")])
 
     remember_me = BooleanField("Remember Me", default=False)
 
 
 class RegisterForm(Form):
-    username = TextField("Username", validators=[
-        Required(message="Username required"),
+    username = StringField("Username", validators=[
+        DataRequired(message="Username required"),
         is_username])
 
-    email = TextField("E-Mail", validators=[
-        Required(message="Email adress required"),
+    email = StringField("E-Mail", validators=[
+        DataRequired(message="Email adress required"),
         Email(message="This email is invalid")])
 
     password = PasswordField("Password", validators=[
-        Required(message="Password required")])
+        DataRequired(message="Password required")])
 
     confirm_password = PasswordField("Confirm Password", validators=[
-        Required(message="Confirm Password required"),
+        DataRequired(message="Confirm Password required"),
         EqualTo("password", message="Passwords do not match")])
 
     accept_tos = BooleanField("Accept Terms of Service", default=True)
@@ -73,27 +74,28 @@ class RegisterRecaptchaForm(RegisterForm):
 
 
 class ReauthForm(Form):
-    password = PasswordField('Password', [Required()])
+    password = PasswordField('Password', valdidators=[
+        DataRequired()])
 
 
 class ForgotPasswordForm(Form):
-    email = TextField('Email', validators=[
-        Required(message="Email reguired"),
+    email = StringField('Email', validators=[
+        DataRequired(message="Email reguired"),
         Email()])
 
 
 class ResetPasswordForm(Form):
     token = HiddenField('Token')
 
-    email = TextField('Email', validators=[
-        Required(),
+    email = StringField('Email', validators=[
+        DataRequired(),
         Email()])
 
     password = PasswordField('Password', validators=[
-        Required()])
+        DataRequired()])
 
     confirm_password = PasswordField('Confirm password', validators=[
-        Required(),
+        DataRequired(),
         EqualTo('password', message='Passwords must match')])
 
     def validate_email(self, field):

+ 3 - 4
flaskbb/configs/default.py

@@ -79,11 +79,10 @@ class DefaultConfig(object):
     # Where to logger should send the emails to
     ADMINS = ["admin@example.org"]
 
-    ## Flask-And-Redis
+    # Flask-Redis
     REDIS_ENABLED = False
-    REDIS_HOST = 'localhost'
-    REDIS_PORT = 6379
-    REDIS_DB = 0
+    REDIS_URL = "redis://:password@localhost:6379"
+    REDIS_DATABASE = 0
 
     FORUM_URL_PREFIX = ""
     USER_URL_PREFIX = "/user"

+ 3 - 4
flaskbb/configs/production.py.example

@@ -80,11 +80,10 @@ class ProductionConfig(DefaultConfig):
     INFO_LOG = "info.log"
     ERROR_LOG = "error.log"
 
-    # Redis
+    # Flask-Redis
     REDIS_ENABLED = False
-    REDIS_HOST = 'localhost'
-    REDIS_PORT = 6379
-    REDIS_DB = 0
+    REDIS_URL = "redis://:password@localhost:6379"
+    REDIS_DATABASE = 0
 
     # URL Prefixes.
     FORUM_URL_PREFIX = ""

+ 2 - 6
flaskbb/email.py

@@ -30,12 +30,8 @@ def send_reset_token(user, token):
     )
 
 
-def send_email(subject, recipients, text_body, html_body, sender=""):
-    if not sender:
-        msg = Message(subject, recipients=recipients)
-    else:
-        msg = Message(subject, recipients=recipients, sender=sender)
-
+def send_email(subject, recipients, text_body, html_body, sender=None):
+    msg = Message(subject, recipients=recipients, sender=sender)
     msg.body = text_body
     msg.html = html_body
     mail.send(msg)

+ 1 - 1
flaskbb/extensions.py

@@ -32,7 +32,7 @@ mail = Mail()
 cache = Cache()
 
 # Redis
-redis = Redis()
+redis_store = Redis()
 
 # Debugtoolbar
 debugtoolbar = DebugToolbarExtension()

+ 12 - 0
flaskbb/fixtures/settings.py

@@ -14,6 +14,11 @@ from flask.ext.themes2 import get_themes_list
 def available_themes():
     return [(theme.identifier, theme.name) for theme in get_themes_list()]
 
+
+def available_markups():
+    return [('bbcode', 'BBCode'), ('markdown', 'Markdown')]
+
+
 fixture = (
     # Settings Group
     ('general', {
@@ -79,6 +84,13 @@ fixture = (
                 'extra':        {'min': 0},
                 'name':         "Tracker length",
                 'description':  "The days for how long the forum should deal with unread topics. 0 to disable it."
+            }),
+            ('markup_type', {
+                'value':        "bbcode",
+                'value_type':   "select",
+                'extra':        {'choices': available_markups},
+                'name':         "Post markup",
+                'description':  "Select post markup type."
             })
         ),
     }),

+ 15 - 14
flaskbb/forum/forms.py

@@ -9,8 +9,9 @@
     :license: BSD, see LICENSE for more details.
 """
 from flask.ext.wtf import Form
-from wtforms import TextAreaField, TextField, SelectMultipleField, BooleanField
-from wtforms.validators import Required, Optional, Length
+from wtforms import (TextAreaField, StringField, SelectMultipleField,
+                     BooleanField)
+from wtforms.validators import DataRequired, Optional, Length
 
 from flaskbb.forum.models import Topic, Post, Report, Forum
 from flaskbb.user.models import User
@@ -18,7 +19,7 @@ from flaskbb.user.models import User
 
 class QuickreplyForm(Form):
     content = TextAreaField("Quickreply", validators=[
-        Required(message="You cannot post a reply without content.")])
+        DataRequired(message="You cannot post a reply without content.")])
 
     def save(self, user, topic):
         post = Post(**self.data)
@@ -27,7 +28,7 @@ class QuickreplyForm(Form):
 
 class ReplyForm(Form):
     content = TextAreaField("Content", validators=[
-        Required(message="You cannot post a reply without content.")])
+        DataRequired(message="You cannot post a reply without content.")])
 
     track_topic = BooleanField("Track this topic", default=False, validators=[
         Optional()])
@@ -41,11 +42,11 @@ class ReplyForm(Form):
 
 
 class NewTopicForm(ReplyForm):
-    title = TextField("Topic Title", validators=[
-        Required(message="A topic title is required")])
+    title = StringField("Topic Title", validators=[
+        DataRequired(message="A topic title is required")])
 
     content = TextAreaField("Content", validators=[
-        Required(message="You cannot post a reply without content.")])
+        DataRequired(message="You cannot post a reply without content.")])
 
     track_topic = BooleanField("Track this topic", default=False, validators=[
         Optional()])
@@ -61,8 +62,8 @@ class NewTopicForm(ReplyForm):
 
 class ReportForm(Form):
     reason = TextAreaField("Reason", validators=[
-        Required(message="Please insert a reason why you want to report this \
-                          post")
+        DataRequired(message="Please insert a reason why you want to report \
+                              this post")
     ])
 
     def save(self, user, post):
@@ -71,7 +72,7 @@ class ReportForm(Form):
 
 
 class UserSearchForm(Form):
-    search_query = TextField("Search", validators=[
+    search_query = StringField("Search", validators=[
         Optional(), Length(min=3, max=50)
     ])
 
@@ -81,12 +82,12 @@ class UserSearchForm(Form):
 
 
 class SearchPageForm(Form):
-    search_query = TextField("Criteria", validators=[
-        Required(), Length(min=3, max=50)])
+    search_query = StringField("Criteria", validators=[
+        DataRequired(), Length(min=3, max=50)])
 
     search_types = SelectMultipleField("Content", validators=[
-        Required()], choices=[('post', 'Post'), ('topic', 'Topic'),
-                              ('forum', 'Forum'), ('user', 'Users')])
+        DataRequired()], choices=[('post', 'Post'), ('topic', 'Topic'),
+                                  ('forum', 'Forum'), ('user', 'Users')])
 
     def get_results(self):
         # Because the DB is not yet initialized when this form is loaded,

+ 34 - 4
flaskbb/forum/models.py

@@ -156,7 +156,7 @@ class Post(db.Model):
                                        use_alter=True,
                                        name="fk_post_topic_id",
                                        ondelete="CASCADE"))
-    user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
+    user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
     username = db.Column(db.String(200), nullable=False)
     content = db.Column(db.Text, nullable=False)
     date_created = db.Column(db.DateTime, default=datetime.utcnow())
@@ -211,7 +211,13 @@ class Post(db.Model):
 
             # Now lets update the last post id
             topic.last_post_id = self.id
+
+            # Update the last post info for the forum
             topic.forum.last_post_id = self.id
+            topic.forum.last_post_title = topic.title
+            topic.forum.last_post_user_id = user.id
+            topic.forum.last_post_username = user.username
+            topic.forum.last_post_created = datetime.utcnow()
 
             # Update the post counts
             user.post_count += 1
@@ -301,7 +307,7 @@ class Topic(db.Model):
                                 foreign_keys=[last_post_id])
 
     # One-to-many
-    posts = db.relationship("Post", backref="topic", lazy="joined",
+    posts = db.relationship("Post", backref="topic", lazy="dynamic",
                             primaryjoin="Post.topic_id == Topic.id",
                             cascade="all, delete-orphan", post_update=True)
 
@@ -493,6 +499,7 @@ class Topic(db.Model):
 
         :param post: The post object which is connected to the topic
         """
+
         # Updates the topic
         if self.id:
             db.session.add(self)
@@ -507,6 +514,8 @@ class Topic(db.Model):
         # Set the last_updated time. Needed for the readstracker
         self.last_updated = datetime.utcnow()
 
+        self.date_created = datetime.utcnow()
+
         # Insert and commit the topic
         db.session.add(self)
         db.session.commit()
@@ -543,6 +552,10 @@ class Topic(db.Model):
             # There is no second last post
             except IndexError:
                 self.forum.last_post_id = None
+                self.forum.last_post_title = None
+                self.forum.last_post_user_id = None
+                self.forum.last_post_username = None
+                self.forum.last_post_created = None
 
             # Commit the changes
             db.session.commit()
@@ -597,8 +610,14 @@ class Forum(db.Model):
     last_post = db.relationship("Post", backref="last_post_forum",
                                 uselist=False, foreign_keys=[last_post_id])
 
+    # Not nice, but needed to improve the performance
+    last_post_title = db.Column(db.String(255))
+    last_post_user_id = db.Column(db.Integer, db.ForeignKey("users.id"))
+    last_post_username = db.Column(db.String(255))
+    last_post_created = db.Column(db.DateTime, default=datetime.utcnow())
+
     # One-to-many
-    topics = db.relationship("Topic", backref="forum", lazy="joined",
+    topics = db.relationship("Topic", backref="forum", lazy="dynamic",
                              cascade="all, delete-orphan")
 
     # Many-to-many
@@ -621,6 +640,11 @@ class Forum(db.Model):
             return self.external
         return url_for("forum.view_forum", forum_id=self.id, slug=self.slug)
 
+    @property
+    def last_post_url(self):
+        """Returns the url for the last post in the forum"""
+        return url_for("forum.view_post", post_id=self.last_post_id)
+
     # Methods
     def __repr__(self):
         """Set to a unique key specific to the object in the database.
@@ -671,6 +695,11 @@ class Forum(db.Model):
         if not user.is_authenticated() or topicsread is None:
             return False
 
+        read_cutoff = None
+        if flaskbb_config['TRACKER_LENGTH'] > 0:
+            read_cutoff = datetime.utcnow() - timedelta(
+                days=flaskbb_config['TRACKER_LENGTH'])
+
         # fetch the unread posts in the forum
         unread_count = Topic.query.\
             outerjoin(TopicsRead,
@@ -680,6 +709,7 @@ class Forum(db.Model):
                       db.and_(ForumsRead.forum_id == Topic.forum_id,
                               ForumsRead.user_id == user.id)).\
             filter(Topic.forum_id == self.id,
+                   Topic.last_updated > read_cutoff,
                    db.or_(TopicsRead.last_read == None,
                           TopicsRead.last_read < Topic.last_updated)).\
             count()
@@ -706,7 +736,7 @@ class Forum(db.Model):
             forumsread.save()
             return True
 
-        # Nothing updated, because there are still more than 0 unread topics
+        # Nothing updated, because there are still more than 0 unread topicsread
         return False
 
     def save(self, moderators=None):

+ 103 - 96
flaskbb/forum/views.py

@@ -17,7 +17,8 @@ from flask.ext.login import login_required, current_user
 
 from flaskbb.extensions import db
 from flaskbb.utils.settings import flaskbb_config
-from flaskbb.utils.helpers import get_online_users, time_diff, render_template
+from flaskbb.utils.helpers import (get_online_users, time_diff, render_template,
+                                   format_quote)
 from flaskbb.utils.permissions import (can_post_reply, can_post_topic,
                                        can_delete_topic, can_delete_post,
                                        can_edit_post, can_moderate)
@@ -76,16 +77,21 @@ def view_category(category_id, slug=None):
 def view_forum(forum_id, slug=None):
     page = request.args.get('page', 1, type=int)
 
-    forum, forumsread = Forum.get_forum(forum_id=forum_id, user=current_user)
+    forum_instance, forumsread = Forum.get_forum(forum_id=forum_id,
+                                                 user=current_user)
 
-    if forum.external:
-        return redirect(forum.external)
+    if forum_instance.external:
+        return redirect(forum_instance.external)
 
-    topics = Forum.get_topics(forum_id=forum.id, user=current_user, page=page,
-                              per_page=flaskbb_config["TOPICS_PER_PAGE"])
+    topics = Forum.get_topics(
+        forum_id=forum_instance.id, user=current_user, page=page,
+        per_page=flaskbb_config["TOPICS_PER_PAGE"]
+    )
 
-    return render_template("forum/forum.html", forum=forum, topics=topics,
-                           forumsread=forumsread,)
+    return render_template(
+        "forum/forum.html", forum=forum_instance,
+        topics=topics, forumsread=forumsread,
+    )
 
 
 @forum.route("/topic/<int:topic_id>", methods=["POST", "GET"])
@@ -93,13 +99,20 @@ def view_forum(forum_id, slug=None):
 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()
-    posts = Post.query.filter_by(topic_id=topic.id).\
-        order_by(Post.id.asc()).\
-        paginate(page, flaskbb_config['POSTS_PER_PAGE'], False)
 
     # Count the topic views
     topic.views += 1
+    topic.save()
+
+    # fetch the posts in the topic
+    posts = Post.query.\
+        join(User, Post.user_id == User.id).\
+        filter(Post.topic_id == topic.id).\
+        add_entity(User).\
+        order_by(Post.id.asc()).\
+        paginate(page, flaskbb_config['POSTS_PER_PAGE'], False)
 
     # Update the topicsread status if the user hasn't read it
     forumsread = None
@@ -109,19 +122,14 @@ def view_topic(topic_id, slug=None):
                       forum_id=topic.forum.id).first()
 
     topic.update_read(current_user, topic.forum, forumsread)
-    topic.save()
 
     form = None
 
-    if not topic.locked \
-        and not topic.forum.locked \
-        and can_post_reply(user=current_user,
-                           forum=topic.forum):
-
-            form = QuickreplyForm()
-            if form.validate_on_submit():
-                post = form.save(current_user, topic)
-                return view_post(post.id)
+    if can_post_reply(user=current_user, topic=topic):
+        form = QuickreplyForm()
+        if form.validate_on_submit():
+            post = form.save(current_user, topic)
+            return view_post(post.id)
 
     return render_template("forum/topic.html", topic=topic, posts=posts,
                            last_seen=time_diff(), form=form)
@@ -145,12 +153,7 @@ def view_post(post_id):
 @forum.route("/<int:forum_id>-<slug>/topic/new", methods=["POST", "GET"])
 @login_required
 def new_topic(forum_id, slug=None):
-    forum = Forum.query.filter_by(id=forum_id).first_or_404()
-
-    if forum.locked:
-        flash("This forum is locked; you cannot submit new topics or posts.",
-              "danger")
-        return redirect(forum.url)
+    forum_instance = Forum.query.filter_by(id=forum_id).first_or_404()
 
     if not can_post_topic(user=current_user, forum=forum):
         flash("You do not have the permissions to create a new topic.",
@@ -160,13 +163,18 @@ def new_topic(forum_id, slug=None):
     form = NewTopicForm()
     if form.validate_on_submit():
         if request.form['button'] == 'preview':
-            return render_template("forum/new_topic.html", forum=forum, form=form, preview=form.content.data)
+            return render_template(
+                "forum/new_topic.html", forum=forum_instance,
+                form=form, preview=form.content.data
+            )
         else:
-            topic = form.save(current_user, forum)
+            topic = form.save(current_user, forum_instance)
 
             # redirect to the new topic
             return redirect(url_for('forum.view_topic', topic_id=topic.id))
-    return render_template("forum/new_topic.html", forum=forum, form=form)
+    return render_template(
+        "forum/new_topic.html", forum=forum_instance, form=form
+    )
 
 
 @forum.route("/topic/<int:topic_id>/delete")
@@ -175,8 +183,7 @@ def new_topic(forum_id, slug=None):
 def delete_topic(topic_id, slug=None):
     topic = Topic.query.filter_by(id=topic_id).first_or_404()
 
-    if not can_delete_topic(user=current_user, forum=topic.forum,
-                            post_user_id=topic.first_post.user_id):
+    if not can_delete_topic(user=current_user, topic=topic):
 
         flash("You do not have the permissions to delete the topic", "danger")
         return redirect(topic.forum.url)
@@ -223,23 +230,28 @@ def unlock_topic(topic_id, slug=None):
 
 
 @forum.route("/topic/<int:topic_id>/move/<int:forum_id>")
-@forum.route("/topic/<int:topic_id>-<topic_slug>/move/<int:forum_id>-<forum_slug>")
+@forum.route(
+    "/topic/<int:topic_id>-<topic_slug>/move/<int:forum_id>-<forum_slug>"
+)
 @login_required
 def move_topic(topic_id, forum_id, topic_slug=None, forum_slug=None):
-    forum = Forum.query.filter_by(id=forum_id).first_or_404()
+    forum_instance = Forum.query.filter_by(id=forum_id).first_or_404()
     topic = Topic.query.filter_by(id=topic_id).first_or_404()
 
     # TODO: Bulk move
 
     if not can_moderate(user=current_user, forum=topic.forum):
         flash("Yo do not have the permissions to move this topic", "danger")
-        return redirect(forum.url)
+        return redirect(forum_instance.url)
 
-    if not topic.move(forum):
-        flash("Could not move the topic to forum %s" % forum.title, "danger")
+    if not topic.move(forum_instance):
+        flash(
+            "Could not move the topic to forum %s" % forum_instance.title,
+            "danger"
+        )
         return redirect(topic.url)
 
-    flash("Topic was moved to forum %s" % forum.title, "success")
+    flash("Topic was moved to forum %s" % forum_instance.title, "success")
     return redirect(topic.url)
 
 
@@ -247,21 +259,22 @@ def move_topic(topic_id, forum_id, topic_slug=None, forum_slug=None):
 @forum.route("/topic/<int:old_id>-<old_slug>/merge/<int:new_id>-<new_slug>")
 @login_required
 def merge_topic(old_id, new_id, old_slug=None, new_slug=None):
-    old_topic = Topic.query.filter_by(id=old_id).first_or_404()
-    new_topic = Topic.query.filter_by(id=new_id).first_or_404()
+    _old_topic = Topic.query.filter_by(id=old_id).first_or_404()
+    _new_topic = Topic.query.filter_by(id=new_id).first_or_404()
 
     # TODO: Bulk merge
 
-    if not can_moderate(user=current_user, forum=topic.forum):
+    # Looks to me that the user should have permissions on both forums, right?
+    if not can_moderate(user=current_user, forum=_old_topic.forum):
         flash("Yo do not have the permissions to merge this topic", "danger")
-        return redirect(old_topic.url)
+        return redirect(_old_topic.url)
 
-    if not old_topic.merge(new_topic):
+    if not _old_topic.merge(_new_topic):
         flash("Could not merge the topic.", "danger")
-        return redirect(old_topic.url)
+        return redirect(_old_topic.url)
 
     flash("Topic succesfully merged.", "success")
-    return redirect(new_topic.url)
+    return redirect(_new_topic.url)
 
 
 @forum.route("/topic/<int:topic_id>/post/new", methods=["POST", "GET"])
@@ -270,23 +283,17 @@ def merge_topic(old_id, new_id, old_slug=None, new_slug=None):
 def new_post(topic_id, slug=None):
     topic = Topic.query.filter_by(id=topic_id).first_or_404()
 
-    if topic.forum.locked:
-        flash("This forum is locked; you cannot submit new topics or posts.",
-              "danger")
-        return redirect(topic.forum.url)
-
-    if topic.locked:
-        flash("The topic is locked.", "danger")
-        return redirect(topic.forum.url)
-
-    if not can_post_reply(user=current_user, forum=topic.forum):
-        flash("You do not have the permissions to delete the topic", "danger")
+    if not can_post_reply(user=current_user, topic=topic):
+        flash("You do not have the permissions to post here", "danger")
         return redirect(topic.forum.url)
 
     form = ReplyForm()
     if form.validate_on_submit():
         if request.form['button'] == 'preview':
-            return render_template("forum/new_post.html", topic=topic, form=form, preview=form.content.data)
+            return render_template(
+                "forum/new_post.html", topic=topic,
+                form=form, preview=form.content.data
+            )
         else:
             post = form.save(current_user, topic)
             return view_post(post.id)
@@ -294,34 +301,30 @@ def new_post(topic_id, slug=None):
     return render_template("forum/new_post.html", topic=topic, form=form)
 
 
-@forum.route("/topic/<int:topic_id>/post/<int:post_id>/reply", methods=["POST", "GET"])
+@forum.route(
+    "/topic/<int:topic_id>/post/<int:post_id>/reply", methods=["POST", "GET"]
+)
 @login_required
 def reply_post(topic_id, post_id):
     topic = Topic.query.filter_by(id=topic_id).first_or_404()
     post = Post.query.filter_by(id=post_id).first_or_404()
 
-    if post.topic.forum.locked:
-        flash("This forum is locked; you cannot submit new topics or posts.",
-              "danger")
-        return redirect(post.topic.forum.url)
-
-    if post.topic.locked:
-        flash("The topic is locked.", "danger")
-        return redirect(post.topic.forum.url)
-
-    if not can_post_reply(user=current_user, forum=topic.forum):
+    if not can_post_reply(user=current_user, topic=topic):
         flash("You do not have the permissions to post in this topic", "danger")
         return redirect(topic.forum.url)
 
     form = ReplyForm()
     if form.validate_on_submit():
         if request.form['button'] == 'preview':
-            return render_template("forum/new_post.html", topic=topic, form=form, preview=form.content.data)
+            return render_template(
+                "forum/new_post.html", topic=topic,
+                form=form, preview=form.content.data
+            )
         else:
             form.save(current_user, topic)
             return redirect(post.topic.url)
     else:
-        form.content.data = '[quote]{}[/quote]'.format(post.content)
+        form.content.data = format_quote(post)
 
     return render_template("forum/new_post.html", topic=post.topic, form=form)
 
@@ -331,24 +334,17 @@ def reply_post(topic_id, post_id):
 def edit_post(post_id):
     post = Post.query.filter_by(id=post_id).first_or_404()
 
-    if post.topic.forum.locked:
-        flash("This forum is locked; you cannot submit new topics or posts.",
-              "danger")
-        return redirect(post.topic.forum.url)
-
-    if post.topic.locked:
-        flash("The topic is locked.", "danger")
-        return redirect(post.topic.forum.url)
-
-    if not can_edit_post(user=current_user, forum=post.topic.forum,
-                         post_user_id=post.user_id):
+    if not can_edit_post(user=current_user, post=post):
         flash("You do not have the permissions to edit this post", "danger")
         return redirect(post.topic.url)
 
     form = ReplyForm()
     if form.validate_on_submit():
         if request.form['button'] == 'preview':
-            return render_template("forum/new_post.html", topic=post.topic, form=form, preview=form.content.data)
+            return render_template(
+                "forum/new_post.html", topic=post.topic,
+                form=form, preview=form.content.data
+            )
         else:
             form.populate_obj(post)
             post.date_modified = datetime.datetime.utcnow()
@@ -368,17 +364,20 @@ def delete_post(post_id, slug=None):
 
     # TODO: Bulk delete
 
-    if not can_delete_post(user=current_user, forum=post.topic.forum,
-                           post_user_id=post.user_id):
+    if not can_delete_post(user=current_user, post=post):
         flash("You do not have the permissions to edit this post", "danger")
         return redirect(post.topic.url)
 
+    first_post = post.first_post
+    topic_url = post.topic.url
+    forum_url = post.topic.forum.url
+
     post.delete()
 
     # If the post was the first post in the topic, redirect to the forums
-    if post.first_post:
-        return redirect(post.topic.forum.url)
-    return redirect(post.topic.url)
+    if first_post:
+        return redirect(forum_url)
+    return redirect(topic_url)
 
 
 @forum.route("/post/<int:post_id>/report", methods=["GET", "POST"])
@@ -394,6 +393,13 @@ def report_post(post_id):
     return render_template("forum/report_post.html", form=form)
 
 
+@forum.route("/post/<int:post_id>/raw", methods=["POST", "GET"])
+@login_required
+def raw_post(post_id):
+    post = Post.query.filter_by(id=post_id).first_or_404()
+    return format_quote(post)
+
+
 @forum.route("/markread")
 @forum.route("/<int:forum_id>/markread")
 @forum.route("/<int:forum_id>-<slug>/markread")
@@ -401,16 +407,17 @@ def report_post(post_id):
 def markread(forum_id=None, slug=None):
     # Mark a single forum as read
     if forum_id:
-        forum = Forum.query.filter_by(id=forum_id).first_or_404()
-        forumsread = ForumsRead.query.filter_by(user_id=current_user.id,
-                                                forum_id=forum.id).first()
+        forum_instance = Forum.query.filter_by(id=forum_id).first_or_404()
+        forumsread = ForumsRead.query.filter_by(
+            user_id=current_user.id, forum_id=forum_instance.id
+        ).first()
         TopicsRead.query.filter_by(user_id=current_user.id,
-                                   forum_id=forum.id).delete()
+                                   forum_id=forum_instance.id).delete()
 
         if not forumsread:
             forumsread = ForumsRead()
             forumsread.user_id = current_user.id
-            forumsread.forum_id = forum.id
+            forumsread.forum_id = forum_instance.id
 
         forumsread.last_read = datetime.datetime.utcnow()
         forumsread.cleared = datetime.datetime.utcnow()
@@ -418,7 +425,7 @@ def markread(forum_id=None, slug=None):
         db.session.add(forumsread)
         db.session.commit()
 
-        return redirect(forum.url)
+        return redirect(forum_instance.url)
 
     # Mark all forums as read
     ForumsRead.query.filter_by(user_id=current_user.id).delete()
@@ -426,10 +433,10 @@ def markread(forum_id=None, slug=None):
 
     forums = Forum.query.all()
     forumsread_list = []
-    for forum in forums:
+    for forum_instance in forums:
         forumsread = ForumsRead()
         forumsread.user_id = current_user.id
-        forumsread.forum_id = forum.id
+        forumsread.forum_id = forum_instance.id
         forumsread.last_read = datetime.datetime.utcnow()
         forumsread.cleared = datetime.datetime.utcnow()
         forumsread_list.append(forumsread)
@@ -477,7 +484,7 @@ def topictracker():
                   db.and_(TopicsRead.topic_id == Topic.id,
                           TopicsRead.user_id == current_user.id)).\
         add_entity(TopicsRead).\
-        order_by(Post.id.desc()).\
+        order_by(Topic.last_updated.desc()).\
         paginate(page, flaskbb_config['TOPICS_PER_PAGE'], True)
 
     return render_template("forum/topictracker.html", topics=topics)

+ 65 - 47
flaskbb/management/forms.py

@@ -9,10 +9,10 @@
     :license: BSD, see LICENSE for more details.
 """
 from flask.ext.wtf import Form
-from wtforms import (TextField, TextAreaField, PasswordField, IntegerField,
+from wtforms import (StringField, TextAreaField, PasswordField, IntegerField,
                      BooleanField, SelectField, DateField)
-from wtforms.validators import (Required, Optional, Email, regexp, Length, URL,
-                                ValidationError)
+from wtforms.validators import (DataRequired, Optional, Email, regexp, Length,
+                                URL, ValidationError)
 
 from wtforms.ext.sqlalchemy.fields import (QuerySelectField,
                                            QuerySelectMultipleField)
@@ -36,16 +36,16 @@ def selectable_categories():
 
 
 def select_primary_group():
-    return Group.query.filter(Group.guest == False).order_by(Group.id)
+    return Group.query.filter(Group.guest != True).order_by(Group.id)
 
 
 class UserForm(Form):
-    username = TextField("Username", validators=[
-        Required(),
+    username = StringField("Username", validators=[
+        DataRequired(message="A username is required."),
         is_username])
 
-    email = TextField("E-Mail", validators=[
-        Required(),
+    email = StringField("E-Mail", validators=[
+        DataRequired(message="A E-Mail address is required."),
         Email(message="This email is invalid")])
 
     password = PasswordField("Password", validators=[
@@ -60,13 +60,13 @@ class UserForm(Form):
         ("Male", "Male"),
         ("Female", "Female")])
 
-    location = TextField("Location", validators=[
+    location = StringField("Location", validators=[
         Optional()])
 
-    website = TextField("Website", validators=[
+    website = StringField("Website", validators=[
         Optional(), URL()])
 
-    avatar = TextField("Avatar", validators=[
+    avatar = StringField("Avatar", validators=[
         Optional(), URL()])
 
     signature = TextAreaField("Forum Signature", validators=[
@@ -85,15 +85,15 @@ class UserForm(Form):
         # TODO: Template rendering errors "NoneType is not callable"
         #       without this, figure out why.
         query_factory=select_primary_group,
-        allow_blank=True,
         get_label="name")
 
     def validate_username(self, field):
         if hasattr(self, "user"):
             user = User.query.filter(
-                db.and_(User.username.like(field.data),
-                        db.not_(User.id == self.user.id)
-                        )
+                db.and_(
+                    User.username.like(field.data),
+                    db.not_(User.id == self.user.id)
+                )
             ).first()
         else:
             user = User.query.filter(User.username.like(field.data)).first()
@@ -104,9 +104,10 @@ class UserForm(Form):
     def validate_email(self, field):
         if hasattr(self, "user"):
             user = User.query.filter(
-                db.and_(User.email.like(field.data),
-                        db.not_(User.id == self.user.id)
-                        )
+                db.and_(
+                    User.email.like(field.data),
+                    db.not_(User.id == self.user.id)
+                )
             ).first()
         else:
             user = User.query.filter(User.email.like(field.data)).first()
@@ -131,8 +132,8 @@ class EditUserForm(UserForm):
 
 
 class GroupForm(Form):
-    name = TextField("Group Name", validators=[
-        Required(message="Group name required")])
+    name = StringField("Group Name", validators=[
+        DataRequired(message="Group name required")])
 
     description = TextAreaField("Description", validators=[
         Optional()])
@@ -194,9 +195,10 @@ class GroupForm(Form):
     def validate_name(self, field):
         if hasattr(self, "group"):
             group = Group.query.filter(
-                db.and_(Group.name.like(field.data),
-                        db.not_(Group.id == self.group.id)
-                        )
+                db.and_(
+                    Group.name.like(field.data),
+                    db.not_(Group.id == self.group.id)
+                )
             ).first()
         else:
             group = Group.query.filter(Group.name.like(field.data)).first()
@@ -207,9 +209,10 @@ class GroupForm(Form):
     def validate_banned(self, field):
         if hasattr(self, "group"):
             group = Group.query.filter(
-                db.and_(Group.banned == True,
-                        db.not_(Group.id == self.group.id)
-                        )
+                db.and_(
+                    Group.banned,
+                    db.not_(Group.id == self.group.id)
+                )
             ).count()
         else:
             group = Group.query.filter_by(banned=True).count()
@@ -220,9 +223,10 @@ class GroupForm(Form):
     def validate_guest(self, field):
         if hasattr(self, "group"):
             group = Group.query.filter(
-                db.and_(Group.guest == True,
-                        db.not_(Group.id == self.group.id)
-                        )
+                db.and_(
+                    Group.guest,
+                    db.not_(Group.id == self.group.id)
+                )
             ).count()
         else:
             group = Group.query.filter_by(guest=True).count()
@@ -247,15 +251,22 @@ class AddGroupForm(GroupForm):
 
 
 class ForumForm(Form):
-    title = TextField("Forum Title", validators=[
-        Required(message="Forum title required")])
+    title = StringField(
+        "Forum Title",
+        validators=[DataRequired(message="Forum title required")]
+    )
 
-    description = TextAreaField("Description", validators=[
-        Optional()],
-        description="You can format your description with BBCode.")
+    description = TextAreaField(
+        "Description",
+        validators=[Optional()],
+        description="You can format your description with BBCode."
+    )
 
-    position = IntegerField("Position", default=1, validators=[
-        Required(message="Forum position required")])
+    position = IntegerField(
+        "Position",
+        default=1,
+        validators=[DataRequired(message="Forum position required")]
+    )
 
     category = QuerySelectField(
         "Category",
@@ -265,11 +276,13 @@ class ForumForm(Form):
         description="The category that contains this forum."
     )
 
-    external = TextField("External link", validators=[
-        Optional(), URL()],
-        description="A link to a website i.e. 'http://flaskbb.org'")
+    external = StringField(
+        "External link",
+        validators=[Optional(), URL()],
+        description="A link to a website i.e. 'http://flaskbb.org'"
+    )
 
-    moderators = TextField(
+    moderators = StringField(
         "Moderators",
         description="Comma seperated usernames. Leave it blank if you do not \
                      want to set any moderators."
@@ -352,15 +365,20 @@ class AddForumForm(ForumForm):
 
 
 class CategoryForm(Form):
-    title = TextField("Category title", validators=[
-        Required(message="Category title required")])
+    title = StringField("Category title", validators=[
+        DataRequired(message="Category title required")])
 
-    description = TextAreaField("Description", validators=[
-        Optional()],
-        description="You can format your description with BBCode.")
+    description = TextAreaField(
+        "Description",
+        validators=[Optional()],
+        description="You can format your description with BBCode."
+    )
 
-    position = IntegerField("Position", default=1, validators=[
-        Required(message="Category position required")])
+    position = IntegerField(
+        "Position",
+        default=1,
+        validators=[DataRequired(message="Category position required")]
+    )
 
     def save(self):
         category = Category(**self.data)

+ 21 - 28
flaskbb/management/models.py

@@ -12,6 +12,7 @@ import sys
 from wtforms import (TextField, IntegerField, FloatField, BooleanField,
                      SelectField, SelectMultipleField, validators)
 from flask.ext.wtf import Form
+from flaskbb._compat import max_integer, text_type, iteritems
 from flaskbb.extensions import db, cache
 
 
@@ -74,32 +75,23 @@ class Setting(db.Model):
         for setting in group.settings:
             field_validators = []
 
+            if setting.value_type in ("integer", "float"):
+                validator_class = validators.NumberRange
+            elif setting.value_type == "string":
+                validator_class = validators.Length
+
             # generate the validators
             if "min" in setting.extra:
                 # Min number validator
-                if setting.value_type in ("integer", "float"):
-                    field_validators.append(
-                        validators.NumberRange(min=setting.extra["min"])
-                    )
-
-                # Min text length validator
-                elif setting.value_type in ("string"):
-                    field_validators.append(
-                        validators.Length(min=setting.extra["min"])
-                    )
+                field_validators.append(
+                    validator_class(min=setting.extra["min"])
+                )
 
             if "max" in setting.extra:
                 # Max number validator
-                if setting.value_type in ("integer", "float"):
-                    field_validators.append(
-                        validators.NumberRange(max=setting.extra["max"])
-                    )
-
-                # Max text length validator
-                elif setting.value_type in ("string"):
-                    field_validators.append(
-                        validators.Length(max=setting.extra["max"])
-                    )
+                field_validators.append(
+                    validator_class(max=setting.extra["max"])
+                )
 
             # Generate the fields based on value_type
             # IntegerField
@@ -118,7 +110,7 @@ class Setting(db.Model):
                 )
 
             # TextField
-            if setting.value_type == "string":
+            elif setting.value_type == "string":
                 setattr(
                     SettingsForm, setting.key,
                     TextField(setting.name, validators=field_validators,
@@ -126,12 +118,12 @@ class Setting(db.Model):
                 )
 
             # SelectMultipleField
-            if setting.value_type == "selectmultiple":
+            elif setting.value_type == "selectmultiple":
                 # if no coerce is found, it will fallback to unicode
                 if "coerce" in setting.extra:
                     coerce_to = setting.extra['coerce']
                 else:
-                    coerce_to = unicode
+                    coerce_to = text_type
 
                 setattr(
                     SettingsForm, setting.key,
@@ -144,12 +136,12 @@ class Setting(db.Model):
                 )
 
             # SelectField
-            if setting.value_type == "select":
+            elif setting.value_type == "select":
                 # if no coerce is found, it will fallback to unicode
                 if "coerce" in setting.extra:
                     coerce_to = setting.extra['coerce']
                 else:
-                    coerce_to = unicode
+                    coerce_to = text_type
 
                 setattr(
                     SettingsForm, setting.key,
@@ -161,7 +153,7 @@ class Setting(db.Model):
                 )
 
             # BooleanField
-            if setting.value_type == "boolean":
+            elif setting.value_type == "boolean":
                 setattr(
                     SettingsForm, setting.key,
                     BooleanField(setting.name, description=setting.description)
@@ -181,7 +173,7 @@ class Setting(db.Model):
         :param settings: A dictionary with setting items.
         """
         # update the database
-        for key, value in settings.iteritems():
+        for key, value in iteritems(settings):
             setting = cls.query.filter(Setting.key == key.lower()).first()
 
             setting.value = value
@@ -218,7 +210,7 @@ class Setting(db.Model):
         return settings
 
     @classmethod
-    @cache.memoize(timeout=sys.maxint)
+    @cache.memoize(timeout=max_integer)
     def as_dict(cls, from_group=None, upper=True):
         """Returns all settings as a dict. This method is cached. If you want
         to invalidate the cache, simply execute ``self.invalidate_cache()``.
@@ -235,6 +227,7 @@ class Setting(db.Model):
                 first_or_404()
             result = result.settings
         else:
+            print(Setting.query)
             result = cls.query.all()
 
         for setting in result:

+ 14 - 10
flaskbb/management/views.py

@@ -18,13 +18,14 @@ from flask.ext.login import current_user
 from flask.ext.plugins import get_all_plugins, get_plugin, get_plugin_from_all
 
 from flaskbb import __version__ as flaskbb_version
+from flaskbb._compat import iteritems
 from flaskbb.forum.forms import UserSearchForm
 from flaskbb.utils.settings import flaskbb_config
 from flaskbb.utils.helpers import render_template
 from flaskbb.utils.decorators import admin_required, moderator_required
 from flaskbb.utils.permissions import can_ban_user, can_edit_user
 from flaskbb.extensions import db
-from flaskbb.user.models import User, Group
+from flaskbb.user.models import Guest, User, Group
 from flaskbb.forum.models import Post, Topic, Forum, Category, Report
 from flaskbb.management.models import Setting, SettingsGroup
 from flaskbb.management.forms import (AddUserForm, EditUserForm, AddGroupForm,
@@ -70,7 +71,7 @@ def settings(slug=None):
     form = SettingsForm()
 
     if form.validate_on_submit():
-        for key, values in old_settings.iteritems():
+        for key, values in iteritems(old_settings):
             try:
                 # check if the value has changed
                 if values['value'] == form[key].data:
@@ -82,7 +83,7 @@ def settings(slug=None):
 
         Setting.update(settings=new_settings, app=current_app)
     else:
-        for key, values in old_settings.iteritems():
+        for key, values in iteritems(old_settings):
             try:
                 form[key].data = values['value']
             except (KeyError, ValueError):
@@ -124,7 +125,7 @@ def edit_user(user_id):
 
     secondary_group_query = Group.query.filter(
         db.not_(Group.id == user.primary_group_id),
-        db.not_(Group.banned == True),
+        db.not_(Group.banned),
         db.not_(Group.guest == True))
 
     form = EditUserForm(user)
@@ -179,7 +180,6 @@ def banned_users():
         Group.id == User.primary_group_id
     ).paginate(page, flaskbb_config['USERS_PER_PAGE'], False)
 
-
     if search_form.validate():
         users = search_form.get_results().\
             paginate(page, flaskbb_config['USERS_PER_PAGE'], False)
@@ -203,10 +203,10 @@ def ban_user(user_id):
     # Do not allow moderators to ban admins
     if user.get_permissions()['admin'] and \
             (current_user.permissions['mod'] or
-                current_user.permissions['super_mod']):
+             current_user.permissions['super_mod']):
 
-            flash("A moderator cannot ban an admin user.", "danger")
-            return redirect(url_for("management.overview"))
+        flash("A moderator cannot ban an admin user.", "danger")
+        return redirect(url_for("management.overview"))
 
     if user.ban():
         flash("User was banned successfully.", "success")
@@ -314,6 +314,9 @@ def edit_group(group_id):
         form.populate_obj(group)
         group.save()
 
+        if group.guest:
+            Guest.invalidate_cache()
+
         flash("Group successfully edited.", "success")
         return redirect(url_for("management.groups", group_id=group.id))
 
@@ -365,8 +368,9 @@ def edit_forum(forum_id):
         return redirect(url_for("management.edit_forum", forum_id=forum.id))
     else:
         if forum.moderators:
-            form.moderators.data = ",".join([user.username
-                                            for user in forum.moderators])
+            form.moderators.data = ",".join([
+                user.username for user in forum.moderators
+            ])
         else:
             form.moderators.data = None
 

+ 113 - 115
flaskbb/plugins/portal/templates/index.html

@@ -76,132 +76,130 @@
 {% endblock %}
 
 {% block content %}
-  <div class="container main-content">
-    <div class="row">
-
-      <!-- Left -->
-      <div class="col-md-8">
-        <div class="panel panel-default">
-          <div class="panel-heading">
-            <h3 class="panel-title">News</h3>
-          </div>
-          <div class="panel-body" style="padding-top: 0px">
-
-          {% for topic in news.items %}
-            <h1><a href="{{ topic.url }}">{{ topic.title }}</a></h1>
-            <ul class="portal-info">
-                <li><i class="fa fa-calendar"></i> {{ topic.date_created|format_date('%b %d %Y') }}</li>
-                <li><i class="fa fa-user"></i> <a href="{{ url_for('user.profile', username=topic.user.username) }}">{{ topic.user.username }}</a></li>
-                <li><i class="fa fa-comment"></i> <a href="{{ topic.url }}">Comments ({{ topic.post_count }})</a></li>
-            </ul>
-            <div class="portal-content">
-                {{ topic.first_post.content | markup | safe }}<br />
-            </div>
-            {% if not loop.last %}<hr>{% endif %}
-          {% endfor %}
+<div class="row">
 
-          </div> <!-- /.panel-body -->
+  <!-- Left -->
+  <div class="col-md-8">
+    <div class="panel panel-default panel-widget">
+      <div class="panel-heading panel-widget-heading">
+        <h3 class="panel-title">News</h3>
+      </div>
+      <div class="panel-body panel-widget-body" style="padding-top: 0px">
+
+      {% for topic in news.items %}
+        <h1><a href="{{ topic.url }}">{{ topic.title }}</a></h1>
+        <ul class="portal-info">
+            <li><i class="fa fa-calendar"></i> {{ topic.date_created|format_date('%b %d %Y') }}</li>
+            <li><i class="fa fa-user"></i> <a href="{{ url_for('user.profile', username=topic.user.username) }}">{{ topic.user.username }}</a></li>
+            <li><i class="fa fa-comment"></i> <a href="{{ topic.url }}">Comments ({{ topic.post_count }})</a></li>
+        </ul>
+        <div class="portal-content">
+            {{ topic.first_post.content | markup | safe }}<br />
         </div>
+        {% if not loop.last %}<hr>{% endif %}
+      {% endfor %}
+
+      </div> <!-- /.panel-body -->
+    </div>
 
+  </div>
+
+  <!-- Right -->
+  <div class="col-md-4">
+    <div class="panel panel-default panel-widget">
+      <div class="panel-heading panel-widget-heading">
+        <h3 class="panel-title">Recent Topics</h3>
       </div>
+      <div class="panel-body panel-widget-body">
+      {% for topic in recent_topics %}
 
-      <!-- Right -->
-      <div class="col-md-4">
-        <div class="panel panel-default">
-          <div class="panel-heading">
-            <h3 class="panel-title">Recent Topics</h3>
-          </div>
-          <div class="panel-body">
-          {% for topic in recent_topics %}
-
-              <div class="portal-topic">
-                <div class="portal-topic-name">
-                  <a href="{{ topic.url }}">{{ topic.title }}</a>
-                </div>
-                <div class="portal-topic-updated-by">
-                  <a href="{{ url_for('user.profile', username=topic.user.username) }}">{{ topic.user.username }}</a>
-                </div>
-                <div class="portal-topic-updated">
-                  {{ topic.date_created | time_since }}
-                </div>
-              </div> <!-- /.topic -->
-
-          {% endfor %}
-          </div>
-        </div>
+          <div class="portal-topic">
+            <div class="portal-topic-name">
+              <a href="{{ topic.url }}">{{ topic.title | truncate(length=45) }}</a>
+            </div>
+            <div class="portal-topic-updated-by">
+              <a href="{{ url_for('user.profile', username=topic.user.username) }}">{{ topic.user.username }}</a>
+            </div>
+            <div class="portal-topic-updated">
+              {{ topic.date_created | time_since }}
+            </div>
+          </div> <!-- /.topic -->
 
-        <div class="panel panel-default">
-          <div class="panel-heading">
-            <h3 class="panel-title">Statistics</h3>
-          </div>
-          <div class="panel-body">
-
-                <div class="portal-stats">
-                  <div class="portal-stats-left">
-                    Topics
-                  </div>
-                  <div class="portal-stats-right">
-                    {{ topic_count }}
-                  </div>
-                </div>
-
-                <div class="portal-stats">
-                  <div class="portal-stats-left">
-                    Posts
-                  </div>
-                  <div class="portal-stats-right">
-                    {{ post_count }}
-                  </div>
-                </div>
-
-                <div class="portal-stats">
-                  <div class="portal-stats-left">
-                    Registered Users
-                  </div>
-                  <div class="portal-stats-right">
-                    {{ user_count }}
-                  </div>
-                </div>
-
-                {% if newest_user %}
-                <div class="portal-stats">
-                  <div class="portal-stats-left">
-                    Newest User
-                  </div>
-                  <div class="portal-stats-right">
-                    <a href="{{ newest_user.url }}">{{ newest_user.username }}</a>
-                  </div>
-                </div>
-                {% endif %}
-
-                <div class="portal-stats">
-                  <div class="portal-stats-left">
-                    Online Users
-                  </div>
-
-                  <div class="portal-stats-right">
-                    {{ online_users }}
-                  </div>
-                </div>
-
-                {% if config["REDIS_ENABLED"] %}
-                <div class="portal-stats">
-                  <div class="portal-stats-left">
-                    Guests online
-                  </div>
-
-                  <div class="portal-stats-right">
-                    {{ online_guests }}
-                  </div>
-                </div>
-                {% endif %}
-          </div>
-        </div>
+      {% endfor %}
+      </div>
+    </div>
+
+    <div class="panel panel-default panel-widget">
+      <div class="panel-heading panel-widget-heading">
+        <h3 class="panel-title">Statistics</h3>
       </div>
+      <div class="panel-body panel-widget-body">
+
+            <div class="portal-stats">
+              <div class="portal-stats-left">
+                Topics
+              </div>
+              <div class="portal-stats-right">
+                {{ topic_count }}
+              </div>
+            </div>
+
+            <div class="portal-stats">
+              <div class="portal-stats-left">
+                Posts
+              </div>
+              <div class="portal-stats-right">
+                {{ post_count }}
+              </div>
+            </div>
+
+            <div class="portal-stats">
+              <div class="portal-stats-left">
+                Registered Users
+              </div>
+              <div class="portal-stats-right">
+                {{ user_count }}
+              </div>
+            </div>
+
+            {% if newest_user %}
+            <div class="portal-stats">
+              <div class="portal-stats-left">
+                Newest User
+              </div>
+              <div class="portal-stats-right">
+                <a href="{{ newest_user.url }}">{{ newest_user.username }}</a>
+              </div>
+            </div>
+            {% endif %}
 
+            <div class="portal-stats">
+              <div class="portal-stats-left">
+                Online Users
+              </div>
+
+              <div class="portal-stats-right">
+                {{ online_users }}
+              </div>
+            </div>
+
+            {% if config["REDIS_ENABLED"] %}
+            <div class="portal-stats">
+              <div class="portal-stats-left">
+                Guests online
+              </div>
+
+              <div class="portal-stats-right">
+                {{ online_guests }}
+              </div>
+            </div>
+            {% endif %}
+      </div>
     </div>
   </div>
 
+</div>
+
 
 
 {% endblock %}

+ 1 - 1
flaskbb/plugins/portal/views.py

@@ -28,7 +28,7 @@ def index():
         order_by(Topic.id.desc()).\
         paginate(page, flaskbb_config["TOPICS_PER_PAGE"], True)
 
-    recent_topics = Topic.query.order_by(Topic.date_created).limit(5).offset(0)
+    recent_topics = Topic.query.order_by(Topic.last_updated.desc()).limit(5)
 
     user_count = User.query.count()
     topic_count = Topic.query.count()

+ 12 - 16
flaskbb/static/js/topic.js

@@ -1,22 +1,18 @@
 /**
- * Topic.js
+ * topic.js
  */
 $(document).ready(function () {
-        $(".quote_btn").click(function (event) {
-            event.preventDefault();
+    // Quote
+    $('.quote_btn').click(function (event) {
+        event.preventDefault();
+        var post_id = $(this).attr('data-post-id');
 
-            // QuickReply Textarea
-            var $contents = $(".reply-content textarea#content");
-            // Original Post
-            var $original = $(".post_body#" + $(this).attr('data-post-id'));
-            // Content of the Post, in plaintext (strips tags) and without the signature
-            var content = $original.clone().find('.signature').remove().end().text().trim();
-
-            // Add quote to the Quickreply Textarea
-            if ($contents.length > 0) {
-                $contents.val($contents.val() + "\n[quote]" + content + "[/quote]");
-            } else {
-        $contents.val("[quote]" + content + "[/quote]");
-        }
+        $.get('/post/' + post_id + '/raw', function(text) {
+            var $contents = $('.reply-content textarea#content');
+            $contents.val(($contents.val() + '\n' + text).trim() + '\n');
+            $contents.selectionStart = $contents.selectionEnd = $contents.val().length;
+            $contents[0].scrollTop = $contents[0].scrollHeight;
+            window.location.href = '#content';
+        });
     });
 });

+ 6 - 6
flaskbb/templates/forum/category_layout.html

@@ -71,16 +71,16 @@
 
             <td valign="top" align="right" style="white-space: nowrap">
                 {% if forum.last_post_id %}
-                <a href="{{ forum.last_post.url }}" title="{{ forum.last_post.topic.title }}">
-                    <strong>{{ forum.last_post.topic.title|crop_title }}</strong>
+                <a href="{{ forum.last_post_url }}" title="{{ forum.last_post_title }}">
+                    <strong>{{ forum.last_post_title|crop_title }}</strong>
                 </a>
                 <br />
-                {{ forum.last_post.date_created|time_since }}<br />
+                {{ forum.last_post_created|time_since }}<br />
 
-                    {% if forum.last_post.user_id %}
-                    by <a href="{{ url_for('user.profile', username=forum.last_post.user.username) }}">{{ forum.last_post.user.username }}</a>
+                    {% if forum.last_post_user_id %}
+                    by <a href="{{ url_for('user.profile', username=forum.last_post_username) }}">{{ forum.last_post_username }}</a>
                     {% else %}
-                    {{ forum.last_post.username }}
+                    {{ forum.last_post_username }}
                     {% endif %}
 
                 {% else %}

+ 22 - 63
flaskbb/templates/forum/topic.html

@@ -12,54 +12,11 @@
     <li class="active">{{ topic.title }}</li>
 </ol>
 
-<div class="pull-left" style="padding-bottom: 10px">
-    {{ render_pagination(posts, topic.url) }}
-</div> <!-- end span pagination -->
-
-<div class="pull-right" style="padding-bottom: 10px">
-    <div class="btn btn-group">
-    {% if current_user|delete_topic(topic.first_post.user_id, topic.forum) %}
-        <a href="{{ url_for('forum.delete_topic', topic_id=topic.id, slug=topic.slug) }}" class="btn btn-danger">
-            <span class="fa fa-trash-o"></span> Delete Topic
-        </a>
-    {% endif %}
-    {% if current_user|can_moderate(topic.forum) %}
-        {% if not topic.locked %}
-            <a href="{{ url_for('forum.lock_topic', topic_id=topic.id, slug=topic.slug) }}" class="btn btn-warning">
-                <span class="fa fa-lock"></span> Lock Topic
-            </a>
-        {% else %}
-            <a href="{{ url_for('forum.unlock_topic', topic_id=topic.id, slug=topic.slug) }}" class="btn btn-warning">
-                <span class="fa fa-unlock"></span> Unlock Topic
-            </a>
-        {% endif %}
-    {% endif %}
-    </div>
-
-    {% if current_user.is_authenticated() %}
-    <div class="btn btn-group">
-        {% if current_user.is_tracking_topic(topic) %}
-        <a href="{{ url_for('forum.untrack_topic', topic_id=topic.id, slug=topic.slug) }}" class="btn btn-default"><span class="fa fa-star">
-            </span> Untrack Topic
-        </a>
-        {% else %}
-        <a href="{{ url_for('forum.track_topic', topic_id=topic.id, slug=topic.slug) }}" class="btn btn-default">
-            <span class="fa fa-star"></span> Track Topic
-        </a>
-        {% endif %}
-
-        {% if current_user|post_reply(topic.forum) and not (topic.locked or topic.forum.locked) %}
-        <a href="{{ url_for('forum.new_post', topic_id=topic.id, slug=topic.slug) }}" class="btn btn-primary">
-            <span class="fa fa-pencil"></span> Reply
-        </a>
-        {% endif %}
-    </div>
-    {% endif %}
-</div>
+{% include 'forum/topic_controls.html' %}
 
 <table class="table table-bordered">
     <tbody>
-        {% for post in posts.items %}
+        {% for post, user in posts.items %}
         <tr>
             <td >
                 <span class="pull-right">
@@ -90,28 +47,28 @@
             <table class="table table-borderless">
                 <tr>
                 {% if post.user_id %}
-                    {% if post.user.avatar %}
+                    {% if user.avatar %}
                     <td width="1">
-                        <img src="{{ post.user.avatar }}" alt="Avatar" height="100" width="100">
+                        <img src="{{ user.avatar }}" alt="Avatar" height="100" width="100">
                     </td>
                     {% endif %}
                     <td>
-                        <a href="{{ post.user.url }}">
-                            <span style="font-weight:bold">{{ post.user.username }}</span> <!-- TODO: Implement userstyles -->
+                        <a href="{{ user.url }}">
+                            <span style="font-weight:bold">{{ user.username }}</span> <!-- TODO: Implement userstyles -->
                         </a>
-                            {%- if post.user|is_online %}
+                            {%- if user|is_online %}
                             <span class="label label-success">Online</span>
                             {%- else %}
                             <span class="label label-default">Offline</span>
                             {%- endif %}
                             <div class="profile primary-group">
-                            {{ post.user.primary_group.name }}
+                            {{ user.primary_group.name }}
                             </div>
                     </td>
 
                     <td class="pull-right">
-                        Posts: {{ post.user.post_count }}<br />
-                        Registered since: {{ post.user.date_joined|format_date('%b %d %Y') }}<br />
+                        Posts: {{ user.post_count }}<br />
+                        Registered since: {{ user.date_joined|format_date('%b %d %Y') }}<br />
                     </td>
                 {% else %}
                     <td>
@@ -131,10 +88,10 @@
                 {% autoescape false %}
                     {{ post.content|markup }}
                     <!-- Signature Begin -->
-                    {% if post.user_id and post.user.signature %}
+                    {% if post.user_id and user.signature %}
                     <div class="signature">
                         <hr>
-                        {{ post.user.signature|markup }}
+                        {{ user.signature|markup }}
                     </div>
                     {% endif %}
                     <!-- Signature End -->
@@ -148,8 +105,8 @@
                     {% if current_user.is_authenticated() and post.user_id and post.user_id != current_user.id %}
                     <a href="{{ url_for('user.new_message', to_user=post.user.username) }}">PM</a>
                     {% endif %}
-                    {% if post.user.website %}
-                    | <a href="{{post.user.website}}">Website</a>
+                    {% if user.website %}
+                    {% if current_user.is_authenticated() %}| {% endif %}<a href="{{ user.website }}">Website</a>
                     {% endif %}
                 </span>
 
@@ -159,21 +116,21 @@
                         Report
                     </a> |
                     {% endif %}
-                    {% if current_user|edit_post(post.user_id, topic.forum) %}
+                    {% if current_user|edit_post(post) %}
                     <a href="{{ url_for('forum.edit_post', post_id=post.id) }}">Edit</a> |
                     {% endif %}
                     {% if topic.first_post_id == post.id %}
-                        {% if current_user|delete_topic(topic.first_post.user_id, topic.forum) %}
+                        {% if current_user|delete_topic(topic) %}
                         <a href="{{ url_for('forum.delete_topic', topic_id=topic.id, slug=topic.slug) }}">Delete</a> |
                         {% endif %}
                     {% else %}
-                        {% if current_user|delete_post(post.user_id, topic.forum) %}
+                        {% if current_user|delete_post(post) %}
                         <a href="{{ url_for('forum.delete_post', post_id=post.id) }}">Delete</a> |
                         {% endif %}
                     {% endif %}
-                    {% if current_user|post_reply(topic.forum) and not (topic.locked or topic.forum.locked) %}
+                    {% if current_user|post_reply(topic) %}
                         <!-- Quick quote -->
-                        <a href="#" class="quote_btn" data-post-id="pid{{ post.id }}">Quote</a> |
+                        <a href="#" class="quote_btn" data-post-id="{{ post.id }}">Quote</a> |
                         <!-- Full quote/reply -->
                         <a href="{{ url_for('forum.reply_post', topic_id=topic.id, post_id=post.id) }}">Reply</a>
                     {% endif %}
@@ -184,6 +141,8 @@
     </tbody>
 </table>
 
+{% include 'forum/topic_controls.html' %}
+
 {% if form %}
     {% from "macros.html" import render_field %}
     <form class="form" action="#" method="post">
@@ -198,5 +157,5 @@
 {% endblock %}
 
 {% block scripts %}
-<script type="text/javascript" src="{{ url_for('static', filename='js/topic.js') }}"></script>
+    <script type="text/javascript" src="{{ url_for('static', filename='js/topic.js') }}"></script>
 {% endblock %}

+ 46 - 0
flaskbb/templates/forum/topic_controls.html

@@ -0,0 +1,46 @@
+<div class="pull-left" style="padding-bottom: 10px">
+    {{ render_pagination(posts, topic.url) }}
+</div> <!-- end span pagination -->
+
+<div class="pull-right" style="padding-bottom: 10px">
+    <div class="btn btn-group">
+        {% if current_user|delete_topic(topic) %}
+        <a href="{{ url_for('forum.delete_topic', topic_id=topic.id, slug=topic.slug) }}" class="btn btn-danger">
+            <span class="fa fa-trash-o"></span> Delete Topic
+        </a>
+        {% endif %}
+        {% if current_user|can_moderate(topic.forum) %}
+        {% if not topic.locked %}
+        <a href="{{ url_for('forum.lock_topic', topic_id=topic.id, slug=topic.slug) }}" class="btn btn-warning">
+            <span class="fa fa-lock"></span> Lock Topic
+        </a>
+        {% else %}
+        <a href="{{ url_for('forum.unlock_topic', topic_id=topic.id, slug=topic.slug) }}" class="btn btn-warning">
+            <span class="fa fa-unlock"></span> Unlock Topic
+        </a>
+        {% endif %}
+        {% endif %}
+    </div>
+
+    {% if current_user.is_authenticated() %}
+    <div class="btn btn-group">
+        {% if current_user.is_tracking_topic(topic) %}
+        <a href="{{ url_for('forum.untrack_topic', topic_id=topic.id, slug=topic.slug) }}" class="btn btn-default"><span class="fa fa-star">
+            </span> Untrack Topic
+        </a>
+        {% else %}
+        <a href="{{ url_for('forum.track_topic', topic_id=topic.id, slug=topic.slug) }}" class="btn btn-default">
+            <span class="fa fa-star"></span> Track Topic
+        </a>
+        {% endif %}
+
+        {% if current_user|post_reply(topic) %}
+        <a href="{{ url_for('forum.new_post', topic_id=topic.id, slug=topic.slug) }}" class="btn btn-primary">
+            <span class="fa fa-pencil"></span> Reply
+        </a>
+        {% endif %}
+    </div>
+    {% endif %}
+</div>
+
+<div class="clearfix"></div>

+ 19 - 19
flaskbb/user/forms.py

@@ -10,9 +10,9 @@
 """
 from flask.ext.login import current_user
 from flask.ext.wtf import Form
-from wtforms import (TextField, PasswordField, DateField, TextAreaField,
+from wtforms import (StringField, PasswordField, DateField, TextAreaField,
                      SelectField, ValidationError)
-from wtforms.validators import (Length, Required, Email, EqualTo, regexp,
+from wtforms.validators import (Length, DataRequired, Email, EqualTo, regexp,
                                 Optional, URL)
 
 from flaskbb.user.models import User, PrivateMessage
@@ -34,16 +34,16 @@ class GeneralSettingsForm(Form):
 
 
 class ChangeEmailForm(Form):
-    old_email = TextField("Old E-Mail Address", validators=[
-        Required(message="Email adress required"),
+    old_email = StringField("Old E-Mail Address", validators=[
+        DataRequired(message="Email address required"),
         Email(message="This email is invalid")])
 
-    new_email = TextField("New E-Mail Address", validators=[
-        Required(message="Email adress required"),
+    new_email = StringField("New E-Mail Address", validators=[
+        DataRequired(message="Email address required"),
         Email(message="This email is invalid")])
 
-    confirm_new_email = TextField("Confirm E-Mail Address", validators=[
-        Required(message="Email adress required"),
+    confirm_new_email = StringField("Confirm E-Mail Address", validators=[
+        DataRequired(message="Email adress required"),
         Email(message="This email is invalid"),
         EqualTo("new_email", message="E-Mails do not match")])
 
@@ -62,13 +62,13 @@ class ChangeEmailForm(Form):
 
 class ChangePasswordForm(Form):
     old_password = PasswordField("Old Password", validators=[
-        Required(message="Password required")])
+        DataRequired(message="Password required")])
 
     new_password = PasswordField("New Password", validators=[
-        Required(message="Password required")])
+        DataRequired(message="Password required")])
 
     confirm_new_password = PasswordField("Confirm New Password", validators=[
-        Required(message="Password required"),
+        DataRequired(message="Password required"),
         EqualTo("new_password", message="Passwords do not match")])
 
 
@@ -82,13 +82,13 @@ class ChangeUserDetailsForm(Form):
         ("Male", "Male"),
         ("Female", "Female")])
 
-    location = TextField("Location", validators=[
+    location = StringField("Location", validators=[
         Optional()])
 
-    website = TextField("Website", validators=[
+    website = StringField("Website", validators=[
         Optional(), URL()])
 
-    avatar = TextField("Avatar", validators=[
+    avatar = StringField("Avatar", validators=[
         Optional(), URL()])
 
     signature = TextAreaField("Forum Signature", validators=[
@@ -99,12 +99,12 @@ class ChangeUserDetailsForm(Form):
 
 
 class NewMessageForm(Form):
-    to_user = TextField("To User", validators=[
-        Required(message="A username is required.")])
-    subject = TextField("Subject", validators=[
-        Required(message="A subject is required.")])
+    to_user = StringField("To User", validators=[
+        DataRequired(message="A username is required.")])
+    subject = StringField("Subject", validators=[
+        DataRequired(message="A subject is required.")])
     message = TextAreaField("Message", validators=[
-        Required(message="A message is required.")])
+        DataRequired(message="A message is required.")])
 
     def validate_to_user(self, field):
         user = User.query.filter_by(username=field.data).first()

+ 20 - 2
flaskbb/user/models.py

@@ -8,7 +8,6 @@
     :copyright: (c) 2014 by the FlaskBB Team.
     :license: BSD, see LICENSE for more details.
 """
-import sys
 from datetime import datetime
 
 from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
@@ -16,6 +15,7 @@ from itsdangerous import SignatureExpired
 from werkzeug.security import generate_password_hash, check_password_hash
 from flask import current_app, url_for
 from flask.ext.login import UserMixin, AnonymousUserMixin
+from flaskbb._compat import max_integer
 from flaskbb.extensions import db, cache
 from flaskbb.utils.settings import flaskbb_config
 from flaskbb.forum.models import (Post, Topic, topictracker, TopicsRead,
@@ -316,7 +316,7 @@ class User(db.Model, UserMixin):
         return self.secondary_groups.filter(
             groups_users.c.group_id == group.id).count() > 0
 
-    @cache.memoize(timeout=sys.maxint)
+    @cache.memoize(timeout=max_integer)
     def get_permissions(self, exclude=None):
         """Returns a dictionary with all the permissions the user has.
 
@@ -419,6 +419,17 @@ class User(db.Model, UserMixin):
         ForumsRead.query.filter_by(user_id=self.id).delete()
         TopicsRead.query.filter_by(user_id=self.id).delete()
 
+        # This should actually be handeld by the dbms.. but dunno why it doesnt
+        # work here
+        from flaskbb.forum.models import Forum
+
+        last_post_forums = Forum.query.\
+            filter_by(last_post_user_id=self.id).all()
+
+        for forum in last_post_forums:
+            forum.last_post_user_id = None
+            forum.save()
+
         db.session.delete(self)
         db.session.commit()
 
@@ -430,6 +441,7 @@ class Guest(AnonymousUserMixin):
     def permissions(self):
         return self.get_permissions()
 
+    @cache.memoize(timeout=max_integer)
     def get_permissions(self, exclude=None):
         """Returns a dictionary with all permissions the user has"""
         exclude = exclude or []
@@ -444,6 +456,12 @@ class Guest(AnonymousUserMixin):
             perms[c.name] = getattr(group, c.name)
         return perms
 
+    @classmethod
+    def invalidate_cache(cls):
+        """Invalidates this objects cached metadata."""
+
+        cache.delete_memoized(cls.get_permissions, cls)
+
 
 class PrivateMessage(db.Model):
     __tablename__ = "privatemessages"

+ 1 - 1
flaskbb/user/views.py

@@ -165,7 +165,7 @@ def new_message():
     to_user = request.args.get("to_user")
 
     if request.method == "POST":
-        if "save_message" in request.form:
+        if "save_message" in request.form and form.validate():
             to_user = User.query.filter_by(username=form.to_user.data).first()
 
             form.save(from_user=current_user.id,

+ 53 - 21
flaskbb/utils/helpers.py

@@ -12,16 +12,18 @@ import re
 import time
 import itertools
 import operator
-from unicodedata import normalize
 from datetime import datetime, timedelta
 
-from flask import session
+from flask import session, url_for
 from flask.ext.themes2 import render_theme_template
 from flask.ext.login import current_user
 
 from postmarkup import render_bbcode
+from markdown2 import markdown as render_markdown
+import unidecode
+from flaskbb._compat import range_method, text_type
 
-from flaskbb.extensions import redis
+from flaskbb.extensions import redis_store
 from flaskbb.utils.settings import flaskbb_config
 
 _punct_re = re.compile(r'[\t !"#$%&\'()*\-/<=>?@\[\\\]^_`{|},.]+')
@@ -34,12 +36,12 @@ def slugify(text, delim=u'-'):
    :param text: The text which should be slugified
    :param delim: Default "-". The delimeter for whitespace
     """
+    text = unidecode.unidecode(text)
     result = []
     for word in _punct_re.split(text.lower()):
-        word = normalize('NFKD', word).encode('ascii', 'ignore')
         if word:
             result.append(word)
-    return unicode(delim.join(result))
+    return text_type(delim.join(result))
 
 
 def render_template(template, **context):
@@ -152,12 +154,19 @@ def forum_is_unread(forum, forumsread, user):
     # If the user hasn't visited a topic in the forum - therefore,
     # forumsread is None and we need to check if it is still unread
     if forum and not forumsread:
-        return forum.last_post.date_created > read_cutoff
+        return forum.last_post_created > read_cutoff
 
     try:
-        return forumsread.cleared > forum.last_post.date_created
+        # check if the forum has been cleared and if there is a new post
+        # since it have been cleared
+        if forum.last_post_created > forumsread.cleared:
+            if forum.last_post_created < forumsread.last_read:
+                return False
     except TypeError:
-        return forumsread.last_read < forum.last_post.date_created
+        pass
+
+    # else just check if the user has read the last post
+    return forum.last_post_created > forumsread.last_read
 
 
 def topic_is_unread(topic, topicsread, user, forumsread=None):
@@ -222,7 +231,7 @@ def mark_online(user_id, guest=False):
     else:
         all_users_key = 'online-users/%d' % (now // 60)
         user_key = 'user-activity/%s' % user_id
-    p = redis.pipeline()
+    p = redis_store.pipeline()
     p.sadd(all_users_key, user_id)
     p.set(user_key, now)
     p.expireat(all_users_key, expires)
@@ -238,9 +247,9 @@ def get_last_user_activity(user_id, guest=False):
     :param guest: If the user is a guest (not signed in)
     """
     if guest:
-        last_active = redis.get('guest-activity/%s' % user_id)
+        last_active = redis_store.get('guest-activity/%s' % user_id)
     else:
-        last_active = redis.get('user-activity/%s' % user_id)
+        last_active = redis_store.get('user-activity/%s' % user_id)
 
     if last_active is None:
         return None
@@ -253,12 +262,12 @@ def get_online_users(guest=False):
     :param guest: If True, it will return the online guests
     """
     current = int(time.time()) // 60
-    minutes = xrange(flaskbb_config['ONLINE_LAST_MINUTES'])
+    minutes = range_method(flaskbb_config['ONLINE_LAST_MINUTES'])
     if guest:
-        return redis.sunion(['online-guests/%d' % (current - x)
-                             for x in minutes])
-    return redis.sunion(['online-users/%d' % (current - x)
-                         for x in minutes])
+        return redis_store.sunion(['online-guests/%d' % (current - x)
+                                   for x in minutes])
+    return redis_store.sunion(['online-users/%d' % (current - x)
+                               for x in minutes])
 
 
 def crop_title(title):
@@ -277,7 +286,11 @@ def render_markup(text):
 
     :param text: The text that should be rendered as bbcode
     """
-    return render_bbcode(text)
+    if flaskbb_config['MARKUP_TYPE'] == 'bbcode':
+        return render_bbcode(text)
+    elif flaskbb_config['MARKUP_TYPE'] == 'markdown':
+        return render_markdown(text, extras=['tables'])
+    return text
 
 
 def is_online(user):
@@ -320,7 +333,6 @@ def time_delta_format(dt, default=None):
     note: when Babel1.0 is released, use format_timedelta/timedeltaformat
           instead
     """
-
     if default is None:
         default = 'just now'
 
@@ -338,13 +350,33 @@ def time_delta_format(dt, default=None):
     )
 
     for period, singular, plural in periods:
-
-        if not period:
+        if period < 1:
             continue
 
-        if period == 1:
+        if 1 <= period < 2:
             return u'%d %s ago' % (period, singular)
         else:
             return u'%d %s ago' % (period, plural)
 
     return default
+
+
+def format_quote(post):
+    """Returns a formatted quote depending on the markup language.
+
+    :param post: The quoted post.
+    """
+    if flaskbb_config['MARKUP_TYPE'] == 'markdown':
+        profile_url = url_for('user.profile', username=post.username)
+        content = "\n> ".join(post.content.strip().split('\n'))
+        quote = "**[{post.username}]({profile_url}) wrote:**\n> {content}\n".\
+                format(post=post, profile_url=profile_url, content=content)
+
+        return quote
+    else:
+        profile_url = url_for('user.profile', username=post.username,
+                              _external=True)
+        quote = '[b][url={profile_url}]{post.username}[/url] wrote:[/b][quote]{post.content}[/quote]\n'.\
+                format(post=post, profile_url=profile_url)
+
+        return quote

+ 26 - 15
flaskbb/utils/permissions.py

@@ -27,9 +27,11 @@ def check_perm(user, perm, forum, post_user_id=None):
     """
     if can_moderate(user=user, forum=forum):
         return True
+
     if post_user_id and user.is_authenticated():
         return user.permissions[perm] and user.id == post_user_id
-    return user.permissions[perm]
+
+    return not user.permissions['banned'] and user.permissions[perm]
 
 
 def is_moderator(user):
@@ -92,36 +94,45 @@ def can_moderate(user, forum=None, perm=None):
     return user.permissions['super_mod'] or user.permissions['admin']
 
 
-def can_edit_post(user, post_user_id, forum):
+def can_edit_post(user, post):
     """Check if the post can be edited by the user"""
+    topic = post.topic
+
+    if can_moderate(user, topic.forum):
+        return True
 
-    return check_perm(user=user, perm='editpost', forum=forum,
-                      post_user_id=post_user_id)
+    if topic.locked or topic.forum.locked:
+        return False
 
+    return check_perm(user=user, perm='editpost', forum=post.topic.forum,
+                      post_user_id=post.user_id)
 
-def can_delete_post(user, post_user_id, forum):
-    """Check if the post can be deleted by the user"""
 
-    return check_perm(user=user, perm='deletepost', forum=forum,
-                      post_user_id=post_user_id)
+def can_delete_post(user, post):
+    """Check if the post can be deleted by the user"""
+    return check_perm(user=user, perm='deletepost', forum=post.topic.forum,
+                      post_user_id=post.user_id)
 
 
-def can_delete_topic(user, post_user_id, forum):
+def can_delete_topic(user, topic):
     """Check if the topic can be deleted by the user"""
-
-    return check_perm(user=user, perm='deletetopic', forum=forum,
-                      post_user_id=post_user_id)
+    return check_perm(user=user, perm='deletetopic', forum=topic.forum,
+                      post_user_id=topic.user_id)
 
 
-def can_post_reply(user, forum):
+def can_post_reply(user, topic):
     """Check if the user is allowed to post in the forum"""
+    if can_moderate(user, topic.forum):
+        return True
 
-    return check_perm(user=user, perm='postreply', forum=forum)
+    if topic.locked or topic.forum.locked:
+        return False
+
+    return check_perm(user=user, perm='postreply', forum=topic.forum)
 
 
 def can_post_topic(user, forum):
     """Checks if the user is allowed to create a new topic in the forum"""
-
     return check_perm(user=user, perm='posttopic', forum=forum)
 
 

+ 87 - 6
flaskbb/utils/populate.py

@@ -8,14 +8,15 @@
     :copyright: (c) 2014 by the FlaskBB Team.
     :license: BSD, see LICENSE for more details.
 """
-from datetime import datetime
-
 from flaskbb.management.models import Setting, SettingsGroup
 from flaskbb.user.models import User, Group
 from flaskbb.forum.models import Post, Topic, Forum, Category
 
 
 def delete_settings_from_fixture(fixture):
+    """
+    Deletes the settings from a fixture from the database.
+    """
     for settingsgroup in fixture:
         group = SettingsGroup.query.filter_by(key=settingsgroup[0]).first()
 
@@ -26,6 +27,9 @@ def delete_settings_from_fixture(fixture):
 
 
 def create_settings_from_fixture(fixture):
+    """
+    Inserts the settings from a fixture into the database.
+    """
     for settingsgroup in fixture:
         group = SettingsGroup(
             key=settingsgroup[0],
@@ -49,7 +53,51 @@ def create_settings_from_fixture(fixture):
             setting.save()
 
 
+def update_settings_from_fixture(fixture, overwrite_group=False,
+                                 overwrite_setting=False):
+    """
+    Updates the database settings from a fixture.
+    Returns the number of updated groups and settings.
+    """
+    groups_count = 0
+    settings_count = 0
+    for settingsgroup in fixture:
+
+        group = SettingsGroup.query.filter_by(key=settingsgroup[0]).first()
+
+        if group is not None and overwrite_group or group is None:
+            groups_count += 1
+            group = SettingsGroup(
+                key=settingsgroup[0],
+                name=settingsgroup[1]['name'],
+                description=settingsgroup[1]['description']
+            )
+
+            group.save()
+
+        for settings in settingsgroup[1]['settings']:
+
+            setting = Setting.query.filter_by(key=settings[0]).first()
+
+            if setting is not None and overwrite_setting or setting is None:
+                settings_count += 1
+                setting = Setting(
+                    key=settings[0],
+                    value=settings[1]['value'],
+                    value_type=settings[1]['value_type'],
+                    name=settings[1]['name'],
+                    description=settings[1]['description'],
+                    extra=settings[1].get('extra', ""),
+                    settingsgroup=group.key
+                )
+                setting.save()
+    return groups_count, settings_count
+
+
 def create_default_settings():
+    """
+    Creates the default settings
+    """
     from flaskbb.fixtures.settings import fixture
     create_settings_from_fixture(fixture)
 
@@ -76,8 +124,13 @@ def create_admin_user(username, password, email):
     Creates the administrator user
     """
     admin_group = Group.query.filter_by(admin=True).first()
-    user = User(username=username, password=password, email=email,
-                date_joined=datetime.utcnow(), primary_group_id=admin_group.id)
+    user = User()
+
+    user.username = username
+    user.password = password
+    user.email = email
+    user.primary_group_id = admin_group.id
+
     user.save()
 
 
@@ -86,7 +139,6 @@ def create_welcome_forum():
     This will create the `welcome forum` that nearly every
     forum software has after the installation process is finished
     """
-
     if User.query.count() < 1:
         raise "You need to create the admin user first!"
 
@@ -106,7 +158,10 @@ def create_welcome_forum():
 
 
 def create_test_data():
-
+    """
+    Creates 5 users, 2 categories and 2 forums in each category. It also opens
+    a new topic topic in each forum with a post.
+    """
     create_default_groups()
     create_default_settings()
 
@@ -150,3 +205,29 @@ def create_test_data():
             post = Post()
             post.content = "Test Post"
             post.save(user=user2, topic=topic)
+
+
+def insert_mass_data():
+    """
+    Creates 100 topics in the first forum and each topic has 100 posts.
+    """
+    user1 = User.query.filter_by(id=1).first()
+    user2 = User.query.filter_by(id=2).first()
+    forum = Forum.query.filter_by(id=1).first()
+
+    # create 1000 topics
+    for i in range(1, 101):
+
+        # create a topic
+        topic = Topic()
+        post = Post()
+
+        topic.title = "Test Title %s" % i
+        post.content = "Test Content"
+        topic.save(post=post, user=user1, forum=forum)
+
+        # create 100 posts in each topic
+        for j in range(1, 100):
+            post = Post()
+            post.content = "Test Post"
+            post.save(user=user2, topic=topic)

+ 40 - 1
manage.py

@@ -13,6 +13,7 @@
 import sys
 
 from flask import current_app
+from werkzeug.utils import import_string
 from sqlalchemy.exc import IntegrityError, OperationalError
 from flask.ext.script import (Manager, Shell, Server, prompt, prompt_pass,
                               prompt_bool)
@@ -22,7 +23,8 @@ from flaskbb import create_app
 from flaskbb.extensions import db
 from flaskbb.utils.populate import (create_test_data, create_welcome_forum,
                                     create_admin_user, create_default_groups,
-                                    create_default_settings)
+                                    create_default_settings, insert_mass_data,
+                                    update_settings_from_fixture)
 
 # Use the development configuration if available
 try:
@@ -60,6 +62,35 @@ def dropdb():
     db.drop_all()
 
 
+@manager.option('-s', '--settings', dest="settings")
+@manager.option('-f', '--force', dest="force")
+def update(settings=None, force=False):
+    """Updates the settings via a fixture. All fixtures have to be placed
+    in the `fixture`.
+    Usage: python manage.py update -s your_fixture
+    """
+    try:
+        fixture = import_string(
+            "flaskbb.fixtures.{}".format(settings)
+        )
+        fixture = fixture.fixture
+    except ImportError:
+        raise "{} fixture is not available".format(settings)
+
+    if force:
+        count = update_settings_from_fixture(fixture, overwrite_group=True,
+                                             overwrite_setting=True)
+        app.logger.info(
+            "{} groups and {} settings forcefully updated."
+            .format(count[0], count[1])
+        )
+    else:
+        count = update_settings_from_fixture(fixture)
+        app.logger.info(
+            "{} groups and {} settings updated.".format(count[0], count[1])
+        )
+
+
 @manager.command
 def createall(dropdb=False, createdb=False):
     """Creates the database with some testing content.
@@ -136,5 +167,13 @@ def initflaskbb(username=None, password=None, email=None):
     app.logger.info("Congratulations! FlaskBB has been successfully installed")
 
 
+@manager.command
+def insertmassdata():
+    """Warning: This can take a long time!.
+    Creates 100 topics and each topic contains 100 posts.
+    """
+    insert_mass_data()
+
+
 if __name__ == "__main__":
     manager.run()

+ 18 - 15
requirements.txt

@@ -1,32 +1,35 @@
 Flask==0.10.1
-Flask-And-Redis==0.5
 Flask-Cache==0.13.1
 Flask-DebugToolbar==0.9.0
 Flask-Login==0.2.11
-Flask-Mail==0.9.0
+Flask-Mail==0.9.1
 Flask-Migrate==1.2.0
-Flask-Plugins==1.4
-Flask-SQLAlchemy==1.0
+Flask-Plugins==1.5
+Flask-SQLAlchemy==2.0
 Flask-Script==2.0.5
 Flask-Themes2==0.1.3
-Flask-WTF==0.10
-Flask-WhooshAlchemy==0.56
+Flask-WTF==0.10.2
 Jinja2==2.7.3
 Mako==1.0.0
 MarkupSafe==0.23
 Pygments==1.6
-SQLAlchemy==0.9.7
+SQLAlchemy==0.9.8
 WTForms==2.0.1
 Werkzeug==0.9.6
 Whoosh==2.6.0
-alembic==0.6.5
+alembic==0.6.7
 blinker==1.3
+cov-core==1.14.0
+coverage==3.7.1
 itsdangerous==0.24
-py==1.4.22
-pytest==2.6.0
+py==1.4.25
+pytest==2.6.3
+pytest-cov==1.8.0
 pytest-random==0.02
-pytest-cov==1.7.0
-redis==2.10.1
-simplejson==3.6.0
-
-https://github.com/frol/postmarkup/tarball/master#egg=postmarkup
+redis==2.10.3
+simplejson==3.6.4
+flask-redis==0.0.6
+unidecode==0.04.16
+markdown2==2.3.0
+https://github.com/frol/postmarkup/tarball/master#egg=postmarkup
+https://github.com/jshipley/Flask-WhooshAlchemy/archive/master.zip#egg=Flask-Whooshalchemy

+ 5 - 3
setup.py

@@ -40,7 +40,7 @@ setup(
     platforms='any',
     install_requires=[
         'Flask',
-        'Flask-And-Redis',
+        'Flask-Redis',
         'Flask-Cache',
         'Flask-DebugToolbar',
         'Flask-Login',
@@ -69,10 +69,12 @@ setup(
         'pytest-cov',
         'redis',
         'simplejson',
-        'postmarkup'
+        'postmarkup',
+        'unidecode'
     ],
     dependency_links=[
-        'https://github.com/frol/postmarkup/tarball/master#egg=postmarkup'
+        'https://github.com/frol/postmarkup/tarball/master#egg=postmarkup',
+        'https://github.com/jshipley/Flask-WhooshAlchemy/archive/master.zip#egg=Flask-WhooshAlchemy'
     ],
     classifiers=[
         'Development Status :: 4 - Beta',

+ 1 - 1
tests/unit/test_forum_models.py

@@ -380,7 +380,7 @@ def test_topic_move(topic):
 
     assert topic.move(forum_other)
 
-    assert forum_old.topics == []
+    assert forum_old.topics.all() == []
     assert forum_old.last_post_id is None
 
     assert forum_old.topic_count == 0

+ 23 - 23
tests/unit/utils/test_permissions.py

@@ -13,13 +13,13 @@ def test_moderator_permissions_in_forum(
 
     assert moderator_user in forum.moderators
 
-    assert can_post_reply(moderator_user, forum)
+    assert can_post_reply(moderator_user, topic)
     assert can_post_topic(moderator_user, forum)
-    assert can_edit_post(moderator_user, topic.user_id, forum)
+    assert can_edit_post(moderator_user, topic.first_post)
 
     assert can_moderate(moderator_user, forum)
-    assert can_delete_post(moderator_user, topic.user_id, forum)
-    assert can_delete_topic(moderator_user, topic.user_id, forum)
+    assert can_delete_post(moderator_user, topic.first_post)
+    assert can_delete_topic(moderator_user, topic)
 
 
 def test_moderator_permissions_without_forum(
@@ -32,17 +32,17 @@ def test_moderator_permissions_without_forum(
     assert not moderator_user in forum.moderators
     assert not can_moderate(moderator_user, forum)
 
-    assert can_post_reply(moderator_user, forum)
+    assert can_post_reply(moderator_user, topic)
     assert can_post_topic(moderator_user, forum)
 
-    assert not can_edit_post(moderator_user, topic.user_id, forum)
-    assert not can_delete_post(moderator_user, topic.user_id, forum)
-    assert not can_delete_topic(moderator_user, topic.user_id, forum)
+    assert not can_edit_post(moderator_user, topic.first_post)
+    assert not can_delete_post(moderator_user, topic.first_post)
+    assert not can_delete_topic(moderator_user, topic)
 
     # Test with own topic
-    assert can_delete_post(moderator_user, topic_moderator.user_id, forum)
-    assert can_delete_topic(moderator_user, topic_moderator.user_id, forum)
-    assert can_edit_post(moderator_user, topic_moderator.user_id, forum)
+    assert can_delete_post(moderator_user, topic_moderator.first_post)
+    assert can_delete_topic(moderator_user, topic_moderator)
+    assert can_edit_post(moderator_user, topic_moderator.first_post)
 
     # Test moderator permissions
     assert can_edit_user(moderator_user)
@@ -53,12 +53,12 @@ def test_normal_permissions(forum, user, topic):
     """Test the permissions for a normal user."""
     assert not can_moderate(user, forum)
 
-    assert can_post_reply(user, forum)
+    assert can_post_reply(user, topic)
     assert can_post_topic(user, forum)
 
-    assert can_edit_post(user, topic.user_id, forum)
-    assert not can_delete_post(user, topic.user_id, forum)
-    assert not can_delete_topic(user, topic.user_id, forum)
+    assert can_edit_post(user, topic.first_post)
+    assert not can_delete_post(user, topic.first_post)
+    assert not can_delete_topic(user, topic)
 
     assert not can_edit_user(user)
     assert not can_ban_user(user)
@@ -68,12 +68,12 @@ def test_admin_permissions(forum, admin_user, topic):
     """Test the permissions for a admin user."""
     assert can_moderate(admin_user, forum)
 
-    assert can_post_reply(admin_user, forum)
+    assert can_post_reply(admin_user, topic)
     assert can_post_topic(admin_user, forum)
 
-    assert can_edit_post(admin_user, topic.user_id, forum)
-    assert can_delete_post(admin_user, topic.user_id, forum)
-    assert can_delete_topic(admin_user, topic.user_id, forum)
+    assert can_edit_post(admin_user, topic.first_post)
+    assert can_delete_post(admin_user, topic.first_post)
+    assert can_delete_topic(admin_user, topic)
 
     assert can_edit_user(admin_user)
     assert can_ban_user(admin_user)
@@ -83,12 +83,12 @@ def test_super_moderator_permissions(forum, super_moderator_user, topic):
     """Test the permissions for a super moderator user."""
     assert can_moderate(super_moderator_user, forum)
 
-    assert can_post_reply(super_moderator_user, forum)
+    assert can_post_reply(super_moderator_user, topic)
     assert can_post_topic(super_moderator_user, forum)
 
-    assert can_edit_post(super_moderator_user, topic.user_id, forum)
-    assert can_delete_post(super_moderator_user, topic.user_id, forum)
-    assert can_delete_topic(super_moderator_user, topic.user_id, forum)
+    assert can_edit_post(super_moderator_user, topic.first_post)
+    assert can_delete_post(super_moderator_user, topic.first_post)
+    assert can_delete_topic(super_moderator_user, topic)
 
     assert can_edit_user(super_moderator_user)
     assert can_ban_user(super_moderator_user)