Browse Source

Split the commands module into more smaller ones

sh4nks 8 years ago
parent
commit
4633d294b6
9 changed files with 941 additions and 843 deletions
  1. 11 1
      flaskbb/cli/__init__.py
  2. 0 837
      flaskbb/cli/commands.py
  3. 406 0
      flaskbb/cli/main.py
  4. 130 0
      flaskbb/cli/plugins.py
  5. 84 0
      flaskbb/cli/themes.py
  6. 76 0
      flaskbb/cli/translations.py
  7. 89 0
      flaskbb/cli/users.py
  8. 139 0
      flaskbb/cli/utils.py
  9. 6 5
      setup.py

+ 11 - 1
flaskbb/cli/__init__.py

@@ -6,7 +6,17 @@
     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.commands import main
+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

+ 0 - 837
flaskbb/cli/commands.py

@@ -1,837 +0,0 @@
-# -*- coding: utf-8 -*-
-"""
-    flaskbb.cli.commands
-    ~~~~~~~~~~~~~~~~~~~~
-
-    This module contains all commands.
-
-    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.
-"""
-import sys
-import os
-import re
-import time
-import shutil
-import requests
-
-import click
-from werkzeug.utils import import_string, ImportStringError
-from flask import current_app, __version__ as flask_version
-from flask.cli import FlaskGroup, ScriptInfo, with_appcontext
-from sqlalchemy.exc import IntegrityError
-from sqlalchemy_utils.functions import database_exists, drop_database
-from flask_migrate import upgrade as upgrade_database
-from flask_plugins import (get_all_plugins, get_enabled_plugins,
-                           get_plugin_from_all)
-from flask_themes2 import get_themes_list, get_theme
-
-from flaskbb import create_app, __version__
-from flaskbb._compat import iteritems
-from flaskbb.extensions import db, whooshee, plugin_manager, celery
-from flaskbb.user.models import User
-from flaskbb.utils.settings import flaskbb_config
-from flaskbb.utils.populate import (create_test_data, create_welcome_forum,
-                                    create_user, update_user,
-                                    create_default_groups,
-                                    create_default_settings, insert_bulk_data,
-                                    update_settings_from_fixture)
-from flaskbb.utils.translations import (add_translations, compile_translations,
-                                        update_translations,
-                                        add_plugin_translations,
-                                        compile_plugin_translations,
-                                        update_plugin_translations)
-# Not optimal and subject to change
-try:
-    from flaskbb.configs.production import ProductionConfig as Config
-except ImportError:
-    try:
-        from flaskbb.configs.development import DevelopmentConfig as Config
-    except ImportError:
-        from flaskbb.configs.default import DefaultConfig as Config
-
-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()
-
-
-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 main():
-    """This is the commandline interface for flaskbb."""
-    pass
-
-
-@main.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)
-
-
-@main.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()
-
-
-@main.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)
-
-
-@main.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
-            )
-
-
-@main.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.
-    """
-    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)
-
-
-@main.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")
-
-
-@main.command()
-def reindex():
-    """Reindexes the search index."""
-    click.secho("[+] Reindexing search index...", fg="cyan")
-    whooshee.reindex()
-
-
-@main.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")
-        )
-
-
-@main.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")
-
-
-@main.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)
-
-
-@main.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")
-
-
-@main.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)
-
-
-@main.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))

+ 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()

+ 6 - 5
setup.py

@@ -9,19 +9,20 @@ 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, find_packages
 from setuptools.command.test import test as TestCommand
@@ -110,7 +111,7 @@ setup(
     ],
     entry_points='''
         [console_scripts]
-        flaskbb=flaskbb.cli:main
+        flaskbb=flaskbb.cli:flaskbb
     ''',
     test_suite='tests',
     tests_require=[