├── e2e ├── config.json └── test.js ├── .python-version ├── App ├── models │ ├── __init__.py │ └── user.py ├── tests │ ├── __init__.py │ └── test_app.py ├── static │ ├── style.css │ ├── main.js │ └── static-user.html ├── default_config.py ├── controllers │ ├── __init__.py │ ├── initialize.py │ ├── user.py │ └── auth.py ├── __init__.py ├── templates │ ├── admin │ │ └── index.html │ ├── message.html │ ├── index.html │ ├── 401.html │ ├── users.html │ └── layout.html ├── database.py ├── views │ ├── __init__.py │ ├── index.py │ ├── admin.py │ ├── user.py │ └── auth.py ├── config.py └── main.py ├── .flaskenv ├── images ├── fig1.png └── gitperms.png ├── setup.cfg ├── .vscode └── settings.json ├── pytest.ini ├── requirements.txt ├── gunicorn_config.py ├── .Dockerfile ├── package.json ├── .github └── workflows │ └── dev.yml ├── render.yaml ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── wsgi.py ├── .gitignore └── readme.md /e2e/config.json: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.9.10 2 | -------------------------------------------------------------------------------- /App/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .user import * -------------------------------------------------------------------------------- /App/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from .test_app import * -------------------------------------------------------------------------------- /App/static/style.css: -------------------------------------------------------------------------------- 1 | html { 2 | padding: 0; 3 | } -------------------------------------------------------------------------------- /.flaskenv: -------------------------------------------------------------------------------- 1 | FLASK_RUN_PORT=8080 2 | FLASK_APP=wsgi.py 3 | FLASK_DEBUG=True -------------------------------------------------------------------------------- /images/fig1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uwidcit/flaskmvc/HEAD/images/fig1.png -------------------------------------------------------------------------------- /images/gitperms.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uwidcit/flaskmvc/HEAD/images/gitperms.png -------------------------------------------------------------------------------- /App/default_config.py: -------------------------------------------------------------------------------- 1 | SQLALCHEMY_DATABASE_URI="sqlite:///temp-database.db" 2 | SECRET_KEY="secret key" -------------------------------------------------------------------------------- /App/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | from .user import * 2 | from .auth import * 3 | from .initialize import * 4 | -------------------------------------------------------------------------------- /App/__init__.py: -------------------------------------------------------------------------------- 1 | from .models import * 2 | from .views import * 3 | from .controllers import * 4 | from .main import * -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | testpaths = App/tests 3 | 4 | [coverage:run] 5 | branch = True 6 | source = 7 | App -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.unittestEnabled": false, 3 | "python.testing.pytestEnabled": true 4 | } -------------------------------------------------------------------------------- /App/templates/admin/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/master.html' %} 2 | 3 | {% block body %} 4 |

Hello world

5 | {% endblock %} -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | filterwarnings = ignore::DeprecationWarning 3 | testpaths = App/tests 4 | log_cli = 1 5 | log_cli_level = INFO 6 | log_cli_format = %(message)s 7 | log_cli_date_format=%Y-%m-%d %H:%M:%S -------------------------------------------------------------------------------- /App/controllers/initialize.py: -------------------------------------------------------------------------------- 1 | from .user import create_user 2 | from App.database import db 3 | 4 | 5 | def initialize(): 6 | db.drop_all() 7 | db.create_all() 8 | create_user('bob', 'bobpass') 9 | -------------------------------------------------------------------------------- /App/database.py: -------------------------------------------------------------------------------- 1 | from flask_sqlalchemy import SQLAlchemy 2 | from flask_migrate import Migrate 3 | 4 | 5 | db = SQLAlchemy() 6 | 7 | def get_migrate(app): 8 | return Migrate(app, db) 9 | 10 | def create_db(): 11 | db.create_all() 12 | 13 | def init_db(app): 14 | db.init_app(app) -------------------------------------------------------------------------------- /App/views/__init__.py: -------------------------------------------------------------------------------- 1 | # blue prints are imported 2 | # explicitly instead of using * 3 | from .user import user_views 4 | from .index import index_views 5 | from .auth import auth_views 6 | from .admin import setup_admin 7 | 8 | 9 | views = [user_views, index_views, auth_views] 10 | # blueprints must be added to this list -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==2.3.3 2 | Flask-SQLAlchemy==3.1.1 3 | Flask-Migrate==3.1.0 4 | Flask-Reuploaded==1.2.0 5 | Flask-Cors==3.0.10 6 | Flask-JWT-Extended==4.4.4 7 | Flask-Admin==1.6.1 8 | Werkzeug>=3.0.0 9 | click==8.1.3 10 | gunicorn==20.1.0 11 | gevent==22.10.2 12 | pytest==7.0.1 13 | psycopg2-binary==2.9.9 14 | python-dotenv==1.0.1 15 | rich==13.4.2 16 | 17 | -------------------------------------------------------------------------------- /App/templates/message.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}{{title}}{% endblock %} 3 | {% block page %}{{title}}{% endblock %} 4 | 5 | {{ super() }} 6 | 7 | {% block content %} 8 |
9 |
10 |
11 |

{{message}}

12 |
13 |
14 |
15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /App/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}Flask MVC App{% endblock %} 3 | {% block page %}Flask MVC App{% endblock %} 4 | 5 | {{ super() }} 6 | 7 | {% block content %} 8 |

Flask MVC

9 | {% if is_authenticated %} 10 |

Welcome {{current_user.username}}

11 | {% endif %} 12 |

This is a boileplate flask application which follows the MVC pattern for structuring the project.

