sh4nks 8 лет назад
Родитель
Сommit
0ab97ffc30

+ 2 - 1
.travis.yml

@@ -7,9 +7,10 @@ python:
 install:
   - "pip install -r requirements-dev.txt"
   - "pip install coveralls"
+  - "pip install -e ."
 # command to run tests
 script:
-  - python manage.py compile_translations
+  - flaskbb translations compile
   - py.test --cov=flaskbb --cov-report=term-missing tests
 after_success:
   - coveralls

+ 9 - 0
MANIFEST.in

@@ -0,0 +1,9 @@
+include LICENSE AUTHORS CHANGES README.md celery_worker.py babel.cfg pytest.ini
+graft flaskbb
+graft tests
+graft migrations
+prune flaskbb/themes/*/node_modules
+prune flaskbb/themes/*/.sass-cache
+prune flaskbb/themes/*/src/*
+global-exclude __pycache__
+global-exclude *.py[co]

+ 4 - 3
Makefile

@@ -2,7 +2,7 @@
 
 help:
 	@echo "  clean      remove unwanted stuff"
-	@echo "  install    install flaskbb and setup"
+	@echo "  install    install dependencies and flaskbb"
 	@echo "  test       run the testsuite"
 	@echo "  run        run the development server"
 	@echo "  docs       build the documentation"
@@ -20,11 +20,12 @@ test:
 	py.test
 
 run:
-	python manage.py runserver -dr
+	flaskbb run
 
 install:dependencies
 	clear
-	python manage.py install
+	pip install -e .
+	flaskbb install
 
 docs:
 	$(MAKE) -C docs html

+ 9 - 13
README.md

@@ -3,32 +3,28 @@
 [![Code Health](https://landscape.io/github/sh4nks/flaskbb/master/landscape.svg?style=flat)](https://landscape.io/github/sh4nks/flaskbb/master)
 [![License](https://img.shields.io/badge/license-BSD-blue.svg)](https://flaskbb.org)
 
-# INTRODUCTION
+# Introduction
 
 [FlaskBB](http://flaskbb.org) is a forum software written in python
 using the micro framework Flask.
 
 
-## FEATURES
+## Features
 
 * A Bulletin Board like FluxBB or DjangoBB in Flask
 * Private Messages
 * Admin Interface
 * Group based permissions
-* BBCode Support
+* Markdown Support
 * Topic Tracker
 * Unread Topics/Forums
 * i18n Support
 * Completely Themeable
 * Plugin System
+* Command Line Interface
 
 
-## TODO
-
-* See the github [issues](https://github.com/sh4nks/flaskbb/issues?state=open)
-
-
-## INSTALLATION
+## Quickstart
 
 For a complete installation guide please visit the installation documentation
 [here](https://flaskbb.readthedocs.org/en/latest/installation.html).
@@ -39,20 +35,20 @@ For a complete installation guide please visit the installation documentation
 * Install dependencies and FlaskBB
     * `make install`
 * Run the development server
-    * `make runserver`
+    * `make run`
 * Visit [localhost:8080](http://localhost:8080)
 
 
-## DOCUMENTATION
+## Documentation
 
 The documentation is located [here](http://flaskbb.readthedocs.org/en/latest/).
 
 
-## LICENSE
+## License
 
 [BSD LICENSE](http://flask.pocoo.org/docs/license/#flask-license)
 
 
-## ACKNOWLEDGEMENTS
+## Acknowledgements
 
 [/r/flask](http://reddit.com/r/flask), [Flask](http://flask.pocoo.org), it's [extensions](http://flask.pocoo.org/extensions/) and everyone who has helped me!

+ 4 - 1
babel.cfg

@@ -1,4 +1,7 @@
+[ignore: .tox/**]
+[ignore: .venv/**]
 [ignore: **/plugins/**]
-[python: **.py]
+[ignore: **/node_modules/**]
+[python: **/flaskbb/**.py]
 [jinja2: **/templates/**.html]
 extensions=jinja2.ext.autoescape,jinja2.ext.with_

+ 341 - 0
docs/cli.rst

@@ -0,0 +1,341 @@
+.. _commandline:
+
+Command Line Interface
+======================
+
+Here you can find the documentation about FlaskBB's Command Line Interface.
+
+To get help for a commands, just type ``flaskbb COMMAND --help``.
+If no command options or arguments are used it will display all available
+commands.
+
+.. sourcecode:: text
+
+    Usage: flaskbb [OPTIONS] COMMAND [ARGS]...
+
+      This is the commandline interface for flaskbb.
+
+    Options:
+      --version      Show the FlaskBB version.
+      --config TEXT  Specify the config to use in dotted module notation e.g.
+                     flaskbb.configs.default.DefaultConfig
+      --help         Show this message and exit.
+
+    Commands:
+      celery           Preconfigured wrapper around the 'celery' command.
+      db               Perform database migrations.
+      download-emojis  Downloads emojis from emoji-cheat-sheet.com.
+      install          Installs flaskbb.
+      plugins          Plugins command sub group.
+      populate         Creates the necessary tables and groups for FlaskBB.
+      reindex          Reindexes the search index.
+      run              Runs a development server.
+      shell            Runs a shell in the app context.
+      start            Starts a production ready wsgi server.
+      themes           Themes command sub group.
+      translations     Translations command sub group.
+      upgrade          Updates the migrations and fixtures.
+      urls             Show routes for the app.
+      users            Create, update or delete users.
+
+
+Commands
+--------
+
+Here you will find a detailed description of every command including all
+of their options and arguments.
+
+.. I am cheating here as i don't know how else to get rid of the warnings
+
+.. describe:: flaskbb install
+
+    Installs flaskbb. If no arguments are used, an interactive setup
+    will be run.
+
+    .. describe:: --welcome, -w
+
+        Disables the generation of the welcome forum.
+
+    .. describe:: --force, -f
+
+        Doesn't ask for confirmation if the database should be deleted or not.
+
+    .. describe:: --username USERNAME, -u USERNAME
+
+        The username of the user.
+
+    .. describe:: --email EMAIL, -e EMAIL
+
+        The email address of the user.
+
+    .. describe:: --password PASSWORD, -p PASSWORD
+
+        The password of the user.
+
+    .. describe:: --group GROUP, -g GROUP
+
+        The primary group of the user. The group ``GROUP`` has to be
+        one of ``admin``, ``super_mod``, ``mod`` or ``member``.
+
+.. describe:: flaskbb upgrade
+
+    Updates the migrations and fixtures.
+
+    .. describe:: --all, -a
+
+        Upgrades migrations AND fixtures to the latest version.
+
+    .. describe:: --fixture FIXTURE, -f FIXTURE
+
+        The fixture which should be upgraded or installed.
+        All fixtures have to be places inside flaskbb/fixtures/
+
+    .. describe:: --force-fixture, -ff
+
+        Forcefully upgrades the fixtures. WARNING: This will also overwrite
+        any settings.
+
+.. describe:: flaskbb populate
+
+    Creates the necessary tables and groups for FlaskBB.
+
+    .. describe:: --test-data, -t
+
+        Adds some test data.
+
+    .. describe:: --bulk-data, -b
+
+        Adds a lot of test data. Has to be used in combination with
+        ``--posts`` and ``--topics``.
+
+    .. describe:: --posts
+
+        Number of posts to create in each topic (default: 100).
+
+    .. describe:: --topics
+
+        Number of topics to create (default: 100).
+
+    .. describe:: --force, -f
+
+        Will delete the database without asking before populating it.
+
+    .. describe:: --initdb, -i
+
+        Initializes the database before populating it.
+
+.. describe:: flaskbb runserver
+
+    Starts the development server
+
+.. describe:: flaskbb start
+
+    Starts a production ready wsgi server.
+    Other versions of starting FlaskBB are still supported!
+
+    .. describe:: --server SERVER, -s SERVER
+
+        Defaults to ``gunicorn``. The following WSGI Servers are supported:
+            - gunicorn (default)
+            - gevent
+
+    .. describe:: --host HOST, -h HOST
+
+        The interface to bind FlaskBB to. Defaults to ``127.0.0.1``.
+
+    .. describe:: --port PORT, -p PORT
+
+        The port to bind FlaskBB to. Defaults to ``8000``.
+
+    .. describe:: --workers WORKERS, -w WORKERS
+
+        The number of worker processes for handling requests.
+        Defaults to ``4``.
+
+    .. describe:: --daemon, -d
+
+        Starts gunicorn in daemon mode.
+
+    .. describe:: --config, -c
+
+        The configuration file to use for the FlaskBB WSGI Application.
+
+.. describe:: flaskbb celery CELERY_ARGS
+
+    Starts celery. This is just a preconfigured wrapper around the ``celery``
+    command. Additional arguments are directly passed to celery.
+
+    .. describe:: --help-celery
+
+        Shows the celery help message.
+
+.. describe:: flaskbb shell
+
+    Creates a python shell with an app context.
+
+.. describe:: flaskbb urls
+
+    Lists all available routes.
+
+    .. describe:: --route, -r
+
+        Order by route.
+
+    .. describe:: --endpoint, -e
+
+        Order by endpoint
+
+    .. describe:: --methods, m
+
+        Order by methods
+
+.. describe:: flaskbb reindex
+
+    Reindexes the search index.
+
+.. describe:: flaskbb translations
+
+    Translations command sub group.
+
+    .. describe:: new LANGUAGE_CODE
+
+        Adds a new language to FlaskBB's translations.
+        The ``LANGUAGE_CODE`` is the short identifier for the language i.e.
+        '``en``', '``de``', '``de_AT``', etc.
+
+        .. describe:: --plugin PLUGIN_NAME, --p PLUGIN_NAME
+
+            Adds a new language to a plugin.
+
+    .. describe:: update
+
+        Updates the translations.
+
+        .. describe:: --all, -a
+
+            Updates all translations, including the ones from the plugins.
+
+        .. describe:: --plugin PLUGIN_NAME, --p PLUGIN_NAME
+
+            Update the language of the given plugin.
+
+    .. describe:: compile
+
+        Compiles the translations.
+
+        .. describe:: --all, -a
+
+            Compiles all translations, including the ones from the plugins.
+
+        .. describe:: --plugin PLUGIN_NAME, --p PLUGIN_NAME
+
+            Compiles only the given plugin translation.
+
+.. describe:: flaskbb plugins
+
+    Plugins command sub group.
+
+    .. describe:: new PLUGIN_IDENTIFIER
+
+        Creates a new plugin based on the cookiecutter plugin template.
+        Defaults to this template:
+        https://github.com/sh4nks/cookiecutter-flaskbb-plugin.
+        It will either accept a valid path on the filesystem
+        or a URL to a Git repository which contains the cookiecutter template.
+
+    .. describe:: install PLUGIN_IDENTIFIER
+
+        Installs a plugin by using the plugin's identifier.
+
+    .. describe:: uninstall PLUGIN_IDENTIFIER
+
+        Uninstalls a plugin by using the plugin's identifier.
+
+    .. describe:: remove PLUGIN_IDENTIFIER
+
+        Removes a plugin from the filesystem by using the plugin's identifier.
+
+        describe:: --force, -f
+
+            Removes the plugin without asking for confirmation first.
+
+    .. describe:: list
+
+        Lists all installed plugins.
+
+.. describe:: flaskbb themes
+
+    Themes command sub group.
+
+    .. describe:: new THEME_IDENTIFIER
+
+        Creates a new theme based on the cookiecutter theme
+        template. Defaults to this template:
+        https://github.com/sh4nks/cookiecutter-flaskbb-theme.
+        It will either accept a valid path on the filesystem
+        or a URL to a Git repository which contains the cookiecutter template.
+
+    .. describe:: remove THEME_IDENTIFIER
+
+        Removes a theme from the filesystem by the theme's identifier.
+
+    .. describe:: list
+
+        Lists all installed themes.
+
+.. describe:: flaskbb users
+
+    Creates a new user. If an option is missing, you will be interactivly
+    prompted to type it.
+
+    .. describe:: new
+
+        Creates a new user.
+
+        .. describe:: --username USERNAME, -u USERNAME
+
+            The username of the user.
+
+        .. describe:: --email EMAIL, -e EMAIL
+
+            The email address of the user.
+
+        .. describe:: --password PASSWORD, -p PASSWORD
+
+            The password of the user.
+
+        .. describe:: --group GROUP, -g GROUP
+
+            The primary group of the user. The group ``GROUP`` has to be
+            one of ``admin``, ``super_mod``, ``mod`` or ``member``.
+
+    .. describe:: update
+
+        Updates an user.
+
+        .. describe:: --username USERNAME, -u USERNAME
+
+            The username of the user.
+
+        .. describe:: --email EMAIL, -e EMAIL
+
+            The email address of the user.
+
+        .. describe:: --password PASSWORD, -p PASSWORD
+
+            The password of the user.
+
+        .. describe:: --group GROUP, -g GROUP
+
+            The primary group of the user. The group ``GROUP`` has to be
+            one of ``admin``, ``super_mod``, ``mod`` or ``member``.
+
+    .. describe:: delete
+
+        .. describe:: --username USERNAME, -u USERNAME
+
+            The username of the user.
+
+        .. describe:: --force, -f
+
+            Removes the user without asking for confirmation first.

+ 2 - 1
docs/contents.rst.inc

@@ -5,10 +5,11 @@ Contents
    :maxdepth: 2
 
    installation
+   theming
+   cli
    plugins
    plugin_tutorial/index
    events
-   theming
    settings
    permissions
    models

+ 7 - 6
docs/installation.rst

@@ -23,14 +23,14 @@ For example, on archlinux you can install it with
 
     $ sudo pacman -S python2-virtualenvwrapper
 
-or, if you own a Mac, you can simply install it with
+or, if you own a Mac, you can install it with
 ::
 
     $ sudo pip install virtualenvwrapper
 
 For more information checkout the  `virtualenvwrapper <http://virtualenvwrapper.readthedocs.org/en/latest/install.html#basic-installation>`_ installation.
 
-After that you can create your virtualenv with
+After that, you can create your virtualenv with
 ::
 
     $ mkvirtualenv -a /path/to/flaskbb -p $(which python2) flaskbb
@@ -160,12 +160,13 @@ For a guided install, run
 
     $ make install
 
-or:
+or
+::
 
-    python manage.py install
+    flaskbb install
 
 During the installation process you are asked about your username,
-your email address and the password for your administrator user. Using the 
+your email address and the password for your administrator user. Using the
 `make install` command is recommended as it checks that the dependencies are also
 installed.
 
@@ -177,7 +178,7 @@ If the database models changed after a release, you have to run the ``upgrade``
 command
 ::
 
-    python manage.py db upgrade
+    flaskbb db upgrade
 
 
 Deploying

+ 23 - 10
flaskbb/app.py

@@ -18,6 +18,7 @@ from sqlalchemy.engine import Engine
 from flask import Flask, request
 from flask_login import current_user
 
+from flaskbb._compat import string_types
 # views
 from flaskbb.user.views import user
 from flaskbb.message.views import message
@@ -52,19 +53,15 @@ from flaskbb.utils.settings import flaskbb_config
 def create_app(config=None):
     """Creates the app.
 
-    :param config: The configuration object.
+    :param config: The configuration file or object.
+                   The environment variable is weightet as the heaviest.
+                   For example, if the config is specified via an file
+                   and a ENVVAR, it will load the config via the file and
+                   later overwrite it from the ENVVAR.
     """
-
-    # Initialize the app
     app = Flask("flaskbb")
 
-    # Use the default config and override it afterwards
-    app.config.from_object('flaskbb.configs.default.DefaultConfig')
-    # Update the config
-    app.config.from_object(config)
-    # try to update the config via the environment variable
-    app.config.from_envvar("FLASKBB_SETTINGS", silent=True)
-
+    configure_app(app, config)
     configure_celery_app(app, celery)
     configure_blueprints(app)
     configure_extensions(app)
@@ -77,6 +74,22 @@ def create_app(config=None):
     return app
 
 
+def configure_app(app, config):
+    """Configures FlaskBB."""
+    # Use the default config and override it afterwards
+    app.config.from_object('flaskbb.configs.default.DefaultConfig')
+
+    if isinstance(config, string_types) and \
+            os.path.exists(os.path.abspath(config)):
+        app.config.from_pyfile(os.path.abspath(config))
+    else:
+        # try to update the config from the object
+        app.config.from_object(config)
+
+    # try to update the config via the environment variable
+    app.config.from_envvar("FLASKBB_SETTINGS", silent=True)
+
+
 def configure_celery_app(app, celery):
     """Configures the celery app."""
     app.config.update({'BROKER_URL': app.config["CELERY_BROKER_URL"]})

+ 22 - 0
flaskbb/cli/__init__.py

@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.cli
+    ~~~~~~~~~~~
+
+    FlaskBB's Command Line Interface.
+    To make it work, you have to install FlaskBB via ``pip install -e .``.
+
+    Plugin and Theme templates are generated via cookiecutter.
+    In order to generate those project templates you have to
+    cookiecutter first::
+
+        pip install cookiecutter
+
+    :copyright: (c) 2016 by the FlaskBB Team.
+    :license: BSD, see LICENSE for more details.
+"""
+from flaskbb.cli.main import flaskbb
+from flaskbb.cli.plugins import plugins
+from flaskbb.cli.themes import themes
+from flaskbb.cli.translations import translations
+from flaskbb.cli.users import users

+ 406 - 0
flaskbb/cli/main.py

@@ -0,0 +1,406 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.cli.commands
+    ~~~~~~~~~~~~~~~~~~~~
+
+    This module contains the main commands.
+
+    :copyright: (c) 2016 by the FlaskBB Team.
+    :license: BSD, see LICENSE for more details.
+"""
+import sys
+import os
+import time
+import requests
+
+import click
+from werkzeug.utils import import_string, ImportStringError
+from flask import current_app
+from flask.cli import FlaskGroup, ScriptInfo, with_appcontext
+from sqlalchemy_utils.functions import database_exists, drop_database
+from flask_migrate import upgrade as upgrade_database
+
+from flaskbb import create_app
+from flaskbb._compat import iteritems
+from flaskbb.extensions import db, whooshee, celery
+from flaskbb.cli.utils import (get_version, save_user_prompt, FlaskBBCLIError,
+                               EmailType)
+from flaskbb.utils.populate import (create_test_data, create_welcome_forum,
+                                    create_default_groups,
+                                    create_default_settings, insert_bulk_data,
+                                    update_settings_from_fixture)
+from flaskbb.utils.translations import compile_translations
+
+
+def make_app(script_info):
+    config_file = getattr(script_info, "config_file")
+    if config_file is not None:
+        # check if config file exists
+        if os.path.exists(os.path.abspath(config_file)):
+            click.secho("[+] Using config from: {}".format(
+                        os.path.abspath(config_file)), fg="cyan")
+        # config file doesn't exist, maybe it's a module
+        else:
+            try:
+                import_string(config_file)
+                click.secho("[+] Using config from: {}".format(config_file),
+                            fg="cyan")
+            except ImportStringError:
+                click.secho("[~] Config '{}' doesn't exist. "
+                            "Using default config.".format(config_file),
+                            fg="red")
+                config_file = None
+    else:
+        click.secho("[~] Using default config.", fg="yellow")
+
+    return create_app(config_file)
+
+
+def set_config(ctx, param, value):
+    """This will pass the config file to the create_app function."""
+    ctx.ensure_object(ScriptInfo).config_file = value
+
+
+@click.group(cls=FlaskGroup, create_app=make_app)
+@click.option("--version", expose_value=False, callback=get_version,
+              is_flag=True, is_eager=True, help="Show the FlaskBB version.")
+@click.option("--config", expose_value=False, callback=set_config,
+              required=False, is_flag=False, is_eager=True,
+              help="Specify the config to use in dotted module notation "
+                   "e.g. flaskbb.configs.default.DefaultConfig")
+def flaskbb():
+    """This is the commandline interface for flaskbb."""
+    pass
+
+
+@flaskbb.command()
+@click.option("--welcome", "-w", default=True, is_flag=True,
+              help="Disable the welcome forum.")
+@click.option("--force", "-f", default=False, is_flag=True,
+              help="Doesn't ask for confirmation.")
+@click.option("--username", "-u", help="The username of the user.")
+@click.option("--email", "-e", type=EmailType(),
+              help="The email address of the user.")
+@click.option("--password", "-p", help="The password of the user.")
+@click.option("--group", "-g", help="The group of the user.",
+              type=click.Choice(["admin", "super_mod", "mod", "member"]))
+def install(welcome, force, username, email, password, group):
+    """Installs flaskbb. If no arguments are used, an interactive setup
+    will be run.
+    """
+    click.secho("[+] Installing FlaskBB...", fg="cyan")
+    if database_exists(db.engine.url):
+        if force or click.confirm(click.style(
+            "Existing database found. Do you want to delete the old one and "
+            "create a new one?", fg="magenta")
+        ):
+            drop_database(db.engine.url)
+            upgrade_database()
+        else:
+            sys.exit(0)
+    else:
+        upgrade_database()
+
+    click.secho("[+] Creating default settings...", fg="cyan")
+    create_default_groups()
+    create_default_settings()
+
+    click.secho("[+] Creating admin user...", fg="cyan")
+    save_user_prompt(username, email, password, group)
+
+    if welcome:
+        click.secho("[+] Creating welcome forum...", fg="cyan")
+        create_welcome_forum()
+
+    click.secho("[+] Compiling translations...", fg="cyan")
+    compile_translations()
+
+    click.secho("[+] FlaskBB has been successfully installed!",
+                fg="green", bold=True)
+
+
+@flaskbb.command()
+@click.option("--test-data", "-t", default=False, is_flag=True,
+              help="Adds some test data.")
+@click.option("--bulk-data", "-b", default=False, is_flag=True,
+              help="Adds a lot of data.")
+@click.option("--posts", default=100,
+              help="Number of posts to create in each topic (default: 100).")
+@click.option("--topics", default=100,
+              help="Number of topics to create (default: 100).")
+@click.option("--force", "-f", is_flag=True,
+              help="Will delete the database before populating it.")
+@click.option("--initdb", "-i", is_flag=True,
+              help="Initializes the database before populating it.")
+def populate(bulk_data, test_data, posts, topics, force, initdb):
+    """Creates the necessary tables and groups for FlaskBB."""
+    if force:
+        click.secho("[+] Recreating database...", fg="cyan")
+        drop_database(db.engine.url)
+
+        # do not initialize the db if -i is passed
+        if not initdb:
+            upgrade_database()
+
+    if initdb:
+        click.secho("[+] Initializing database...", fg="cyan")
+        upgrade_database()
+
+    if test_data:
+        click.secho("[+] Adding some test data...", fg="cyan")
+        create_test_data()
+
+    if bulk_data:
+        timer = time.time()
+        topic_count, post_count = insert_bulk_data(int(topics), int(posts))
+        elapsed = time.time() - timer
+        click.secho("[+] It took {} seconds to create {} topics and {} posts"
+                    .format(elapsed, topic_count, post_count), fg="cyan")
+
+    # this just makes the most sense for the command name; use -i to
+    # init the db as well
+    if not test_data:
+        click.secho("[+] Populating the database with some defaults...",
+                    fg="cyan")
+        create_default_groups()
+        create_default_settings()
+
+
+@flaskbb.command()
+def reindex():
+    """Reindexes the search index."""
+    click.secho("[+] Reindexing search index...", fg="cyan")
+    whooshee.reindex()
+
+
+@flaskbb.command()
+@click.option("all_latest", "--all", "-a", default=False, is_flag=True,
+              help="Upgrades migrations AND fixtures to the latest version.")
+@click.option("--fixture/", "-f", default=None,
+              help="The fixture which should be upgraded or installed.")
+@click.option("--force", default=False, is_flag=True,
+              help="Forcefully upgrades the fixtures.")
+def upgrade(all_latest, fixture, force):
+    """Updates the migrations and fixtures."""
+    if all_latest:
+        click.secho("[+] Upgrading migrations to the latest version...",
+                    fg="cyan")
+        upgrade_database()
+
+    if fixture or all_latest:
+        try:
+            settings = import_string(
+                "flaskbb.fixtures.{}".format(fixture)
+            )
+            settings = settings.fixture
+        except ImportError:
+            raise FlaskBBCLIError("{} fixture is not available"
+                                  .format(fixture), fg="red")
+
+        click.secho("[+] Updating fixtures...")
+        count = update_settings_from_fixture(
+            fixture=settings, overwrite_group=force, overwrite_setting=force
+        )
+        click.secho("[+] {} groups and {} settings updated.".format(
+            len(count.keys()), len(count.values()), fg="green")
+        )
+
+
+@flaskbb.command("download-emojis")
+@with_appcontext
+def download_emoji():
+    """Downloads emojis from emoji-cheat-sheet.com.
+    This command is probably going to be removed in future version.
+    """
+    click.secho("[+] Downloading emojis...", fg="cyan")
+    HOSTNAME = "https://api.github.com"
+    REPO = "/repos/arvida/emoji-cheat-sheet.com/contents/public/graphics/emojis"
+    FULL_URL = "{}{}".format(HOSTNAME, REPO)
+    DOWNLOAD_PATH = os.path.join(current_app.static_folder, "emoji")
+    response = requests.get(FULL_URL)
+
+    cached_count = 0
+    count = 0
+    for image in response.json():
+        if not os.path.exists(os.path.abspath(DOWNLOAD_PATH)):
+            raise FlaskBBCLIError(
+                "{} does not exist.".format(os.path.abspath(DOWNLOAD_PATH)),
+                fg="red")
+
+        full_path = os.path.join(DOWNLOAD_PATH, image["name"])
+        if not os.path.exists(full_path):
+            count += 1
+            f = open(full_path, 'wb')
+            f.write(requests.get(image["download_url"]).content)
+            f.close()
+            if count == cached_count + 50:
+                cached_count = count
+                click.secho("[+] {} out of {} Emojis downloaded...".format(
+                            cached_count, len(response.json())), fg="cyan")
+
+    click.secho("[+] Finished downloading {} Emojis.".format(count),
+                fg="green")
+
+
+@flaskbb.command("celery", context_settings=dict(ignore_unknown_options=True,))
+@click.argument('celery_args', nargs=-1, type=click.UNPROCESSED)
+@click.option("show_help", "--help", "-h", is_flag=True,
+              help="Shows this message and exits")
+@click.option("show_celery_help", "--help-celery", is_flag=True,
+              help="Shows the celery help message")
+@click.pass_context
+@with_appcontext
+def start_celery(ctx, show_help, show_celery_help, celery_args):
+    """Preconfigured wrapper around the 'celery' command.
+    Additional CELERY_ARGS arguments are passed to celery."""
+    if show_help:
+        click.echo(ctx.get_help())
+        sys.exit(0)
+
+    if show_celery_help:
+        click.echo(celery.start(argv=["--help"]))
+        sys.exit(0)
+
+    default_args = ['celery']
+    default_args = default_args + list(celery_args)
+    celery.start(argv=default_args)
+
+
+@flaskbb.command()
+@click.option("--server", "-s", default="gunicorn",
+              type=click.Choice(["gunicorn", "gevent"]),
+              help="The WSGI Server to run FlaskBB on.")
+@click.option("--host", "-h", default="127.0.0.1",
+              help="The interface to bind FlaskBB to.")
+@click.option("--port", "-p", default="8000", type=int,
+              help="The port to bind FlaskBB to.")
+@click.option("--workers", "-w", default=4,
+              help="The number of worker processes for handling requests.")
+@click.option("--daemon", "-d", default=False, is_flag=True,
+              help="Starts gunicorn as daemon.")
+@click.option("--config", "-c",
+              help="The configuration file to use for FlaskBB.")
+def start(server, host, port, workers, config, daemon):
+    """Starts a production ready wsgi server.
+    TODO: Figure out a way how to forward additional args to gunicorn
+          without causing any errors.
+    """
+    if server == "gunicorn":
+        try:
+            from gunicorn.app.base import Application
+
+            class FlaskBBApplication(Application):
+                def __init__(self, app, options=None):
+                    self.options = options or {}
+                    self.application = app
+                    super(FlaskBBApplication, self).__init__()
+
+                def load_config(self):
+                    config = dict([
+                        (key, value) for key, value in iteritems(self.options)
+                        if key in self.cfg.settings and value is not None
+                    ])
+                    for key, value in iteritems(config):
+                        self.cfg.set(key.lower(), value)
+
+                def load(self):
+                    return self.application
+
+            options = {
+                "bind": "{}:{}".format(host, port),
+                "workers": workers,
+                "daemon": daemon,
+            }
+            FlaskBBApplication(create_app(config=config), options).run()
+        except ImportError:
+            raise FlaskBBCLIError("Cannot import gunicorn. "
+                                  "Make sure it is installed.", fg="red")
+
+    elif server == "gevent":
+        try:
+            from gevent import __version__
+            from gevent.pywsgi import WSGIServer
+            click.secho("* Starting gevent {}".format(__version__))
+            click.secho("* Listening on http://{}:{}/".format(host, port))
+            http_server = WSGIServer((host, port), create_app(config=config))
+            http_server.serve_forever()
+        except ImportError:
+            raise FlaskBBCLIError("Cannot import gevent. "
+                                  "Make sure it is installed.", fg="red")
+
+
+@flaskbb.command("shell", short_help="Runs a shell in the app context.")
+@with_appcontext
+def shell_command():
+    """Runs an interactive Python shell in the context of a given
+    Flask application.  The application will populate the default
+    namespace of this shell according to it"s configuration.
+    This is useful for executing small snippets of management code
+    without having to manually configuring the application.
+
+    This code snippet is taken from Flask"s cli module and modified to
+    run IPython and falls back to the normal shell if IPython is not
+    available.
+    """
+    import code
+    banner = "Python %s on %s\nInstance Path: %s" % (
+        sys.version,
+        sys.platform,
+        current_app.instance_path,
+    )
+    ctx = {"db": db}
+
+    # Support the regular Python interpreter startup script if someone
+    # is using it.
+    startup = os.environ.get("PYTHONSTARTUP")
+    if startup and os.path.isfile(startup):
+        with open(startup, "r") as f:
+            eval(compile(f.read(), startup, "exec"), ctx)
+
+    ctx.update(current_app.make_shell_context())
+
+    try:
+        import IPython
+        IPython.embed(banner1=banner, user_ns=ctx)
+    except ImportError:
+        code.interact(banner=banner, local=ctx)
+
+
+@flaskbb.command("urls", short_help="Show routes for the app.")
+@click.option("--route", "-r", "order_by", flag_value="rule", default=True,
+              help="Order by route")
+@click.option("--endpoint", "-e", "order_by", flag_value="endpoint",
+              help="Order by endpoint")
+@click.option("--methods", "-m", "order_by", flag_value="methods",
+              help="Order by methods")
+@with_appcontext
+def list_urls(order_by):
+    """Lists all available routes."""
+    from flask import current_app
+
+    rules = sorted(
+        current_app.url_map.iter_rules(),
+        key=lambda rule: getattr(rule, order_by)
+    )
+
+    max_rule_len = max(len(rule.rule) for rule in rules)
+    max_rule_len = max(max_rule_len, len("Route"))
+
+    max_endpoint_len = max(len(rule.endpoint) for rule in rules)
+    max_endpoint_len = max(max_endpoint_len, len("Endpoint"))
+
+    max_method_len = max(len(", ".join(rule.methods)) for rule in rules)
+    max_method_len = max(max_method_len, len("Methods"))
+
+    column_header_len = max_rule_len + max_endpoint_len + max_method_len + 4
+    column_template = "{:<%s}  {:<%s}  {:<%s}" % (
+        max_rule_len, max_endpoint_len, max_method_len
+    )
+
+    click.secho(column_template.format("Route", "Endpoint", "Methods"),
+                fg="blue", bold=True)
+    click.secho("=" * column_header_len, bold=True)
+
+    for rule in rules:
+        methods = ", ".join(rule.methods)
+        click.echo(column_template.format(rule.rule, rule.endpoint, methods))

+ 130 - 0
flaskbb/cli/plugins.py

@@ -0,0 +1,130 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.cli.plugins
+    ~~~~~~~~~~~~~~~~~~~
+
+    This module contains all plugin commands.
+
+    :copyright: (c) 2016 by the FlaskBB Team.
+    :license: BSD, see LICENSE for more details.
+"""
+import sys
+import os
+import shutil
+
+import click
+from flask import current_app
+from flask_plugins import (get_all_plugins, get_enabled_plugins,
+                           get_plugin_from_all)
+
+from flaskbb.cli.main import flaskbb
+from flaskbb.cli.utils import check_cookiecutter, validate_plugin
+from flaskbb.extensions import plugin_manager
+
+try:
+    from cookiecutter.main import cookiecutter
+except ImportError:
+    pass
+
+
+@flaskbb.group()
+def plugins():
+    """Plugins command sub group."""
+    pass
+
+
+@plugins.command("new")
+@click.argument("plugin_identifier", callback=check_cookiecutter)
+@click.option("--template", "-t", type=click.STRING,
+              default="https://github.com/sh4nks/cookiecutter-flaskbb-plugin",
+              help="Path to a cookiecutter template or to a valid git repo.")
+def new_plugin(plugin_identifier, template):
+    """Creates a new plugin based on the cookiecutter plugin
+    template. Defaults to this template:
+    https://github.com/sh4nks/cookiecutter-flaskbb-plugin.
+    It will either accept a valid path on the filesystem
+    or a URL to a Git repository which contains the cookiecutter template.
+    """
+    out_dir = os.path.join(current_app.root_path, "plugins", plugin_identifier)
+    click.secho("[+] Creating new plugin {}".format(plugin_identifier),
+                fg="cyan")
+    cookiecutter(template, output_dir=out_dir)
+    click.secho("[+] Done. Created in {}".format(out_dir),
+                fg="green", bold=True)
+
+
+@plugins.command("install")
+@click.argument("plugin_identifier")
+def install_plugin(plugin_identifier):
+    """Installs a new plugin."""
+    validate_plugin(plugin_identifier)
+    plugin = get_plugin_from_all(plugin_identifier)
+    click.secho("[+] Installing plugin {}...".format(plugin.name), fg="cyan")
+    try:
+        plugin_manager.install_plugins([plugin])
+    except Exception as e:
+        click.secho("[-] Couldn't install plugin because of following "
+                    "exception: \n{}".format(e), fg="red")
+
+
+@plugins.command("uninstall")
+@click.argument("plugin_identifier")
+def uninstall_plugin(plugin_identifier):
+    """Uninstalls a plugin from FlaskBB."""
+    validate_plugin(plugin_identifier)
+    plugin = get_plugin_from_all(plugin_identifier)
+    click.secho("[+] Uninstalling plugin {}...".format(plugin.name), fg="cyan")
+    try:
+        plugin_manager.uninstall_plugins([plugin])
+    except AttributeError:
+        pass
+
+
+@plugins.command("remove")
+@click.argument("plugin_identifier")
+@click.option("--force", "-f", default=False, is_flag=True,
+              help="Removes the plugin without asking for confirmation.")
+def remove_plugin(plugin_identifier, force):
+    """Removes a plugin from the filesystem."""
+    validate_plugin(plugin_identifier)
+    if not force and not \
+            click.confirm(click.style("Are you sure?", fg="magenta")):
+        sys.exit(0)
+
+    plugin = get_plugin_from_all(plugin_identifier)
+    click.secho("[+] Uninstalling plugin {}...".format(plugin.name), fg="cyan")
+    try:
+        plugin_manager.uninstall_plugins([plugin])
+    except Exception as e:
+        click.secho("[-] Couldn't uninstall plugin because of following "
+                    "exception: \n{}".format(e), fg="red")
+        if not click.confirm(click.style(
+            "Do you want to continue anyway?", fg="magenta")
+        ):
+            sys.exit(0)
+
+    click.secho("[+] Removing plugin from filesystem...", fg="cyan")
+    shutil.rmtree(plugin.path, ignore_errors=False, onerror=None)
+
+
+@plugins.command("list")
+def list_plugins():
+    """Lists all installed plugins."""
+    click.secho("[+] Listing all installed plugins...", fg="cyan")
+
+    # This is subject to change as I am not happy with the current
+    # plugin system
+    enabled_plugins = get_enabled_plugins()
+    disabled_plugins = set(get_all_plugins()) - set(enabled_plugins)
+    if len(enabled_plugins) > 0:
+        click.secho("[+] Enabled Plugins:", fg="blue", bold=True)
+        for plugin in enabled_plugins:
+            click.secho("    - {} (version {})".format(
+                plugin.name, plugin.version), bold=True
+            )
+    if len(disabled_plugins) > 0:
+        click.secho("[+] Disabled Plugins:", fg="yellow", bold=True)
+        for plugin in disabled_plugins:
+            click.secho("    - {} (version {})".format(
+                plugin.name, plugin.version), bold=True
+            )

+ 84 - 0
flaskbb/cli/themes.py

@@ -0,0 +1,84 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.cli.themes
+    ~~~~~~~~~~~~~~~~~~
+
+    This module contains all theme commands.
+
+    :copyright: (c) 2016 by the FlaskBB Team.
+    :license: BSD, see LICENSE for more details.
+"""
+import sys
+import os
+import shutil
+
+import click
+from flask import current_app
+from flask_themes2 import get_themes_list, get_theme
+
+from flaskbb.cli.main import flaskbb
+from flaskbb.cli.utils import check_cookiecutter, validate_theme
+from flaskbb.utils.settings import flaskbb_config
+
+
+@flaskbb.group()
+def themes():
+    """Themes command sub group."""
+    pass
+
+
+@themes.command("list")
+def list_themes():
+    """Lists all installed themes."""
+    click.secho("[+] Listing all installed themes...", fg="cyan")
+
+    active_theme = get_theme(flaskbb_config['DEFAULT_THEME'])
+    available_themes = set(get_themes_list()) - set([active_theme])
+
+    click.secho("[+] Active Theme:", fg="blue", bold=True)
+    click.secho("    - {} (version {})".format(
+        active_theme.name, active_theme.version), bold=True
+    )
+
+    click.secho("[+] Available Themes:", fg="yellow", bold=True)
+    for theme in available_themes:
+        click.secho("    - {} (version {})".format(
+            theme.name, theme.version), bold=True
+        )
+
+
+@themes.command("new")
+@click.argument("theme_identifier", callback=check_cookiecutter)
+@click.option("--template", "-t", type=click.STRING,
+              default="https://github.com/sh4nks/cookiecutter-flaskbb-theme",
+              help="Path to a cookiecutter template or to a valid git repo.")
+def new_theme(theme_identifier, template):
+    """Creates a new theme based on the cookiecutter theme
+    template. Defaults to this template:
+    https://github.com/sh4nks/cookiecutter-flaskbb-theme.
+    It will either accept a valid path on the filesystem
+    or a URL to a Git repository which contains the cookiecutter template.
+    """
+    from cookiecutter.main import cookiecutter
+    out_dir = os.path.join(current_app.root_path, "themes")
+    click.secho("[+] Creating new theme {}".format(theme_identifier),
+                fg="cyan")
+    cookiecutter(template, output_dir=out_dir)
+    click.secho("[+] Done. Created in {}".format(out_dir),
+                fg="green", bold=True)
+
+
+@themes.command("remove")
+@click.argument("theme_identifier")
+@click.option("--force", "-f", default=False, is_flag=True,
+              help="Removes the theme without asking for confirmation.")
+def remove_theme(theme_identifier, force):
+    """Removes a theme from the filesystem."""
+    validate_theme(theme_identifier)
+    if not force and not \
+            click.confirm(click.style("Are you sure?", fg="magenta")):
+        sys.exit(0)
+
+    theme = get_theme(theme_identifier)
+    click.secho("[+] Removing theme from filesystem...", fg="cyan")
+    shutil.rmtree(theme.path, ignore_errors=False, onerror=None)

+ 76 - 0
flaskbb/cli/translations.py

@@ -0,0 +1,76 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.cli.translations
+    ~~~~~~~~~~~~~~~~~~~~~~~~
+
+    This module contains all translation commands.
+
+    :copyright: (c) 2016 by the FlaskBB Team.
+    :license: BSD, see LICENSE for more details.
+"""
+import click
+
+from flaskbb.cli.main import flaskbb
+from flaskbb.cli.utils import validate_plugin
+from flaskbb.utils.translations import (add_translations, compile_translations,
+                                        update_translations,
+                                        add_plugin_translations,
+                                        compile_plugin_translations,
+                                        update_plugin_translations)
+
+
+@flaskbb.group()
+def translations():
+    """Translations command sub group."""
+    pass
+
+
+@translations.command("new")
+@click.option("--plugin", "-p", type=click.STRING,
+              help="Adds a new language to a plugin.")
+@click.argument("lang")
+def new_translation(lang, plugin):
+    """Adds a new language to the translations. "lang" is the language code
+    of the language, like, "de_AT"."""
+    if plugin:
+        validate_plugin(plugin)
+        click.secho("[+] Adding new language {} for plugin {}..."
+                    .format(lang, plugin), fg="cyan")
+        add_plugin_translations(plugin, lang)
+    else:
+        click.secho("[+] Adding new language {}...".format(lang), fg="cyan")
+        add_translations(lang)
+
+
+@translations.command("update")
+@click.option("is_all", "--all", "-a", default=True, is_flag=True,
+              help="Updates the plugin translations as well.")
+@click.option("--plugin", "-p", type=click.STRING,
+              help="Updates the language of the given plugin.")
+def update_translation(is_all, plugin):
+    """Updates all translations."""
+    if plugin is not None:
+        validate_plugin(plugin)
+        click.secho("[+] Updating language files for plugin {}..."
+                    .format(plugin), fg="cyan")
+        update_plugin_translations(plugin)
+    else:
+        click.secho("[+] Updating language files...", fg="cyan")
+        update_translations(include_plugins=is_all)
+
+
+@translations.command("compile")
+@click.option("is_all", "--all", "-a", default=True, is_flag=True,
+              help="Compiles the plugin translations as well.")
+@click.option("--plugin", "-p", type=click.STRING,
+              help="Compiles the translations for a given plugin.")
+def compile_translation(is_all, plugin):
+    """Compiles the translations."""
+    if plugin is not None:
+        validate_plugin(plugin)
+        click.secho("[+] Compiling language files for plugin {}..."
+                    .format(plugin), fg="cyan")
+        compile_plugin_translations(plugin)
+    else:
+        click.secho("[+] Compiling language files...", fg="cyan")
+        compile_translations(include_plugins=is_all)

+ 89 - 0
flaskbb/cli/users.py

@@ -0,0 +1,89 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.cli.users
+    ~~~~~~~~~~~~~~~~~
+
+    This module contains all user commands.
+
+    :copyright: (c) 2016 by the FlaskBB Team.
+    :license: BSD, see LICENSE for more details.
+"""
+import sys
+import os
+
+import click
+from sqlalchemy.exc import IntegrityError
+
+from flaskbb.cli.main import flaskbb
+from flaskbb.cli.utils import FlaskBBCLIError, EmailType, save_user_prompt
+from flaskbb.user.models import User
+
+
+@flaskbb.group()
+def users():
+    """Create, update or delete users."""
+    pass
+
+
+@users.command("new")
+@click.option("--username", "-u", help="The username of the user.")
+@click.option("--email", "-e", type=EmailType(),
+              help="The email address of the user.")
+@click.option("--password", "-p", help="The password of the user.")
+@click.option("--group", "-g", help="The group of the user.",
+              type=click.Choice(["admin", "super_mod", "mod", "member"]))
+def new_user(username, email, password, group):
+    """Creates a new user. Omit any options to use the interactive mode."""
+    try:
+        user = save_user_prompt(username, email, password, group)
+
+        click.secho("[+] User {} with Email {} in Group {} created.".format(
+            user.username, user.email, user.primary_group.name), fg="cyan"
+        )
+    except IntegrityError:
+        raise FlaskBBCLIError("Couldn't create the user because the "
+                              "username or email address is already taken.",
+                              fg="red")
+
+
+@users.command("update")
+@click.option("--username", "-u", help="The username of the user.")
+@click.option("--email", "-e", type=EmailType(),
+              help="The email address of the user.")
+@click.option("--password", "-p", help="The password of the user.")
+@click.option("--group", "-g", help="The group of the user.",
+              type=click.Choice(["admin", "super_mod", "mod", "member"]))
+def change_user(username, password, email, group):
+    """Updates an user. Omit any options to use the interactive mode."""
+
+    user = save_user_prompt(username, password, email, group)
+    if user is None:
+        raise FlaskBBCLIError("The user with username {} does not exist."
+                              .format(username), fg="red")
+
+    click.secho("[+] User {} updated.".format(user.username), fg="cyan")
+
+
+@users.command("delete")
+@click.option("--username", "-u", help="The username of the user.")
+@click.option("--force", "-f", default=False, is_flag=True,
+              help="Removes the user without asking for confirmation.")
+def delete_user(username, force):
+    """Deletes an user."""
+    if not username:
+        username = click.prompt(
+            click.style("Username", fg="magenta"), type=str,
+            default=os.environ.get("USER", "")
+        )
+
+    user = User.query.filter_by(username=username).first()
+    if user is None:
+        raise FlaskBBCLIError("The user with username {} does not exist."
+                              .format(username), fg="red")
+
+    if not force and not \
+            click.confirm(click.style("Are you sure?", fg="magenta")):
+        sys.exit(0)
+
+    user.delete()
+    click.secho("[+] User {} deleted.".format(user.username), fg="cyan")

+ 139 - 0
flaskbb/cli/utils.py

@@ -0,0 +1,139 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.cli.utils
+    ~~~~~~~~~~~~~~~~~
+
+    This module contains some utility helpers that are used across
+    commands.
+
+    :copyright: (c) 2016 by the FlaskBB Team.
+    :license: BSD, see LICENSE for more details.
+"""
+import sys
+import os
+import re
+
+
+import click
+
+from flask import __version__ as flask_version
+from flask_themes2 import get_theme
+
+from flaskbb import __version__
+from flaskbb.extensions import plugin_manager
+from flaskbb.utils.populate import create_user, update_user
+
+
+cookiecutter_available = False
+try:
+    from cookiecutter.main import cookiecutter
+    cookiecutter_available = True
+except ImportError:
+    pass
+
+_email_regex = r"[^@]+@[^@]+\.[^@]+"
+
+
+class FlaskBBCLIError(click.ClickException):
+    """An exception that signals a usage error including color support.
+    This aborts any further handling.
+
+    :param styles: The style kwargs which should be forwarded to click.secho.
+    """
+
+    def __init__(self, message, **styles):
+        click.ClickException.__init__(self, message)
+        self.styles = styles
+
+    def show(self, file=None):
+        if file is None:
+            file = click._compat.get_text_stderr()
+        click.secho("[-] Error: %s" % self.format_message(), file=file,
+                    **self.styles)
+
+
+class EmailType(click.ParamType):
+    """The choice type allows a value to be checked against a fixed set of
+    supported values.  All of these values have to be strings.
+    See :ref:`choice-opts` for an example.
+    """
+    name = "email"
+
+    def convert(self, value, param, ctx):
+        # Exact match
+        if re.match(_email_regex, value):
+            return value
+        else:
+            self.fail(("invalid email: %s" % value), param, ctx)
+
+    def __repr__(self):
+        return "email"
+
+
+def save_user_prompt(username, email, password, group, only_update=False):
+    if not username:
+        username = click.prompt(
+            click.style("Username", fg="magenta"), type=str,
+            default=os.environ.get("USER", "")
+        )
+    if not email:
+        email = click.prompt(
+            click.style("Email address", fg="magenta"), type=EmailType()
+        )
+    if not password:
+        password = click.prompt(
+            click.style("Password", fg="magenta"), hide_input=True,
+            confirmation_prompt=True
+        )
+    if not group:
+        group = click.prompt(
+            click.style("Group", fg="magenta"),
+            type=click.Choice(["admin", "super_mod", "mod", "member"]),
+            default="admin"
+        )
+
+    if only_update:
+        return update_user(username, password, email, group)
+    return create_user(username, password, email, group)
+
+
+def validate_plugin(plugin):
+    """Checks if a plugin is installed.
+    TODO: Figure out how to use this in a callback. Doesn't work because
+          the appcontext can't be found and using with_appcontext doesn't
+          help either.
+    """
+    if plugin not in plugin_manager.all_plugins.keys():
+        raise FlaskBBCLIError("Plugin {} not found.".format(plugin), fg="red")
+    return True
+
+
+def validate_theme(theme):
+    """Checks if a theme is installed."""
+    try:
+        get_theme(theme)
+    except KeyError:
+        raise FlaskBBCLIError("Theme {} not found.".format(theme), fg="red")
+
+
+def check_cookiecutter(ctx, param, value):
+    if not cookiecutter_available:
+        raise FlaskBBCLIError(
+            "Can't create {} because cookiecutter is not installed. "
+            "You can install it with 'pip install cookiecutter'.".
+            format(value), fg="red"
+        )
+    return value
+
+
+def get_version(ctx, param, value):
+    if not value or ctx.resilient_parsing:
+        return
+    message = ("FlaskBB %(version)s using Flask %(flask_version)s on "
+               "Python %(python_version)s")
+    click.echo(message % {
+        'version': __version__,
+        'flask_version': flask_version,
+        'python_version': sys.version.split("\n")[0]
+    }, color=ctx.color)
+    ctx.exit()

+ 2 - 0
flaskbb/configs/default.py

@@ -182,3 +182,5 @@ class DefaultConfig(object):
     MESSAGE_URL_PREFIX = "/message"
     AUTH_URL_PREFIX = "/auth"
     ADMIN_URL_PREFIX = "/admin"
+    # Plugin Folder
+    PLUGINS_FOLDER = os.path.join(_basedir, "flaskbb", "plugins")

+ 8 - 0
flaskbb/user/models.py

@@ -70,6 +70,14 @@ class Group(db.Model, CRUDMixin):
     def get_guest_group(cls):
         return cls.query.filter(cls.guest == True).first()
 
+    @classmethod
+    def get_member_group(cls):
+        """Returns the first member group."""
+        # This feels ugly..
+        return cls.query.filter(cls.admin == False, cls.super_mod == False,
+                                cls.mod == False, cls.guest == False,
+                                cls.banned == False).first()
+
 
 class User(db.Model, UserMixin, CRUDMixin):
     __tablename__ = "users"

+ 5 - 0
flaskbb/utils/database.py

@@ -17,6 +17,11 @@ class CRUDMixin(object):
     def __repr__(self):
         return "<{}>".format(self.__class__.__name__)
 
+    @classmethod
+    def create(cls, **kwargs):
+        instance = cls(**kwargs)
+        return instance.save()
+
     def save(self):
         """Saves the object to the database."""
         db.session.add(self)

+ 35 - 11
flaskbb/utils/populate.py

@@ -157,25 +157,49 @@ def create_default_groups():
     return result
 
 
-def create_admin_user(username, password, email):
-    """Creates the administrator user.
-    Returns the created admin user.
+def create_user(username, password, email, groupname):
+    """Creates a user.
+    Returns the created user.
 
     :param username: The username of the user.
     :param password: The password of the user.
     :param email: The email address of the user.
+    :param groupname: The name of the group to which the user
+                      should belong to.
     """
-    admin_group = Group.query.filter_by(admin=True).first()
-    user = User()
+    if groupname == "member":
+        group = Group.get_member_group()
+    else:
+        group = Group.query.filter(getattr(Group, groupname) == True).first()
+
+    user = User.create(username=username, password=password, email=email,
+                       primary_group_id=group.id, activated=True)
+    return user
+
+
+def update_user(username, password, email, groupname):
+    """Update an existing user.
+    Returns the updated user.
+
+    :param username: The username of the user.
+    :param password: The password of the user.
+    :param email: The email address of the user.
+    :param groupname: The name of the group to which the user
+                      should belong to.
+    """
+    user = User.query.filter_by(username=username).first()
+    if user is None:
+        return None
+
+    if groupname == "member":
+        group = Group.get_member_group()
+    else:
+        group = Group.query.filter(getattr(Group, groupname) == True).first()
 
-    user.username = username
     user.password = password
     user.email = email
-    user.primary_group_id = admin_group.id
-    user.activated = True
-
-    user.save()
-    return user
+    user.primary_group_id = group.id
+    return user.save()
 
 
 def create_welcome_forum():

+ 94 - 0
flaskbb/utils/translations.py

@@ -9,12 +9,16 @@
     :license: BSD, see LICENSE for more details.
 """
 import os
+import subprocess
 
 import babel
+from flask import current_app
 
 from flask_babelplus import Domain, get_locale
 from flask_plugins import get_enabled_plugins
 
+from flaskbb.extensions import plugin_manager
+
 
 class FlaskBBDomain(Domain):
     def __init__(self, app):
@@ -72,3 +76,93 @@ class FlaskBBDomain(Domain):
             cache[str(locale)] = translations
 
         return translations
+
+
+def update_translations(include_plugins=False):
+    """Updates all translations.
+
+    :param include_plugins: If set to `True` it will also update the
+                            translations for all plugins.
+    """
+
+    # update flaskbb translations
+    translations_folder = os.path.join(current_app.root_path, "translations")
+    source_file = os.path.join(translations_folder, "messages.pot")
+
+    subprocess.call(["pybabel", "extract", "-F", "babel.cfg",
+                     "-k", "lazy_gettext", "-o", source_file, "."])
+    subprocess.call(["pybabel", "update", "-i", source_file,
+                     "-d", translations_folder])
+
+    if include_plugins:
+        # updates all plugin translations too
+        for plugin in plugin_manager.all_plugins:
+            update_plugin_translations(plugin)
+
+
+def add_translations(translation):
+    """Adds a new language to the translations."""
+
+    translations_folder = os.path.join(current_app.root_path, "translations")
+    source_file = os.path.join(translations_folder, "messages.pot")
+
+    subprocess.call(["pybabel", "extract", "-F", "babel.cfg",
+                     "-k", "lazy_gettext", "-o", source_file, "."])
+    subprocess.call(["pybabel", "init", "-i", source_file,
+                     "-d", translations_folder, "-l", translation])
+
+
+def compile_translations(include_plugins=False):
+    """Compiles all translations.
+
+    :param include_plugins: If set to `True` it will also compile the
+                            translations for all plugins.
+    """
+
+    # compile flaskbb translations
+    translations_folder = os.path.join(current_app.root_path, "translations")
+    subprocess.call(["pybabel", "compile", "-d", translations_folder])
+
+    if include_plugins:
+        # compile all plugin translations
+        for plugin in plugin_manager.all_plugins:
+            compile_plugin_translations(plugin)
+
+
+def add_plugin_translations(plugin, translation):
+    """Adds a new language to the plugin translations. Expects the name
+    of the plugin and the translations name like "en".
+    """
+
+    plugin_folder = os.path.join(current_app.config["PLUGINS_FOLDER"], plugin)
+    translations_folder = os.path.join(plugin_folder, "translations")
+    source_file = os.path.join(translations_folder, "messages.pot")
+
+    subprocess.call(["pybabel", "extract", "-F", "babel.cfg",
+                     "-k", "lazy_gettext", "-o", source_file,
+                     plugin_folder])
+    subprocess.call(["pybabel", "init", "-i", source_file,
+                     "-d", translations_folder, "-l", translation])
+
+
+def update_plugin_translations(plugin):
+    """Updates the plugin translations. Expects the name of the plugin."""
+
+    plugin_folder = os.path.join(current_app.config["PLUGINS_FOLDER"], plugin)
+    translations_folder = os.path.join(plugin_folder, "translations")
+    source_file = os.path.join(translations_folder, "messages.pot")
+
+    subprocess.call(["pybabel", "extract", "-F", "babel.cfg",
+                     "-k", "lazy_gettext", "-o", source_file,
+                     plugin_folder])
+    subprocess.call(["pybabel", "update", "-i", source_file,
+                     "-d", translations_folder])
+
+
+def compile_plugin_translations(plugin):
+    """Compile the plugin translations. Expects the name of the plugin."""
+
+    plugin_folder = os.path.join(current_app.config["PLUGINS_FOLDER"], plugin)
+    translations_folder = os.path.join(plugin_folder, "translations")
+
+    subprocess.call(["pybabel", "compile", "-d", translations_folder])

+ 0 - 339
manage.py

@@ -1,339 +0,0 @@
-#!/usr/bin/env python
-
-"""
-    flaskbb.manage
-    ~~~~~~~~~~~~~~~~~~~~
-
-    This script provides some easy to use commands for
-    creating the database with or without some sample content.
-    You can also run the development server with it.
-    Just type `python manage.py` to see the full list of commands.
-
-    TODO: When Flask 1.0 is released, get rid of Flask-Script and use click.
-          Then it's also possible to split the commands in "command groups"
-          which would make the commands better seperated from each other
-          and less confusing.
-
-    :copyright: (c) 2014 by the FlaskBB Team.
-    :license: BSD, see LICENSE for more details.
-"""
-from __future__ import print_function
-import sys
-import os
-import subprocess
-import requests
-import time
-
-from flask import current_app
-from werkzeug.utils import import_string
-from sqlalchemy.exc import IntegrityError, OperationalError
-from flask_script import (Manager, Shell, Server, prompt, prompt_pass,
-                          prompt_bool)
-from flask_migrate import MigrateCommand, upgrade
-
-from flaskbb import create_app
-from flaskbb.extensions import db, plugin_manager, whooshee
-from flaskbb.utils.populate import (create_test_data, create_welcome_forum,
-                                    create_admin_user, create_default_groups,
-                                    create_default_settings, insert_bulk_data,
-                                    update_settings_from_fixture)
-
-# Use the development configuration if available
-try:
-    from flaskbb.configs.development import DevelopmentConfig as Config
-except ImportError:
-    try:
-        from flaskbb.configs.production import ProductionConfig as Config
-    except ImportError:
-        from flaskbb.configs.default import DefaultConfig as Config
-
-app = create_app(Config)
-manager = Manager(app)
-
-# Used to get the plugin translations
-PLUGINS_FOLDER = os.path.join(app.root_path, "plugins")
-
-# Run local server
-manager.add_command("runserver", Server("localhost", port=8080))
-
-# Migration commands
-manager.add_command('db', MigrateCommand)
-
-
-# Add interactive project shell
-def make_shell_context():
-    return dict(app=current_app, db=db)
-manager.add_command("shell", Shell(make_context=make_shell_context))
-
-
-@manager.command
-def initdb(default_settings=True):
-    """Creates the database."""
-    upgrade()
-
-    if default_settings:
-        print("Creating default data...")
-        create_default_groups()
-        create_default_settings()
-
-
-@manager.command
-def dropdb():
-    """Deletes the database."""
-    db.drop_all()
-
-
-@manager.command
-def populate(dropdb=False, createdb=False):
-    """Creates the database with some default data.
-    To drop or create the databse use the '-d' or '-c' options.
-    """
-    if dropdb:
-        print("Dropping database...")
-        db.drop_all()
-
-    if createdb:
-        print("Creating database...")
-        upgrade()
-
-    print("Creating test data...")
-    create_test_data()
-
-
-@manager.command
-def reindex():
-    """Reindexes the search index."""
-    print("Reindexing search index...")
-    whooshee.reindex()
-
-
-@manager.option('-u', '--username', dest='username')
-@manager.option('-p', '--password', dest='password')
-@manager.option('-e', '--email', dest='email')
-def create_admin(username=None, password=None, email=None):
-    """Creates the admin user."""
-
-    if not (username and password and email):
-        username = unicode(prompt("Username"))
-        email = unicode(prompt("A valid email address"))
-        password = unicode(prompt_pass("Password"))
-
-    create_admin_user(username=username, password=password, email=email)
-
-
-@manager.option('-u', '--username', dest='username')
-@manager.option('-p', '--password', dest='password')
-@manager.option('-e', '--email', dest='email')
-def install(username=None, password=None, email=None):
-    """Installs FlaskBB with all necessary data."""
-
-    print("Creating default data...")
-    try:
-        create_default_groups()
-        create_default_settings()
-    except IntegrityError:
-        print("Couldn't create the default data because it already exist!")
-        if prompt_bool("Found an existing database."
-                       "Do you want to recreate the database? (y/n)"):
-            db.session.rollback()
-            db.drop_all()
-            upgrade()
-            create_default_groups()
-            create_default_settings()
-        else:
-            sys.exit(0)
-    except OperationalError:
-        print("No database found.")
-        if prompt_bool("Do you want to create the database now? (y/n)"):
-            db.session.rollback()
-            upgrade()
-            create_default_groups()
-            create_default_settings()
-        else:
-            sys.exit(0)
-
-    print("Creating admin user...")
-    if username and password and email:
-        create_admin_user(username=username, password=password, email=email)
-    else:
-        create_admin()
-
-    print("Creating welcome forum...")
-    create_welcome_forum()
-
-    print("Compiling translations...")
-    compile_translations()
-
-    if prompt_bool("Do you want to use Emojis? (y/n)"):
-        print("Downloading emojis. This can take a few minutes.")
-        download_emoji()
-
-    print("Congratulations! FlaskBB has been successfully installed")
-
-
-@manager.option('-t', '--topics', dest="topics", default=100)
-@manager.option('-p', '--posts', dest="posts", default=100)
-def insertbulkdata(topics, posts):
-    """Warning: This can take a long time!.
-    Creates 100 topics and each topic contains 100 posts.
-    """
-    timer = time.time()
-    created_topics, created_posts = insert_bulk_data(int(topics), int(posts))
-    elapsed = time.time() - timer
-
-    print("It took {time} seconds to create {topics} topics and "
-          "{posts} posts"
-          .format(time=elapsed, topics=created_topics, posts=created_posts))
-
-
-@manager.option('-f', '--force', dest="force", default=False)
-@manager.option('-s', '--settings', dest="settings")
-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
-    """
-    if settings is None:
-        settings = "settings"
-
-    try:
-        fixture = import_string(
-            "flaskbb.fixtures.{}".format(settings)
-        )
-        fixture = fixture.fixture
-    except ImportError:
-        raise "{} fixture is not available".format(settings)
-
-    overwrite_group = overwrite_setting = False
-    if force:
-        overwrite_group = overwrite_setting = True
-
-    count = update_settings_from_fixture(
-        fixture=fixture,
-        overwrite_group=overwrite_group,
-        overwrite_setting=overwrite_setting
-    )
-    print("{} groups and {} settings updated.".format(
-        len(count.keys()), len(count.values()))
-    )
-
-
-@manager.command
-def update_translations():
-    """Updates all translations."""
-
-    # update flaskbb translations
-    translations_folder = os.path.join(app.root_path, "translations")
-    source_file = os.path.join(translations_folder, "messages.pot")
-
-    subprocess.call(["pybabel", "extract", "-F", "babel.cfg",
-                     "-k", "lazy_gettext", "-o", source_file, "."])
-    subprocess.call(["pybabel", "update", "-i", source_file,
-                     "-d", translations_folder])
-
-    # updates all plugin translations too
-    for plugin in plugin_manager.all_plugins:
-        update_plugin_translations(plugin)
-
-
-@manager.command
-def add_translations(translation):
-    """Adds a new language to the translations."""
-
-    translations_folder = os.path.join(app.root_path, "translations")
-    source_file = os.path.join(translations_folder, "messages.pot")
-
-    subprocess.call(["pybabel", "extract", "-F", "babel.cfg",
-                     "-k", "lazy_gettext", "-o", source_file, "."])
-    subprocess.call(["pybabel", "init", "-i", source_file,
-                     "-d", translations_folder, "-l", translation])
-
-
-@manager.command
-def compile_translations():
-    """Compiles all translations."""
-
-    # compile flaskbb translations
-    translations_folder = os.path.join(app.root_path, "translations")
-    subprocess.call(["pybabel", "compile", "-d", translations_folder])
-
-    # compile all plugin translations
-    for plugin in plugin_manager.all_plugins:
-        compile_plugin_translations(plugin)
-
-
-# Plugin translation commands
-@manager.command
-def add_plugin_translations(plugin, translation):
-    """Adds a new language to the plugin translations. Expects the name
-    of the plugin and the translations name like "en".
-    """
-
-    plugin_folder = os.path.join(PLUGINS_FOLDER, plugin)
-    translations_folder = os.path.join(plugin_folder, "translations")
-    source_file = os.path.join(translations_folder, "messages.pot")
-
-    subprocess.call(["pybabel", "extract", "-F", "babel.cfg",
-                     "-k", "lazy_gettext", "-o", source_file,
-                     plugin_folder])
-    subprocess.call(["pybabel", "init", "-i", source_file,
-                     "-d", translations_folder, "-l", translation])
-
-
-@manager.command
-def update_plugin_translations(plugin):
-    """Updates the plugin translations. Expects the name of the plugin."""
-
-    plugin_folder = os.path.join(PLUGINS_FOLDER, plugin)
-    translations_folder = os.path.join(plugin_folder, "translations")
-    source_file = os.path.join(translations_folder, "messages.pot")
-
-    subprocess.call(["pybabel", "extract", "-F", "babel.cfg",
-                     "-k", "lazy_gettext", "-o", source_file,
-                     plugin_folder])
-    subprocess.call(["pybabel", "update", "-i", source_file,
-                     "-d", translations_folder])
-
-
-@manager.command
-def compile_plugin_translations(plugin):
-    """Compile the plugin translations. Expects the name of the plugin."""
-
-    plugin_folder = os.path.join(PLUGINS_FOLDER, plugin)
-    translations_folder = os.path.join(plugin_folder, "translations")
-
-    subprocess.call(["pybabel", "compile", "-d", translations_folder])
-
-
-@manager.command
-def download_emoji():
-    """Downloads emojis from emoji-cheat-sheet.com."""
-    HOSTNAME = "https://api.github.com"
-    REPO = "/repos/arvida/emoji-cheat-sheet.com/contents/public/graphics/emojis"
-    FULL_URL = "{}{}".format(HOSTNAME, REPO)
-    DOWNLOAD_PATH = os.path.join(app.static_folder, "emoji")
-
-    response = requests.get(FULL_URL)
-
-    cached_count = 0
-    count = 0
-    for image in response.json():
-        if not os.path.exists(os.path.abspath(DOWNLOAD_PATH)):
-            print("{} does not exist.".format(os.path.abspath(DOWNLOAD_PATH)))
-            sys.exit(1)
-
-        full_path = os.path.join(DOWNLOAD_PATH, image["name"])
-        if not os.path.exists(full_path):
-            count += 1
-            f = open(full_path, 'wb')
-            f.write(requests.get(image["download_url"]).content)
-            f.close()
-            if count == cached_count + 50:
-                cached_count = count
-                print("{} out of {} Emojis downloaded...".format(
-                      cached_count, len(response.json())))
-
-    print("Finished downloading {} Emojis.".format(count))
-
-if __name__ == "__main__":
-    manager.run()

+ 0 - 1
requirements.txt

@@ -17,7 +17,6 @@ Flask-Mail==0.9.1
 Flask-Migrate==2.0.0
 Flask-Plugins==1.6.1
 Flask-Redis==0.3.0
-Flask-Script==2.0.5
 Flask-SQLAlchemy==2.1
 Flask-Themes2==0.1.4
 flask-whooshee==0.2.3

+ 14 - 10
setup.py

@@ -9,21 +9,22 @@ And Easy to Setup
 -----------------
 
 .. code:: bash
-    $ python manage.py createall
+    $ pip install -e .
 
-    $ python manage.py runserver
+    $ flaskbb install
+
+    $ flaskbb runserver
      * Running on http://localhost:8080/
 
 
 Resources
 ---------
 
-* `website <http://flaskbb.org>`_
+* `website <https://flaskbb.org>`_
 * `source <https://github.com/sh4nks/flaskbb>`_
 * `issues <https://github.com/sh4nks/flaskbb/issues>`_
-
 """
-from setuptools import setup
+from setuptools import setup, find_packages
 from setuptools.command.test import test as TestCommand
 import sys
 
@@ -51,11 +52,11 @@ setup(
     version='1.0.dev0',
     url='http://github.com/sh4nks/flaskbb/',
     license='BSD',
-    author='sh4nks',
-    author_email='sh4nks7@gmail.com',
-    description='A forum software written with flask',
+    author='Peter Justin',
+    author_email='peter.justin@outlook.com',
+    description='A classic Forum Software in Python using Flask.',
     long_description=__doc__,
-    packages=['flaskbb'],
+    packages=find_packages(),
     include_package_data=True,
     zip_safe=False,
     platforms='any',
@@ -81,7 +82,6 @@ setup(
         'Flask-Migrate',
         'Flask-Plugins',
         'Flask-Redis',
-        'Flask-Script',
         'Flask-SQLAlchemy',
         'Flask-Themes2',
         'flask-whooshee',
@@ -109,6 +109,10 @@ setup(
         'Whoosh',
         'WTForms'
     ],
+    entry_points='''
+        [console_scripts]
+        flaskbb=flaskbb.cli:flaskbb
+    ''',
     test_suite='tests',
     tests_require=[
         'py',

+ 5 - 5
tests/unit/utils/test_populate.py

@@ -42,12 +42,12 @@ def test_update_settings_from_fixture(database):
     assert len(force_updated) == SettingsGroup.query.count()
 
 
-def test_create_admin_user(default_groups):
+def test_create_user(default_groups):
     user = User.query.filter_by(username="admin").first()
     assert not user
 
-    user = create_admin_user(username="admin", password="test",
-                             email="test@example.org")
+    user = create_user(username="admin", password="test",
+                       email="test@example.org", groupname="admin")
     assert user.username == "admin"
     assert user.permissions["admin"]
 
@@ -55,8 +55,8 @@ def test_create_admin_user(default_groups):
 def test_create_welcome_forum(default_groups):
     assert not create_welcome_forum()
 
-    create_admin_user(username="admin", password="test",
-                      email="test@example.org")
+    create_user(username="admin", password="test",
+                email="test@example.org", groupname="admin")
     assert create_welcome_forum()