├── .idea └── watcherTasks.xml ├── Dockerfile ├── Dockerrun.aws.json ├── README.md ├── _docker_app_script.sh ├── build_ecs.sh ├── client ├── api │ ├── authApi.js │ └── authenticatedResourceApi.js ├── components │ ├── authenticatedResourceButton.jsx │ └── homePage.jsx ├── main.jsx ├── reducers │ ├── authReducer.js │ ├── authenticedResourceReducer.js │ └── rootReducer.js └── sagas │ ├── authSagas.js │ ├── authenticatedResourceSagas.js │ └── rootSaga.js ├── config.py ├── config_files ├── local_config.ini ├── local_docker_config.ini └── prod_config.ini ├── docker-compose.yml ├── manage.py ├── package.json ├── proxy ├── Dockerfile └── default.conf ├── requirements.txt ├── run.py ├── server ├── __init__.py ├── api │ ├── __init__.py │ ├── auth.py │ └── secure_endpoint_example.py ├── models.py ├── static │ ├── css │ │ └── styles.css │ └── javascript │ │ ├── bundle.js │ │ └── bundle.js.map ├── templates │ └── index.html ├── utils │ ├── __init__.py │ └── auth.py └── views │ ├── __init__.py │ └── index.py └── webpack.config.js /.idea/watcherTasks.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6-alpine 2 | 3 | RUN apk update \ 4 | && apk add gcc g++ libffi libffi-dev libstdc++ python3-dev musl-dev \ 5 | && apk add postgresql-dev 6 | 7 | RUN apk add linux-headers 8 | 9 | COPY ./requirements.txt / 10 | RUN pip install -r requirements.txt 11 | 12 | ADD ./server /src/server 13 | ADD ./migrations /src/migrations 14 | ADD ./config_files /src/config_files 15 | ADD ./config.py /src 16 | ADD ./manage.py /src 17 | ADD ./_docker_app_script.sh / 18 | 19 | WORKDIR / 20 | 21 | RUN chmod +x /_docker_app_script.sh 22 | 23 | EXPOSE 9000 24 | 25 | CMD ["/_docker_app_script.sh"] -------------------------------------------------------------------------------- /Dockerrun.aws.json: -------------------------------------------------------------------------------- 1 | { 2 | "AWSEBDockerrunVersion": 2, 3 | "containerDefinitions": [ 4 | { 5 | "name": "app", 6 | "image": "[PUT YOUR REPOSITORY URI HERE]:server", 7 | "essential": true, 8 | "memory": 200, 9 | "cpu": 1, 10 | "links": [ 11 | "redis:redis" 12 | ], 13 | "environment": [ 14 | { 15 | "name": "CONTAINER_TYPE", 16 | "value": "APP" 17 | }, 18 | { 19 | "name": "LOCATION", 20 | "value": "PROD" 21 | } 22 | ] 23 | }, 24 | { 25 | "name": "proxy", 26 | "image": "[PUT YOUR REPOSITORY URI HERE]:proxy", 27 | "essential": true, 28 | "memory": 200, 29 | "cpu": 1, 30 | "portMappings": [ 31 | { 32 | "hostPort": 80, 33 | "containerPort": 80, 34 | "protocol": "tcp" 35 | } 36 | ], 37 | "links": [ 38 | "app:app" 39 | ] 40 | }, 41 | { 42 | "name": "redis", 43 | "image": "redis:alpine", 44 | "essential": true, 45 | "memory": 128, 46 | "portMappings": [{ 47 | "hostPort": 6379, 48 | "containerPort": 6379 49 | }], 50 | "mountPoints": [] 51 | }, 52 | { 53 | "name": "worker", 54 | "image": "[PUT YOUR REPOSITORY URI HERE]:server", 55 | "essential": false, 56 | "memory": 128, 57 | "links": [ 58 | "redis:redis" 59 | ], 60 | "mountPoints": [], 61 | "environment": [ 62 | { 63 | "name": "CONTAINER_TYPE", 64 | "value": "WORKER" 65 | }, 66 | { 67 | "name": "LOCATION", 68 | "value": "PROD" 69 | } 70 | ] 71 | } 72 | 73 | ] 74 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Data-App-Boilerplate 2 | 3 | Some boilerplate code for building applications that have a lot of data-science smarts in the background. 4 | Ready to deploy to production on AWS in less than an hour! 5 | 6 | Includes user authentication using Auth0. 7 | - Python 3 8 | - React.js 9 | - React Redux 10 | - Redux Sagas 11 | - Auth0 authentication 12 | - Postgres + SQLAlchemy 13 | - FlaskMigrate 14 | - Webpack 15 | - Docker 16 | 17 | 18 | 19 | ## To deploy to Amazon: 20 | ### Install Docker locally 21 | https://www.docker.com/ 22 | 23 | ### Install the Amazon CLI with ability to deploy docker images to ECR 24 | - Register an AWS account (https://aws.amazon.com/) 25 | - Download and set up the amazon cli (https://aws.amazon.com/cli/) 26 | 27 | - In the IAM section of AWS, create a user with name 'ECS' with permissions to deploy to ECR 28 | (Click on this link and then click 'CREATE USER') 29 | https://console.aws.amazon.com/iam/home#/users$new?step=review&accessKey&userNames=ECS4&permissionType=policies&policies=arn:aws:iam::aws:policy%2FAmazonEC2ContainerRegistryFullAccess 30 | 31 | - Add the credentials of the ECS user to your local AWS configuration 32 | ``` 33 | aws configure --profile ECS 34 | ``` 35 | 36 | 37 | 38 | ### Install Front-End Requirements 39 | From project direcotry: 40 | ``` 41 | npm install 42 | ``` 43 | 44 | ### Install Python Requirements for local editing: 45 | ``` 46 | pip3 install -r requirements.txt 47 | ``` 48 | (consider using a virtual environment https://docs.python.org/3/tutorial/venv.html) 49 | 50 | ### Upload Docker Images to amazon ECR 51 | (make sure you have an account set with permission to call ecr:GetAuthorizationToken ) 52 | 53 | - Create a repository in the Amazon EC2 Container Registry in your chosen geographic location ( for example https://ap-southeast-2.console.aws.amazon.com/ecs/home?region=ap-southeast-2#/repositories for Amazon's AP-Southeast-2 region) 54 | and create a new repository. 55 | 56 | - In in your project files 'dockerrun.aws.json' and 'build_ecs.sh', wherever you see '[PUT YOUR REPOSITORY URI HERE]' (there are 4 places in total), paste in the URI for the repository you just created ( looks like `290492953667.dkr.ecr.ap-southeast-2.amazonaws.com/databoilerplate`). 57 | WARNING, you cannot have a space in the variable definition in build_ecs.sh. That is: 58 | ``` 59 | REPOSITORY_URI=290492953667.dkr.ecr.ap-southeast-2.amazonaws.com/boilerplate 60 | ``` 61 | is good 62 | ``` 63 | REPOSITORY_URI = 290492953667.dkr.ecr.ap-southeast-2.amazonaws.com/boilerplate 64 | ``` 65 | is Bad 66 | 67 | - In 'build_ecs.sh' modify [PUT YOUR REGION HERE] to the region you are deploying in (eg ap-southeast-2) 68 | 69 | - run build_ecs.sh 70 | ``` 71 | bash build_ecs.sh 72 | ``` 73 | 74 | ### Deploy app 75 | In terminal run: 76 | ``` 77 | eb init 78 | ```` 79 | (Choose a location, don't set up source control or SHH, and accept defaults for everything else) 80 | 81 | ``` 82 | eb create 83 | ``` 84 | (Choose a name you like and then defaults) 85 | 86 | ``` 87 | eb deploy 88 | ``` 89 | (accept defaults) 90 | 91 | 92 | ### Set up DB 93 | - Go to Amazon RDS and set up a postgres database in the same Availability zone as the app you just created. 94 | Follow this guide here to link the database to your newly deployed app: 95 | http://docs.aws.amazon.com/elasticbeanstalk/latest/dg/AWSHowTo.RDS.html#rds-external-defaultvpc 96 | 97 | - Modify [database] settings in config_files/prod_config.ini to match the settings of the database you just created 98 | 99 | 100 | ### Optional: Set up Auth0 101 | - Go to www.auth0.com and create an account. Update the [Auth0] fields in config_files/prod_config.ini with your own settings. In the auth0 settings, be sure to add your domain to the allowed web origins and CORS origins. Also consider adding local host 102 | 103 | 104 | ### Update app with new settings 105 | - once again run build_ecs.sh and `eb deploy` 106 | 107 | 108 | all done! 109 | 110 | -------------------------------------------------------------------------------- /_docker_app_script.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | if [ $CONTAINER_TYPE == 'APP' ]; then 4 | cd src 5 | python manage.py db upgrade 6 | uwsgi --socket 0.0.0.0:9000 --protocol http --enable-threads -w server:app 7 | else 8 | cd src 9 | python manage.py runworker 10 | fi 11 | -------------------------------------------------------------------------------- /build_ecs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | REPOSITORY_URI=[PUT YOUR REPOSITORY URI HERE] 4 | 5 | npm run build 6 | 7 | docker build -t server . 8 | docker build -t proxy ./proxy 9 | 10 | eval $(aws ecr get-login --no-include-email --region [PUT YOUR REGION HERE] --profile ECS); 11 | 12 | docker tag proxy:latest $REPOSITORY_URI:proxy 13 | docker push $REPOSITORY_URI:proxy 14 | 15 | docker tag server:latest $REPOSITORY_URI:server 16 | docker push $REPOSITORY_URI:server 17 | -------------------------------------------------------------------------------- /client/api/authApi.js: -------------------------------------------------------------------------------- 1 | import Auth0Lock from 'auth0-lock'; 2 | 3 | export const storeToken = (token) => { 4 | localStorage.setItem('sessionToken', token); 5 | }; 6 | 7 | export const removeToken = () => { 8 | localStorage.removeItem('sessionToken'); 9 | }; 10 | 11 | export const getToken = () => { 12 | try { 13 | return localStorage.getItem('sessionToken'); 14 | } catch (err) { 15 | removeToken(); 16 | return null 17 | } 18 | }; 19 | 20 | 21 | const handle_response = (response) => { 22 | if (response.ok) { 23 | return response.json(); 24 | } 25 | throw response 26 | }; 27 | 28 | //Auth API Call 29 | export const requestApiToken = (auth0AccessToken) => { 30 | return fetch('/api/auth/request_api_token/' , { 31 | headers: { 32 | 'Accept': 'application/json', 33 | 'Content-Type': 'application/json' 34 | }, 35 | method: 'post', 36 | body: JSON.stringify({ 37 | 'auth0AccessToken': auth0AccessToken 38 | }) 39 | }) 40 | .then(response => { 41 | return handle_response(response) 42 | }) 43 | .catch(error => { 44 | throw error; 45 | }) 46 | }; 47 | 48 | export const refreshApiToken = () => { 49 | return fetch('/api/auth/refresh_api_token/' ,{ 50 | headers: { 51 | 'Accept': 'application/json', 52 | 'Content-Type': 'application/json', 53 | 'pragma': 'no-cache', 54 | 'cache-control': 'no-cache' 55 | }, 56 | method: 'post', 57 | body: JSON.stringify({ 58 | 'ApiToken': getToken() 59 | }) 60 | }) 61 | .then(response => { 62 | 63 | return handle_response(response) 64 | }) 65 | .catch(error => { 66 | throw error; 67 | }) 68 | }; -------------------------------------------------------------------------------- /client/api/authenticatedResourceApi.js: -------------------------------------------------------------------------------- 1 | import { getToken } from './authApi' 2 | 3 | const handle_response = (response) => { 4 | if (response.ok) { 5 | return response.json(); 6 | } 7 | throw response 8 | }; 9 | 10 | //Auth API Call 11 | export const requestAuthenticatedResourceAPI= () => { 12 | return fetch('/api/secure_endpoint/' , { 13 | headers: { 14 | 'Authorization': getToken() 15 | }, 16 | method: 'get' 17 | }) 18 | .then(response => { 19 | return handle_response(response) 20 | }) 21 | .catch(error => { 22 | throw error; 23 | }) 24 | }; -------------------------------------------------------------------------------- /client/components/authenticatedResourceButton.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import styled from 'styled-components'; 5 | 6 | import { requestResource } from '../reducers/authenticedResourceReducer' 7 | 8 | const mapStateToProps = (state) => { 9 | return { 10 | }; 11 | }; 12 | 13 | const mapDispatchToProps = (dispatch) => { 14 | return { 15 | requestResource: () => dispatch(requestResource()) 16 | }; 17 | }; 18 | 19 | 20 | const AuthenticatedResource = ({requestResource}) => { 21 | 22 | return ( 23 |
24 | 25 | 28 | 29 |
30 | ); 31 | 32 | }; 33 | 34 | export default connect(mapStateToProps, mapDispatchToProps)(AuthenticatedResource); 35 | 36 | const Button = styled.button` 37 | border-radius: 3px; 38 | padding: 0.25em 1em; 39 | margin: 0 1em; 40 | background: transparent; 41 | color: palevioletred; 42 | border: 2px solid palevioletred; 43 | font-size: 1.5em; 44 | `; 45 | -------------------------------------------------------------------------------- /client/components/homePage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import styled from 'styled-components'; 4 | 5 | import { loginRequest, logout } from '../reducers/authReducer' 6 | 7 | import AuthenticatedResource from './authenticatedResourceButton.jsx' 8 | 9 | const mapStateToProps = (state) => { 10 | return { 11 | loggedIn: (state.auth.token !== null), 12 | resourceData: state.authenticatedResourceData 13 | }; 14 | }; 15 | 16 | const mapDispatchToProps = (dispatch) => { 17 | return { 18 | loginRequest: () => dispatch(loginRequest()), 19 | logout: () => dispatch(logout()) 20 | }; 21 | }; 22 | 23 | 24 | const HomePage = ({loginRequest, logout, loggedIn, resourceData}) => { 25 | 26 | if (loggedIn) { 27 | var button = ( 28 | 31 | ) 32 | } else { 33 | var button = ( 34 | 37 | ) 38 | } 39 | 40 | if (resourceData.isRequesting) { 41 | var dataMessage = 'Loading' 42 | } else if (resourceData.error) { 43 | dataMessage = resourceData.error 44 | } else if (resourceData.data) { 45 | dataMessage = 'foo: ' + resourceData.data.foo 46 | } else { 47 | dataMessage = '' 48 | } 49 | 50 | return ( 51 | 52 | 53 | { button } 54 | 55 | {loggedIn? 'Logged in!':'Not logged in'} 56 | 57 | 58 | 59 | { dataMessage } 60 | 61 | 62 | ); 63 | 64 | }; 65 | 66 | export default connect(mapStateToProps, mapDispatchToProps)(HomePage); 67 | 68 | const WrapperDiv = styled.div` 69 | width: 100vw; 70 | height: 100vh; 71 | display: flex; 72 | justify-content: center; 73 | align-items: center; 74 | flex-direction: column; 75 | position: relative; 76 | `; 77 | 78 | const Button = styled.button` 79 | border-radius: 3px; 80 | padding: 0.25em 1em; 81 | margin: 1em; 82 | background: transparent; 83 | color: palevioletred; 84 | border: 2px solid palevioletred; 85 | font-size: 1.5em; 86 | `; 87 | 88 | const Message = styled.div` 89 | margin: 1em; 90 | `; 91 | 92 | -------------------------------------------------------------------------------- /client/main.jsx: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | import { createStore, applyMiddleware, compose } from 'redux' 6 | import { connect, Provider } from 'react-redux'; 7 | import createSagaMiddleware from 'redux-saga' 8 | 9 | import { BrowserRouter, Switch, Route } from 'react-router-dom' 10 | 11 | import rootReducer from './reducers/rootReducer' 12 | import rootsaga from './sagas/rootSaga' 13 | 14 | import HomePage from './components/homePage.jsx' 15 | 16 | 17 | const sagaMiddleware = createSagaMiddleware(); 18 | 19 | // Setup redux dev tools 20 | const composeSetup = process.env.NODE_ENV !== 'prod' && typeof window === 'object' && 21 | window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ? 22 | window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ : compose 23 | 24 | const store = createStore( 25 | rootReducer, 26 | composeSetup( 27 | applyMiddleware(sagaMiddleware) 28 | ) 29 | ); 30 | 31 | sagaMiddleware.run(rootsaga); 32 | 33 | 34 | ReactDOM.render( 35 | 36 | 37 | 38 | 39 | 40 | 41 | , 42 | document.getElementById('app') 43 | ); 44 | 45 | -------------------------------------------------------------------------------- /client/reducers/authReducer.js: -------------------------------------------------------------------------------- 1 | 2 | export const LOGIN_REQUEST = 'LOGIN_REQUEST'; 3 | export const LOGIN_SUCCESS = 'LOGIN_SUCCESS'; 4 | export const LOGIN_FAILURE = 'LOGIN_FAILURE'; 5 | export const LOGOUT = 'LOGOUT'; 6 | 7 | export const initialState = { 8 | isLoggingIn: false, 9 | token: null, 10 | error: null 11 | }; 12 | 13 | export const auth = (state = initialState, action) => { 14 | switch (action.type) { 15 | case LOGIN_REQUEST: 16 | return {...state, isLoggingIn: true}; 17 | case LOGIN_SUCCESS: 18 | return {...state, isLoggingIn: false, token: action.token}; 19 | case LOGIN_FAILURE: 20 | return {...state, isLoggingIn: false, token: null, error: action.error || 'unknown error'}; 21 | case LOGOUT: 22 | return initialState; 23 | default: 24 | return state; 25 | } 26 | } 27 | 28 | export const loginRequest = () => ( 29 | { 30 | type: LOGIN_REQUEST 31 | } 32 | ); 33 | 34 | export const loginSuccess = (idToken) => ( 35 | { 36 | type: LOGIN_SUCCESS, 37 | idToken 38 | } 39 | ); 40 | 41 | export const loginFailure = error => ( 42 | { 43 | type: LOGIN_FAILURE, 44 | error 45 | } 46 | ); 47 | 48 | export const logout = () => ( 49 | { 50 | type: LOGOUT 51 | } 52 | ); 53 | -------------------------------------------------------------------------------- /client/reducers/authenticedResourceReducer.js: -------------------------------------------------------------------------------- 1 | export const RESOURCE_REQUEST = 'RESOURCE_REQUEST'; 2 | export const RESOURCE_SUCCESS = 'RESOURCE_SUCCESS'; 3 | export const RESOURCE_FAILURE = 'RESOURCE_FAILURE'; 4 | 5 | export const initialState = { 6 | isRequesting: false, 7 | data: null, 8 | error: null 9 | }; 10 | 11 | export const authenticatedResourceData = (state = initialState, action) => { 12 | switch (action.type) { 13 | case RESOURCE_REQUEST: 14 | return {...state, isRequesting: true, error: null}; 15 | case RESOURCE_SUCCESS: 16 | return {...state, isRequesting: false, data: action.resourceRequestResult}; 17 | case RESOURCE_FAILURE: 18 | return {...state, isRequesting: false, error: action.error || 'unknown error'}; 19 | default: 20 | return state; 21 | } 22 | }; 23 | 24 | export const requestResource = () => ( 25 | { 26 | type: RESOURCE_REQUEST 27 | } 28 | ); -------------------------------------------------------------------------------- /client/reducers/rootReducer.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | import { auth } from './authReducer' 4 | import { authenticatedResourceData } from './authenticedResourceReducer' 5 | 6 | const rootReducer = combineReducers({ 7 | auth, 8 | authenticatedResourceData 9 | }); 10 | 11 | export default rootReducer; -------------------------------------------------------------------------------- /client/sagas/authSagas.js: -------------------------------------------------------------------------------- 1 | import { call, fork, put, take, all, cancelled, cancel } from 'redux-saga/effects'; 2 | 3 | import { lock, requestApiToken, refreshApiToken, storeToken, getToken, removeToken } from '../api/authApi' 4 | 5 | import { 6 | LOGIN_REQUEST, 7 | LOGIN_SUCCESS, 8 | LOGIN_FAILURE, 9 | LOGOUT, 10 | loginFailure, 11 | loginSuccess, 12 | } from '../reducers/authReducer'; 13 | 14 | export function* loginFlow() { 15 | var reauth = yield call(refreshToken); 16 | while (true) { 17 | if (!(reauth.token)) { 18 | yield take(LOGIN_REQUEST); 19 | var task = yield fork(authorise); 20 | } 21 | const action = yield take([LOGOUT, LOGIN_FAILURE]); 22 | if (action.type === LOGOUT && !(typeof task === 'undefined')) { 23 | yield cancel(task); 24 | } 25 | yield call(removeToken) 26 | } 27 | } 28 | 29 | function* refreshToken() { 30 | try { 31 | const token_request = yield call(refreshApiToken); 32 | if (token_request.token) { 33 | yield put({type: LOGIN_SUCCESS, token: token_request.token}); 34 | yield call(storeToken, token_request.token ); 35 | } 36 | return token_request 37 | } catch(error) { 38 | yield put({type: LOGOUT}); 39 | yield call(removeToken); 40 | return error 41 | } finally { 42 | if (yield cancelled()) { 43 | // ... put special cancellation handling code here 44 | } 45 | } 46 | } 47 | 48 | function* requestToken(auth0_accessToken) { 49 | try { 50 | const token_request = yield call(requestApiToken, auth0_accessToken); 51 | yield put({type: LOGIN_SUCCESS, token: token_request.token}); 52 | yield call(storeToken, token_request.token ); 53 | return token_request 54 | } catch(error) { 55 | yield put({type: LOGIN_FAILURE, error: error.statusText}) 56 | } finally { 57 | if (yield cancelled()) { 58 | // ... put special cancellation handling code here 59 | } 60 | } 61 | } 62 | 63 | 64 | export function* authorise() { 65 | 66 | const showLock = () => 67 | new Promise((resolve, reject) => { 68 | lock.on('hide', () => reject('Lock closed')); 69 | 70 | lock.on('authenticated', (authResult) => { 71 | lock.hide(); 72 | resolve({idToken: authResult.accessToken }); 73 | }); 74 | 75 | lock.on('unrecoverable_error', (error) => { 76 | lock.hide(); 77 | reject(error); 78 | }); 79 | 80 | lock.show(); 81 | }); 82 | 83 | try { 84 | const { idToken } = yield call(showLock); 85 | yield call(requestToken, idToken); 86 | } catch (error) { 87 | yield put(loginFailure(error)); 88 | } 89 | } 90 | 91 | export default function* authSagas() { 92 | yield all([ 93 | loginFlow() 94 | ]) 95 | } -------------------------------------------------------------------------------- /client/sagas/authenticatedResourceSagas.js: -------------------------------------------------------------------------------- 1 | import { take, fork, put, takeEvery, call, all, cancelled, cancel, race} from 'redux-saga/effects' 2 | 3 | import { 4 | RESOURCE_REQUEST, 5 | RESOURCE_SUCCESS, 6 | RESOURCE_FAILURE 7 | } from '../reducers/authenticedResourceReducer'; 8 | 9 | import { requestAuthenticatedResourceAPI } from '../api/authenticatedResourceApi' 10 | 11 | // Load Authenticated Resource Saga 12 | function* loadResource() { 13 | try { 14 | const resourceRequestResult = yield call(requestAuthenticatedResourceAPI); 15 | yield put({type: RESOURCE_SUCCESS, resourceRequestResult}); 16 | } catch (error) { 17 | yield put({type: RESOURCE_FAILURE, error: error.statusText}) 18 | } 19 | } 20 | 21 | function* watchLoadResourceRequest() { 22 | yield takeEvery(RESOURCE_REQUEST, loadResource); 23 | } 24 | 25 | 26 | 27 | export default function* authenticatedResourceSagas() { 28 | yield all([ 29 | watchLoadResourceRequest() 30 | ]) 31 | } -------------------------------------------------------------------------------- /client/sagas/rootSaga.js: -------------------------------------------------------------------------------- 1 | import { all } from 'redux-saga/effects' 2 | 3 | import authSagas from './authSagas' 4 | import authenticatedResourceSagas from './authenticatedResourceSagas' 5 | 6 | 7 | export default function* rootSaga() { 8 | yield all([ 9 | authSagas(), 10 | authenticatedResourceSagas() 11 | ]) 12 | } 13 | 14 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import configparser 3 | 4 | parser = configparser.ConfigParser() 5 | 6 | basedir = os.path.abspath(os.path.dirname(__file__)) 7 | 8 | location = os.environ.get('LOCATION') 9 | 10 | if location == "PROD": 11 | parser.read(os.path.join(basedir,'config_files/prod_config.ini')) 12 | elif location == "LOCAL_DOCKER": 13 | parser.read(os.path.join(basedir,'config_files/local_docker_config.ini')) 14 | else: 15 | parser.read(os.path.join(basedir,'config_files/local_config.ini')) 16 | 17 | SECRET_KEY = parser['APP']['SECRET_KEY'] 18 | 19 | SQLALCHEMY_DATABASE_URI = 'postgresql://%(user)s:%(password)s@%(host)s:%(port)s/%(database)s' % parser['DATABASE'] 20 | 21 | SQLALCHEMY_TRACK_MODIFICATIONS = False 22 | 23 | REDIS_URL = parser['REDIS']['URI'] 24 | 25 | MERGE_OAUTH_PROFILES = True 26 | AUTH0_DOMAIN = parser['AUTH0']['domain'] 27 | AUTH0_CLIENT_ID = parser['AUTH0']['client_id'] 28 | AUTH0_CLIENT_SECRET = parser['AUTH0']['client_secret'] 29 | 30 | 31 | TOKEN_EXPIRATION = 60 * 60 * 24 * 7 # 1 Week 32 | -------------------------------------------------------------------------------- /config_files/local_config.ini: -------------------------------------------------------------------------------- 1 | [APP] 2 | SECRET_KEY = b'\x0742\xba\xe4\x08\xa9\x04!N\x1a\x87\xc3z\xbd\xc6[\x99:\xf8\xe1\x19\x90\xfd' 3 | ; DO NOT RE-USE THIS KEY, use >>> import os >>> os.urandom(24) to create good random keys 4 | 5 | [DATABASE] 6 | host = localhost 7 | port = 5432 8 | user = postgres 9 | password = password 10 | database = data_app_boilerplate 11 | 12 | [REDIS] 13 | URI = redis://localhost:6379 14 | 15 | [AUTH0] 16 | ;Go to https://auth0.com to get your own credientials (these won't work). You'll need to use your local instance from localhost to get auth0 to work 17 | domain = sempo.au.auth0.com 18 | client_id = ElwQEnpMH_BV-atoVDeJaXc-ehN0yv4A 19 | client_secret = rUl_RnP5wPHLt-BYTEYSmL8hZeqNpykLMJo6JT2UAb9nYpyO2LxkDo8uPDs4obpC -------------------------------------------------------------------------------- /config_files/local_docker_config.ini: -------------------------------------------------------------------------------- 1 | [APP] 2 | SECRET_KEY = b'\x0742\xba\xe4\x08\xa9\x04!N\x1a\x87\xc3z\xbd\xc6[\x99:\xf8\xe1\x19\x90\xfd' 3 | ; DO NOT RE-USE THIS KEY, use >>> import os >>> os.urandom(24) to create good random keys 4 | 5 | [DATABASE] 6 | host = docker.for.mac.localhost 7 | port = 5432 8 | user = postgres 9 | password = password 10 | database = data_app_boilerplate 11 | 12 | [REDIS] 13 | URI = redis://redis:6379 14 | 15 | [AUTH0] 16 | ;Go to https://auth0.com to get your own credientials (these won't work). You'll need to use your local instance from localhost to get auth0 to work 17 | domain = sempo.au.auth0.com 18 | client_id = ElwQEnpMH_BV-atoVDeJaXc-ehN0yv4A 19 | client_secret = rUl_RnP5wPHLt-BYTEYSmL8hZeqNpykLMJo6JT2UAb9nYpyO2LxkDo8uPDs4obpC -------------------------------------------------------------------------------- /config_files/prod_config.ini: -------------------------------------------------------------------------------- 1 | [APP] 2 | SECRET_KEY = b'\n\x15W0\xbe\x1e\xa6\x1b>> import os >>> os.urandom(24) to create good random keys 4 | 5 | [DATABASE] 6 | host = localhost 7 | port = 5432 8 | user = postgres 9 | password = password 10 | database = data_app_boilerplate 11 | 12 | [REDIS] 13 | URI = redis://redis:6379 14 | 15 | [AUTH0] 16 | ;Go to https://auth0.com to get your own credientials (these won't work) 17 | domain = sempo.au.auth0.com 18 | client_id = ElwQEnpMH_BV-atoVDeJaXc-ehN0yv4A 19 | client_secret = rUl_RnP5wPHLt-BYTEYSmL8hZeqNpykLMJo6JT2UAb9nYpyO2LxkDo8uPDs4obpC -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | app: 4 | build: . 5 | image: server 6 | environment: 7 | LOCATION: "LOCAL_DOCKER" 8 | CONTAINER_TYPE: "APP" 9 | 10 | depends_on: 11 | - redis 12 | 13 | worker: 14 | build: . 15 | image: server 16 | environment: 17 | LOCATION: "LOCAL_DOCKER" 18 | CONTAINER_TYPE: "WORKER" 19 | 20 | depends_on: 21 | - redis 22 | - app 23 | 24 | 25 | proxy: 26 | build: ./proxy 27 | environment: 28 | LOCATION: "LOCAL_DOCKER" 29 | ports: 30 | - "80:80" 31 | depends_on: 32 | - app 33 | 34 | redis: 35 | image: "redis:alpine" 36 | 37 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | from flask_script import Manager, Command 2 | from flask_migrate import Migrate, MigrateCommand 3 | import redis 4 | import config 5 | 6 | from server import app, db 7 | from rq import Worker, Queue, Connection 8 | 9 | 10 | # command to run the redis worker 11 | class RunWorker(Command): 12 | def run(self): 13 | redis_url = config.REDIS_URL 14 | listen = ['default'] 15 | redis_connection = redis.from_url(redis_url) 16 | with Connection(redis_connection): 17 | worker = Worker(list(map(Queue, listen))) 18 | worker.work() 19 | 20 | manager = Manager(app) 21 | 22 | migrate = Migrate(app, db) 23 | 24 | manager.add_command('db', MigrateCommand) 25 | manager.add_command('runworker', RunWorker()) 26 | 27 | if __name__ == '__main__': 28 | manager.run() -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "data-app-boilerplate", 3 | "version": "0.1.0", 4 | "description": "Boilerplate for data-driven web apps using Python-Flask and React", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "dev": "NODE_ENV=dev && webpack -d --watch", 9 | "build": "NODE_ENV=prod && webpack -p" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/enjeyw/data-app-boilerplate.git" 14 | }, 15 | "keywords": [ 16 | "data", 17 | "python", 18 | "react", 19 | "flask", 20 | "boilerplate" 21 | ], 22 | "author": "Nick Williams", 23 | "license": "MIT", 24 | "devDependencies": { 25 | "babel-core": "^6.26.0", 26 | "babel-loader": "^7.1.2", 27 | "babel-polyfill": "^6.26.0", 28 | "babel-preset-env": "^1.6.1", 29 | "babel-preset-react": "^6.24.1", 30 | "babel-preset-stage-2": "^6.24.1", 31 | "webpack": "^3.8.1" 32 | }, 33 | "dependencies": { 34 | "auth0-lock": "^10.23.1", 35 | "react": "^16.0.0", 36 | "react-dom": "^16.0.0", 37 | "react-redux": "^5.0.6", 38 | "react-router": "^4.2.0", 39 | "react-router-dom": "^4.2.2", 40 | "react-update": "^0.4.4", 41 | "redux": "^3.7.2", 42 | "redux-saga": "^0.16.0", 43 | "styled-components": "^2.2.3" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /proxy/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:latest 2 | COPY default.conf /etc/nginx/conf.d/ 3 | EXPOSE 80 4 | CMD ["nginx", "-g", "daemon off;"] -------------------------------------------------------------------------------- /proxy/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | location / { 4 | proxy_pass http://app:9000; 5 | proxy_set_header Host $host; 6 | proxy_set_header X-Real-IP $remote_addr; 7 | } 8 | } -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alembic==0.9.6 2 | aniso8601==1.3.0 3 | certifi==2017.11.5 4 | chardet==3.0.4 5 | click==6.7 6 | Flask==0.12.2 7 | Flask-Migrate==2.1.1 8 | Flask-RESTful==0.3.6 9 | Flask-Script==2.0.6 10 | Flask-SQLAlchemy==2.3.2 11 | idna==2.6 12 | itsdangerous==0.24 13 | Jinja2==2.9.6 14 | Mako==1.0.7 15 | MarkupSafe==1.0 16 | psycopg2==2.7.3.2 17 | PyJWT==1.5.3 18 | python-dateutil==2.6.1 19 | python-editor==1.0.3 20 | pytz==2017.3 21 | redis==2.10.6 22 | requests==2.18.4 23 | rq==0.9.1 24 | six==1.11.0 25 | SQLAlchemy==1.1.15 26 | urllib3==1.22 27 | uWSGI==2.0.15 28 | Werkzeug==0.12.2 29 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | #!flask/bin/python 2 | import os 3 | 4 | os.environ["LOCATION"] = "LOCAL" 5 | 6 | from server import app 7 | 8 | app.run(debug=True, threaded=True, port=5000) -------------------------------------------------------------------------------- /server/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from flask_sqlalchemy import SQLAlchemy 3 | 4 | from rq import Queue 5 | from rq.job import Job 6 | # from server import worker 7 | 8 | app = Flask(__name__) 9 | app.config.from_object('config') 10 | db = SQLAlchemy(app) 11 | 12 | from .api.auth import auth_api 13 | from .api.secure_endpoint_example import secure_endpoint_api 14 | from .views.index import index_view 15 | 16 | app.register_blueprint(auth_api.blueprint, url_prefix='/api') 17 | app.register_blueprint(secure_endpoint_api.blueprint, url_prefix='/api') 18 | 19 | app.register_blueprint(index_view) 20 | 21 | # q = Queue(connection=worker.conn) 22 | 23 | -------------------------------------------------------------------------------- /server/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/enjeyw/Data-App-Boilerplate/41265a92863bb81c621e00b78590b7f5ae85c249/server/api/__init__.py -------------------------------------------------------------------------------- /server/api/auth.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | from flask_restful import Api, Resource, abort, reqparse 3 | 4 | from server import db, app 5 | from server.models import User, OAuthProfile 6 | from server.utils.auth import get_auth0_profile, generate_token, verify_token 7 | 8 | auth_api = Api(Blueprint('auth_api', __name__)) 9 | 10 | @auth_api.resource('/auth/refresh_api_token/') 11 | class Refresh_API_Token(Resource): 12 | 13 | 14 | def post(self): 15 | try: 16 | args = self.reqparse.parse_args() 17 | old_api_token = args.get('ApiToken') 18 | user = verify_token(old_api_token) 19 | 20 | if user: 21 | token = generate_token(user) 22 | return {'token': token} 23 | else: 24 | return {'token': None} 25 | 26 | except: 27 | return {'token': None} 28 | 29 | def __init__(self): 30 | self.reqparse = reqparse.RequestParser() 31 | self.reqparse.add_argument('ApiToken', location = 'json') 32 | super().__init__() 33 | 34 | 35 | @auth_api.resource('/auth/request_api_token/') 36 | class Request_API_Token(Resource): 37 | 38 | def post(self): 39 | 40 | try: 41 | args = self.reqparse.parse_args() 42 | auth0_profile = get_auth0_profile(args['auth0AccessToken']) 43 | profile_type, profile_id = auth0_profile['sub'].split('|') 44 | email = auth0_profile.get('email') 45 | except: 46 | abort(401) 47 | 48 | stored_profile = OAuthProfile.query.filter_by(profile_type = profile_type, profile_id = profile_id).first() 49 | 50 | if stored_profile: 51 | user = stored_profile.user 52 | token = generate_token(user) 53 | else: 54 | 55 | user = None 56 | 57 | if app.config['MERGE_OAUTH_PROFILES'] and auth0_profile.get('email_verified'): 58 | user = User.query.filter_by(email = email).first() 59 | 60 | if not user: 61 | user = User(email = email) 62 | db.session.add(user) 63 | 64 | stored_profile = OAuthProfile(profile_type = profile_type, profile_id = profile_id) 65 | stored_profile.user = user 66 | db.session.add(stored_profile) 67 | 68 | db.session.commit() 69 | 70 | token = generate_token(user) 71 | 72 | return {'token': token} 73 | 74 | def __init__(self): 75 | self.reqparse = reqparse.RequestParser() 76 | self.reqparse.add_argument('auth0AccessToken', type = str, required = True, location = 'json') 77 | super().__init__() 78 | 79 | -------------------------------------------------------------------------------- /server/api/secure_endpoint_example.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | from flask_restful import Api, Resource, abort, reqparse 3 | 4 | from server import db, app 5 | from server.utils.auth import AuthenticatedResource 6 | 7 | secure_endpoint_api = Api(Blueprint('secure_endpoint_api', __name__)) 8 | 9 | @secure_endpoint_api.resource('/secure_endpoint/') 10 | class Refresh_API_Token(AuthenticatedResource): 11 | 12 | def get(self): 13 | return {'foo': 'bar'} 14 | 15 | -------------------------------------------------------------------------------- /server/models.py: -------------------------------------------------------------------------------- 1 | from server import db 2 | import datetime 3 | 4 | class User(db.Model): 5 | __tablename__ = 'user' 6 | 7 | id = db.Column(db.Integer, primary_key=True) 8 | created = db.Column(db.DateTime, default=datetime.datetime.utcnow) 9 | email = db.Column(db.String()) 10 | 11 | oauth_profiles = db.relationship('OAuthProfile', backref='user', lazy='dynamic') 12 | 13 | class OAuthProfile(db.Model): 14 | __tablename__ = 'oauth_profile' 15 | 16 | id = db.Column(db.Integer, primary_key=True) 17 | created = db.Column(db.DateTime, default=datetime.datetime.utcnow) 18 | profile_type = db.Column(db.String()) 19 | profile_id = db.Column(db.String()) 20 | 21 | user_id = db.Column(db.Integer, db.ForeignKey('user.id')) 22 | 23 | 24 | -------------------------------------------------------------------------------- /server/static/css/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 'Open Sans', sans-serif; 3 | font-weight: 200; 4 | color: #555; 5 | } 6 | -------------------------------------------------------------------------------- /server/static/javascript/bundle.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"bundle.js","sources":["webpack:///bundle.js"],"mappings":"AAAA;;;;;AA6nCA;;;;;;;;;;;;;;AA4ijBA;;;;;;AAstqDA;;;;;;AA4CA","sourceRoot":""} -------------------------------------------------------------------------------- /server/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | React Python Boilerplate 6 | 7 | 8 | 9 | 10 | 11 | 12 | 19 | 20 | 21 | 22 | 26 | 27 |
28 | 29 |
30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /server/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/enjeyw/Data-App-Boilerplate/41265a92863bb81c621e00b78590b7f5ae85c249/server/utils/__init__.py -------------------------------------------------------------------------------- /server/utils/auth.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from flask import request, g 3 | import requests 4 | from itsdangerous import TimedJSONWebSignatureSerializer as Serializer 5 | from itsdangerous import SignatureExpired, BadSignature 6 | from server import app, models 7 | 8 | from flask_restful import Resource, abort 9 | 10 | 11 | def get_auth0_profile(auth_0_identity_token): 12 | 13 | headers = {'content-type': 'application/json', 14 | 'authorization': 'Bearer ' + auth_0_identity_token} 15 | 16 | r = requests.get('https://' + app.config['AUTH0_DOMAIN'] + '/userinfo', headers = headers) 17 | 18 | try: 19 | profile = r.json() 20 | except: 21 | raise 22 | 23 | return profile 24 | 25 | def generate_token(user, expiration=app.config['TOKEN_EXPIRATION']): 26 | s = Serializer(app.config['SECRET_KEY'], expires_in=expiration) 27 | token = s.dumps({ 28 | 'id': user.id 29 | }).decode('utf-8') 30 | return token 31 | 32 | def verify_token(token): 33 | s = Serializer(app.config['SECRET_KEY']) 34 | try: 35 | data = s.loads(token) 36 | except (BadSignature, SignatureExpired): 37 | return None 38 | user = models.User.query.get(data['id']) 39 | return user 40 | 41 | def requires_auth(f): 42 | @wraps(f) 43 | def decorated(*args, **kwargs): 44 | token = request.headers.get('Authorization', None) 45 | if token: 46 | string_token = token.encode('ascii', 'ignore') 47 | user = verify_token(string_token) 48 | if user: 49 | g.current_user = user 50 | return f(*args, **kwargs) 51 | 52 | return abort(401, message="Authentication is required to access this resource") 53 | 54 | return decorated 55 | 56 | 57 | class AuthenticatedResource(Resource): 58 | method_decorators = [requires_auth] 59 | -------------------------------------------------------------------------------- /server/views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/enjeyw/Data-App-Boilerplate/41265a92863bb81c621e00b78590b7f5ae85c249/server/views/__init__.py -------------------------------------------------------------------------------- /server/views/index.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from flask import render_template, Blueprint, make_response, jsonify 3 | 4 | index_view = Blueprint('index', __name__, 5 | template_folder='templates') 6 | 7 | @index_view.route('/') 8 | def index(): 9 | return render_template('index.html') 10 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | 3 | var path = require('path'); 4 | var APP_DIR = path.resolve(__dirname, 'client'); 5 | var BUILD_DIR = path.resolve(__dirname, 'server/static/javascript'); 6 | 7 | var config = { 8 | entry: APP_DIR + '/main.jsx', 9 | output: { 10 | path: BUILD_DIR, 11 | filename: 'bundle.js' 12 | }, 13 | devtool: "#cheap-module-source-map.", // #eval-source-map" #cheap-module-source-map." 14 | module: { 15 | loaders : [ 16 | { 17 | test : /\.(js|jsx)$/, 18 | exclude: /node_modules/, 19 | include : APP_DIR, 20 | loader : 'babel-loader', 21 | options: { 22 | babelrc: false, 23 | presets:["env", "react", "stage-2"] 24 | } 25 | } 26 | ] 27 | } 28 | }; 29 | 30 | module.exports = config; --------------------------------------------------------------------------------