main.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566
  1. # -*- coding: utf-8 -*-
  2. """
  3. flaskbb.cli.commands
  4. ~~~~~~~~~~~~~~~~~~~~
  5. This module contains the main commands.
  6. :copyright: (c) 2016 by the FlaskBB Team.
  7. :license: BSD, see LICENSE for more details.
  8. """
  9. import binascii
  10. import logging
  11. import os
  12. import sys
  13. import time
  14. import traceback
  15. from datetime import datetime
  16. import click
  17. import click_log
  18. from flask import current_app
  19. from flask.cli import FlaskGroup, ScriptInfo, with_appcontext
  20. from flask_alembic import alembic_click
  21. from jinja2 import Environment, FileSystemLoader
  22. from sqlalchemy_utils.functions import database_exists
  23. from werkzeug.utils import import_string
  24. from flaskbb import create_app
  25. from flaskbb.cli.utils import (EmailType, FlaskBBCLIError, get_version,
  26. prompt_config_path, prompt_save_user,
  27. write_config)
  28. from flaskbb.extensions import alembic, celery, db, whooshee
  29. from flaskbb.utils.populate import (create_default_groups,
  30. create_default_settings, create_latest_db,
  31. create_test_data, create_welcome_forum,
  32. insert_bulk_data, run_plugin_migrations,
  33. update_settings_from_fixture)
  34. from flaskbb.utils.translations import compile_translations
  35. logger = logging.getLogger(__name__)
  36. click_log.basic_config(logger)
  37. class FlaskBBGroup(FlaskGroup):
  38. def __init__(self, *args, **kwargs):
  39. super(FlaskBBGroup, self).__init__(*args, **kwargs)
  40. self._loaded_flaskbb_plugins = False
  41. def _load_flaskbb_plugins(self, ctx):
  42. if self._loaded_flaskbb_plugins:
  43. return
  44. try:
  45. app = ctx.ensure_object(ScriptInfo).load_app()
  46. app.pluggy.hook.flaskbb_cli(cli=self, app=app)
  47. self._loaded_flaskbb_plugins = True
  48. except Exception:
  49. logger.error(
  50. "Error while loading CLI Plugins",
  51. exc_info=traceback.format_exc()
  52. )
  53. else:
  54. shell_context_processors = app.pluggy.hook.flaskbb_shell_context()
  55. for p in shell_context_processors:
  56. app.shell_context_processor(p)
  57. def get_command(self, ctx, name):
  58. self._load_flaskbb_plugins(ctx)
  59. return super(FlaskBBGroup, self).get_command(ctx, name)
  60. def list_commands(self, ctx):
  61. self._load_flaskbb_plugins(ctx)
  62. return super(FlaskBBGroup, self).list_commands(ctx)
  63. def make_app():
  64. ctx = click.get_current_context(silent=True)
  65. script_info = None
  66. if ctx is not None:
  67. script_info = ctx.obj
  68. config_file = getattr(script_info, "config_file", None)
  69. instance_path = getattr(script_info, "instance_path", None)
  70. return create_app(config_file, instance_path)
  71. def set_config(ctx, param, value):
  72. """This will pass the config file to the create_app function."""
  73. ctx.ensure_object(ScriptInfo).config_file = value
  74. def set_instance(ctx, param, value):
  75. """This will pass the instance path on the script info which can then
  76. be used in 'make_app'."""
  77. ctx.ensure_object(ScriptInfo).instance_path = value
  78. @click.group(cls=FlaskBBGroup, create_app=make_app, add_version_option=False,
  79. invoke_without_command=True)
  80. @click.option("--config", expose_value=False, callback=set_config,
  81. required=False, is_flag=False, is_eager=True, metavar="CONFIG",
  82. help="Specify the config to use either in dotted module "
  83. "notation e.g. 'flaskbb.configs.default.DefaultConfig' "
  84. "or by using a path like '/path/to/flaskbb.cfg'")
  85. @click.option("--instance", expose_value=False, callback=set_instance,
  86. required=False, is_flag=False, is_eager=True, metavar="PATH",
  87. help="Specify the instance path to use. By default the folder "
  88. "'instance' next to the package or module is assumed to "
  89. "be the instance path.")
  90. @click.option("--version", expose_value=False, callback=get_version,
  91. is_flag=True, is_eager=True, help="Show the FlaskBB version.")
  92. @click.pass_context
  93. @click_log.simple_verbosity_option(logger)
  94. def flaskbb(ctx):
  95. """This is the commandline interface for flaskbb."""
  96. if ctx.invoked_subcommand is None:
  97. # show the help text instead of an error
  98. # when just '--config' option has been provided
  99. click.echo(ctx.get_help())
  100. flaskbb.add_command(alembic_click, "db")
  101. @flaskbb.command()
  102. @click.option("--welcome", "-w", default=True, is_flag=True,
  103. help="Disable the welcome forum.")
  104. @click.option("--force", "-f", default=False, is_flag=True,
  105. help="Doesn't ask for confirmation.")
  106. @click.option("--username", "-u", help="The username of the user.")
  107. @click.option("--email", "-e", type=EmailType(),
  108. help="The email address of the user.")
  109. @click.option("--password", "-p", help="The password of the user.")
  110. @click.option("--no-plugins", "-n", default=False, is_flag=True,
  111. help="Don't run the migrations for the default plugins.")
  112. @with_appcontext
  113. def install(welcome, force, username, email, password, no_plugins):
  114. """Installs flaskbb. If no arguments are used, an interactive setup
  115. will be run.
  116. """
  117. if not current_app.config["CONFIG_PATH"]:
  118. click.secho(
  119. "[!] No 'flaskbb.cfg' config found. "
  120. "You can generate a configuration file with 'flaskbb makeconfig'.",
  121. fg="red",
  122. )
  123. sys.exit(1)
  124. click.secho("[+] Installing FlaskBB...", fg="cyan")
  125. if database_exists(db.engine.url):
  126. if force or click.confirm(click.style(
  127. "Existing database found. Do you want to delete the old one and "
  128. "create a new one?", fg="magenta")
  129. ):
  130. db.drop_all()
  131. else:
  132. sys.exit(0)
  133. # creating database from scratch and 'stamping it'
  134. create_latest_db()
  135. click.secho("[+] Creating default settings...", fg="cyan")
  136. create_default_groups()
  137. create_default_settings()
  138. click.secho("[+] Creating admin user...", fg="cyan")
  139. prompt_save_user(username, email, password, "admin")
  140. if welcome:
  141. click.secho("[+] Creating welcome forum...", fg="cyan")
  142. create_welcome_forum()
  143. if not no_plugins:
  144. click.secho("[+] Installing default plugins...", fg="cyan")
  145. run_plugin_migrations()
  146. click.secho("[+] Compiling translations...", fg="cyan")
  147. compile_translations()
  148. click.secho("[+] FlaskBB has been successfully installed!",
  149. fg="green", bold=True)
  150. @flaskbb.command()
  151. @click.option("--test-data", "-t", default=False, is_flag=True,
  152. help="Adds some test data.")
  153. @click.option("--bulk-data", "-b", default=False, is_flag=True,
  154. help="Adds a lot of data.")
  155. @click.option("--posts", default=100,
  156. help="Number of posts to create in each topic (default: 100).")
  157. @click.option("--topics", default=100,
  158. help="Number of topics to create (default: 100).")
  159. @click.option("--force", "-f", is_flag=True,
  160. help="Will delete the database before populating it.")
  161. @click.option("--initdb", "-i", is_flag=True,
  162. help="Initializes the database before populating it.")
  163. def populate(bulk_data, test_data, posts, topics, force, initdb):
  164. """Creates the necessary tables and groups for FlaskBB."""
  165. if force:
  166. click.secho("[+] Recreating database...", fg="cyan")
  167. db.drop_all()
  168. # do not initialize the db if -i is passed
  169. if not initdb:
  170. create_latest_db()
  171. if initdb:
  172. click.secho("[+] Initializing database...", fg="cyan")
  173. create_latest_db()
  174. run_plugin_migrations()
  175. if test_data:
  176. click.secho("[+] Adding some test data...", fg="cyan")
  177. create_test_data()
  178. if bulk_data:
  179. click.secho("[+] Adding a lot of test data...", fg="cyan")
  180. timer = time.time()
  181. rv = insert_bulk_data(int(topics), int(posts))
  182. if not rv and not test_data:
  183. create_test_data()
  184. rv = insert_bulk_data(int(topics), int(posts))
  185. elapsed = time.time() - timer
  186. click.secho("[+] It took {:.2f} seconds to create {} topics and {} "
  187. "posts.".format(elapsed, rv[0], rv[1]), fg="cyan")
  188. # this just makes the most sense for the command name; use -i to
  189. # init the db as well
  190. if not test_data and not bulk_data:
  191. click.secho("[+] Populating the database with some defaults...",
  192. fg="cyan")
  193. create_default_groups()
  194. create_default_settings()
  195. @flaskbb.command()
  196. def reindex():
  197. """Reindexes the search index."""
  198. click.secho("[+] Reindexing search index...", fg="cyan")
  199. whooshee.reindex()
  200. @flaskbb.command()
  201. @click.option("all_latest", "--all", "-a", default=False, is_flag=True,
  202. help="Upgrades migrations AND fixtures to the latest version.")
  203. @click.option("--fixture/", "-f", default=None,
  204. help="The fixture which should be upgraded or installed.")
  205. @click.option("--force", default=False, is_flag=True,
  206. help="Forcefully upgrades the fixtures.")
  207. def upgrade(all_latest, fixture, force):
  208. """Updates the migrations and fixtures."""
  209. if all_latest:
  210. click.secho("[+] Upgrading migrations to the latest version...",
  211. fg="cyan")
  212. alembic.upgrade()
  213. if fixture or all_latest:
  214. try:
  215. settings = import_string(
  216. "flaskbb.fixtures.{}".format(fixture)
  217. )
  218. settings = settings.fixture
  219. except ImportError:
  220. raise FlaskBBCLIError("{} fixture is not available"
  221. .format(fixture), fg="red")
  222. click.secho("[+] Updating fixtures...", fg="cyan")
  223. count = update_settings_from_fixture(
  224. fixture=settings, overwrite_group=force, overwrite_setting=force
  225. )
  226. click.secho("[+] {settings} settings in {groups} setting groups "
  227. "updated.".format(groups=len(count), settings=sum(
  228. len(settings) for settings in count.values())
  229. ), fg="green")
  230. @flaskbb.command("celery", add_help_option=False,
  231. context_settings={"ignore_unknown_options": True,
  232. "allow_extra_args": True})
  233. @click.pass_context
  234. @with_appcontext
  235. def start_celery(ctx):
  236. """Preconfigured wrapper around the 'celery' command."""
  237. celery.start(ctx.args)
  238. @flaskbb.command("shell", short_help="Runs a shell in the app context.")
  239. @with_appcontext
  240. def shell_command():
  241. """Runs an interactive Python shell in the context of a given
  242. Flask application. The application will populate the default
  243. namespace of this shell according to it"s configuration.
  244. This is useful for executing small snippets of management code
  245. without having to manually configuring the application.
  246. This code snippet is taken from Flask"s cli module and modified to
  247. run IPython and falls back to the normal shell if IPython is not
  248. available.
  249. """
  250. import code
  251. banner = "Python %s on %s\nInstance Path: %s" % (
  252. sys.version,
  253. sys.platform,
  254. current_app.instance_path,
  255. )
  256. ctx = {"db": db}
  257. # Support the regular Python interpreter startup script if someone
  258. # is using it.
  259. startup = os.environ.get("PYTHONSTARTUP")
  260. if startup and os.path.isfile(startup):
  261. with open(startup, "r") as f:
  262. eval(compile(f.read(), startup, "exec"), ctx)
  263. ctx.update(current_app.make_shell_context())
  264. try:
  265. import IPython
  266. from traitlets.config import get_config
  267. c = get_config()
  268. # This makes the prompt to use colors again
  269. c.InteractiveShellEmbed.colors = "Linux"
  270. IPython.embed(config=c, banner1=banner, user_ns=ctx)
  271. except ImportError:
  272. code.interact(banner=banner, local=ctx)
  273. @flaskbb.command("urls", short_help="Show routes for the app.")
  274. @click.option("--route", "-r", "order_by", flag_value="rule", default=True,
  275. help="Order by route")
  276. @click.option("--endpoint", "-e", "order_by", flag_value="endpoint",
  277. help="Order by endpoint")
  278. @click.option("--methods", "-m", "order_by", flag_value="methods",
  279. help="Order by methods")
  280. @with_appcontext
  281. def list_urls(order_by):
  282. """Lists all available routes."""
  283. from flask import current_app
  284. rules = sorted(
  285. current_app.url_map.iter_rules(),
  286. key=lambda rule: getattr(rule, order_by)
  287. )
  288. max_rule_len = max(len(rule.rule) for rule in rules)
  289. max_rule_len = max(max_rule_len, len("Route"))
  290. max_endpoint_len = max(len(rule.endpoint) for rule in rules)
  291. max_endpoint_len = max(max_endpoint_len, len("Endpoint"))
  292. max_method_len = max(len(", ".join(rule.methods)) for rule in rules)
  293. max_method_len = max(max_method_len, len("Methods"))
  294. column_header_len = max_rule_len + max_endpoint_len + max_method_len + 4
  295. column_template = "{:<%s} {:<%s} {:<%s}" % (
  296. max_rule_len, max_endpoint_len, max_method_len
  297. )
  298. click.secho(column_template.format("Route", "Endpoint", "Methods"),
  299. fg="blue", bold=True)
  300. click.secho("=" * column_header_len, bold=True)
  301. for rule in rules:
  302. methods = ", ".join(rule.methods)
  303. click.echo(column_template.format(rule.rule, rule.endpoint, methods))
  304. @flaskbb.command("makeconfig")
  305. @click.option("--development", "-d", default=False, is_flag=True,
  306. help="Creates a development config with DEBUG set to True.")
  307. @click.option("--output", "-o", required=False,
  308. help="The path where the config file will be saved at. "
  309. "Defaults to the flaskbb's root folder.")
  310. @click.option("--force", "-f", default=False, is_flag=True,
  311. help="Overwrite any existing config file if one exists.")
  312. def generate_config(development, output, force):
  313. """Generates a FlaskBB configuration file."""
  314. config_env = Environment(
  315. loader=FileSystemLoader(os.path.join(current_app.root_path, "configs"))
  316. )
  317. config_template = config_env.get_template('config.cfg.template')
  318. if output:
  319. config_path = os.path.abspath(output)
  320. else:
  321. config_path = os.path.dirname(current_app.root_path)
  322. if os.path.exists(config_path) and not os.path.isfile(config_path):
  323. config_path = os.path.join(config_path, "flaskbb.cfg")
  324. # An override to handle database location paths on Windows environments
  325. database_path = "sqlite:///" + os.path.join(
  326. os.path.dirname(current_app.instance_path), "flaskbb.sqlite")
  327. if os.name == 'nt':
  328. database_path = database_path.replace("\\", r"\\")
  329. default_conf = {
  330. "is_debug": False,
  331. "server_name": "example.org",
  332. "use_https": True,
  333. "database_uri": database_path,
  334. "redis_enabled": False,
  335. "redis_uri": "redis://localhost:6379",
  336. "mail_server": "localhost",
  337. "mail_port": 25,
  338. "mail_use_tls": False,
  339. "mail_use_ssl": False,
  340. "mail_username": "",
  341. "mail_password": "",
  342. "mail_sender_name": "FlaskBB Mailer",
  343. "mail_sender_address": "noreply@yourdomain",
  344. "mail_admin_address": "admin@yourdomain",
  345. "secret_key": binascii.hexlify(os.urandom(24)).decode(),
  346. "csrf_secret_key": binascii.hexlify(os.urandom(24)).decode(),
  347. "timestamp": datetime.utcnow().strftime("%A, %d. %B %Y at %H:%M"),
  348. "log_config_path": "",
  349. "deprecation_level": "default"
  350. }
  351. if not force:
  352. config_path = prompt_config_path(config_path)
  353. if force and os.path.exists(config_path):
  354. click.secho("Overwriting existing config file: {}".format(config_path),
  355. fg="yellow")
  356. if development:
  357. default_conf["is_debug"] = True
  358. default_conf["use_https"] = False
  359. default_conf["server_name"] = "localhost:5000"
  360. write_config(default_conf, config_template, config_path)
  361. sys.exit(0)
  362. # SERVER_NAME
  363. click.secho("The name and port number of the exposed server.\n"
  364. "If FlaskBB is accesible on port 80 you can just omit the "
  365. "port.\n For example, if FlaskBB is accessible via "
  366. "example.org:8080 than this is also what you would set here.",
  367. fg="cyan")
  368. default_conf["server_name"] = click.prompt(
  369. click.style("Server Name", fg="magenta"), type=str,
  370. default=default_conf.get("server_name"))
  371. # HTTPS or HTTP
  372. click.secho("Is HTTPS (recommended) or HTTP used for to serve FlaskBB?",
  373. fg="cyan")
  374. default_conf["use_https"] = click.confirm(
  375. click.style("Use HTTPS?", fg="magenta"),
  376. default=default_conf.get("use_https"))
  377. # SQLALCHEMY_DATABASE_URI
  378. click.secho("For Postgres use:\n"
  379. " postgresql://flaskbb@localhost:5432/flaskbb\n"
  380. "For more options see the SQLAlchemy docs:\n"
  381. " http://docs.sqlalchemy.org/en/latest/core/engines.html",
  382. fg="cyan")
  383. default_conf["database_uri"] = click.prompt(
  384. click.style("Database URI", fg="magenta"),
  385. default=default_conf.get("database_uri"))
  386. # REDIS_ENABLED
  387. click.secho("Redis will be used for things such as the task queue, "
  388. "caching and rate limiting.", fg="cyan")
  389. default_conf["redis_enabled"] = click.confirm(
  390. click.style("Would you like to use redis?", fg="magenta"),
  391. default=True) # default_conf.get("redis_enabled") is False
  392. # REDIS_URI
  393. if default_conf.get("redis_enabled", False):
  394. default_conf["redis_uri"] = click.prompt(
  395. click.style("Redis URI", fg="magenta"),
  396. default=default_conf.get("redis_uri"))
  397. else:
  398. default_conf["redis_uri"] = ""
  399. # MAIL_SERVER
  400. click.secho("To use 'localhost' make sure that you have sendmail or\n"
  401. "something similar installed. Gmail is also supprted.",
  402. fg="cyan")
  403. default_conf["mail_server"] = click.prompt(
  404. click.style("Mail Server", fg="magenta"),
  405. default=default_conf.get("mail_server"))
  406. # MAIL_PORT
  407. click.secho("The port on which the SMTP server is listening on.",
  408. fg="cyan")
  409. default_conf["mail_port"] = click.prompt(
  410. click.style("Mail Server SMTP Port", fg="magenta"),
  411. default=default_conf.get("mail_port"))
  412. # MAIL_USE_TLS
  413. click.secho("If you are using a local SMTP server like sendmail this is "
  414. "not needed. For external servers it is required.",
  415. fg="cyan")
  416. default_conf["mail_use_tls"] = click.confirm(
  417. click.style("Use TLS for sending mails?", fg="magenta"),
  418. default=default_conf.get("mail_use_tls"))
  419. # MAIL_USE_SSL
  420. click.secho("Same as above. TLS is the successor to SSL.", fg="cyan")
  421. default_conf["mail_use_ssl"] = click.confirm(
  422. click.style("Use SSL for sending mails?", fg="magenta"),
  423. default=default_conf.get("mail_use_ssl"))
  424. # MAIL_USERNAME
  425. click.secho("Not needed if you are using a local smtp server.\nFor gmail "
  426. "you have to put in your email address here.", fg="cyan")
  427. default_conf["mail_username"] = click.prompt(
  428. click.style("Mail Username", fg="magenta"),
  429. default=default_conf.get("mail_username"))
  430. # MAIL_PASSWORD
  431. click.secho("Not needed if you are using a local smtp server.\nFor gmail "
  432. "you have to put in your gmail password here.", fg="cyan")
  433. default_conf["mail_password"] = click.prompt(
  434. click.style("Mail Password", fg="magenta"),
  435. default=default_conf.get("mail_password"))
  436. # MAIL_DEFAULT_SENDER
  437. click.secho("The name of the sender. You probably want to change it to "
  438. "something like '<your_community> Mailer'.", fg="cyan")
  439. default_conf["mail_sender_name"] = click.prompt(
  440. click.style("Mail Sender Name", fg="magenta"),
  441. default=default_conf.get("mail_sender_name"))
  442. click.secho("On localhost you want to use a noreply address here. "
  443. "Use your email address for gmail here.", fg="cyan")
  444. default_conf["mail_sender_address"] = click.prompt(
  445. click.style("Mail Sender Address", fg="magenta"),
  446. default=default_conf.get("mail_sender_address"))
  447. # ADMINS
  448. click.secho("Logs and important system messages are sent to this address. "
  449. "Use your email address for gmail here.", fg="cyan")
  450. default_conf["mail_admin_address"] = click.prompt(
  451. click.style("Mail Admin Email", fg="magenta"),
  452. default=default_conf.get("mail_admin_address"))
  453. click.secho("Optional filepath to load a logging configuration file from. "
  454. "See the Python logging documentation for more detail.\n"
  455. "\thttps://docs.python.org/library/logging.config.html#logging-config-fileformat", # noqa
  456. fg="cyan")
  457. default_conf["log_config_path"] = click.prompt(
  458. click.style("Logging Config Path", fg="magenta"),
  459. default=default_conf.get("log_config_path"))
  460. deprecation_mesg = (
  461. "Warning level for deprecations. options are: \n"
  462. "\terror\tturns deprecation warnings into exceptions\n"
  463. "\tignore\tnever warns about deprecations\n"
  464. "\talways\talways warns about deprecations even if the warning has been issued\n" # noqa
  465. "\tdefault\tshows deprecation warning once per usage\n"
  466. "\tmodule\tshows deprecation warning once per module\n"
  467. "\tonce\tonly shows deprecation warning once regardless of location\n"
  468. "If you are unsure, select default\n"
  469. "for more details see: https://docs.python.org/3/library/warnings.html#the-warnings-filter" # noqa
  470. )
  471. click.secho(deprecation_mesg, fg="cyan")
  472. default_conf["deprecation_level"] = click.prompt(
  473. click.style("Deperecation warning level", fg="magenta"),
  474. default=default_conf.get("deprecation_level")
  475. )
  476. write_config(default_conf, config_template, config_path)
  477. # Finished
  478. click.secho("The configuration file has been saved to:\n{cfg}\n"
  479. "Feel free to adjust it as needed."
  480. .format(cfg=config_path), fg="blue", bold=True)
  481. click.secho("Usage: \nflaskbb --config {cfg} run"
  482. .format(cfg=config_path), fg="green")