Another Ham Radio Logbook -- Web, Multi-user multiple-logbook, with eQSL upload support
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

app.py 12KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. # encoding: utf-8
  2. import logging
  3. import subprocess
  4. from logging.handlers import RotatingFileHandler
  5. from flask import Flask, render_template, g, send_from_directory, jsonify, request, safe_join, Response
  6. from flask_bootstrap import Bootstrap
  7. from flask_mail import Mail
  8. from flask_migrate import Migrate
  9. from flask_security import Security
  10. from flask_security import signals as FlaskSecuritySignals
  11. from flask_security import confirmable as FSConfirmable
  12. from flask_security.utils import encrypt_password
  13. from flask_uploads import configure_uploads, UploadSet, IMAGES, patch_request_class
  14. from flask_babelex import gettext, Babel
  15. from forms import ExtendedRegisterForm
  16. from models import user_datastore, Role
  17. from utils import dt_utc_to_user_tz, InvalidUsage, show_date_no_offset, is_admin
  18. from pprint import pprint as pp
  19. import datetime
  20. import os
  21. import texttable
  22. from flask_debugtoolbar import DebugToolbarExtension
  23. from crons import (
  24. update_qsos_without_countries,
  25. update_dxcc_from_cty_xml,
  26. populate_logs_gridsquare_cache,
  27. cron_sync_eqsl,
  28. update_qsos_from_hamqth,
  29. cron_sync_from_eqsl,
  30. )
  31. from dbseed import make_db_seed
  32. from models import db
  33. import click
  34. from version import VERSION
  35. __VERSION__ = VERSION
  36. try:
  37. from raven.contrib.flask import Sentry
  38. import raven
  39. print(" * Sentry support loaded")
  40. HAS_SENTRY = True
  41. except ImportError:
  42. print(" * No sentry support")
  43. HAS_SENTRY = False
  44. mail = Mail()
  45. def create_app(config_filename="config.py", app_name=None, register_blueprints=True):
  46. # App configuration
  47. app = Flask(app_name or __name__)
  48. app.config.from_pyfile(config_filename)
  49. Bootstrap(app)
  50. app.jinja_env.add_extension("jinja2.ext.with_")
  51. app.jinja_env.add_extension("jinja2.ext.do")
  52. app.jinja_env.globals.update(is_admin=is_admin)
  53. app.jinja_env.filters["localize"] = dt_utc_to_user_tz
  54. app.jinja_env.filters["show_date_no_offset"] = show_date_no_offset
  55. if HAS_SENTRY:
  56. app.config["SENTRY_RELEASE"] = raven.fetch_git_sha(os.path.dirname(__file__))
  57. sentry = Sentry(app, dsn=app.config["SENTRY_DSN"]) # noqa: F841
  58. print(" * Sentry support activated")
  59. print(" * Sentry DSN: %s" % app.config["SENTRY_DSN"])
  60. if app.config["DEBUG"] is True:
  61. app.jinja_env.auto_reload = True
  62. app.logger.setLevel(logging.DEBUG)
  63. # Logging
  64. if not app.debug:
  65. formatter = logging.Formatter("%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]")
  66. file_handler = RotatingFileHandler("%s/errors_app.log" % os.getcwd(), "a", 1000000, 1)
  67. file_handler.setLevel(logging.DEBUG)
  68. file_handler.setFormatter(formatter)
  69. app.logger.addHandler(file_handler)
  70. mail.init_app(app)
  71. migrate = Migrate(app, db) # noqa: F841
  72. babel = Babel(app) # noqa: F841
  73. toolbar = DebugToolbarExtension(app) # noqa: F841
  74. db.init_app(app)
  75. # Setup Flask-Security
  76. security = Security( # noqa: F841
  77. app, user_datastore, register_form=ExtendedRegisterForm, confirm_register_form=ExtendedRegisterForm
  78. )
  79. @FlaskSecuritySignals.password_reset.connect_via(app)
  80. @FlaskSecuritySignals.password_changed.connect_via(app)
  81. def log_password_reset(sender, user):
  82. if not user:
  83. return
  84. # add_user_log(user.id, user.id, "user", "info", "Your password has been changed !")
  85. @FlaskSecuritySignals.reset_password_instructions_sent.connect_via(app)
  86. def log_reset_password_instr(sender, user, token):
  87. if not user:
  88. return
  89. # add_user_log(user.id, user.id, "user", "info", "Password reset instructions sent.")
  90. git_version = ""
  91. gitpath = os.path.join(os.getcwd(), ".git")
  92. if os.path.isdir(gitpath):
  93. git_version = subprocess.check_output(["git", "rev-parse", "--short", "HEAD"])
  94. if git_version:
  95. git_version = git_version.strip().decode("UTF-8")
  96. @babel.localeselector
  97. def get_locale():
  98. # if a user is logged in, use the locale from the user settings
  99. # FIXME: not implemented yet
  100. # identity = getattr(g, "identity", None)
  101. # if identity is not None and identity.id:
  102. # return identity.user.locale
  103. # otherwise try to guess the language from the user accept
  104. # header the browser transmits. We support fr/en in this
  105. # example. The best match wins.
  106. return request.accept_languages.best_match(["fr", "en"])
  107. @babel.timezoneselector
  108. def get_timezone():
  109. # identity = getattr(g, "identity", None)
  110. # if identity is not None and identity.id:
  111. # return identity.user.timezone
  112. # FIXME: not implemented yet
  113. pass
  114. @app.before_request
  115. def before_request():
  116. cfg = {
  117. "AHRL_VERSION_VER": VERSION,
  118. "AHRL_VERSION_GIT": git_version,
  119. "AHRL_VERSION": "{0} ({1})".format(VERSION, git_version),
  120. }
  121. g.cfg = cfg
  122. @app.errorhandler(InvalidUsage)
  123. def handle_invalid_usage(error):
  124. response = jsonify(error.to_dict())
  125. response.status_code = error.status_code
  126. return response
  127. pictures = UploadSet("pictures", IMAGES)
  128. configure_uploads(app, pictures)
  129. patch_request_class(app, 5 * 1024 * 1024) # 5m limit
  130. if register_blueprints:
  131. from controllers.admin import bp_admin
  132. app.register_blueprint(bp_admin)
  133. from controllers.contacts import bp_contacts
  134. app.register_blueprint(bp_contacts)
  135. from controllers.logbooks import bp_logbooks
  136. app.register_blueprint(bp_logbooks)
  137. from controllers.main import bp_main
  138. app.register_blueprint(bp_main)
  139. from controllers.notes import bp_notes
  140. app.register_blueprint(bp_notes)
  141. from controllers.qsos import bp_qsos
  142. app.register_blueprint(bp_qsos)
  143. from controllers.tools import bp_tools
  144. app.register_blueprint(bp_tools)
  145. from controllers.users import bp_users
  146. app.register_blueprint(bp_users)
  147. from controllers.extapi import bp_extapi
  148. app.register_blueprint(bp_extapi)
  149. @app.route("/uploads/<string:thing>/<path:stuff>", methods=["GET"])
  150. def get_uploads_stuff(thing, stuff):
  151. if app.debug:
  152. directory = safe_join(app.config["UPLOADS_DEFAULT_DEST"], thing)
  153. app.logger.debug(f"serving {stuff} from {directory}")
  154. return send_from_directory(directory, stuff, as_attachment=True)
  155. else:
  156. app.logger.debug(f"X-Accel-Redirect serving {stuff}")
  157. resp = Response("")
  158. resp.headers["Content-Disposition"] = f"attachment; filename={stuff}"
  159. resp.headers["X-Accel-Redirect"] = f"/_protected/media/tracks/{thing}/{stuff}"
  160. return resp
  161. @app.errorhandler(404)
  162. def page_not_found(msg):
  163. pcfg = {
  164. "title": gettext("Whoops, something failed."),
  165. "error": 404,
  166. "message": gettext("Page not found"),
  167. "e": msg,
  168. }
  169. return render_template("error_page.jinja2", pcfg=pcfg), 404
  170. @app.errorhandler(403)
  171. def err_forbidden(msg):
  172. pcfg = {
  173. "title": gettext("Whoops, something failed."),
  174. "error": 403,
  175. "message": gettext("Access forbidden"),
  176. "e": msg,
  177. }
  178. return render_template("error_page.jinja2", pcfg=pcfg), 403
  179. @app.errorhandler(410)
  180. def err_gone(msg):
  181. pcfg = {"title": gettext("Whoops, something failed."), "error": 410, "message": gettext("Gone"), "e": msg}
  182. return render_template("error_page.jinja2", pcfg=pcfg), 410
  183. if not app.debug:
  184. @app.errorhandler(500)
  185. def err_failed(msg):
  186. pcfg = {
  187. "title": gettext("Whoops, something failed."),
  188. "error": 500,
  189. "message": gettext("Something is broken"),
  190. "e": msg,
  191. }
  192. return render_template("error_page.jinja2", pcfg=pcfg), 500
  193. @app.after_request
  194. def set_x_powered_by(response):
  195. response.headers["X-Powered-By"] = "ahrl"
  196. return response
  197. # Commands from Flask CLI
  198. @app.cli.command()
  199. def routes():
  200. """Dump all routes of defined app"""
  201. table = texttable.Texttable()
  202. table.set_deco(texttable.Texttable().HEADER)
  203. table.set_cols_dtype(["t", "t", "t"])
  204. table.set_cols_align(["l", "l", "l"])
  205. table.set_cols_width([50, 30, 80])
  206. table.add_rows([["Prefix", "Verb", "URI Pattern"]])
  207. for rule in sorted(app.url_map.iter_rules(), key=lambda x: str(x)):
  208. methods = ",".join(rule.methods)
  209. table.add_row([rule.endpoint, methods, rule])
  210. print(table.draw())
  211. @app.cli.command()
  212. def config():
  213. """Dump config"""
  214. pp(app.config)
  215. @app.cli.command()
  216. def seed():
  217. """Seed database with default content"""
  218. make_db_seed(db)
  219. @app.cli.command()
  220. def createuser():
  221. """Create an user"""
  222. username = click.prompt("Username", type=str)
  223. email = click.prompt("Email", type=str)
  224. password = click.prompt("Password", type=str, hide_input=True, confirmation_prompt=True)
  225. while True:
  226. role = click.prompt("Role [admin/user]", type=str)
  227. if role == "admin" or role == "user":
  228. break
  229. if click.confirm("Do you want to continue ?"):
  230. role = Role.query.filter(Role.name == role).first()
  231. if not role:
  232. raise click.UsageError("Roles not present in database")
  233. u = user_datastore.create_user(
  234. name=username, email=email, password=encrypt_password(password), roles=[role]
  235. )
  236. db.session.commit()
  237. if FSConfirmable.requires_confirmation(u):
  238. FSConfirmable.send_confirmation_instructions(u)
  239. print("Look at your emails for validation instructions.")
  240. @app.cli.group()
  241. def cron():
  242. """Commands to be run regullary"""
  243. pass
  244. @cron.command()
  245. @click.option(
  246. "--file", default=None, help="Local file to import instead of downloading", type=click.Path(exists=True)
  247. )
  248. def update_dxcc_from_cty(file):
  249. """Update DXCC tables from cty.xml"""
  250. print("-- STARTED on {0}".format(datetime.datetime.now()))
  251. update_dxcc_from_cty_xml(file)
  252. print("-- FINISHED on {0}".format(datetime.datetime.now()))
  253. @cron.command()
  254. def update_qsos_countries():
  255. """Update QSOs with empty country"""
  256. print("-- STARTED on {0}".format(datetime.datetime.now()))
  257. update_qsos_without_countries()
  258. print("-- FINISHED on {0}".format(datetime.datetime.now()))
  259. @cron.command()
  260. @click.option("--dryrun", default=False, help="Dry run, doesn't commit anything")
  261. def sync_to_eqsl(dryrun=False):
  262. """Push to eQSL logs with requested eQSL sync"""
  263. print("-- STARTED on {0}".format(datetime.datetime.now()))
  264. cron_sync_eqsl(dryrun)
  265. print("-- FINISHED on {0}".format(datetime.datetime.now()))
  266. @cron.command()
  267. @click.option("--dryrun", default=False, help="Dry run, doesn't commit anything")
  268. def sync_from_eqsl(dryrun=False):
  269. """Fetch from eQSL logs """
  270. print("-- STARTED on {0}".format(datetime.datetime.now()))
  271. cron_sync_from_eqsl(dryrun)
  272. print("-- FINISHED on {0}".format(datetime.datetime.now()))
  273. @cron.command()
  274. def update_qsos_hamqth():
  275. """Update QSOs with datas from HamQTH"""
  276. print("-- STARTED on {0}".format(datetime.datetime.now()))
  277. update_qsos_from_hamqth()
  278. print("-- FINISHED on {0}".format(datetime.datetime.now()))
  279. @cron.command()
  280. def populate_logs_gridsquare():
  281. """Update QSOs with empty gridsquare cache"""
  282. print("-- STARTED on {0}".format(datetime.datetime.now()))
  283. populate_logs_gridsquare_cache()
  284. print("-- FINISHED on {0}".format(datetime.datetime.now()))
  285. return app