diff --git a/flagging_site/admin.py b/flagging_site/admin.py index ad2768da..3b5c7240 100644 --- a/flagging_site/admin.py +++ b/flagging_site/admin.py @@ -1,13 +1,13 @@ +import re import io import pandas as pd -import datetime from flask import Flask -from flask import redirect from flask import request from flask import Response from flask import send_file from flask import abort +from flask import url_for from flask_admin import Admin from flask_admin import BaseView from flask_admin import expose @@ -19,22 +19,12 @@ from .data import db + admin = Admin(template_mode='bootstrap3') basic_auth = BasicAuth() -# Taken from https://computableverse.com/blog/flask-admin-using-basicauth -class AuthException(HTTPException): - def __init__(self, message): - """HTTP Forbidden error that prompts for login""" - super().__init__(message, Response( - 'You could not be authenticated. Please refresh the page.', - status=401, - headers={'WWW-Authenticate': 'Basic realm="Login Required"'} - )) - - def init_admin(app: Flask): """Registers the Flask-Admin extensions to the app, and attaches the model views to the admin panel. @@ -48,65 +38,49 @@ def init_admin(app: Flask): @app.before_request def auth(): """Authorize all paths that start with /admin/.""" - if request.path.startswith('/admin/'): - validate_credentials() + if re.match('^/admin(?:$|/+)', request.path): + _validate_credentials() with app.app_context(): # Register /admin sub-views from .data.manual_overrides import ManualOverridesModelView admin.add_view(ManualOverridesModelView(db.session)) + from .data.database import Boathouses + + class BoathousesView(AdminModelView): + def __init__(self, session, **kwargs): + super().__init__(Boathouses, session, **kwargs) + + admin.add_view(BoathousesView(db.session)) + # Database functions admin.add_view(DatabaseView( - name='Update Database', url='db/update', category='Database' + name='Update Database', url='db/update', category='Manage DB' )) admin.add_view(DownloadView( - name='Download', url='db/download', category='Database' + name='Download', url='db/download', category='Manage DB' )) admin.add_view(LogoutView(name='Logout')) -def validate_credentials() -> bool: - """ - Protect admin pages with basic_auth. - If logged out and current page is /admin/, then ask for credentials. - Otherwise, raises HTTP 401 error and redirects user to /admin/ on the - frontend (redirecting with HTTP redirect causes user to always be - redirected to /admin/ even after logging in). - - We redirect to /admin/ because our logout method only works if the path to - /logout is the same as the path to where we put in our credentials. So if - we put in credentials at /admin/cyanooverride, then we would need to logout - at /admin/cyanooverride/logout, which would be difficult to arrange. Instead, - we always redirect to /admin/ to put in credentials, and then logout at - /admin/logout. +def _validate_credentials(): + """Check if properly authenticated. If not, then return a 401 error. (The + 401 error page will in turn prompt the user for a username and password.) """ if not basic_auth.authenticate(): - if request.path.startswith('/admin/'): - raise AuthException('Not authenticated. Refresh the page.') - else: - raise HTTPException( - 'Attempted to visit admin page but not authenticated.', - Response( - ''' - Not authenticated. Navigate to /admin/ to login. - - ''', - status=401 # 'Forbidden' status - ) - ) - return True + abort(401) class AdminBaseView(BaseView): def is_accessible(self): - return validate_credentials() + return basic_auth.authenticate() def inaccessible_callback(self, name, **kwargs): - """Ask for credentials when access fails""" - return redirect(basic_auth.challenge()) + """Ask for credentials when access fails.""" + return _validate_credentials() # Adapted from https://computableverse.com/blog/flask-admin-using-basicauth @@ -115,6 +89,8 @@ class AdminModelView(sqla.ModelView, AdminBaseView): Extension of SQLAlchemy ModelView that requires BasicAuth authentication, and shows all columns in the form (including primary keys). """ + can_export = True + export_types = ['csv'] def __init__(self, model, *args, **kwargs): # Show all columns in form @@ -126,21 +102,9 @@ def __init__(self, model, *args, **kwargs): class LogoutView(AdminBaseView): @expose('/') def index(self): - """ - To log out of basic auth for admin pages, - we raise an HTTP 401 error (there isn't really a cleaner way) - and then redirect on the frontend to home. - """ - raise HTTPException( - 'Logged out.', - Response( - ''' - Successfully logged out. - - ''', - status=401 - ) - ) + body = self.render('admin/logout.html') + status = 401 + return body, status class DatabaseView(AdminBaseView): @@ -154,29 +118,19 @@ def update_db(self): designed to be available in the app during runtime, and is protected by BasicAuth so that only administrators can run it. """ - # If auth passed, then update database. from .data.database import update_database update_database() # Notify the user that the update was successful, then redirect: - return ''' - -
- -Databases updated. Redirecting in 3 seconds...
- - - ''' + return self.render('admin/redirect.html', + message='Database updated.', + redirect_to=url_for('admin.index')) def _send_csv_attachment_of_dataframe( df: pd.DataFrame, file_name: str, - date_prefix: bool = False + date_prefix: bool = True ): strio = io.StringIO() df.to_csv(strio, index=False) @@ -191,8 +145,8 @@ def _send_csv_attachment_of_dataframe( if date_prefix: todays_date = ( pd.Timestamp('now', tz='UTC') - .tz_convert('US/Eastern') - .strftime('%Y_%m_%d') + .tz_convert('US/Eastern') + .strftime('%Y_%m_%d') ) file_name = f'{todays_date}-{file_name}' @@ -229,7 +183,6 @@ def download_from_db(self, sql_table_name: str): # Be careful when parameterizing queries like how we do it below. # The reason it's OK in this case is because users don't touch it. # However it is dangerous to do this in some other contexts. - # We are doing it like this to avoid needing to utilize sessions. query = f'''SELECT * FROM {sql_table_name}''' try: df = execute_sql(query) @@ -237,15 +190,14 @@ def download_from_db(self, sql_table_name: str): raise HTTPException( 'Invalid SQL.', Response( - f'Invalid SQL query: {query}', + f'Invalid SQL query: {query}', status=500 ) ) return _send_csv_attachment_of_dataframe( df=df, - file_name=f'{sql_table_name}.csv', - date_prefix=True + file_name=f'{sql_table_name}.csv' ) @expose('/csv/hobolink_source') @@ -255,8 +207,7 @@ def source_hobolink(self): return _send_csv_attachment_of_dataframe( df=df_hobolink, - file_name=f'hobolink_source.csv', - date_prefix=True + file_name='hobolink_source.csv' ) @expose('/csv/usgs_source') @@ -266,8 +217,7 @@ def source_usgs(self): return _send_csv_attachment_of_dataframe( df=df_usgs, - file_name=f'usgs_source.csv', - date_prefix=True + file_name='usgs_source.csv' ) @expose('/csv/model_outputs') @@ -283,8 +233,7 @@ def source_model_outputs(self): return _send_csv_attachment_of_dataframe( df=df, - file_name=f'model_outputs_source.csv', - date_prefix=True + file_name='model_outputs_source.csv' ) @expose('/csv/model_outputs') @@ -303,8 +252,5 @@ def source_model_outputs(self): return _send_csv_attachment_of_dataframe( df=model_outs, - file_name=f'model_outputs_source.csv', - date_prefix=True + file_name='model_outputs_source.csv' ) - - diff --git a/flagging_site/app.py b/flagging_site/app.py index ffe6a017..ad708e2f 100644 --- a/flagging_site/app.py +++ b/flagging_site/app.py @@ -12,7 +12,10 @@ from typing import Dict from typing import Union -from flask import Flask, render_template, jsonify, request +from flask import Flask +from flask import render_template +from flask import jsonify +from flask import request from flask import current_app from flask import Markup from flask.json import JSONEncoder @@ -71,24 +74,40 @@ def create_app(config: Optional[Union[Config, str]] = None) -> Flask: from .data import db db.init_app(app) - - # And we're all set! We can hand the app over to flask at this point. + @app.errorhandler(401) + def bad_auth(e): + """ Return error 401 """ + body = render_template( + 'error.html', + title='Invalid Authorization', + status_code=401, + msg='Bad username or password provided.' + ) + status = 401 + headers = {'WWW-Authenticate': 'Basic realm="Login Required"'} + return body, status, headers @app.errorhandler(404) def page_not_found(e): """ Return error 404 """ if request.path.startswith('/api/'): # we return a json saying so - return jsonify(Message = "404 Error - Method Not Allowed") + body = jsonify(Message='404 Error - Method Not Allowed') else: # if not, direct user to generic site-wide 404 page - return render_template('error.html', type = '404', msg = "This page doesn't exist!") + body = render_template( + 'error.html', + title='Page not found', + status_code=404, + msg="This page doesn't exist!" + ) + return body, 404 @app.errorhandler(500) - def internal_server_error(error): + def internal_server_error(e): """ Return error 500 """ - bp.logger.error('Server Error: %s', (error)) - return render_template('error.html', type = "500", msg = "Something went wrong.") + app.logger.error(f'Server Error: {e}') + return render_template('error.html', type=500, msg='Something went wrong.'), 500 # Register admin from .admin import init_admin @@ -101,7 +120,7 @@ def internal_server_error(error): add_social_svg_files_to_jinja(app) class CustomJSONEncoder(JSONEncoder): - """Add support for Decimal types""" + """Add support for Decimal types and datetimes.""" def default(self, o): if isinstance(o, decimal.Decimal): return float(o) @@ -307,17 +326,20 @@ def update_config_from_vault(app: Flask) -> None: def add_social_svg_files_to_jinja(app: Flask): - with open(os.path.join(app.static_folder, 'images', 'github.svg')) as f: - GITHUB_SVG = f.read() - - with open(os.path.join(app.static_folder, 'images', 'twitter.svg')) as f: - TWITTER_SVG = f.read() + """It's much more flexible to work with raw SVG markup in an HTML file, + rather than using an SVG file rendered as an image. (E.g. doing this allows + us to change the colors using CSS.) This function loads SVG markup into our + Jinja environment via reading from SVG files. + """ - with open(os.path.join(app.static_folder, 'images', 'hamburger.svg')) as f: - HAMBURGER_SVG = f.read() + def _load_svg(file_name: str): + """Load an svg file from `static/images/`.""" + with open(os.path.join(app.static_folder, 'images', file_name)) as f: + s = f.read() + return Markup(s) app.jinja_env.globals.update({ - 'GITHUB_SVG': Markup(GITHUB_SVG), - 'TWITTER_SVG': Markup(TWITTER_SVG), - 'HAMBURGER_SVG': Markup(HAMBURGER_SVG) + 'GITHUB_SVG': _load_svg('github.svg'), + 'TWITTER_SVG': _load_svg('twitter.svg'), + 'HAMBURGER_SVG': _load_svg('hamburger.svg') }) diff --git a/flagging_site/templates/admin/logout.html b/flagging_site/templates/admin/logout.html new file mode 100644 index 00000000..ef98b9a1 --- /dev/null +++ b/flagging_site/templates/admin/logout.html @@ -0,0 +1,23 @@ +{% extends "admin/base.html" %} +{% block head %} + +{% endblock %} +{% block body %} + +Logged out.
+Redirecting...
+{% endblock %} diff --git a/flagging_site/templates/admin/redirect.html b/flagging_site/templates/admin/redirect.html new file mode 100644 index 00000000..c7bc6926 --- /dev/null +++ b/flagging_site/templates/admin/redirect.html @@ -0,0 +1,13 @@ +{% extends "admin/base.html" %} +{% set _seconds = seconds | default(2) %} +{% block head %} + +{% endblock %} +{% block body %} +{{ message }}
+Redirecting in {{ _seconds }} seconds...
+{% endblock %} diff --git a/flagging_site/templates/error.html b/flagging_site/templates/error.html index 53385ed0..93070343 100644 --- a/flagging_site/templates/error.html +++ b/flagging_site/templates/error.html @@ -1,9 +1,6 @@ {% extends "base.html" %} - -{% block title %}Page Not Found{% endblock %} +{% block title %}{{ title }}{% endblock %} {% block content %} - -{{ msg }}
{% endblock %}