commit 0b3dd21a06a3d52411dec0f1147dd361152a5464 Author: Son NK Date: Mon Jul 1 18:18:12 2019 +0300 bootstrap: db models, login, logout, dashboard pages diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..1945b032 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.idea/ +*.pyc +db.sqlite \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 00000000..a4a4b69c --- /dev/null +++ b/app/auth/__init__.py @@ -0,0 +1 @@ +from .views import login, logout diff --git a/app/auth/base.py b/app/auth/base.py new file mode 100644 index 00000000..72638fee --- /dev/null +++ b/app/auth/base.py @@ -0,0 +1,3 @@ +from flask import Blueprint + +auth_bp = Blueprint(name="auth", import_name=__name__, url_prefix="/auth") diff --git a/app/auth/views/__init__.py b/app/auth/views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/auth/views/login.py b/app/auth/views/login.py new file mode 100644 index 00000000..0c82ab93 --- /dev/null +++ b/app/auth/views/login.py @@ -0,0 +1,36 @@ +from flask import request, flash, render_template, redirect, url_for +from flask_login import login_user +from wtforms import Form, StringField, validators + +from app.auth.base import auth_bp +from app.log import LOG +from app.models import User + + +class LoginForm(Form): + email = StringField("Email", validators=[validators.DataRequired()]) + password = StringField("Password", validators=[validators.DataRequired()]) + + +@auth_bp.route("/login", methods=["GET", "POST"]) +def login(): + form = LoginForm(request.form) + + if request.method == "POST": + if form.validate(): + user = User.query.filter_by(email=form.email.data).first() + + if not user: + flash("No such email", "warning") + return render_template("auth/login.html", form=form) + + if not user.check_password(form.password.data): + flash("Wrong password", "warning") + return render_template("auth/login.html", form=form) + + LOG.debug("log user %s in", user) + login_user(user) + + return redirect(url_for("dashboard.index")) + + return render_template("auth/login.html", form=form) diff --git a/app/auth/views/logout.py b/app/auth/views/logout.py new file mode 100644 index 00000000..56ac7e39 --- /dev/null +++ b/app/auth/views/logout.py @@ -0,0 +1,10 @@ +from flask import render_template +from flask_login import logout_user + +from app.auth.base import auth_bp + + +@auth_bp.route("/logout") +def logout(): + logout_user() + return render_template("auth/logout.html") diff --git a/app/dashboard/__init__.py b/app/dashboard/__init__.py new file mode 100644 index 00000000..86bd305b --- /dev/null +++ b/app/dashboard/__init__.py @@ -0,0 +1 @@ +from .views import index diff --git a/app/dashboard/base.py b/app/dashboard/base.py new file mode 100644 index 00000000..b7e8189b --- /dev/null +++ b/app/dashboard/base.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +dashboard_bp = Blueprint( + name="dashboard", import_name=__name__, url_prefix="/dashboard" +) diff --git a/app/dashboard/views/__init__.py b/app/dashboard/views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/dashboard/views/index.py b/app/dashboard/views/index.py new file mode 100644 index 00000000..6fb63e82 --- /dev/null +++ b/app/dashboard/views/index.py @@ -0,0 +1,10 @@ +from flask import render_template +from flask_login import login_required + +from app.dashboard.base import dashboard_bp + + +@dashboard_bp.route("/") +@login_required +def index(): + return render_template("dashboard/index.html") diff --git a/app/extensions.py b/app/extensions.py new file mode 100644 index 00000000..6fd9adf2 --- /dev/null +++ b/app/extensions.py @@ -0,0 +1,34 @@ +from flask_login import LoginManager +from flask_sqlalchemy import SQLAlchemy, Model + + +class CRUDMixin(Model): + """Mixin that adds convenience methods for CRUD (create, read, update, delete) operations.""" + + @classmethod + def create(cls, **kwargs): + """Create a new record and save it the database.""" + instance = cls(**kwargs) + return instance.save() + + def update(self, commit=True, **kwargs): + """Update specific fields of a record.""" + for attr, value in kwargs.items(): + setattr(self, attr, value) + return commit and self.save() or self + + def save(self, commit=True): + """Save the record.""" + db.session.add(self) + if commit: + db.session.commit() + return self + + def delete(self, commit=True): + """Remove the record from the database.""" + db.session.delete(self) + return commit and db.session.commit() + + +db = SQLAlchemy(model_class=CRUDMixin) +login_manager = LoginManager() diff --git a/app/log.py b/app/log.py new file mode 100644 index 00000000..a2fd366d --- /dev/null +++ b/app/log.py @@ -0,0 +1,45 @@ +import logging +import sys +import time + +_log_format = "%(asctime)s - %(name)s - %(levelname)s - %(process)d - %(module)s:%(lineno)d - %(funcName)s - %(message)s" +_log_formatter = logging.Formatter(_log_format) + + +def _get_console_handler(level=None): + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setFormatter(_log_formatter) + console_handler.formatter.converter = time.gmtime + + if level: + console_handler.setLevel(level) + + return console_handler + + +def get_logger(name): + logger = logging.getLogger(name) + + logger.setLevel(logging.DEBUG) + + # leave the handlers level at NOTSET so the level checking is only handled by the logger + logger.addHandler(_get_console_handler()) + + # no propagation to avoid unexpected behaviour + logger.propagate = False + + return logger + + +print(f">>> init logging <<<") + +# ### config root logger ### +# do not use the default (buggy) logger +logging.root.handlers.clear() + +# add handlers with the default level = "warn" +# need to add level at handler level as there's no level check in root logger +# all the libs logs having level >= WARN will be handled by these 2 handlers +logging.root.addHandler(_get_console_handler(logging.WARN)) + +LOG = get_logger("yourkey") diff --git a/app/models.py b/app/models.py new file mode 100644 index 00000000..7c491ba5 --- /dev/null +++ b/app/models.py @@ -0,0 +1,51 @@ +# <<< Models >>> +from datetime import datetime + +import bcrypt +from flask_login import UserMixin + +from app.extensions import db + + +class ModelMixin(object): + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=None, onupdate=datetime.utcnow) + + +class Client(db.Model, ModelMixin): + client_id = db.Column(db.String(128), unique=True) + client_secret = db.Column(db.String(128)) + redirect_uri = db.Column(db.String(1024)) + name = db.Column(db.String(128)) + + +class User(db.Model, ModelMixin, UserMixin): + email = db.Column(db.String(128), unique=True) + salt = db.Column(db.String(128), nullable=False) + password = db.Column(db.String(128), nullable=False) + name = db.Column(db.String(128)) + + def set_password(self, password): + salt = bcrypt.gensalt() + password_hash = bcrypt.hashpw(password.encode(), salt).decode() + self.salt = salt.decode() + self.password = password_hash + + def check_password(self, password) -> bool: + password_hash = bcrypt.hashpw(password.encode(), self.salt.encode()) + return self.password.encode() == password_hash + + +class AuthorizationCode(db.Model, ModelMixin): + code = db.Column(db.String(128), unique=True) + client_id = db.Column(db.ForeignKey(Client.id)) + user_id = db.Column(db.ForeignKey(User.id)) + + +class OauthToken(db.Model, ModelMixin): + access_token = db.Column(db.String(128), unique=True) + client_id = db.Column(db.ForeignKey(Client.id)) + user_id = db.Column(db.ForeignKey(User.id)) + + user = db.relationship(User) diff --git a/app/monitor/__init__.py b/app/monitor/__init__.py new file mode 100644 index 00000000..14cd5bd9 --- /dev/null +++ b/app/monitor/__init__.py @@ -0,0 +1 @@ +from . import views diff --git a/app/monitor/base.py b/app/monitor/base.py new file mode 100644 index 00000000..7c965fb5 --- /dev/null +++ b/app/monitor/base.py @@ -0,0 +1,3 @@ +from flask import Blueprint + +monitor_bp = Blueprint(name="monitor", import_name=__name__, url_prefix="/") diff --git a/app/monitor/views.py b/app/monitor/views.py new file mode 100644 index 00000000..0b361528 --- /dev/null +++ b/app/monitor/views.py @@ -0,0 +1,16 @@ +import subprocess + +from app.monitor.base import monitor_bp + +SHA1 = subprocess.getoutput("git rev-parse HEAD") + + +@monitor_bp.route("/git") +def git_sha1(): + return SHA1 + + +@monitor_bp.route("/exception") +def test_exception(): + raise Exception("to make sure sentry works") + return "never reach here" diff --git a/app/utils.py b/app/utils.py new file mode 100644 index 00000000..9d838f44 --- /dev/null +++ b/app/utils.py @@ -0,0 +1,8 @@ +import random +import string + + +def random_string(length=10): + """Generate a random string of fixed length """ + letters = string.ascii_lowercase + return "".join(random.choice(letters) for _ in range(length)) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..66732313 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +flask_sqlalchemy +flask +flask_login +wtforms \ No newline at end of file diff --git a/server.py b/server.py new file mode 100644 index 00000000..ee0710bd --- /dev/null +++ b/server.py @@ -0,0 +1,76 @@ +import os + +from flask import Flask + +from app.auth.base import auth_bp +from app.dashboard.base import dashboard_bp +from app.extensions import db, login_manager +from app.log import LOG +from app.models import Client, User +from app.monitor.base import monitor_bp + + +def create_app() -> Flask: + app = Flask(__name__) + + app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///db.sqlite" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.secret_key = "secret" + + app.config["TEMPLATES_AUTO_RELOAD"] = True + + init_extensions(app) + register_blueprints(app) + + return app + + +def fake_data(): + # Remove db if exist + if os.path.exists("db.sqlite"): + os.remove("db.sqlite") + + db.create_all() + + # fake data + client = Client( + client_id="client-id", + client_secret="client-secret", + redirect_uri="http://localhost:7000/callback", + name="Continental", + ) + db.session.add(client) + + user = User(id=1, email="john@wick.com", name="John Wick") + user.set_password("password") + db.session.add(user) + + db.session.commit() + + +@login_manager.user_loader +def load_user(user_id): + user = User.query.get(user_id) + + return user + + +def register_blueprints(app: Flask): + app.register_blueprint(auth_bp) + app.register_blueprint(monitor_bp) + app.register_blueprint(dashboard_bp) + + +def init_extensions(app: Flask): + LOG.debug("init extensions") + login_manager.init_app(app) + db.init_app(app) + + +if __name__ == "__main__": + app = create_app() + + with app.app_context(): + fake_data() + + app.run(debug=True, threaded=False) diff --git a/templates/_formhelpers.html b/templates/_formhelpers.html new file mode 100644 index 00000000..ecf2246d --- /dev/null +++ b/templates/_formhelpers.html @@ -0,0 +1,20 @@ +{% macro render_field(field) %} +
+ +
+ {{ field(**kwargs)|safe }} + + + {{ field.description }} + + + {% if field.errors %} +
    + {% for error in field.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} +
