cli.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459
  1. from io import BytesIO
  2. import os
  3. import shutil
  4. from typing import Optional, Union
  5. from urllib.request import urlopen
  6. from zipfile import ZipFile
  7. import click
  8. from flask import current_app
  9. from flask.cli import with_appcontext
  10. import jinja2
  11. from .const import AUTH_DB, AUTH_LDAP, AUTH_OAUTH, AUTH_OID, AUTH_REMOTE_USER
  12. SQLA_REPO_URL = (
  13. "https://github.com/dpgaspar/Flask-AppBuilder-Skeleton/archive/master.zip"
  14. )
  15. MONGOENGIE_REPO_URL = (
  16. "https://github.com/dpgaspar/Flask-AppBuilder-Skeleton-me/archive/master.zip"
  17. )
  18. ADDON_REPO_URL = (
  19. "https://github.com/dpgaspar/Flask-AppBuilder-Skeleton-AddOn/archive/master.zip"
  20. )
  21. MIN_SECRET_KEY_SIZE = 20
  22. def validate_secret_key(ctx, param, value):
  23. if len(value) < MIN_SECRET_KEY_SIZE:
  24. raise click.BadParameter(f"SECRET_KEY size is less then {MIN_SECRET_KEY_SIZE}")
  25. return value
  26. def echo_header(title):
  27. click.echo(click.style(title, fg="green"))
  28. click.echo(click.style("-" * len(title), fg="green"))
  29. def cast_int_like_to_int(cli_arg: Union[None, str, int]) -> Union[None, str, int]:
  30. """Cast int-like objects to int if possible
  31. If the arg cannot be cast to an integer, return the unmodified object instead."""
  32. try:
  33. cli_arg_int = int(cli_arg)
  34. return cli_arg_int
  35. except TypeError:
  36. # Don't cast if None
  37. return cli_arg
  38. except ValueError:
  39. # Don't cast non-int-like strings
  40. return cli_arg
  41. @click.group()
  42. def fab():
  43. """FAB flask group commands"""
  44. pass
  45. @fab.command("create-admin")
  46. @click.option("--username", default="admin", prompt="Username")
  47. @click.option("--firstname", default="admin", prompt="User first name")
  48. @click.option("--lastname", default="user", prompt="User last name")
  49. @click.option("--email", default="admin@fab.org", prompt="Email")
  50. @click.password_option()
  51. @with_appcontext
  52. def create_admin(username, firstname, lastname, email, password):
  53. """
  54. Creates an admin user
  55. """
  56. auth_type = {
  57. AUTH_DB: "Database Authentications",
  58. AUTH_OID: "OpenID Authentication",
  59. AUTH_LDAP: "LDAP Authentication",
  60. AUTH_REMOTE_USER: "WebServer REMOTE_USER Authentication",
  61. AUTH_OAUTH: "OAuth Authentication",
  62. }
  63. click.echo(
  64. click.style(
  65. "Recognized {0}.".format(
  66. auth_type.get(current_app.appbuilder.sm.auth_type, "No Auth method")
  67. ),
  68. fg="green",
  69. )
  70. )
  71. user = current_app.appbuilder.sm.find_user(username=username)
  72. if user:
  73. click.echo(click.style(f"Error! User already exists {username}", fg="red"))
  74. return
  75. user = current_app.appbuilder.sm.find_user(email=email)
  76. if user:
  77. click.echo(click.style(f"Error! User already exists {username}", fg="red"))
  78. return
  79. role_admin = current_app.appbuilder.sm.find_role(
  80. current_app.appbuilder.sm.auth_role_admin
  81. )
  82. user = current_app.appbuilder.sm.add_user(
  83. username, firstname, lastname, email, role_admin, password
  84. )
  85. if user:
  86. click.echo(click.style("Admin User {0} created.".format(username), fg="green"))
  87. else:
  88. click.echo(click.style("No user created an error occured", fg="red"))
  89. @fab.command("create-user")
  90. @click.option("--role", default="Public", prompt="Role")
  91. @click.option("--username", prompt="Username")
  92. @click.option("--firstname", prompt="User first name")
  93. @click.option("--lastname", prompt="User last name")
  94. @click.option("--email", prompt="Email")
  95. @click.password_option()
  96. @with_appcontext
  97. def create_user(role, username, firstname, lastname, email, password):
  98. """
  99. Create a user
  100. """
  101. user = current_app.appbuilder.sm.find_user(username=username)
  102. if user:
  103. click.echo(click.style(f"Error! User already exists {username}", fg="red"))
  104. return
  105. user = current_app.appbuilder.sm.find_user(email=email)
  106. if user:
  107. click.echo(click.style(f"Error! User already exists {username}", fg="red"))
  108. return
  109. role_object = current_app.appbuilder.sm.find_role(role)
  110. if not role_object:
  111. click.echo(click.style(f"Error! Role not found {role}", fg="red"))
  112. return
  113. user = current_app.appbuilder.sm.add_user(
  114. username, firstname, lastname, email, role_object, password
  115. )
  116. if user:
  117. click.echo(click.style("User {0} created.".format(username), fg="green"))
  118. else:
  119. click.echo(click.style("Error! No user created", fg="red"))
  120. @fab.command("reset-password")
  121. @click.option(
  122. "--username",
  123. default="admin",
  124. prompt="The username",
  125. help="Resets the password for a particular user.",
  126. )
  127. @click.password_option()
  128. @with_appcontext
  129. def reset_password(username, password):
  130. """
  131. Resets a user's password
  132. """
  133. user = current_app.appbuilder.sm.find_user(username=username)
  134. if not user:
  135. click.echo("User {0} not found.".format(username))
  136. else:
  137. current_app.appbuilder.sm.reset_password(user.id, password)
  138. click.echo(click.style("User {0} reseted.".format(username), fg="green"))
  139. @fab.command("create-db")
  140. @with_appcontext
  141. def create_db():
  142. """
  143. Create all your database objects (SQLAlchemy specific).
  144. """
  145. from flask_appbuilder.models.sqla import Model
  146. engine = current_app.appbuilder.get_session.get_bind(mapper=None, clause=None)
  147. Model.metadata.create_all(engine)
  148. click.echo(click.style("DB objects created", fg="green"))
  149. @fab.command("export-roles")
  150. @with_appcontext
  151. @click.option("--path", "-path", help="Specify filepath to export roles to")
  152. @click.option("--indent", help="Specify indent of generated JSON file")
  153. def export_roles(
  154. path: Optional[str] = None, indent: Optional[Union[int, str]] = None
  155. ) -> None:
  156. """Exports roles with permissions and view menus to JSON file"""
  157. # Cast negative numbers to int (as they are passed as str from CLI)
  158. cast_indent = cast_int_like_to_int(indent)
  159. current_app.appbuilder.sm.export_roles(path=path, indent=cast_indent)
  160. @fab.command("import-roles")
  161. @with_appcontext
  162. @click.option(
  163. "--path", "-p", help="Path to a JSON file containing roles", required=True
  164. )
  165. def import_roles(path: str) -> None:
  166. """Imports roles with permissions and view menus from JSON file"""
  167. current_app.appbuilder.sm.import_roles(path)
  168. @fab.command("version")
  169. @with_appcontext
  170. def version():
  171. """
  172. Flask-AppBuilder package version
  173. """
  174. click.echo(
  175. click.style(
  176. "F.A.B Version: {0}.".format(current_app.appbuilder.version),
  177. bg="blue",
  178. fg="white",
  179. )
  180. )
  181. @fab.command("security-cleanup")
  182. @with_appcontext
  183. def security_cleanup():
  184. """
  185. Cleanup unused permissions from views and roles.
  186. """
  187. current_app.appbuilder.security_cleanup()
  188. click.echo(click.style("Finished security cleanup", fg="green"))
  189. @fab.command("security-converge")
  190. @click.option(
  191. "--dry-run", "-d", is_flag=True, help="Dry run & print state transitions."
  192. )
  193. @with_appcontext
  194. def security_converge(dry_run=False):
  195. """
  196. Converges security deletes previous_class_permission_name
  197. """
  198. state_transitions = current_app.appbuilder.security_converge(dry=dry_run)
  199. if dry_run:
  200. click.echo(click.style("Computed security converge:", fg="green"))
  201. click.echo(click.style("Add to Roles:", fg="green"))
  202. for _from, _to in state_transitions["add"].items():
  203. click.echo(f"Where {_from} add {_to}")
  204. click.echo(click.style("Del from Roles:", fg="green"))
  205. for pvm in state_transitions["del_role_pvm"]:
  206. click.echo(pvm)
  207. click.echo(click.style("Remove views:", fg="green"))
  208. for views in state_transitions["del_views"]:
  209. click.echo(views)
  210. click.echo(click.style("Remove permissions:", fg="green"))
  211. for perms in state_transitions["del_perms"]:
  212. click.echo(perms)
  213. else:
  214. click.echo(click.style("Finished security converge", fg="green"))
  215. @fab.command("create-permissions")
  216. @with_appcontext
  217. def create_permissions():
  218. """
  219. Creates all permissions and add them to the ADMIN Role.
  220. """
  221. current_app.appbuilder.add_permissions(update_perms=True)
  222. click.echo(click.style("Created all permissions", fg="green"))
  223. @fab.command("list-views")
  224. @with_appcontext
  225. def list_views():
  226. """
  227. List all registered views
  228. """
  229. echo_header("List of registered views")
  230. for view in current_app.appbuilder.baseviews:
  231. click.echo(
  232. "View:{0} | Route:{1} | Perms:{2}".format(
  233. view.__class__.__name__, view.route_base, view.base_permissions
  234. )
  235. )
  236. @fab.command("list-users")
  237. @with_appcontext
  238. def list_users():
  239. """
  240. List all users on the database
  241. """
  242. echo_header("List of users")
  243. for user in current_app.appbuilder.sm.get_all_users():
  244. click.echo(
  245. "username:{0} | email:{1} | role:{2}".format(
  246. user.username, user.email, user.roles
  247. )
  248. )
  249. @fab.command("create-app")
  250. @click.option(
  251. "--name",
  252. prompt="Your new app name",
  253. help="Your application name, directory will have this name",
  254. )
  255. @click.option(
  256. "--engine",
  257. prompt="Your engine type, SQLAlchemy or MongoEngine",
  258. type=click.Choice(["SQLAlchemy", "MongoEngine"]),
  259. default="SQLAlchemy",
  260. help="Write your engine type",
  261. )
  262. @click.option(
  263. "--secret-key",
  264. prompt="Your app SECRET_KEY. It should be a long random string. Minimal size is 20",
  265. callback=validate_secret_key,
  266. help="This secret key is used by Flask for"
  267. "securely signing the session cookie and can be used for any other security"
  268. "related needs by extensions or your application."
  269. "It should be a long random bytes or str",
  270. )
  271. def create_app(name: str, engine: str, secret_key: str) -> None:
  272. """
  273. Create a Skeleton application (needs internet connection to github)
  274. """
  275. try:
  276. if engine.lower() == "sqlalchemy":
  277. url = urlopen(SQLA_REPO_URL)
  278. dirname = "Flask-AppBuilder-Skeleton-master"
  279. elif engine.lower() == "mongoengine":
  280. url = urlopen(MONGOENGIE_REPO_URL)
  281. dirname = "Flask-AppBuilder-Skeleton-me-master"
  282. zipfile = ZipFile(BytesIO(url.read()))
  283. zipfile.extractall()
  284. os.rename(dirname, name)
  285. template_filename = os.path.join(os.path.abspath(name), "config.py.tpl")
  286. config_filename = os.path.join(os.path.abspath(name), "config.py")
  287. template = jinja2.Template(open(template_filename).read())
  288. rendered_template = template.render({"secret_key": secret_key})
  289. with open(config_filename, "w") as fd:
  290. fd.write(rendered_template)
  291. click.echo(click.style("Downloaded the skeleton app, good coding!", fg="green"))
  292. return True
  293. except Exception as e:
  294. click.echo(click.style("Something went wrong {0}".format(e), fg="red"))
  295. if engine.lower() == "sqlalchemy":
  296. click.echo(
  297. click.style(
  298. "Try downloading from {0}".format(SQLA_REPO_URL), fg="green"
  299. )
  300. )
  301. elif engine.lower() == "mongoengine":
  302. click.echo(
  303. click.style(
  304. "Try downloading from {0}".format(MONGOENGIE_REPO_URL), fg="green"
  305. )
  306. )
  307. return False
  308. @fab.command("create-addon")
  309. @click.option(
  310. "--name",
  311. prompt="Your new addon name",
  312. help="Your addon name will be prefixed by fab_addon_, directory will have this name",
  313. )
  314. def create_addon(name):
  315. """
  316. Create a Skeleton AddOn (needs internet connection to github)
  317. """
  318. try:
  319. full_name = "fab_addon_" + name
  320. dirname = "Flask-AppBuilder-Skeleton-AddOn-master"
  321. url = urlopen(ADDON_REPO_URL)
  322. zipfile = ZipFile(BytesIO(url.read()))
  323. zipfile.extractall()
  324. os.rename(dirname, full_name)
  325. addon_path = os.path.join(full_name, full_name)
  326. os.rename(os.path.join(full_name, "fab_addon"), addon_path)
  327. f = open(os.path.join(full_name, "config.py"), "w")
  328. f.write("ADDON_NAME='" + name + "'\n")
  329. f.write("FULL_ADDON_NAME='fab_addon_' + ADDON_NAME\n")
  330. f.close()
  331. click.echo(
  332. click.style("Downloaded the skeleton addon, good coding!", fg="green")
  333. )
  334. return True
  335. except Exception as e:
  336. click.echo(click.style("Something went wrong {0}".format(e), fg="red"))
  337. return False
  338. @fab.command("collect-static")
  339. @click.option(
  340. "--static_folder", default="app/static", help="Your projects static folder"
  341. )
  342. def collect_static(static_folder):
  343. """
  344. Copies flask-appbuilder static files to your projects static folder
  345. """
  346. appbuilder_static_path = os.path.join(
  347. os.path.dirname(os.path.abspath(__file__)), "static/appbuilder"
  348. )
  349. app_static_path = os.path.join(os.getcwd(), static_folder)
  350. if not os.path.isdir(app_static_path):
  351. click.echo(
  352. click.style(
  353. "Static folder does not exist creating: %s" % app_static_path,
  354. fg="green",
  355. )
  356. )
  357. os.makedirs(app_static_path)
  358. try:
  359. shutil.copytree(
  360. appbuilder_static_path, os.path.join(app_static_path, "appbuilder")
  361. )
  362. except Exception:
  363. click.echo(
  364. click.style(
  365. "Appbuilder static folder already exists on your project", fg="red"
  366. )
  367. )
  368. @fab.command("babel-extract")
  369. @click.option("--config", default="./babel/babel.cfg")
  370. @click.option("--input", default=".")
  371. @click.option("--output", default="./babel/messages.pot")
  372. @click.option("--target", default="app/translations")
  373. @click.option(
  374. "--keywords", "-k", multiple=True, default=["lazy_gettext", "gettext", "_", "__"]
  375. )
  376. def babel_extract(config, input, output, target, keywords):
  377. """
  378. Babel, Extracts and updates all messages marked for translation
  379. """
  380. click.echo(
  381. click.style(
  382. "Starting Extractions config:{0} input:{1} output:{2} keywords:{3}".format(
  383. config, input, output, keywords
  384. ),
  385. fg="green",
  386. )
  387. )
  388. keywords = " -k ".join(keywords)
  389. os.popen(
  390. "pybabel extract -F {0} -k {1} -o {2} {3}".format(
  391. config, keywords, output, input
  392. )
  393. )
  394. click.echo(click.style("Starting Update target:{0}".format(target), fg="green"))
  395. os.popen("pybabel update -N -i {0} -d {1}".format(output, target))
  396. click.echo(click.style("Finish, you can start your translations", fg="green"))
  397. @fab.command("babel-compile")
  398. @click.option(
  399. "--target",
  400. default="app/translations",
  401. help="The target directory where translations reside",
  402. )
  403. def babel_compile(target):
  404. """
  405. Babel, Compiles all translations
  406. """
  407. click.echo(click.style("Starting Compile target:{0}".format(target), fg="green"))
  408. os.popen("pybabel compile -f -d {0}".format(target))