Compare commits

..

4 Commits

Author SHA1 Message Date
Raoul Snyman f9145b63d9 Merge pull request 'Migrate to hatch' (#1) from raoul/statusforce:migrate-to-hatch into master
Reviewed-on: #1
2022-12-10 03:52:03 +00:00
Raoul Snyman 309b37745b Migrate to hatch 2022-12-09 20:50:19 -07:00
Raoul Snyman d6286ac3e4 Rename the database tables 2021-09-17 18:02:13 -07:00
Raoul Snyman 7802bb963e
Get the ball rolling
- Add templates
- Add static assets
- Add module setup
- Add services to the page
- Add incidents to the page
- Add authentication to the admin section
- Add Dockerfile and docker-compose.yaml
2021-09-17 17:02:53 -07:00
14 changed files with 287 additions and 77 deletions

2
.flake8 100644
View File

@ -0,0 +1,2 @@
[flake8]
max-line-length = 120

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
__pycache__ __pycache__
*.py[co] *.py[co]
*.sqlite *.sqlite
data/
dist/

6
Dockerfile 100644
View File

@ -0,0 +1,6 @@
FROM tiangolo/meinheld-gunicorn-flask
LABEL org.opencontainers.image.authors="Liberty Tech Force"
RUN pip install --no-cache-dir flask_admin flask_sqlalchemy flask_login psycopg2-binary pymysql 'greenlet<0.5,>=0.4.5'
COPY ./statusforce /app/statusforce
COPY ./docker/main.py /app/

View File

@ -0,0 +1,20 @@
version: '3'
services:
web:
build:
context: .
ports:
- "8000:80"
environment:
- SQLALCHEMY_DATABASE_URI=postgresql+psycopg2://statusforce:statusforce@db/statusforce?sslmode=disable
- FLASK_SECRET_KEY=my-super-secret-key
depends_on:
- db
db:
image: postgres
environment:
- POSTGRES_USER=statusforce
- POSTGRES_PASSWORD=statusforce
- POSTGRES_DB=statusforce
volumes:
- "./data:/var/lib/postgresql/data"

3
docker/main.py 100644
View File

@ -0,0 +1,3 @@
from statusforce.app import get_app
app = get_app()

64
pyproject.toml 100644
View File

@ -0,0 +1,64 @@
[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"
[project]
name = "StatusForce"
description = "Simple status page for system status"
readme = "README.rst"
license = "MIT"
requires-python = ">=3.10"
authors = [
{ name = "Raoul Snyman", email = "raoul@libertytechforce.com" },
]
keywords = [
"status",
"website",
]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: POSIX",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Topic :: Utilities",
]
dependencies = [
"Flask",
"Flask-Admin",
"Flask-Login",
"Flask-SQLAlchemy",
"psycopg2-binary",
"pymysql",
"greenlet<0.5,>=0.4.5"
]
dynamic = ["version"]
[project.urls]
Homepage = "https://libertytechforce.com"
Issues = "https://git.libertytechforce.com/libertytechforce/statusforce/issues"
Source = "https://git.libertytechforce.com/libertytechforce/statusforce"
[project.optional-dependencies]
tests = [
"pytest",
"pytest-faker",
"pytest-flask"
]
[tool.hatch.version]
source = "vcs"
[tool.hatch.build.targets.sdist]
include = [
"/statusforce",
]
[tool.hatch.build.targets.wheel]
include = [
"statusforce",
]

View File

@ -1,37 +0,0 @@
[metadata]
name = StatusForce
version = 0.0.1
author = Raoul Snyman
author_email = raoul@libertytechforce.com
description = Simple status page for system status
long_description = file:README.rst
long_description_content_type = text/x-rst
url = https://libertytechforce.com
license = MIT
classifiers =
Development Status :: 3 - Alpha
Intended Audience :: Developers
License :: OSI Approved :: MIT License
Operating System :: POSIX
Programming Language :: Python :: 3
Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
Topic :: Utilities
keywords = website, status
[options]
py_modules = statusforce
python_requires = >=3.7
install_requires =
Flask
Flask-Admin
Flask-SQLAlchemy
Flask-Login
[bdist_wheel]
universal = 1
[flake8]
max-line-length = 120

View File

@ -1,6 +0,0 @@
"""
The statusforce package
"""
from setuptools import setup
setup()

View File

@ -1,36 +1,48 @@
from flask import Flask, render_template import os
from flask import Flask
from flask_admin import Admin from flask_admin import Admin
from flask_admin.contrib.sqla import ModelView from flask_login import LoginManager
from statusforce.db import db, session from statusforce.db import db, session
from statusforce.models import Service, Incident from statusforce.models import Service, Incident, User
from statusforce.views import AuthAdminIndexView, AuthModelView, main
# Set up Flask application and initial config def get_app():
app = Flask(__name__) # Set up Flask application and initial config
app.secret_key = b'GSADFGST#$%^$%&^2345234534576476' app = Flask(__name__)
app.config['FLASK_ADMIN_SWATCH'] = 'materia' app.secret_key = b'super-secret-please-replace-me'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///statusforce.sqlite' app.config['FLASK_ADMIN_SWATCH'] = 'cosmo'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///statusforce.sqlite'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
# Set up database # Pull configuration from environment variables
db.init_app(app) for key, value in os.environ.items():
with app.app_context(): if key.startswith('FLASK_ADMIN_') or key.startswith('SQLALCHEMY_'):
db.create_all() app.config[key] = value
elif key == 'FLASK_SECRET_KEY':
# Special case, apparently
app.secret_key = value
elif key.startswith('FLASK_'):
app.config[key.replace('FLASK_', '')] = value
# Set up admin interface # Set up database
admin = Admin(app, name='StatusForce', template_mode='bootstrap4') db.init_app(app)
admin.add_view(ModelView(Service, session)) with app.app_context():
admin.add_view(ModelView(Incident, session)) db.create_all()
# Set up the authentication
login_manager = LoginManager(app)
@app.route('/') @login_manager.user_loader
def index(): def load_user(user_id):
""" return User.query.get(user_id)
Show the status page
""" # Set up admin interface
services = Service.query.all() admin = Admin(app, name='StatusForce', index_view=AuthAdminIndexView(), template_mode='bootstrap4')
incidents = Incident.query.order_by(Incident.created.desc()).limit(10).all() admin.add_view(AuthModelView(Service, session))
service_statuses = [service.status for service in services] admin.add_view(AuthModelView(Incident, session))
overall_status = 'operational' if all(status == 'operational' for status in service_statuses) else \
'offline' if all(status == 'offline' for status in service_statuses) else 'unclear' # Finally, attache the blueprint
return render_template('index.html', services=services, incidents=incidents, overall_status=overall_status) app.register_blueprint(main)
return app

View File

@ -7,9 +7,11 @@ from statusforce.db import Model, Column, ForeignKey, Boolean, DateTime, Enum, I
class Service(Model): class Service(Model):
__tablename__ = 'services'
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, nullable=False) name = Column(String, nullable=False)
status = Column(Enum('operational', 'unclear', 'offline'), default='operational') status = Column(Enum('operational', 'unclear', 'offline', native_enum=False), default='operational')
incidents = relationship('Incident', backref=backref('service', lazy=True)) incidents = relationship('Incident', backref=backref('service', lazy=True))
def __str__(self): def __str__(self):
@ -17,8 +19,10 @@ class Service(Model):
class Incident(Model): class Incident(Model):
__tablename__ = 'incidents'
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
service_id = Column(Integer, ForeignKey('service.id')) service_id = Column(Integer, ForeignKey('services.id'))
title = Column(String, nullable=False) title = Column(String, nullable=False)
description = Column(Text) description = Column(Text)
is_resolved = Column(Boolean, default=False) is_resolved = Column(Boolean, default=False)
@ -27,3 +31,27 @@ class Incident(Model):
def __str__(self): def __str__(self):
return '{} ({})'.format(self.title, self.service.name) return '{} ({})'.format(self.title, self.service.name)
class User(Model):
__tablename__ = 'users'
id = Column(Integer, primary_key=True, autoincrement=True)
full_name = Column(String)
email = Column(String, nullable=False)
password = Column(String, nullable=False)
is_active = Column(Boolean, default=True)
@property
def is_authenticated(self):
return True
@property
def is_anonymous(self):
return False
def get_id(self):
return self.id
def __str__(self):
return self.full_name or self.email

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View File

