diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..9fe58d8 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,17 @@ +name: flake8 Lint + +on: [push, pull_request] + +jobs: + flake8-lint: + runs-on: ubuntu-latest + name: Lint + steps: + - name: Check out source repository + uses: actions/checkout@v3 + - name: Set up Python environment + uses: actions/setup-python@v4 + with: + python-version: "3.11.3" + - name: flake8 Lint + uses: py-actions/flake8@v2 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7da26ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,130 @@ +# Byte-compiled / optimized / DLL files +**/__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ +.DS_Store diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..6e67165 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,15 @@ +{ + "recommendations": [ + // linters and formatters + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "stylelint.vscode-stylelint", + "ms-python.flake8", + "ms-python.black-formatter", + // python + "ms-python.python", + "ms-python.vscode-pylance", + // Jinja + "samuelcolvin.jinjahtml", + ] + } \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3c577b0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8d61066 --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# Flask Boilerplate +Your simple, yet opinionated, boilerplate for Flask projects. + +## Why? +I've been using Flask for a while now, and I've found myself repeating the same steps over and over again when starting a new project. In order to save myself (and you) some time, I decided to create this boilerplate. + +## Features +- User authentication (login, logout, register) +- 'Sane' defaults for Flask configuration +- A understandable project file structure +- A simple SQLite database (you can, of course, replace this with any other database that SQLAlchemy supports) +- Jinja2 templates with Flowbite (Tailwind) CSS (CDN version) +- A CLI for managing aspects of your project (use `kettle --help` to see the available commands)* + +**In order to use `kettle`, you will need to install it with `pip install --editable cli` from the root directory of the project.* + +## Get started + +*I'm assuming you have Python 3.11+ installed on your machine and that you are comfortable with using the command line on your machine.* + +1. Clone this repository and `cd` into it +2. Create a virtual environment with `python3 -m venv venv` (or `python -m venv venv` if you're on Windows) and activate it with `source venv/bin/activate` (or `venv\Scripts\activate.bat` if you're on Windows) +3. Install the dependencies with `pip install -r requirements.txt` +4. Run the application with `flask run` (or `python -m flask run` if you're on Windows) - you can also append `--debug` to enable debug mode +5. Open your browser and go to `http://localhost:5000` + +And voilà, you're ready to open this project in your favorite editor and start working on your next big thing! + +## Contributing +If you have any suggestions or improvements, then feel free to open an issue or a pull request. I'll be happy to take a look at it! + +## Thanks + +### Icons +The boiler, gears and download icon (as seen on the front page) are sourced from [Icons8](https://icons8.com/). + +### CSS/UI +The CSS and UI is sourced from [Flowbite](https://flowbite.com/), which is a library of components and layouts for [Tailwind CSS](https://tailwindcss.com/). + +### User profile avatars +The user profile avatars are sourced from [UI Avatars](https://ui-avatars.com/). + +### Libraries +- [Flask](https://flask.palletsprojects.com/en/) +- [Flask-Login](https://flask-login.readthedocs.io/en/latest/) +- [Flask-WTF](https://flask-wtf.readthedocs.io/en/) +- [Flask-SQLAlchemy](https://flask-sqlalchemy.palletsprojects.com/en/) +- [python-dotenv](https://pypi.org/project/python-dotenv/) + +*See the `requirements.txt` for the full list of libraries that is used.* + +## License/Legal/"Don't sue me, thanks" section +The boilerplate is not licensed, however, the libraries used are. Please check the respective licenses for each library. + +Disclaimer: Flask is a trademark of [Pallets](https://palletsprojects.com/), and is not affiliated with this project in any way. \ No newline at end of file diff --git a/app.db b/app.db new file mode 100644 index 0000000..09fa6c6 Binary files /dev/null and b/app.db differ diff --git a/app.py b/app.py new file mode 100644 index 0000000..0a23b5a --- /dev/null +++ b/app.py @@ -0,0 +1,3 @@ +from app import create_app + +app = create_app() diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..2d543c4 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,38 @@ +from flask import Flask, abort +from config import Config +from flask_sqlalchemy import SQLAlchemy +from flask_login import LoginManager + +from .modules.util.config import eval_bool_env_var + +db = SQLAlchemy() +login = LoginManager() + + +def create_app(config_class=Config): + app = Flask(__name__, template_folder="templates") + app.config.from_object(Config) + + db.init_app(app) + login.init_app(app) + login.login_view = "auth.login" + + from app.main import main as main + + app.register_blueprint(main) + + from app.auth import auth as auth + + app.register_blueprint(auth) + + from app import errors + + app.register_blueprint(errors.error) + + @app.before_request + def maintenance_mode(): + # If maintenance mode is enabled, return a 503 + if eval_bool_env_var(app.config["MAINTENANCE_MODE"]): + abort(503) + + return app diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 0000000..5f44921 --- /dev/null +++ b/app/auth/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +auth = Blueprint("auth", __name__) + +from app.auth import routes # noqa: E402, F401 diff --git a/app/auth/forms.py b/app/auth/forms.py new file mode 100644 index 0000000..38a4cca --- /dev/null +++ b/app/auth/forms.py @@ -0,0 +1,22 @@ +from flask_wtf import FlaskForm +from wtforms import EmailField, StringField, PasswordField, SubmitField +from wtforms.validators import DataRequired, Email, Length + + +class RegistrationForm(FlaskForm): + first_name = StringField( + "First Name", validators=[DataRequired(), Length(min=2, max=30)] + ) + last_name = StringField( + "Last Name", validators=[DataRequired(), Length(min=2, max=30)] + ) + email = EmailField("Email", validators=[DataRequired(), Email()]) + password = PasswordField("Password", validators=[DataRequired()]) + repeat_password = PasswordField("Password", validators=[DataRequired()]) + submit = SubmitField("Register") + + +class LoginForm(FlaskForm): + email = EmailField("Email", validators=[DataRequired(), Email()]) + password = PasswordField("Password", validators=[DataRequired()]) + submit = SubmitField("Login") diff --git a/app/auth/routes.py b/app/auth/routes.py new file mode 100644 index 0000000..66faf62 --- /dev/null +++ b/app/auth/routes.py @@ -0,0 +1,94 @@ +from flask import render_template, redirect, url_for, flash +from flask_login import login_user, logout_user, login_required +from app.auth.forms import LoginForm, RegistrationForm +from app.auth import auth +from app.models import Users +from app import db + +from app.modules.util.decorators import redirect_if_already_authenticated +from app.modules.util.auth import ( + check_if_user_already_exists, + check_password_confirmation, + check_password_strength, +) +from app.modules.util.forms import collect_form_data + + +@auth.route("/login", methods=["GET", "POST"]) +@redirect_if_already_authenticated +def login(): + form = LoginForm() + if form.validate_on_submit(): + user = Users.query.filter_by(email=form.email.data).first() + + if user is None or not user.check_password(form.password.data): + flash("Your credentials are invalid", "warning") + return redirect(url_for("auth.login")) + + login_user(user) + return redirect(url_for("main.index")) + + return render_template("auth/login.html", title="Sign In", form=form) + + +@auth.route("/register", methods=["GET", "POST"]) +@redirect_if_already_authenticated +def register(): + form = RegistrationForm() + if form.validate_on_submit(): + old_form_data = collect_form_data( + form.first_name, form.last_name, form.email + ) + + if check_if_user_already_exists(form.email.data): + flash("Email invalid or already in use", "warning") + return render_template( + "auth/register.html", + title="Register", + form=form, + old_form_data=old_form_data, + ) + + if not check_password_confirmation( + form.password.data, form.repeat_password.data + ): + flash("Passwords do not match", "warning") + return render_template( + "auth/register.html", + title="Register", + form=form, + old_form_data=old_form_data, + ) + + if not check_password_strength(form.password.data): + flash("Password is not strong enough", "warning") + return render_template( + "auth/register.html", + title="Register", + form=form, + old_form_data=old_form_data, + ) + + user = Users( + email=form.email.data, + first_name=form.first_name.data, + last_name=form.last_name.data, + ) + user.set_password(form.password.data) + db.session.add(user) + db.session.commit() + + flash("Account successfully registered", "success") + return redirect(url_for("auth.login")) + + return render_template( + "auth/register.html", title="Register", form=form, old_form_data=None + ) + + +@auth.route("/logout") +@login_required +def logout(): + logout_user() + flash("You've signed out", "success") + return redirect(url_for("auth.login")) diff --git a/app/errors.py b/app/errors.py new file mode 100644 index 0000000..a0c06b0 --- /dev/null +++ b/app/errors.py @@ -0,0 +1,38 @@ +from flask import Blueprint, render_template + +error = Blueprint("error_bp", __name__) + + +@error.app_errorhandler(400) +def bad_request(e): + return render_template("errors/401.html", title="Bad Request"), 400 + + +@error.app_errorhandler(401) +def unauthorized(e): + return render_template("errors/401.html", title="Unauthorized"), 401 + + +@error.app_errorhandler(403) +def forbidden(e): + return render_template("errors/401.html", title="Access Forbidden"), 403 + + +@error.app_errorhandler(405) +def method_not_allowed(e): + return render_template("errors/401.html", title="Method Not Allowed"), 405 + + +@error.app_errorhandler(404) +def page_not_found(e): + return render_template("errors/404.html", title="Not Found"), 404 + + +@error.app_errorhandler(500) +def internal_server_error(e): + return render_template("errors/500.html", title="Server Error"), 500 + + +@error.app_errorhandler(503) +def service_unavailable(e): + return render_template("errors/503.html", title="Service Unavailable"), 503 diff --git a/app/main/__init__.py b/app/main/__init__.py new file mode 100644 index 0000000..1b807ac --- /dev/null +++ b/app/main/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +main = Blueprint("main", __name__) + +from app.main import routes # noqa: E402, F401 diff --git a/app/main/routes.py b/app/main/routes.py new file mode 100644 index 0000000..99b855f --- /dev/null +++ b/app/main/routes.py @@ -0,0 +1,38 @@ +from flask import render_template, __version__ +from app.main import main +from os import environ + +# Versions of Python, Flask as well as other packages (displayed on the index) +# This can be removed if you don't want to display this information +from platform import python_version +from pip import __version__ as pip_version +from flask_login import __version__ as flask_login_version +from flask_sqlalchemy import __version__ as flask_sqlalchemy_version +from sqlalchemy import __version__ as sqlalchemy_version +from jinja2 import __version__ as jinja2_version +from dotenv import version as dotenv_version +from flask_wtf import __version__ as flask_wtf_version +from wtforms import __version__ as wtforms_version + + +@main.route("/", methods=["GET"]) +def index(): + return render_template( + "index.html", + flask_version=__version__, + python_version=python_version(), + pip_version=pip_version, + jinja2_version=jinja2_version, + flask_login_version=flask_login_version, + flask_sqlalchemy_version=flask_sqlalchemy_version, + sqlalchemy_version=sqlalchemy_version, + flask_wtf_version=flask_wtf_version, + wtforms_version=wtforms_version, + dotenv_version=dotenv_version.__version__, + debug_enabled=environ.get("FLASK_DEBUG"), + ) + + +@main.route("/main", methods=["GET"]) +def main(): + return render_template("main/index.html") diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..130536f --- /dev/null +++ b/app/models.py @@ -0,0 +1,45 @@ +from werkzeug.security import generate_password_hash, check_password_hash +from flask_login import UserMixin +from datetime import datetime +from app import db, login + + +class Users(UserMixin, db.Model): + __tablename__ = "users" + id: int = db.Column(db.Integer, primary_key=True) + email: str = db.Column(db.String(120), unique=True) + first_name: str = db.Column(db.String(15)) + last_name: str = db.Column(db.String(15)) + password_hash: str = db.Column(db.String(128)) + created_at: datetime = db.Column(db.DateTime(), default=datetime.utcnow()) + + def __repr__(self): + return f"" + + def set_password(self, password: str) -> bool: + """ + Sets the password hash for the user. + + :param password: password to hash + """ + self.password_hash = generate_password_hash(password) + + def check_password(self, password: str) -> bool: + """ + Checks if the given password matches the user's password hash. + + :param password: password to check + :return: True if the password matches, False otherwise + """ + return check_password_hash(self.password_hash, password) + + +@login.user_loader +def load_user(id): + """ + Loads a user from the database (used by Flask-Login). + + :param id: user ID + :return: user object + """ + return Users.query.get(int(id)) diff --git a/app/modules/__init__.py b/app/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/util/__init__.py b/app/modules/util/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/util/auth.py b/app/modules/util/auth.py new file mode 100644 index 0000000..1af2978 --- /dev/null +++ b/app/modules/util/auth.py @@ -0,0 +1,48 @@ +""" + +Utility methods for authentication. +---------------- + +""" +from app.models import Users +from re import fullmatch + + +def check_if_user_already_exists(email: str) -> bool: + """ + Checks if a user with the given email already exists. + + :param email: email to check + :return: True if the user exists, False otherwise + """ + return Users.query.filter_by(email=email).first() is not None + + +def check_password_confirmation( + password: str, password_confirmation: str +) -> bool: + """ + Checks if the password and password confirmation are the same. + + :param password: password + :param password_confirmation: password confirmation to check against + :return: True if the passwords are the same, False otherwise + """ + return password == password_confirmation + + +def check_password_strength(password: str) -> bool: + """ + Checks the strength of a password. + + Will pass if the given password is at least 8 characters long, has no + repeating characters, and has at least one uppercase letter. + + :param password: password to check + :return: True if the password is strong, False otherwise + """ + return ( + len(password) >= 8 + and not fullmatch(r"(.)\1*", password) + and not fullmatch(r"[a-z]*", password) + ) diff --git a/app/modules/util/config.py b/app/modules/util/config.py new file mode 100644 index 0000000..18dddbd --- /dev/null +++ b/app/modules/util/config.py @@ -0,0 +1,17 @@ +""" + +Utility methods for the config +---------------- + +""" + + +def eval_bool_env_var(env_var: str) -> bool: + """Evaluates a boolean environment variable + + :param env_var: The environment variable to evaluate + :return: The evaluated boolean value + """ + # XXX: python-dotenv doesn't support boolean values nicely, so we have to + # do this ourselves + return True if env_var.lower() in ("true", "t", "1") else False diff --git a/app/modules/util/decorators.py b/app/modules/util/decorators.py new file mode 100644 index 0000000..f42defc --- /dev/null +++ b/app/modules/util/decorators.py @@ -0,0 +1,19 @@ +from functools import wraps +from flask import redirect, url_for +from flask_login import current_user + + +def redirect_if_already_authenticated(f): + """ + Redirects to the index page if the user is already authenticated. + + :return: redirect to `main.index` + """ + + @wraps(f) + def is_authenticated(*args, **kwargs): + if current_user.is_authenticated: + return redirect(url_for("main.index")) + return f(*args, **kwargs) + + return is_authenticated diff --git a/app/modules/util/forms.py b/app/modules/util/forms.py new file mode 100644 index 0000000..25143a0 --- /dev/null +++ b/app/modules/util/forms.py @@ -0,0 +1,19 @@ +""" + +Utility methods for forms +---------------- + +""" + + +def collect_form_data(*args) -> dict: + """ + Collects form data from a list of fields (used for form repopulation). + + :param args: list of fields to collect data from + :return: dictionary of form data + """ + data = {} + for field in args: + data[field.name] = field.data + return data diff --git a/app/static/favicon.ico b/app/static/favicon.ico new file mode 100644 index 0000000..f1a6465 Binary files /dev/null and b/app/static/favicon.ico differ diff --git a/app/static/logo.png b/app/static/logo.png new file mode 100644 index 0000000..180a1fb Binary files /dev/null and b/app/static/logo.png differ diff --git a/app/static/logo.svg b/app/static/logo.svg new file mode 100644 index 0000000..1df5684 --- /dev/null +++ b/app/static/logo.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 0000000..1828554 --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,34 @@ +{#- + Boilerplate - Login page + ------------------------------------------------------------ + Jinja2 template for the login page. +-#} +{% extends 'boilerplate/layout.html' %} +{% block content %} +
+
+

