Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds flask admin as a feature. Bumps flask admin to 1.6.0 #551

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
4 changes: 4 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@ FLASK_DEBUG=1
ALGOLIA_APP_ID=search_id
ALGOLIA_API_KEY=search_key
INDEX_NAME=resources_api
SECRET_KEY=sammy
SECURITY_PASSWORD_SALT=saltedpop
ADMIN_EMAIL=test@me.com
ADMIN_PASSWORD=1234
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we please add a newline to the end of this file?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added

2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ RUN apt-get update \
&& pip install poetry \
&& poetry config virtualenvs.create false

RUN poetry lock
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this necessary?

Copy link
Collaborator Author

@oafernandes oafernandes Jul 8, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I manually added the dependency versions to the pyproject.toml file. Below link to solution for resolving the "....which doesn't match any versions, version solving failed." error

[Known issue]python-poetry/poetry#1281 (comment)


RUN poetry install --no-dev --no-interaction --no-ansi

COPY . /src
Expand Down
1 change: 0 additions & 1 deletion app/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from algoliasearch.search_client import SearchClient
from configs import Config

from flask import Flask
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
Expand Down
45 changes: 45 additions & 0 deletions app/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from flask_admin import Admin, AdminIndexView
from flask_admin.contrib.sqla import ModelView
from flask import url_for, redirect, request, abort
from app import db
from .models import Resource, Category, Language, User, Role
from flask_security import current_user


class AdminView(ModelView):
def is_accessible(self):
return (current_user.is_active and
current_user.is_authenticated and current_user.has_role('admin'))

def _handle_view(self, name, **kwargs):
""" Override builtin _handle_view in order to redirect users when a view
is not accessible.
"""
if not self.is_accessible():
if current_user.is_authenticated:
# permission denied
abort(403)
else:
# login
return redirect(url_for('security.login', next=request.url))


class HomeAdminView(AdminIndexView):
def is_accessible(self):
return current_user.has_role('admin')

def inaccessible_callback(self, name):
return redirect(url_for('security.login', next=request.url))


def run_flask_admin(app):
"""Creates the admin object and defines which views will be visible"""
admin_obj = Admin(app, name='Resources_api', url='/',
base_template='my_master.html',
index_view=HomeAdminView(name='Home'))
admin_obj.add_view(AdminView(Role, db.session))
admin_obj.add_view(AdminView(User, db.session))
admin_obj.add_view(AdminView(Resource, db.session))
admin_obj.add_view(AdminView(Category, db.session))
admin_obj.add_view(AdminView(Language, db.session))
return admin_obj
35 changes: 35 additions & 0 deletions app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from sqlalchemy import DateTime
from sqlalchemy.sql import func
from sqlalchemy_utils import URLType
from flask_security import UserMixin, RoleMixin

