Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 39 additions & 93 deletions flagging_site/admin.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand All @@ -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.
<script>window.location = "/admin/";</script>
''',
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
Expand All @@ -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
Expand All @@ -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.
<script>window.location = "/";</script>
''',
status=401
)
)
body = self.render('admin/logout.html')
status = 401
return body, status


class DatabaseView(AdminBaseView):
Expand All @@ -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 '''<!DOCTYPE html>
<html>
<body>
<script>
setTimeout(function(){
window.location.href = '/admin/';
}, 3000);
</script>
<p>Databases updated. Redirecting in 3 seconds...</p>
</body>
</html>
'''
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)
Expand All @@ -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}'

Expand Down Expand Up @@ -229,23 +183,21 @@ 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)
except ProgrammingError:
raise HTTPException(
'Invalid SQL.',
Response(
f'<b>Invalid SQL query:</b> <tt>{query}</tt>',
f'<b>Invalid SQL query:</b> <samp>{query}</samp>',
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')
Expand All @@ -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')
Expand All @@ -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')
Expand All @@ -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')
Expand All @@ -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'
)


60 changes: 41 additions & 19 deletions flagging_site/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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')
})
23 changes: 23 additions & 0 deletions flagging_site/templates/admin/logout.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{% extends "admin/base.html" %}
{% block head %}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
{% endblock %}
{% block body %}
<script>
$(document).ready(function(){
$.ajax({
async: false,
type: "GET",
url: "/admin",
username: "logout"
})
.done(function(){
})
.fail(function(){
window.location.href = "{{ url_for('flagging.index') }}";
});
});
</script>
<p>Logged out.</p>
<p>Redirecting...</p>
{% endblock %}
Loading