Welcome back

+

Please sign in to your account

+
+ {% include 'boilerplate/flash_message.html' %} + {{ form.csrf_token }} +
+
+
+
+
+
+ +
+

+ No account? Sign up. +

+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/auth/register.html b/app/templates/auth/register.html new file mode 100644 index 0000000..184b33c --- /dev/null +++ b/app/templates/auth/register.html @@ -0,0 +1,48 @@ +{#- + Boilerplate - Registration page + ------------------------------------------------------------ + Jinja2 template for the registration page. +-#} +{% extends 'boilerplate/layout.html' %} +{% block content %} +
+
+

Register for an account

+

It's free, quick and easy

+
+ {% include 'boilerplate/flash_message.html' %} + {{ form.csrf_token }} +
+
+
+
+
+
+
+
+
+
+
+
+ +
+

+ Already have an account? Sign in. +

+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/boilerplate/flash_message.html b/app/templates/boilerplate/flash_message.html new file mode 100644 index 0000000..72d00d4 --- /dev/null +++ b/app/templates/boilerplate/flash_message.html @@ -0,0 +1,39 @@ +{#- + Boilerplate - Flash Messages + ------------------------------------------------------------ + Jinja2 template for displaying flash() messages. + + Example usage: + + flash("Your password is incorrect", 'warning') +-#} +{% with success = get_flashed_messages(category_filter=['success']) %} + {% if success %} + + {% endif %} +{% endwith %} + + +{% with warning = get_flashed_messages(category_filter=['warning']) %} + {% if warning %} + + {% endif %} +{% endwith %} + +{% with danger = get_flashed_messages(category_filter=['danger']) %} + {% if danger %} + + {% endif %} +{% endwith %} \ No newline at end of file diff --git a/app/templates/boilerplate/layout.html b/app/templates/boilerplate/layout.html new file mode 100644 index 0000000..c83688d --- /dev/null +++ b/app/templates/boilerplate/layout.html @@ -0,0 +1,26 @@ +{#- + Boilerplate - Base Template + ------------------------------------------------------------ + Jinja2 base template. This is the template that all other + templates extend from. +-#} + + + + + + + {% if title %}{{ title }} - {% endif %}{{ config['APP_NAME'] }} + + + + + + +{% include 'boilerplate/nav.html' %} + +{% block content %} +{% endblock %} + + + \ No newline at end of file diff --git a/app/templates/boilerplate/macros/user.html b/app/templates/boilerplate/macros/user.html new file mode 100644 index 0000000..977bddd --- /dev/null +++ b/app/templates/boilerplate/macros/user.html @@ -0,0 +1,17 @@ +{#- + Boilerplate - User Macros + ------------------------------------------------------------ + This file contains macros for user related tasks. +-#} + +{#- + Macro: avatar + ------------------------------------------------------------ + This macro generates an avatar image using the ui-avatars.com + API. It takes the first and last name of the user, the + background and text color of the avatar, and returns an + tag with the avatar image. +-#} +{% macro avatar(first_name, last_name, bg_color, text_color, size, class) %} + {{ first_name }}'s avatar +{% endmacro %} \ No newline at end of file diff --git a/app/templates/boilerplate/nav.html b/app/templates/boilerplate/nav.html new file mode 100644 index 0000000..a9abab1 --- /dev/null +++ b/app/templates/boilerplate/nav.html @@ -0,0 +1,44 @@ +{#- + Boilerplate - Navigation bar component + ------------------------------------------------------------ + Jinja2 template for the navigation bar component. +-#} + +{% import 'boilerplate/macros/user.html' as user %} + + \ No newline at end of file diff --git a/app/templates/errors/400.html b/app/templates/errors/400.html new file mode 100644 index 0000000..b5819d1 --- /dev/null +++ b/app/templates/errors/400.html @@ -0,0 +1,11 @@ +{% extends 'boilerplate/layout.html' %} +{% block content %} +
+
+
+

400

+

Bad request.

+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/errors/401.html b/app/templates/errors/401.html new file mode 100644 index 0000000..955919e --- /dev/null +++ b/app/templates/errors/401.html @@ -0,0 +1,12 @@ +{% extends 'boilerplate/layout.html' %} +{% block content %} +
+
+
+

401

+

Unauthorized.

+

You are not authorized to view this page/resource.

+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/errors/403.html b/app/templates/errors/403.html new file mode 100644 index 0000000..a254b6e --- /dev/null +++ b/app/templates/errors/403.html @@ -0,0 +1,11 @@ +{% extends 'boilerplate/layout.html' %} +{% block content %} +
+
+
+

403

+

Access forbidden.

+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/errors/404.html b/app/templates/errors/404.html new file mode 100644 index 0000000..6c98bf8 --- /dev/null +++ b/app/templates/errors/404.html @@ -0,0 +1,13 @@ +{% extends 'boilerplate/layout.html' %} +{% block content %} +
+
+
+

404

+

Page not found.

+

That's all we know.

+ Back to Homepage +
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/errors/405.html b/app/templates/errors/405.html new file mode 100644 index 0000000..1ed6a2a --- /dev/null +++ b/app/templates/errors/405.html @@ -0,0 +1,11 @@ +{% extends 'boilerplate/layout.html' %} +{% block content %} +
+
+
+

405

+

Method not allowed.

+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/errors/500.html b/app/templates/errors/500.html new file mode 100644 index 0000000..47ec53e --- /dev/null +++ b/app/templates/errors/500.html @@ -0,0 +1,12 @@ +{% extends 'boilerplate/layout.html' %} +{% block content %} +
+
+
+

500

+

Internal server error.

+

The site administrator is probably fixing it. Check back later.

+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/errors/503.html b/app/templates/errors/503.html new file mode 100644 index 0000000..a9a9791 --- /dev/null +++ b/app/templates/errors/503.html @@ -0,0 +1,35 @@ +{#- + Boilerplate - Maintenance mode template + ------------------------------------------------------------- + This template is used to display a maintenance mode message + when the application is in maintenance mode. Whilst in this + mode, you cannot include any other resources in this template + (e.g. static files etc.), as all requests will be redirected + to this template. +-#} + + + {{ title }} + + + + + +

503 Service Unavailable

+

Sorry, we are doing some maintenance. Please check back soon.

+
+

{{ config['APP_NAME'] }}

+ + \ No newline at end of file diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..617eaf2 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,62 @@ +{% extends 'boilerplate/layout.html' %} +{% block content %} +
+ {% if not current_user.is_authenticated %} +
+

Welcome to Flask Boilerplate

+

Your simple, yet opinionated, boilerplate for Flask projects.

+
+ {% else %} +
+

Welcome back, {{ current_user.first_name }}.

+

You are signed in.

+
+ {% endif %} +
+ +
+
+
+

Installation Info

+ + + + + + Flask, Jinja2 and Python (inc. pip) versions + +
    +
  • Flask: {{ flask_version }} {% if debug_enabled == '1' %}Debug Mode Enabled{% endif %}
  • +
  • Jinja2: {{ jinja2_version }}
  • +
  • Python: {{ python_version }}
  • +
  • Pip: {{ pip_version }}
  • +
+ + + + + + + + Third-Party package versions + +
    +
  • Flask-Login: {{ flask_login_version }}
  • +
  • Flask-SQLAlchemy: {{ flask_sqlalchemy_version }}
  • +
  • SQLAlchemy: {{ sqlalchemy_version }}
  • +
  • Flask-WTF: {{ flask_wtf_version }}
  • +
  • WTForms: {{ wtforms_version }}
  • +
  • python-dotenv: {{ dotenv_version }}
  • +
+

Run pip list inside your virtualenv to show all installed packages.

+
+ +
+

About

+

This is a simple boilerplate for a Flask app that comes with a few out-of-the-box features (authentication, structure, CLI etc.) to help you get started on your next project.

+

You can read more about it on GitHub. In the meantime, you can edit these pages in the templates folder - you are currently looking at the templates/main/index.html page, with components from templates/boilerplate/base.html and templates/boilerplate/nav.html.

+

If you have an issue or feel that this boilerplate could be improved, then feel free to open an issue or pull request on GitHub. Good luck and build something neat!

+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/main/index.html b/app/templates/main/index.html new file mode 100644 index 0000000..e1a209d --- /dev/null +++ b/app/templates/main/index.html @@ -0,0 +1 @@ +This is the /index route of the main folder! \ No newline at end of file diff --git a/cli/commands/boil.py b/cli/commands/boil.py new file mode 100644 index 0000000..769634a --- /dev/null +++ b/cli/commands/boil.py @@ -0,0 +1,23 @@ +import click +import time + + +@click.command("boil") +@click.option( + "temperature", + "-t", + type=click.IntRange(min=23, max=200, clamp=True), + default=100, + help="The temperature to heat the water to", +) +def boil(temperature: int) -> None: + """Heats water to a specified temperature (an example command)""" + + boil_time = (1 * 4.186 * (temperature - 23)) / 1 + click.secho(f"Boiling water to {temperature}°C...", fg="yellow") + + with click.progressbar(range(int(boil_time)), label="Progress:") as bar: + for i in bar: + time.sleep(1) + + click.secho(f"Water has now been heated {temperature}°C", fg="green") diff --git a/cli/commands/maintenance.py b/cli/commands/maintenance.py new file mode 100644 index 0000000..323cba3 --- /dev/null +++ b/cli/commands/maintenance.py @@ -0,0 +1,67 @@ +import click +import pathlib + +from dotenv import set_key, get_key + +from util import ClickUtils + + +@click.command("maintenance") +@click.option( + "--status", "-s", is_flag=True, + help="Returns the current maintenance mode status" +) +def maintenance_mode(status: bool) -> None: + """Toggle maintenance mode for the site""" + + spinner = ClickUtils.Spinner( + f"{'Checking' if status else 'Toggling'} maintenance mode..." + ) + spinner.start() + + # Get the config file path + config_file_path = ( + pathlib.Path(__file__).parent.parent.parent.absolute() / ".env" + ) + + try: + # Get the current maintenance mode state + maintenance_mode_state = get_key(config_file_path, "MAINTENANCE_MODE") + assert maintenance_mode_state + except AssertionError: + spinner.stop() + click.secho( + "ERROR: Config file not found or is missing the " + "`MAINTENANCE_MODE` key value.", + fg="red", + ) + exit(1) + + cfg = ClickUtils.Config() + maintenance_mode_state = cfg.eval_bool_env_var(maintenance_mode_state) + + if status: + spinner.stop() + click.secho( + "Maintenance mode is currently " + + f"{'enabled' if maintenance_mode_state else 'disabled'}.", + fg="yellow", + ) + exit(0) + + # Toggle the maintenance mode state + if not maintenance_mode_state: + set_key(config_file_path, "MAINTENANCE_MODE", "True") + else: + set_key(config_file_path, "MAINTENANCE_MODE", "False") + + spinner.stop() + click.secho( + "Maintenance mode is now " + + f"{'enabled' if not maintenance_mode_state else 'disabled'}.", + fg="green", + ) + click.secho( + "Please restart the server for the changes to take " + "effect.", fg="yellow" + ) diff --git a/cli/commands/shell.py b/cli/commands/shell.py new file mode 100644 index 0000000..dba23f7 --- /dev/null +++ b/cli/commands/shell.py @@ -0,0 +1,74 @@ +import click +import sys +from flask.cli import with_appcontext +from flask.globals import current_app as app + +from util import ClickUtils + + +@click.command("shell") +@click.option( + "--verbose", + "-v", + is_flag=True, + help="Enable verbose context output" +) +@click.option( + "--no-banner", + "-nb", + is_flag=True, + help="Disable the banner message" +) +@with_appcontext +def shell(verbose: bool, no_banner: bool) -> None: + """Starts an interactive shell with the app context""" + + spinner = ClickUtils.Spinner("Starting interactive shell...") + spinner.start() + + try: + from IPython.terminal.ipapp import TerminalIPythonApp + from IPython import __version__ as ipython_version + except ImportError: + spinner.stop() + click.secho( + "ERROR: IPython is not installed. Please install it with", + " `pip install ipython`.", + fg="red", + ) + exit(1) + + ctx = app.make_shell_context() + + # Banner message + cf = ClickUtils.Colors() + banner = cf.cformat( + f"{cf.YELLOW}** [Boilerplate Interactive Shell Started] **\n" + f"{cf.MAGENTA}Boilerplate is ready for your commands! " + f"Use `exit`/`quit` or use Ctrl + D to quit.\n" + f"Autocompletion is enabled, press Tab to use it.\n" + ) + + if verbose: + # Verbose context and shell information output + banner += cf.cformat(f"\n{cf.CYAN}** [Context Variables] **") + for key, value in ctx.items(): + banner += cf.cformat(f"\n{cf.YELLOW}* {key} -> {cf.GREEN}{value}") + banner += ( + f"\n\n{cf.CYAN}** [Shell Information] **" + f"\n{cf.YELLOW}* Python Version -> {cf.GREEN}v{sys.version}" + f"\n{cf.YELLOW}* IPython Version -> {cf.GREEN}v{ipython_version}\n" + ) + + ipython_app = TerminalIPythonApp.instance( + user_ns=ctx, + display_banner=False + ) + ipython_app.initialize(argv=[]) + click.clear() + + if not no_banner: + ipython_app.shell.show_banner(banner) + + spinner.stop() + ipython_app.start() diff --git a/cli/kettle.py b/cli/kettle.py new file mode 100644 index 0000000..41394b7 --- /dev/null +++ b/cli/kettle.py @@ -0,0 +1,20 @@ +import click + +from commands.maintenance import maintenance_mode +from commands.shell import shell +from commands.boil import boil + + +@click.group() +def kettle(): + pass + + +# Add commands below +kettle.add_command(maintenance_mode) +kettle.add_command(shell) +kettle.add_command(boil) + + +if __name__ == "__main__": + kettle() diff --git a/cli/setup.py b/cli/setup.py new file mode 100644 index 0000000..09d4732 --- /dev/null +++ b/cli/setup.py @@ -0,0 +1,16 @@ +from setuptools import setup + +setup( + name="kettle", + version="1.0", + description="Command line interface for the boilerplate", + py_modules=["kettle"], + install_requires=[ + "Click", + ], + entry_points={ + "console_scripts": [ + "kettle=kettle:kettle", + ], + }, +) diff --git a/cli/util.py b/cli/util.py new file mode 100644 index 0000000..f0abaf3 --- /dev/null +++ b/cli/util.py @@ -0,0 +1,96 @@ +import click +import time +import threading +import itertools + + +class ClickUtils: + """Utility functions for the click-based CLI""" + + class Spinner: + def __init__(self, message: str, color: str = "yellow") -> None: + """A spinner that can be used to indicate that a process is running + + :param message: Message to display next to the spinner + :param color: Color of the spinner and message (uses colors from + :func:`click.style`) - Optional (will default to `yellow`) + """ + self.frames = itertools.cycle([ + "⢿", "⣻", "⣽", "⣾", + "⣷", "⣯", "⣟", "⡿" + ]) + self.thread = None + self.active = False + self.message = message + self.color = color + + def start(self) -> None: + """Starts the spinner animation""" + if self.thread: + raise RuntimeError("Spinner is already running") + + self.active = True + self.thread = threading.Thread(target=self._spin) + self.thread.start() + + def stop(self) -> None: + """Stops the spinner animation""" + if not self.thread: + raise RuntimeError("Spinner is not running") + + self.active = False + self.thread.join() + + def _clear(self) -> None: + """Clears the spinner animation line from the cli""" + message_length = len(self.message) + 2 + click.secho("\r" + " " * message_length + "\r", nl=False) + + def _spin(self) -> None: + """Displays the spinner animation with the message""" + while self.active: + frame = next(self.frames) + message = f"\r{frame} {self.message}" + click.secho(message, fg=self.color, nl=False) + time.sleep(0.1) + self._clear() + + class Colors: + """ + ASNI color codes and formatting + (where :func:`click.style` cannot be used) + """ + + # Color codes pallet + RED = "\033[0;31m" + GREEN = "\033[0;32m" + YELLOW = "\033[1;33m" + CYAN = "\033[0;36m" + MAGENTA = "\033[0;35m" + + # Formatting codes pallet + BOLD = "\033[1m" + UNDERLINE = "\033[4m" + RESET = "\033[0m" # Reset all colors and formatting + + def cformat(self, text: str) -> str: + """Formats text with the given styles + + :param text: The text to format + :param styles: The pallet style(s) to apply to the text + :return: The formatted text + """ + return f"{text}{self.RESET}" + + class Config: + """Config utility methods for the CLI""" + + def eval_bool_env_var(self, env_var: str) -> bool: + """Evaluates a boolean environment variable + + :param env_var: The environment variable to evaluate + :return: The evaluated boolean value + """ + # XXX: python-dotenv doesn't support boolean values nicely, + # so we have to do it manually + return True if env_var.lower() in ("true", "t", "1") else False diff --git a/config.py b/config.py new file mode 100644 index 0000000..004c046 --- /dev/null +++ b/config.py @@ -0,0 +1,23 @@ +import os +import secrets +import pathlib + +basedir = pathlib.Path(__file__).parent.absolute() + + +class Config(object): + """ + Config values for the application (used by Flask and third-party packages). + + Values are set in the `.env` file. If no `.env` file is found, + it will default use the default values below (where applicable). + """ + + FLASK_APP = "app.py" + SECRET_KEY = os.environ.get("SECRET_KEY") or secrets.token_hex(32) + SQLALCHEMY_DATABASE_URI = os.environ.get( + "DATABASE_URL" + ) or "sqlite:///" + os.path.join(basedir, "app.db") + SQLALCHEMY_TRACK_MODIFICATIONS = False + APP_NAME = os.environ.get("APP_NAME") or "Flask App" + MAINTENANCE_MODE = os.environ.get("MAINTENANCE_MODE") or False diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0072594 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,34 @@ +asttokens==2.4.1 +auto-click-auto==0.1.4 +blinker==1.7.0 +click==8.1.7 +decorator==5.1.1 +dnspython==2.5.0 +email-validator==2.1.0.post1 +executing==2.0.1 +Flask==3.0.1 +Flask-Login==0.6.3 +Flask-SQLAlchemy==3.1.1 +Flask-WTF==1.2.1 +idna==3.6 +ipython==8.20.0 +itsdangerous==2.1.2 +jedi==0.19.1 +Jinja2==3.1.3 +MarkupSafe==2.1.4 +matplotlib-inline==0.1.6 +parso==0.8.3 +pexpect==4.9.0 +prompt-toolkit==3.0.43 +ptyprocess==0.7.0 +pure-eval==0.2.2 +Pygments==2.17.2 +python-dotenv==1.0.1 +six==1.16.0 +SQLAlchemy==2.0.25 +stack-data==0.6.3 +traitlets==5.14.1 +typing_extensions==4.9.0 +wcwidth==0.2.13 +Werkzeug==3.0.1 +WTForms==3.1.2