Pārlūkot izejas kodu

bootstrap: db models, login, logout, dashboard pages

Son NK 6 gadi atpakaļ
revīzija
0b3dd21a06

+ 3 - 0
.gitignore

@@ -0,0 +1,3 @@
+.idea/
+*.pyc
+db.sqlite

+ 0 - 0
app/__init__.py


+ 1 - 0
app/auth/__init__.py

@@ -0,0 +1 @@
+from .views import login, logout

+ 3 - 0
app/auth/base.py

@@ -0,0 +1,3 @@
+from flask import Blueprint
+
+auth_bp = Blueprint(name="auth", import_name=__name__, url_prefix="/auth")

+ 0 - 0
app/auth/views/__init__.py


+ 36 - 0
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)

+ 10 - 0
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")

+ 1 - 0
app/dashboard/__init__.py

@@ -0,0 +1 @@
+from .views import index

+ 5 - 0
app/dashboard/base.py

@@ -0,0 +1,5 @@
+from flask import Blueprint
+
+dashboard_bp = Blueprint(
+    name="dashboard", import_name=__name__, url_prefix="/dashboard"
+)

+ 0 - 0
app/dashboard/views/__init__.py


+ 10 - 0
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")

+ 34 - 0
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()

+ 45 - 0
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")

+ 51 - 0
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)

+ 1 - 0
app/monitor/__init__.py

@@ -0,0 +1 @@
+from . import views

+ 3 - 0
app/monitor/base.py

@@ -0,0 +1,3 @@
+from flask import Blueprint
+
+monitor_bp = Blueprint(name="monitor", import_name=__name__, url_prefix="/")

+ 16 - 0
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"

+ 8 - 0
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))

+ 4 - 0
requirements.txt

@@ -0,0 +1,4 @@
+flask_sqlalchemy
+flask
+flask_login
+wtforms

+ 76 - 0
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)

+ 20 - 0
templates/_formhelpers.html

@@ -0,0 +1,20 @@
+{% macro render_field(field) %}
+  <div class="form-group row">
+    <label class="col-sm-2 col-form-label">{{ field.label }}</label>
+    <div class="col-sm-10">
+      {{ field(**kwargs)|safe }}
+
+      <small class="form-text text-muted">
+        {{ field.description }}
+      </small>
+
+      {% if field.errors %}
+        <ul class=errors>
+          {% for error in field.errors %}
+            <li>{{ error }}</li>
+          {% endfor %}
+        </ul>
+      {% endif %}
+    </div>
+  </div>
+{% endmacro %}

+ 17 - 0
templates/auth/login.html

@@ -0,0 +1,17 @@
+{% from "_formhelpers.html" import render_field %}
+
+{% extends 'base.html' %}
+
+{% block title %}
+    Login
+{% endblock %}
+
+{% block content %}
+    <div class="row">
+        <form action="" method="post">
+            {{ render_field(form.email) }}
+            {{ render_field(form.password) }}
+            <button type="submit" class="btn btn-primary">Login</button>
+        </form>
+    </div>
+{% endblock %}

+ 12 - 0
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. <br>
+    <a href="{{ url_for('auth.login') }}">Login</a>
+{% endblock %}

+ 65 - 0
templates/base.html

@@ -0,0 +1,65 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="utf-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+
+    <title>
+        {% block title %}{% endblock %} - Your Key
+    </title>
+
+    <!-- Bootstrap -->
+    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
+          integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
+
+    <!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
+    <!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
+    <!--[if lt IE 9]>
+      <script src="https://oss.maxcdn.com/html5shiv/3.7.3/html5shiv.min.js"></script>
+      <script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
+    <![endif]-->
+
+    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.6.3/css/font-awesome.min.css">
+
+    {% block head %}
+    {% endblock %}
+</head>
+<body>
+
+{% block nav %}
+{% endblock %}
+
+<div class="container">
+    {% with messages = get_flashed_messages(with_categories=true) %}
+        <!-- Categories: success (green), info (blue), warning (yellow), danger (red) -->
+        {% if messages %}
+            {% for category, message in messages %}
+                <div class="alert alert-{{ category }} alert-dismissible" role="alert">
+                    <button type="button" class="close" data-dismiss="alert" aria-label="Close"><span
+                            aria-hidden="true">&times;</span></button>
+                    {{ message }}
+                </div>
+            {% endfor %}
+        {% endif %}
+    {% endwith %}
+
+
+    {% block content %}{% endblock %}
+</div>
+
+
+<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js"
+        integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo"
+        crossorigin="anonymous"></script>
+
+<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"
+        integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM"
+        crossorigin="anonymous"></script>
+
+{% block script %}
+{% endblock %}
+
+
+</body>
+</html>

+ 30 - 0
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') -%}
+
+    <nav class="navbar navbar-expand-lg navbar-light bg-light">
+        <ul class="navbar-nav mr-auto">
+            {% for href, id, caption in navigation_bar %}
+                <li{% if id == active_page %} class="nav-item active" {% else %} class="nav-item" {% endif %}>
+                    <a class="nav-link" href="{{ href|e }}">{{ caption|e }}
+                    </a>
+                </li>
+            {% endfor %}
+        </ul>
+        <ul class="navbar-nav ml-auto">
+            <li class="nav-item">
+                <a class="nav-link" href="{{ url_for('auth.logout') }}">
+                    {{ current_user.email }} (Logout)
+                </a>
+            </li>
+        </ul>
+
+    </nav>
+{% endblock %}

+ 13 - 0
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 %}