13 | {% endblock %} -------------------------------------------------------------------------------- /gunicorn_config.py: -------------------------------------------------------------------------------- 1 | # gunicorn_config.py 2 | import multiprocessing 3 | 4 | # The socket to bind. 5 | # "0.0.0.0" to bind to all interfaces. 8000 is the port number. 6 | bind = "0.0.0.0:8080" 7 | 8 | # The number of worker processes for handling requests. 9 | workers = 4 10 | 11 | # Use the 'gevent' worker type for async performance. 12 | worker_class = 'gevent' 13 | 14 | # Log level 15 | loglevel = 'info' 16 | 17 | # Where to log to 18 | accesslog = '-' # '-' means log to stdout 19 | errorlog = '-' # '-' means log to stderr -------------------------------------------------------------------------------- /App/static/main.js: -------------------------------------------------------------------------------- 1 | 2 | async function getUserData(){ 3 | const response = await fetch('/api/users'); 4 | return response.json(); 5 | } 6 | 7 | function loadTable(users){ 8 | const table = document.querySelector('#result'); 9 | for(let user of users){ 10 | table.innerHTML += ` 11 | ${user.id} 12 | ${user.username} 13 | `; 14 | } 15 | } 16 | 17 | async function main(){ 18 | const users = await getUserData(); 19 | loadTable(users); 20 | } 21 | 22 | main(); -------------------------------------------------------------------------------- /App/views/index.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, redirect, render_template, request, send_from_directory, jsonify 2 | from App.controllers import create_user, initialize 3 | 4 | index_views = Blueprint('index_views', __name__, template_folder='../templates') 5 | 6 | @index_views.route('/', methods=['GET']) 7 | def index_page(): 8 | return render_template('index.html') 9 | 10 | @index_views.route('/init', methods=['GET']) 11 | def init(): 12 | initialize() 13 | return jsonify(message='db initialized!') 14 | 15 | @index_views.route('/health', methods=['GET']) 16 | def health_check(): 17 | return jsonify({'status':'healthy'}) -------------------------------------------------------------------------------- /.Dockerfile: -------------------------------------------------------------------------------- 1 | # Use an official Python runtime as a parent image 2 | FROM python:3.9-slim 3 | 4 | # Set the working directory in the container 5 | WORKDIR /app 6 | 7 | # Install system dependencies required for mysqlclient 8 | RUN apt-get update && apt-get install -y default-libmysqlclient-dev gcc pkg-config && rm -rf /var/lib/apt/lists/* 9 | 10 | # Copy the current directory contents into the container at /app 11 | COPY . /app 12 | 13 | # Install any needed packages specified in requirements.txt 14 | RUN pip install --no-cache-dir -r requirements.txt 15 | 16 | # Make port 8080 available to the world outside this container 17 | EXPOSE 8085 18 | 19 | 20 | # Run gunicorn when the container launches 21 | CMD ["gunicorn", "-c", "gunicorn_config.py", "wsgi:app"] -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flask-template", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "init": "flask init", 8 | "serve": "flask run", 9 | "e2e": "mocha ./e2e/test.js", 10 | "prod-serve": "gunicorn -c gunicorn_config.py wsgi:app" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/Snickdx/flask-template.git" 15 | }, 16 | "keywords": [], 17 | "author": "", 18 | "license": "ISC", 19 | "bugs": { 20 | "url": "https://github.com/Snickdx/flask-template/issues" 21 | }, 22 | "homepage": "https://github.com/Snickdx/flask-template#readme", 23 | "dependencies": { 24 | "chai": "^4.3.6", 25 | "mocha": "^10.0.0", 26 | "puppeteer-core": "^17.1.3" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /App/views/admin.py: -------------------------------------------------------------------------------- 1 | from flask_admin.contrib.sqla import ModelView 2 | from flask_jwt_extended import jwt_required, current_user, unset_jwt_cookies, set_access_cookies 3 | from flask_admin import Admin 4 | from flask import flash, redirect, url_for, request 5 | from App.database import db 6 | from App.models import User 7 | 8 | class AdminView(ModelView): 9 | 10 | @jwt_required() 11 | def is_accessible(self): 12 | return current_user is not None 13 | 14 | def inaccessible_callback(self, name, **kwargs): 15 | # redirect to login page if user doesn't have access 16 | flash("Login to access admin") 17 | return redirect(url_for('index_page', next=request.url)) 18 | 19 | def setup_admin(app): 20 | admin = Admin(app, name='FlaskMVC', template_mode='bootstrap3') 21 | admin.add_view(AdminView(User, db.session)) -------------------------------------------------------------------------------- /App/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | def load_config(app, overrides): 4 | if os.path.exists(os.path.join('./App', 'custom_config.py')): 5 | app.config.from_object('App.custom_config') 6 | else: 7 | app.config.from_object('App.default_config') 8 | app.config.from_prefixed_env() 9 | app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False 10 | app.config['TEMPLATES_AUTO_RELOAD'] = True 11 | app.config['PREFERRED_URL_SCHEME'] = 'https' 12 | app.config['UPLOADED_PHOTOS_DEST'] = "App/uploads" 13 | app.config['JWT_ACCESS_COOKIE_NAME'] = 'access_token' 14 | app.config["JWT_TOKEN_LOCATION"] = ["cookies", "headers"] 15 | app.config["JWT_COOKIE_SECURE"] = True 16 | app.config["JWT_COOKIE_CSRF_PROTECT"] = False 17 | app.config['FLASK_ADMIN_SWATCH'] = 'darkly' 18 | for key in overrides: 19 | app.config[key] = overrides[key] -------------------------------------------------------------------------------- /App/templates/401.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}Not Authorized{% endblock %} 3 | {% block page %}Not Auhtorized{% endblock %} 4 | 5 | {{ super() }} 6 | 7 | {% block content %} 8 |
9 |
10 |
11 |
12 |

