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
def get_app():
# Set up Flask application and initial config # Set up Flask application and initial config
app = Flask(__name__) app = Flask(__name__)
app.secret_key = b'GSADFGST#$%^$%&^2345234534576476' app.secret_key = b'super-secret-please-replace-me'
app.config['FLASK_ADMIN_SWATCH'] = 'materia' app.config['FLASK_ADMIN_SWATCH'] = 'cosmo'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///statusforce.sqlite' app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///statusforce.sqlite'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
# Pull configuration from environment variables
for key, value in os.environ.items():
if key.startswith('FLASK_ADMIN_') or key.startswith('SQLALCHEMY_'):
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 database # Set up database
db.init_app(app) db.init_app(app)
with app.app_context(): with app.app_context():
db.create_all() db.create_all()
# Set up the authentication
login_manager = LoginManager(app)
@login_manager.user_loader
def load_user(user_id):
return User.query.get(user_id)
# Set up admin interface # Set up admin interface
admin = Admin(app, name='StatusForce', template_mode='bootstrap4') admin = Admin(app, name='StatusForce', index_view=AuthAdminIndexView(), template_mode='bootstrap4')
admin.add_view(ModelView(Service, session)) admin.add_view(AuthModelView(Service, session))
admin.add_view(ModelView(Incident, session)) admin.add_view(AuthModelView(Incident, session))
# Finally, attache the blueprint
@app.route('/') app.register_blueprint(main)
def index(): return app
"""
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)

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 %}
{{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 %} {% endif %}
<span class="visually-hidden">{{service.status}}</span>
</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)