├── .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;
--------------------------------------------------------------------------------