401 - Not Authorized

13 |

You do not have permission to access this resource. Please check your credentials and try again.

14 |

{{error}}

15 |
16 |
17 | Go Home 18 | Go Back 19 |
20 |
21 |
22 |
23 | {% endblock %} -------------------------------------------------------------------------------- /App/models/user.py: -------------------------------------------------------------------------------- 1 | from werkzeug.security import check_password_hash, generate_password_hash 2 | from App.database import db 3 | 4 | class User(db.Model): 5 | id = db.Column(db.Integer, primary_key=True) 6 | username = db.Column(db.String(20), nullable=False, unique=True) 7 | password = db.Column(db.String(256), nullable=False) 8 | 9 | def __init__(self, username, password): 10 | self.username = username 11 | self.set_password(password) 12 | 13 | def get_json(self): 14 | return{ 15 | 'id': self.id, 16 | 'username': self.username 17 | } 18 | 19 | def set_password(self, password): 20 | """Create hashed password.""" 21 | self.password = generate_password_hash(password) 22 | 23 | def check_password(self, password): 24 | """Check hashed password.""" 25 | return check_password_hash(self.password, password) 26 | 27 | -------------------------------------------------------------------------------- /.github/workflows/dev.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Unit & Integration Tests 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build-test: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - name: Setup Python 21 | uses: actions/setup-python@v4 22 | with: 23 | python-version: '3.9.18' 24 | cache: pip 25 | 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install -r requirements.txt 30 | 31 | - name: Run Tests 32 | run: pytest -------------------------------------------------------------------------------- /App/controllers/user.py: -------------------------------------------------------------------------------- 1 | from App.models import User 2 | from App.database import db 3 | 4 | def create_user(username, password): 5 | newuser = User(username=username, password=password) 6 | db.session.add(newuser) 7 | db.session.commit() 8 | return newuser 9 | 10 | def get_user_by_username(username): 11 | result = db.session.execute(db.select(User).filter_by(username=username)) 12 | return result.scalar_one_or_none() 13 | 14 | def get_user(id): 15 | return db.session.get(User, id) 16 | 17 | def get_all_users(): 18 | return db.session.scalars(db.select(User)).all() 19 | 20 | def get_all_users_json(): 21 | users = get_all_users() 22 | if not users: 23 | return [] 24 | users = [user.get_json() for user in users] 25 | return users 26 | 27 | def update_user(id, username): 28 | user = get_user(id) 29 | if user: 30 | user.username = username 31 | # user is already in the session; no need to re-add 32 | db.session.commit() 33 | return True 34 | return None 35 | -------------------------------------------------------------------------------- /render.yaml: -------------------------------------------------------------------------------- 1 | 2 | services: 3 | - type: web 4 | name: flask-postgres-api 5 | env: python 6 | repo: https://github.com/uwidcit/flaskmvc.git 7 | plan: free 8 | branch: main 9 | healthCheckPath: /healthcheck 10 | buildCommand: "pip install -r requirements.txt" 11 | startCommand: "gunicorn wsgi:app" 12 | envVars: 13 | - fromGroup: flask-postgres-api-settings 14 | - key: POSTGRES_URL 15 | fromDatabase: 16 | name: flask-postgres-api-db 17 | property: host 18 | - key: POSTGRES_USER 19 | fromDatabase: 20 | name: flask-postgres-api-db 21 | property: user 22 | - key: POSTGRES_PASSWORD 23 | fromDatabase: 24 | name: flask-postgres-api-db 25 | property: password 26 | - key: POSTGRES_DB 27 | fromDatabase: 28 | name: flask-postgres-api-db 29 | property: database 30 | 31 | envVarGroups: 32 | - name: flask-postgres-api-settings 33 | envVars: 34 | - key: ENV 35 | value: production 36 | - key: FLASK_APP 37 | value: wsgi.py 38 | 39 | 40 | databases: 41 | - name: flask-postgres-api-db 42 | plan: free 43 | databaseName: mydb -------------------------------------------------------------------------------- /App/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | from flask import Flask, render_template 3 | from flask_uploads import DOCUMENTS, IMAGES, TEXT, UploadSet, configure_uploads 4 | from flask_cors import CORS 5 | from werkzeug.utils import secure_filename 6 | from werkzeug.datastructures import FileStorage 7 | 8 | from App.database import init_db 9 | from App.config import load_config 10 | 11 | 12 | from App.controllers import ( 13 | setup_jwt, 14 | add_auth_context 15 | ) 16 | 17 | from App.views import views, setup_admin 18 | 19 | 20 | 21 | def add_views(app): 22 | for view in views: 23 | app.register_blueprint(view) 24 | 25 | def create_app(overrides={}): 26 | app = Flask(__name__, static_url_path='/static') 27 | load_config(app, overrides) 28 | CORS(app) 29 | add_auth_context(app) 30 | photos = UploadSet('photos', TEXT + DOCUMENTS + IMAGES) 31 | configure_uploads(app, photos) 32 | add_views(app) 33 | init_db(app) 34 | jwt = setup_jwt(app) 35 | setup_admin(app) 36 | @jwt.invalid_token_loader 37 | @jwt.unauthorized_loader 38 | def custom_unauthorized_response(error): 39 | return render_template('401.html', error=error), 401 40 | app.app_context().push() 41 | return app -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/base:ubuntu-22.04 2 | 3 | ARG DEBIAN_FRONTEND=noninteractive 4 | ARG USER=vscode 5 | RUN apt update \ 6 | && apt install -y --no-install-recommends curl wget git sudo build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev libsqlite3-dev libncursesw5-dev xz-utils tk-dev libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev \ 7 | && apt autoremove -y \ 8 | && rm -rf /var/lib/apt/lists/* \ 9 | && usermod -s /usr/bin/zsh ${USER} 10 | 11 | USER ${USER} 12 | ARG HOME="/home/${USER}" 13 | WORKDIR ${HOME} 14 | 15 | ARG PYTHON_VERSION=3.9 16 | ENV PYENV_ROOT=${HOME}/.pyenv 17 | ARG PYENV_PATH="${PYENV_ROOT}/bin:${PYENV_ROOT}/shims" 18 | ARG PDM_PATH="${HOME}/.local/bin" 19 | ENV PATH="${PYENV_PATH}:${PDM_PATH}:$PATH" 20 | RUN set -x \ 21 | && curl http://pyenv.run | bash \ 22 | && echo 'eval "$(pyenv init -)"' >>${HOME}/.zshrc \ 23 | && pyenv install -v ${PYTHON_VERSION} \ 24 | && pyenv global ${PYTHON_VERSION} 25 | 26 | ARG ZSH_CUSTOM=${HOME}/.oh-my-zsh/custom 27 | RUN curl -sSL https://pdm.fming.dev/install-pdm.py | python - \ 28 | && mkdir ${ZSH_CUSTOM}/plugins/pdm \ 29 | && pdm completion zsh > ${ZSH_CUSTOM}/plugins/pdm/_pdm \ 30 | && sed -i "s|^plugins=(|&pdm |" ${HOME}/.zshrc 31 | -------------------------------------------------------------------------------- /App/views/user.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, render_template, jsonify, request, send_from_directory, flash, redirect, url_for 2 | from flask_jwt_extended import jwt_required, current_user as jwt_current_user 3 | 4 | from.index import index_views 5 | 6 | from App.controllers import ( 7 | create_user, 8 | get_all_users, 9 | get_all_users_json, 10 | jwt_required 11 | ) 12 | 13 | user_views = Blueprint('user_views', __name__, template_folder='../templates') 14 | 15 | @user_views.route('/users', methods=['GET']) 16 | def get_user_page(): 17 | users = get_all_users() 18 | return render_template('users.html', users=users) 19 | 20 | @user_views.route('/users', methods=['POST']) 21 | def create_user_action(): 22 | data = request.form 23 | flash(f"User {data['username']} created!") 24 | create_user(data['username'], data['password']) 25 | return redirect(url_for('user_views.get_user_page')) 26 | 27 | @user_views.route('/api/users', methods=['GET']) 28 | def get_users_action(): 29 | users = get_all_users_json() 30 | return jsonify(users) 31 | 32 | @user_views.route('/api/users', methods=['POST']) 33 | def create_user_endpoint(): 34 | data = request.json 35 | user = create_user(data['username'], data['password']) 36 | return jsonify({'message': f"user {user.username} created with id {user.id}"}) 37 | 38 | @user_views.route('/static/users', methods=['GET']) 39 | def static_user_page(): 40 | return send_from_directory('static', 'static-user.html') -------------------------------------------------------------------------------- /App/templates/users.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}App Users{% endblock %} 3 | {% block page %}App Users{% endblock %} 4 | 5 | {{ super() }} 6 | 7 | {% block content %} 8 |
9 |