+
+{% endmacro %} \ No newline at end of file diff --git a/templates/auth/login.html b/templates/auth/login.html new file mode 100644 index 00000000..5531962f --- /dev/null +++ b/templates/auth/login.html @@ -0,0 +1,17 @@ +{% from "_formhelpers.html" import render_field %} + +{% extends 'base.html' %} + +{% block title %} + Login +{% endblock %} + +{% block content %} +
+
+ {{ render_field(form.email) }} + {{ render_field(form.password) }} + +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/auth/logout.html b/templates/auth/logout.html new file mode 100644 index 00000000..f26b8119 --- /dev/null +++ b/templates/auth/logout.html @@ -0,0 +1,12 @@ +{% from "_formhelpers.html" import render_field %} + +{% extends 'base.html' %} + +{% block title %} + Logout +{% endblock %} + +{% block content %} + You are logged out.
+ Login +{% endblock %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 00000000..f9a18d93 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,65 @@ + + + + + + + + + {% block title %}{% endblock %} - Your Key + + + + + + + + + + + + {% block head %} + {% endblock %} + + + +{% block nav %} +{% endblock %} + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + + + {% block content %}{% endblock %} +
+ + + + + + +{% block script %} +{% endblock %} + + + + \ No newline at end of file diff --git a/templates/base_app.html b/templates/base_app.html new file mode 100644 index 00000000..8b0b7092 --- /dev/null +++ b/templates/base_app.html @@ -0,0 +1,30 @@ +{# Base for all pages after user logs in #} +{% extends 'base.html' %} + +{% block nav %} + {% set navigation_bar = [ + (url_for("dashboard.index"), 'dashboard', 'Dashboard'), +]-%} + + + {% set active_page = active_page|default('index') -%} + + +{% endblock %} \ No newline at end of file diff --git a/templates/dashboard/index.html b/templates/dashboard/index.html new file mode 100644 index 00000000..915990be --- /dev/null +++ b/templates/dashboard/index.html @@ -0,0 +1,13 @@ +{% from "_formhelpers.html" import render_field %} + +{% extends 'base_app.html' %} + +{% set active_page = "dashboard" %} + +{% block title %} + Dashboard +{% endblock %} + +{% block content %} + Dashboard +{% endblock %} \ No newline at end of file