language_identifier = db.Table('language_identifier',
db.Column(
Expand Down Expand Up @@ -206,3 +207,37 @@ class VoteInformation(db.Model):
current_direction = db.Column(db.String, nullable=True)
resource = db.relationship('Resource', back_populates='voters')
voter = db.relationship('Key', back_populates='voted_resources')


roles_users = db.Table(
'roles_users',
db.Column('user_id', db.Integer(), db.ForeignKey('user.id')),
db.Column('role_id', db.Integer(), db.ForeignKey('role.id')))


class Role(db.Model, RoleMixin):
'''Role has three fields, ID, name and description'''
id = db.Column(db.Integer(), primary_key=True)
name = db.Column(db.String(80), unique=True)
description = db.Column(db.String(255))

def __str__(self):
return self.name

# __hash__ method avoids the exception, returns attribute that does not change
# TypeError:unhashable type:'Role' when saving a User
def __hash__(self):
return self.name


class User(db.Model, UserMixin):
id = db.Column(db.Integer(), primary_key=True)
email = db.Column(db.String(255), unique=True)
password = db.Column(db.String(255))
active = db.Column(db.Boolean())
confirmed_at = db.Column(db.DateTime())
roles = db.relationship(
'Role',
secondary=roles_users,
backref=db.backref('users', lazy='dynamic')
)
24 changes: 24 additions & 0 deletions app/templates/admin/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{% extends 'admin/master.html' %}

{% block body %}
{{ super() }}
<div class='container'>
<div>
<div>
<h1>Welcome!</h1>
{% if not current_user.is_authenticated %}
<h3>Please log in to continue</h3>
<br>
<a class='btn btn-primary' href="{{ url_for('security.login') }}">login</a>
</p>
<br>
{% endif %}
{% if current_user.is_authenticated %}
<h3>You have successfully logged in.</h3>
<p>You now have access to the administrator view.</p>
<a href="{{ url_for('security.logout') }}">Log out</a>
{% endif %}
</div>
</div>
</div>
{% endblock body %}
16 changes: 16 additions & 0 deletions app/templates/my_master.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{% extends 'admin/base.html' %}

{% block access_control %}
{% if current_user.is_authenticated==True %}
<div class='navbar-text btn-group pull-right'>
<a data-toggle='dropdown' role='button'>
<i class="glyphicon glyphicon-user"></i>
{{ current_user.email }}
<span class="caret"></span>
</a>
<ul class="dropdown-menu">
<li><a href="{{ url_for('security.logout') }}">Log out</a></li>
</ul>
</div>
{% endif %}
{% endblock %}
20 changes: 20 additions & 0 deletions app/templates/security/login_user.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{% extends 'admin/master.html' %}
{% from "security/_macros.html" import render_field_with_errors, render_field %}

{% block body %}
{{ super() }}
<div class="container">
<div>
<h1>Login</h1>
<div class="well">
<form action="{{ url_for_security('login') }}" method="POST" name="login_user_form">
{{ login_user_form.hidden_tag() }}
{{ render_field_with_errors(login_user_form.email) }}
{{ render_field_with_errors(login_user_form.password) }}
{{ render_field(login_user_form.next) }}
{{ render_field(login_user_form.submit, class="btn btn-primary") }}
</form>
</div>
</div>
</div>
{% endblock %}
23 changes: 23 additions & 0 deletions app/templates/security/register_user.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{% extends 'admin/master.html' %}
{% from "security/_macros.html" import render_field_with_errors, render_field %}

{% block body %}
{{ super() }}
<div class="container">
<div>
<h1>Register</h1>
<div class="well">
<form action="{{ url_for_security('register') }}" method="POST" name="register_user_form">
{{ register_user_form.hidden_tag() }}
{{ render_field_with_errors(register_user_form.email) }}
{{ render_field_with_errors(register_user_form.password) }}
{% if register_user_form.password_confirm %}
{{ render_field_with_errors(register_user_form.password_confirm) }}
{% endif %}
{{ render_field(register_user_form.submit, class="btn btn-primary") }}
</form>
<p>Already signed up? Please <a href="{{ url_for('security.login') }}">log in</a>.</p>
</div>
</div>
</div>
{% endblock body %}
17 changes: 17 additions & 0 deletions configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ def get_sys_exec_root_or_drive():
if not all([algolia_app_id, algolia_api_key]):
print("Application requires 'ALGOLIA_APP_ID' and 'ALGOLIA_API_KEY' for search")

secret_key = os.environ.get('SECRET_KEY', None)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
secret_key = os.environ.get('SECRET_KEY', None)
secret_key = os.environ['SECRET_KEY']

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uses bracket notation instead of .get()

security_password_hash = 'pbkdf2_sha512'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make this an environment variable?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SECURITY_PASSWORD_HASH added to .env

security_password_salt = os.environ.get('SECURITY_PASSWORD_SALT', None)

if not all([secret_key, security_password_salt]):
print('Application requires "SECRET_KEY" and "SECURITY_HASH"')
Comment on lines +44 to +45
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can remove this if we use the bracket syntax instead of .get()

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uses bracket notation instead of .get()


index_name = os.environ.get("INDEX_NAME")


Expand All @@ -49,6 +56,16 @@ class Config:
ALGOLIA_API_KEY = algolia_api_key
INDEX_NAME = index_name

SECRET_KEY = secret_key
SECURITY_URL_PREFIX = "/admin"
SECURITY_PASSWORD_HASH = security_password_hash
SECURITY_PASSWORD_SALT = security_password_salt
SECURITY_LOGIN_URL = "/login/"
SECURITY_LOGOUT_URL = "/logout/"
SECURITY_POST_LOGIN_VIEW = "/admin/"
SECURITY_POST_LOGOUT_VIEW = "/admin/"
SECURITY_REGISTERABLE = False
SECURITY_SEND_REGISTER_EMAIL = False
# Can pass in changes to defaults, such as PaginatorConfig(per_page=40)
RESOURCE_PAGINATOR = PaginatorConfig()
LANGUAGE_PAGINATOR = PaginatorConfig()
Expand Down
10 changes: 7 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ python = "^3.7"
algoliasearch = ">=2.0,<3.0"
alembic = "1.5.8"
bandit = "1.5.1"
click = "7.1.2"
click = "8.1.3"
flake8 = "3.9.0"
flask = "1.1.2"
flask = "2.1.2"
Flask-Cors = "3.0.10"
Flask-Migrate = "2.7.0"
prometheus_client = "0.9.0"
Expand All @@ -27,9 +27,13 @@ requests = "2.25.1"
sqlalchemy = "1.3.22"
SQLAlchemy-Utils = "0.36.8"
uWSGI = "2.0.19.1"
Werkzeug = "1.0.1"
Werkzeug = "2.1.2"
pyjwt = "^2.0.1"
cryptography = "^3.4"
flask-admin = "^1.6.0"
Flask-Login = "^0.6.1"
Flask-Security = "^3.0.0"
email-validator = "^1.2.1"

[tool.poetry.dev-dependencies]

Expand Down
57 changes: 55 additions & 2 deletions run.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
from app import app, cli
from app.models import Category, Language, Resource, db
from app.admin import run_flask_admin
from app.models import Category, Language, Resource, db, Role, User
import os
from flask_security import Security, SQLAlchemyUserDatastore, utils
from flask import url_for
from flask_admin import helpers as admin_helpers
from werkzeug.middleware.dispatcher import DispatcherMiddleware
from prometheus_client import make_wsgi_app
from sqlalchemy import event

admin = run_flask_admin(app)

user_datastore = SQLAlchemyUserDatastore(db, User, Role)
security = Security(app, user_datastore)

if __name__ == "__main__":
app.run()
Expand All @@ -15,6 +25,49 @@
})