@ -0,0 +1,22 @@
{% extends 'admin/master.html' %}
{% import 'admin/lib.html' as lib with context %}
{% from 'admin/lib.html' import extra with context %} {# backward compatible #}
{% block head %}
{{ super() }}
{{ lib.form_css() }}
{% endblock head %}
{% block body %}
{{ super() }}
{% if current_user.is_authenticated %}
<p class="lead">Use the menu items above to add services and incidents.</p>
{% else %}
{{lib.render_form(form)}}
{% endif %}
{% endblock body %}
{% block tail %}
{{super()}}
{{lib.form_js()}}
{% endblock tail %}

View File

@ -6,6 +6,7 @@
<title>System Status</title> <title>System Status</title>
<link href="{{url_for('static', filename='css/bootstrap.min.css')}}" rel="stylesheet"> <link href="{{url_for('static', filename='css/bootstrap.min.css')}}" rel="stylesheet">
<link href="{{url_for('static', filename='css/bootstrap-icons.css')}}" rel="stylesheet"> <link href="{{url_for('static', filename='css/bootstrap-icons.css')}}" rel="stylesheet">
<link rel="icon" href="{{url_for('static', filename='img/ltf-logo.png')}}">
<style> <style>
.bd-placeholder-img { .bd-placeholder-img {
font-size: 1.125rem; font-size: 1.125rem;
@ -26,7 +27,7 @@
<main> <main>
<div class="py-5 text-center"> <div class="py-5 text-center">
<img class="d-block mx-auto mb-4" src="{{url_for('static', filename='img/ltf-logo.svg')}}" alt="" width="72" height="57"> <img class="d-block mx-auto mb-4" src="{{url_for('static', filename='img/ltf-logo.svg')}}" alt="" width="72" height="57">
<h2>Server Status</h2> <h2>Systems Status</h2>
<p class="lead">The current status of the Liberty Tech Force systems.</p> <p class="lead">The current status of the Liberty Tech Force systems.</p>
</div> </div>
{% if overall_status == 'operational' %} {% if overall_status == 'operational' %}
@ -55,13 +56,21 @@
{% endif %} {% endif %}
{{service.name}} {{service.name}}
{% if service.status == 'operational' %} {% if service.status == 'operational' %}
<span class="p-2 bg-success rounded-circle"> <span class="text-success">
{% elif service.status == 'unclear' %} {% elif service.status == 'unclear' %}
<span class="p-2 bg-warning rounded-circle"> <span class="text-warning">
{% elif service.status == 'offline' %} {% elif service.status == 'offline' %}
<span class="p-2 bg-danger rounded-circle"> <span class="text-danger">
{% endif %} {% endif %}
<span class="visually-hidden">{{service.status}}</span> {{service.status.title()}}
&nbsp;
{% if service.status == 'operational' %}
<i class="bi-check-circle-fill"></i>
{% elif service.status == 'unclear' %}
<i class="bi-question-circle-fill"></i>
{% elif service.status == 'unclear' %}
<i class="bi-exclamation-circle-fill"></i>
{% endif %}
</span> </span>
</li> </li>
{% endfor %} {% endfor %}

View File

@ -0,0 +1,85 @@
from flask import Blueprint, redirect, request, url_for, render_template
from flask_admin import AdminIndexView, expose
from flask_admin.contrib.sqla import ModelView
from flask_admin.helpers import validate_form_on_submit
from flask_login import current_user, login_user, logout_user
from werkzeug.security import check_password_hash
from wtforms.form import Form
from wtforms.fields import StringField, PasswordField
from wtforms.validators import ValidationError, required
from statusforce.models import Service, Incident, User
# The blueprint for the main page
main = Blueprint('main', __name__, url_prefix='/')
class LoginForm(Form):
email = StringField(validators=[required()])
password = PasswordField(validators=[required()])
def validate_login(self, field):
user = self.get_user()
if user is None or not check_password_hash(user.password, self.password.data):
raise ValidationError('Unable to log user in')
def get_user(self):
return User.query.filter_by(email=self.email.data).first()
class AuthModelView(ModelView):
"""
A derivative ModelView that supports user authentication
"""
def is_accessible(self):
return current_user.is_authenticated
class AuthAdminIndexView(AdminIndexView):
"""
A derivative AdminIndexView that supports user authentication
"""
@expose('/')
def index(self):
"""
Override the index page implementation to check for user permissions, and redirect to the login page
"""
if not current_user.is_authenticated:
return redirect(url_for('.login'))
return super().index()
@expose('/login/', methods=('GET', 'POST'))
def login(self):
"""
A page for the user to log in
"""
form = LoginForm(request.form)
if validate_form_on_submit(form):
user = form.get_user()
login_user(user)
if current_user.is_authenticated:
return redirect(url_for('.index'))
self._template_args['form'] = form
return super().index()
@expose('/logout/')
def logout(self):
"""
Log the user out of the system
"""
logout_user()
return redirect(url_for('.index'))
@main.route('')
def index():
"""
Show the status page
"""
services = Service.query.all()
incidents = Incident.query.order_by(Incident.created.desc()).limit(10).all()
service_statuses = [service.status for service in services]
overall_status = 'operational' if all(status == 'operational' for status in service_statuses) else \
'offline' if all(status == 'offline' for status in service_statuses) else 'unclear'
return render_template('index.html', services=services, incidents=incidents, overall_status=overall_status)