10 | This is table is renderd on the Server. Flask gets the data from the database and uses jinja templates to dyanmically render this page when a request is sent to /users. 11 |

12 |
13 | 14 |
15 |
16 | 17 |
18 |
19 | 20 | 21 |
22 |
23 | 24 | 25 |
26 |
27 | 28 |
29 |
30 | 31 |
32 |
33 | 34 |
35 |
36 | 37 |
38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | {% for user in users %} 46 | 47 | 48 | 49 | 50 | {% endfor %} 51 | 52 |
IdUsername
{{user.id}}{{user.username}}
53 |
54 | 55 | {% endblock %} 56 | -------------------------------------------------------------------------------- /App/static/static-user.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | App Users 12 | 13 | 14 | 15 | 27 | 28 |
29 |
30 |

31 | This is table is rendered on the Client. JavaScript code requests the data from /api/users and writes it to this page. 32 |

33 |
34 |
35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 |
IdUsername
45 |
46 |
47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /App/controllers/auth.py: -------------------------------------------------------------------------------- 1 | from flask_jwt_extended import create_access_token, jwt_required, JWTManager, get_jwt_identity, verify_jwt_in_request 2 | 3 | from App.models import User 4 | from App.database import db 5 | 6 | def login(username, password): 7 | result = db.session.execute(db.select(User).filter_by(username=username)) 8 | user = result.scalar_one_or_none() 9 | if user and user.check_password(password): 10 | # Store ONLY the user id as a string in JWT 'sub' 11 | return create_access_token(identity=str(user.id)) 12 | return None 13 | 14 | 15 | def setup_jwt(app): 16 | jwt = JWTManager(app) 17 | 18 | # Always store a string user id in the JWT identity (sub), 19 | # whether a User object or a raw id is passed. 20 | @jwt.user_identity_loader 21 | def user_identity_lookup(identity): 22 | user_id = getattr(identity, "id", identity) 23 | return str(user_id) if user_id is not None else None 24 | 25 | @jwt.user_lookup_loader 26 | def user_lookup_callback(_jwt_header, jwt_data): 27 | identity = jwt_data["sub"] 28 | # Cast back to int primary key 29 | try: 30 | user_id = int(identity) 31 | except (TypeError, ValueError): 32 | return None 33 | return db.session.get(User, user_id) 34 | 35 | return jwt 36 | 37 | 38 | # Context processor to make 'is_authenticated' available to all templates 39 | def add_auth_context(app): 40 | @app.context_processor 41 | def inject_user(): 42 | try: 43 | verify_jwt_in_request() 44 | identity = get_jwt_identity() 45 | user_id = int(identity) if identity is not None else None 46 | current_user = db.session.get(User, user_id) if user_id is not None else None 47 | is_authenticated = current_user is not None 48 | except Exception as e: 49 | print(e) 50 | is_authenticated = False 51 | current_user = None 52 | return dict(is_authenticated=is_authenticated, current_user=current_user) -------------------------------------------------------------------------------- /App/views/auth.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, render_template, jsonify, request, flash, send_from_directory, flash, redirect, url_for 2 | from flask_jwt_extended import jwt_required, current_user, unset_jwt_cookies, set_access_cookies 3 | 4 | 5 | from.index import index_views 6 | 7 | from App.controllers import ( 8 | login, 9 | 10 | ) 11 | 12 | auth_views = Blueprint('auth_views', __name__, template_folder='../templates') 13 | 14 | 15 | 16 | 17 | ''' 18 | Page/Action Routes 19 | ''' 20 | 21 | @auth_views.route('/identify', methods=['GET']) 22 | @jwt_required() 23 | def identify_page(): 24 | return render_template('message.html', title="Identify", message=f"You are logged in as {current_user.id} - {current_user.username}") 25 | 26 | 27 | @auth_views.route('/login', methods=['POST']) 28 | def login_action(): 29 | data = request.form 30 | token = login(data['username'], data['password']) 31 | response = redirect(request.referrer) 32 | if not token: 33 | flash('Bad username or password given'), 401 34 | else: 35 | flash('Login Successful') 36 | set_access_cookies(response, token) 37 | return response 38 | 39 | @auth_views.route('/logout', methods=['GET']) 40 | def logout_action(): 41 | response = redirect(request.referrer) 42 | flash("Logged Out!") 43 | unset_jwt_cookies(response) 44 | return response 45 | 46 | ''' 47 | API Routes 48 | ''' 49 | 50 | @auth_views.route('/api/login', methods=['POST']) 51 | def user_login_api(): 52 | data = request.json 53 | token = login(data['username'], data['password']) 54 | if not token: 55 | return jsonify(message='bad username or password given'), 401 56 | response = jsonify(access_token=token) 57 | set_access_cookies(response, token) 58 | return response 59 | 60 | @auth_views.route('/api/identify', methods=['GET']) 61 | @jwt_required() 62 | def identify_user(): 63 | return jsonify({'message': f"username: {current_user.username}, id : {current_user.id}"}) 64 | 65 | @auth_views.route('/api/logout', methods=['GET']) 66 | def logout_api(): 67 | response = jsonify(message="Logged Out!") 68 | unset_jwt_cookies(response) 69 | return response -------------------------------------------------------------------------------- /wsgi.py: -------------------------------------------------------------------------------- 1 | import click, pytest, sys 2 | from flask.cli import with_appcontext, AppGroup 3 | 4 | from App.database import db, get_migrate 5 | from App.models import User 6 | from App.main import create_app 7 | from App.controllers import ( create_user, get_all_users_json, get_all_users, initialize ) 8 | 9 | 10 | # This commands file allow you to create convenient CLI commands for testing controllers 11 | 12 | app = create_app() 13 | migrate = get_migrate(app) 14 | 15 | # This command creates and initializes the database 16 | @app.cli.command("init", help="Creates and initializes the database") 17 | def init(): 18 | initialize() 19 | print('database intialized') 20 | 21 | ''' 22 | User Commands 23 | ''' 24 | 25 | # Commands can be organized using groups 26 | 27 | # create a group, it would be the first argument of the comand 28 | # eg : flask user 29 | user_cli = AppGroup('user', help='User object commands') 30 | 31 | # Then define the command and any parameters and annotate it with the group (@) 32 | @user_cli.command("create", help="Creates a user") 33 | @click.argument("username", default="rob") 34 | @click.argument("password", default="robpass") 35 | def create_user_command(username, password): 36 | create_user(username, password) 37 | print(f'{username} created!') 38 | 39 | # this command will be : flask user create bob bobpass 40 | 41 | @user_cli.command("list", help="Lists users in the database") 42 | @click.argument("format", default="string") 43 | def list_user_command(format): 44 | if format == 'string': 45 | print(get_all_users()) 46 | else: 47 | print(get_all_users_json()) 48 | 49 | app.cli.add_command(user_cli) # add the group to the cli 50 | 51 | ''' 52 | Test Commands 53 | ''' 54 | 55 | test = AppGroup('test', help='Testing commands') 56 | 57 | @test.command("user", help="Run User tests") 58 | @click.argument("type", default="all") 59 | def user_tests_command(type): 60 | if type == "unit": 61 | sys.exit(pytest.main(["-k", "UserUnitTests"])) 62 | elif type == "int": 63 | sys.exit(pytest.main(["-k", "UserIntegrationTests"])) 64 | else: 65 | sys.exit(pytest.main(["-k", "App"])) 66 | 67 | 68 | app.cli.add_command(test) -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Python3 & pyenv & PDM", 3 | "build": { 4 | "dockerfile": "Dockerfile" 5 | }, 6 | // 👇 Features to add to the Dev Container. More info: https://containers.dev/implementors/features. 7 | // "features": { "ghcr.io/devcontainers/features/docker-in-docker:2": {} }, 8 | 9 | // 👇 Use 'forwardPorts' to make a list of ports inside the container available locally. 10 | // "forwardPorts": [], 11 | // 👇 Use 'postCreateCommand' to run commands after the container is created. 12 | "postCreateCommand": "pyenv install && pip install -r requirements.txt", 13 | // "postCreateCommand": "", 14 | // 👇 Configure tool-specific properties. 15 | "customizations": { 16 | "vscode": { 17 | "extensions": [ 18 | // python 19 | "ms-python.python", 20 | "donjayamanne.python-environment-manager", 21 | "GitHub.copilot" 22 | // "kevinrose.vsc-python-indent", 23 | // "visualstudioexptteam.vscodeintellicode", 24 | // "njpwerner.autodocstring", 25 | 26 | // // python lint 27 | // "ms-python.black-formatter", 28 | // "charliermarsh.ruff", 29 | 30 | // // shell 31 | // "foxundermoon.shell-format", 32 | // "timonwong.shellcheck", 33 | 34 | // // env 35 | // "mikestead.dotenv", 36 | 37 | // // git 38 | // "donjayamanne.githistory", 39 | // "eamodio.gitlens", 40 | // "mhutchie.git-graph", 41 | // "GitHub.vscode-pull-request-github", 42 | // "codezombiech.gitignore", 43 | 44 | // // other 45 | // "ultram4rine.vscode-choosealicense", 46 | // "streetsidesoftware.code-spell-checker", 47 | // "tamasfe.even-better-toml", 48 | // "esbenp.prettier-vscode", 49 | // "editorconfig.editorconfig", 50 | // "redhat.vscode-yaml", 51 | // "davidanson.vscode-markdownlint", 52 | // "oderwat.indent-rainbow", 53 | // "christian-kohler.path-intellisense" 54 | ] 55 | // "settings": { 56 | // "shellformat.path": "/usr/local/bin/shfmt", 57 | // "shellformat.useEditorConfig": true, 58 | // "editor.defaultFormatter": "esbenp.prettier-vscode" 59 | // } 60 | } 61 | }, 62 | "forwardPorts": [8080], 63 | "portsAttributes": { 64 | "8080": { 65 | "label": "server", 66 | "protocol": "http" 67 | } 68 | } 69 | // 👇 Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 70 | // "remoteUser": "root" 71 | } 72 | -------------------------------------------------------------------------------- /App/tests/test_app.py: -------------------------------------------------------------------------------- 1 | import os, tempfile, pytest, logging, unittest 2 | from werkzeug.security import check_password_hash, generate_password_hash 3 | 4 | from App.main import create_app 5 | from App.database import db, create_db 6 | from App.models import User 7 | from App.controllers import ( 8 | create_user, 9 | get_all_users_json, 10 | login, 11 | get_user, 12 | get_user_by_username, 13 | update_user 14 | ) 15 | 16 | 17 | LOGGER = logging.getLogger(__name__) 18 | 19 | ''' 20 | Unit Tests 21 | ''' 22 | class UserUnitTests(unittest.TestCase): 23 | 24 | def test_new_user(self): 25 | user = User("bob", "bobpass") 26 | assert user.username == "bob" 27 | 28 | # pure function no side effects or integrations called 29 | def test_get_json(self): 30 | user = User("bob", "bobpass") 31 | user_json = user.get_json() 32 | self.assertDictEqual(user_json, {"id":None, "username":"bob"}) 33 | 34 | def test_hashed_password(self): 35 | password = "mypass" 36 | hashed = generate_password_hash(password) 37 | user = User("bob", password) 38 | assert user.password != password 39 | 40 | def test_check_password(self): 41 | password = "mypass" 42 | user = User("bob", password) 43 | assert user.check_password(password) 44 | 45 | ''' 46 | Integration Tests 47 | ''' 48 | 49 | # This fixture creates an empty database for the test and deletes it after the test 50 | # scope="class" would execute the fixture once and resued for all methods in the class 51 | @pytest.fixture(autouse=True, scope="module") 52 | def empty_db(): 53 | app = create_app({'TESTING': True, 'SQLALCHEMY_DATABASE_URI': 'sqlite:///test.db'}) 54 | create_db() 55 | yield app.test_client() 56 | db.drop_all() 57 | 58 | 59 | def test_authenticate(): 60 | user = create_user("bob", "bobpass") 61 | assert login("bob", "bobpass") != None 62 | 63 | class UsersIntegrationTests(unittest.TestCase): 64 | 65 | def test_create_user(self): 66 | user = create_user("rick", "bobpass") 67 | assert user.username == "rick" 68 | 69 | def test_get_all_users_json(self): 70 | users_json = get_all_users_json() 71 | self.assertListEqual([{"id":1, "username":"bob"}, {"id":2, "username":"rick"}], users_json) 72 | 73 | # Tests data changes in the database 74 | def test_update_user(self): 75 | update_user(1, "ronnie") 76 | user = get_user(1) 77 | assert user.username == "ronnie" 78 | 79 | 80 | -------------------------------------------------------------------------------- /App/templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | {% block title %}{% endblock %} 13 | 14 | 15 | 16 | 17 | 48 | 53 | 54 |
{% block content %}{% endblock %}
55 | 56 | 57 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /e2e/test.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer-core'); 2 | const { expect, assert } = require('chai'); 3 | const config = require('./config.json'); 4 | 5 | let browser; 6 | let page; 7 | let requests = []; 8 | 9 | const host = 'http://localhost:8080'; 10 | 11 | before(async function(){ 12 | this.timeout(config.timeout); 13 | browser = await puppeteer.launch(config); 14 | [page] = await browser.pages(); 15 | 16 | await page.emulateMediaType("screen"); 17 | await page.setRequestInterception(true); 18 | 19 | page.on('request', request => { 20 | requests.push(request.url()); 21 | request.continue(); 22 | }); 23 | 24 | await page.goto(`${host}/static/users`, { waitUntil: 'networkidle2'}); 25 | }); 26 | 27 | function getHTML(selector){ 28 | return page.evaluate(selector=>{ 29 | try{ 30 | return document.querySelector(selector).outerHTML; 31 | }catch(e){ 32 | return null; 33 | } 34 | }, selector); 35 | } 36 | 37 | function getInnerText(a) { 38 | return page.evaluate(a => document.querySelector(a).innerText, a) 39 | } 40 | 41 | function checkElements(a) { 42 | for (let [b, c] of Object.entries(a)) it(`Should have ${b}`, async () => { 43 | expect(await page.$(c)).to.be.ok 44 | }) 45 | } 46 | 47 | context('The /static/users page', ()=>{ 48 | 49 | it('Test 1: Should send a http request to /api/users', async ()=>{ 50 | let reqs = [`${host}/api/users`]; 51 | let count = 0; 52 | 53 | reqs.forEach(req => { 54 | if(requests.includes(req))count++ 55 | }) 56 | 57 | expect(count).to.equal(1); 58 | 59 | }).timeout(2000); 60 | 61 | it("Test 2: Page should have App Users as the title", async () => { 62 | expect(await page.title()).to.eql("App Users") 63 | }); 64 | 65 | //hello im on nick branch 66 | 67 | describe("Test 3: Page should have a users table header", () => { 68 | it("First table header should be 'Id'", async () => { 69 | const html = await page.$eval('tr>th:nth-child(1)', (e) => e.innerHTML); 70 | expect(html).to.eql("Id") 71 | }); 72 | 73 | it("Second table header should be 'First Name'", async () => { 74 | const html = await page.$eval('tr>th:nth-child(2)', (e) => e.innerHTML); 75 | expect(html).to.eql("First Name") 76 | }); 77 | 78 | it("Third table header should be 'Last Name'", async () => { 79 | const html = await page.$eval('tr>th:nth-child(3)', (e) => e.innerHTML); 80 | expect(html).to.eql("Last Name") 81 | }); 82 | 83 | }) 84 | 85 | // it('Test 2: Should user table header on page', async ()=>{ 86 | // await page.waitForSelector('#pokemon-detail') 87 | 88 | // let searchKeys = [ 'grass', '1', '6.9', '0.7' ] 89 | 90 | // let result = await getHTML('#pokemon-detail'); 91 | 92 | // let count = 0; 93 | 94 | // for(let key of searchKeys){ 95 | // if(result.includes(key))count++ 96 | // } 97 | 98 | // expect(count).to.eql(4); 99 | 100 | // }).timeout(2000); 101 | 102 | 103 | }); 104 | 105 | after(async () => { 106 | await browser.close(); 107 | }); -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | App/temp-database.db 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | App/custom_config.py 7 | # C extensions 8 | *.so 9 | 10 | migrations/* 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | cover/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | .pybuilder/ 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | # For a library or package, you might want to ignore these files since the code is 90 | # intended to run in multiple environments; otherwise, check them in: 91 | # .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # poetry 101 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 102 | # This is especially recommended for binary packages to ensure reproducibility, and is more 103 | # commonly ignored for libraries. 104 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 105 | #poetry.lock 106 | 107 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 108 | __pypackages__/ 109 | 110 | # Celery stuff 111 | celerybeat-schedule 112 | celerybeat.pid 113 | 114 | # SageMath parsed files 115 | *.sage.py 116 | 117 | # Environments 118 | .venv 119 | env/ 120 | venv/ 121 | ENV/ 122 | env.bak/ 123 | venv.bak/ 124 | 125 | # Spyder project settings 126 | .spyderproject 127 | .spyproject 128 | 129 | # Rope project settings 130 | .ropeproject 131 | 132 | # mkdocs documentation 133 | /site 134 | 135 | # mypy 136 | .mypy_cache/ 137 | .dmypy.json 138 | dmypy.json 139 | 140 | # Pyre type checker 141 | .pyre/ 142 | 143 | # pytype static type analyzer 144 | .pytype/ 145 | 146 | # Cython debug symbols 147 | cython_debug/ 148 | 149 | # PyCharm 150 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 151 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 152 | # and can be added to the global gitignore or merged into this file. For a more nuclear 153 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 154 | #.idea/ 155 | 156 | node_modules/ -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ![Tests](https://github.com/uwidcit/flaskmvc/actions/workflows/dev.yml/badge.svg) 2 | 3 | # Flask MVC Template 4 | A template for flask applications structured in the Model View Controller pattern [Demo](https://dcit-flaskmvc.herokuapp.com/). [Postman Collection](https://documenter.getpostman.com/view/583570/2s83zcTnEJ) 5 | 6 | 7 | # Dependencies 8 | * Python3/pip3 9 | * Packages listed in requirements.txt 10 | 11 | # Installing Dependencies 12 | ```bash 13 | $ pip install -r requirements.txt 14 | ``` 15 | 16 | # Configuration Management 17 | 18 | 19 | Configuration information such as the database url/port, credentials, API keys etc are to be supplied to the application. However, it is bad practice to stage production information in publicly visible repositories. 20 | Instead, all config is provided by a config file or via [environment variables](https://linuxize.com/post/how-to-set-and-list-environment-variables-in-linux/). 21 | 22 | ## In Development 23 | 24 | When running the project in a development environment (such as gitpod) the app is configured via default_config.py file in the App folder. By default, the config for development uses a sqlite database. 25 | 26 | default_config.py 27 | ```python 28 | SQLALCHEMY_DATABASE_URI = "sqlite:///temp-database.db" 29 | SECRET_KEY = "secret key" 30 | JWT_ACCESS_TOKEN_EXPIRES = 7 31 | ENV = "DEVELOPMENT" 32 | ``` 33 | 34 | These values would be imported and added to the app in load_config() function in config.py 35 | 36 | config.py 37 | ```python 38 | # must be updated to inlude addtional secrets/ api keys & use a gitignored custom-config file instad 39 | def load_config(): 40 | config = {'ENV': os.environ.get('ENV', 'DEVELOPMENT')} 41 | delta = 7 42 | if config['ENV'] == "DEVELOPMENT": 43 | from .default_config import JWT_ACCESS_TOKEN_EXPIRES, SQLALCHEMY_DATABASE_URI, SECRET_KEY 44 | config['SQLALCHEMY_DATABASE_URI'] = SQLALCHEMY_DATABASE_URI 45 | config['SECRET_KEY'] = SECRET_KEY 46 | delta = JWT_ACCESS_TOKEN_EXPIRES 47 | ... 48 | ``` 49 | 50 | ## In Production 51 | 52 | When deploying your application to production/staging you must pass 53 | in configuration information via environment tab of your render project's dashboard. 54 | 55 | ![perms](./images/fig1.png) 56 | 57 | # Flask Commands 58 | 59 | wsgi.py is a utility script for performing various tasks related to the project. You can use it to import and test any code in the project. 60 | You just need create a manager command function, for example: 61 | 62 | ```python 63 | # inside wsgi.py 64 | 65 | user_cli = AppGroup('user', help='User object commands') 66 | 67 | @user_cli.cli.command("create-user") 68 | @click.argument("username") 69 | @click.argument("password") 70 | def create_user_command(username, password): 71 | create_user(username, password) 72 | print(f'{username} created!') 73 | 74 | app.cli.add_command(user_cli) # add the group to the cli 75 | 76 | ``` 77 | 78 | Then execute the command invoking with flask cli with command name and the relevant parameters 79 | 80 | ```bash 81 | $ flask user create bob bobpass 82 | ``` 83 | 84 | 85 | # Running the Project 86 | 87 | _For development run the serve command (what you execute):_ 88 | ```bash 89 | $ flask run 90 | ``` 91 | 92 | _For production using gunicorn (what the production server executes):_ 93 | ```bash 94 | $ gunicorn wsgi:app 95 | ``` 96 | 97 | # Deploying 98 | You can deploy your version of this app to render by clicking on the "Deploy to Render" link above. 99 | 100 | # Initializing the Database 101 | When connecting the project to a fresh empty database ensure the appropriate configuration is set then file then run the following command. This must also be executed once when running the app on heroku by opening the heroku console, executing bash and running the command in the dyno. 102 | 103 | ```bash 104 | $ flask init 105 | ``` 106 | 107 | # Database Migrations 108 | If changes to the models are made, the database must be'migrated' so that it can be synced with the new models. 109 | Then execute following commands using manage.py. More info [here](https://flask-migrate.readthedocs.io/en/latest/) 110 | 111 | ```bash 112 | $ flask db init 113 | $ flask db migrate 114 | $ flask db upgrade 115 | $ flask db --help 116 | ``` 117 | 118 | # Testing 119 | 120 | ## Unit & Integration 121 | Unit and Integration tests are created in the App/test. You can then create commands to run them. Look at the unit test command in wsgi.py for example 122 | 123 | ```python 124 | @test.command("user", help="Run User tests") 125 | @click.argument("type", default="all") 126 | def user_tests_command(type): 127 | if type == "unit": 128 | sys.exit(pytest.main(["-k", "UserUnitTests"])) 129 | elif type == "int": 130 | sys.exit(pytest.main(["-k", "UserIntegrationTests"])) 131 | else: 132 | sys.exit(pytest.main(["-k", "User"])) 133 | ``` 134 | 135 | You can then execute all user tests as follows 136 | 137 | ```bash 138 | $ flask test user 139 | ``` 140 | 141 | You can also supply "unit" or "int" at the end of the comand to execute only unit or integration tests. 142 | 143 | You can run all application tests with the following command 144 | 145 | ```bash 146 | $ pytest 147 | ``` 148 | 149 | ## Test Coverage 150 | 151 | You can generate a report on your test coverage via the following command 152 | 153 | ```bash 154 | $ coverage report 155 | ``` 156 | 157 | You can also generate a detailed html report in a directory named htmlcov with the following comand 158 | 159 | ```bash 160 | $ coverage html 161 | ``` 162 | 163 | # Troubleshooting 164 | 165 | ## Views 404ing 166 | 167 | If your newly created views are returning 404 ensure that they are added to the list in main.py. 168 | 169 | ```python 170 | from App.views import ( 171 | user_views, 172 | index_views 173 | ) 174 | 175 | # New views must be imported and added to this list 176 | views = [ 177 | user_views, 178 | index_views 179 | ] 180 | ``` 181 | 182 | ## Cannot Update Workflow file 183 | 184 | If you are running into errors in gitpod when updateding your github actions file, ensure your [github permissions](https://gitpod.io/integrations) in gitpod has workflow enabled ![perms](./images/gitperms.png) 185 | 186 | ## Database Issues 187 | 188 | If you are adding models you may need to migrate the database with the commands given in the previous database migration section. Alternateively you can delete you database file. 189 | --------------------------------------------------------------------------------