# @event.listens_for(User.password, 'set', retval=True)
# def hash_user_password(target, value, oldvalue, initiator):
# """Encrypts password when new admin created in User View"""
# if value != oldvalue:
# return utils.encrypt_password(value)
# return value
Comment on lines +28 to +33
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This listens for when a new admin password is added in the admin view and encrypts with same method as before_first_request(). Removed.



@security.context_processor
def security_context_processor():
return dict(
admin_base_template=admin.base_template,
admin_view=admin.index_view,
h=admin_helpers,
get_url=url_for
)


@app.shell_context_processor
def make_shell_context():
return {'db': db, 'Resource': Resource, 'Category': Category, 'Language': Language}
return {'db': db, 'Resource': Resource, 'Category': Category, 'Language': Language,
'User': User, 'Role': Role}


@app.before_first_request
def before_first_request():
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please rename this so we don't accidentally shadow or get confused. Choose a name that is descriptive of what the function is doing

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changed to add_admin_role

Comment on lines +52 to +53
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the admin user already exists when this function runs, what happens? Are there side effects?

""" Adds admin/user roles and default admin account and password if none exists"""
db.create_all()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this idempotent?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only way to check would be to delete the table from the database I think? May need help with this.

user_datastore.find_or_create_role(name='admin', description='Administrator')
user_datastore.find_or_create_role(name='user', description='End User')

admin_email = os.environ.get('ADMIN_EMAIL', "admin@example.com")
admin_password = os.environ.get('ADMIN_PASSWORD', 'password')
Comment on lines +59 to +60
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We want this to blow up. Please use bracket syntax here

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to bracket from .get()


encrypted_password = utils.encrypt_password(admin_password)

if not user_datastore.get_user(admin_email):
user_datastore.create_user(email=admin_email, password=encrypted_password)
db.session.commit()

user_datastore.add_role_to_user(admin_email, 'admin')
db.session.commit()


if __name__ == "__main__":
app.run()