| @ -0,0 +1,14 @@ | |||||
| venv/ | |||||
| *.pyc | |||||
| __pycache__/ | |||||
| instance/ | |||||
| .pytest_cache/ | |||||
| .coverage | |||||
| htmlcov/ | |||||
| dist/ | |||||
| build/ | |||||
| *.egg-info/ | |||||
| @ -0,0 +1,4 @@ | |||||
| include interruptor/schema.sql | |||||
| graft interruptor/static | |||||
| graft interruptor/templates | |||||
| global-exclude *.pyc | |||||
| @ -0,0 +1,41 @@ | |||||
| import os | |||||
| from flask import Flask | |||||
| def create_app(test_config=None): | |||||
| # create and configure the app | |||||
| app = Flask(__name__, instance_relative_config=True) | |||||
| app.config.from_mapping( | |||||
| SECRET_KEY='dev', | |||||
| DATABASE=os.path.join(app.instance_path, 'interruptor.sqlite'), | |||||
| ) | |||||
| if test_config is None: | |||||
| # load the instance config, if it exists, when not testing | |||||
| app.config.from_pyfile('config.py', silent=True) | |||||
| else: | |||||
| # load the test config if passed in | |||||
| app.config.from_mapping(test_config) | |||||
| # ensure the instance folder exists | |||||
| try: | |||||
| os.makedirs(app.instance_path) | |||||
| except OSError: | |||||
| pass | |||||
| # setup GPIO | |||||
| import RPi.GPIO as GPIO | |||||
| GPIO.setmode(GPIO.BCM) | |||||
| GPIO.setup(17, GPIO.OUT) | |||||
| from . import db | |||||
| db.init_app(app) | |||||
| from . import auth | |||||
| app.register_blueprint(auth.bp) | |||||
| from . import interruptor | |||||
| app.register_blueprint(interruptor.bp) | |||||
| app.add_url_rule('/', endpoint='index') | |||||
| return app | |||||
| @ -0,0 +1,90 @@ | |||||
| import functools | |||||
| from flask import ( | |||||
| Blueprint, flash, g, redirect, render_template, request, session, url_for | |||||
| ) | |||||
| from werkzeug.security import check_password_hash, generate_password_hash | |||||
| from interruptor.db import get_db | |||||
| bp = Blueprint('auth', __name__, url_prefix='/auth') | |||||
| @bp.route('/register', methods=('GET', 'POST')) | |||||
| def register(): | |||||
| if request.method == 'POST': | |||||
| username = request.form['username'] | |||||
| password = request.form['password'] | |||||
| db = get_db() | |||||
| error = None | |||||
| if not username: | |||||
| error = 'Username is required.' | |||||
| elif not password: | |||||
| error = 'Password is required.' | |||||
| elif db.execute( | |||||
| 'SELECT id FROM user WHERE username = ?', (username,) | |||||
| ).fetchone() is not None: | |||||
| error = 'User {} is already registered.'.format(username) | |||||
| if error is None: | |||||
| db.execute( | |||||
| 'INSERT INTO user (username, password) VALUES (?, ?)', | |||||
| (username, generate_password_hash(password)) | |||||
| ) | |||||
| db.commit() | |||||
| return redirect(url_for('auth.login')) | |||||
| flash(error) | |||||
| return render_template('auth/register.html.j2') | |||||
| @bp.route('/login', methods=('GET', 'POST')) | |||||
| def login(): | |||||
| if request.method == 'POST': | |||||
| username = request.form['username'] | |||||
| password = request.form['password'] | |||||
| db = get_db() | |||||
| error = None | |||||
| user = db.execute( | |||||
| 'SELECT * FROM user WHERE username = ?', (username,) | |||||
| ).fetchone() | |||||
| if user is None: | |||||
| error = 'Incorrect username.' | |||||
| elif not check_password_hash(user['password'], password): | |||||
| error = 'Incorrect password.' | |||||
| if error is None: | |||||
| session.clear() | |||||
| session['user_id'] = user['id'] | |||||
| return redirect(url_for('index')) | |||||
| flash(error) | |||||
| return render_template('auth/login.html.j2') | |||||
| @bp.route('/logout') | |||||
| def logout(): | |||||
| session.clear() | |||||
| return redirect(url_for('index')) | |||||
| @bp.before_app_request | |||||
| def load_logged_in_user(): | |||||
| user_id = session.get('user_id') | |||||
| if user_id is None: | |||||
| g.user = None | |||||
| else: | |||||
| g.user = get_db().execute( | |||||
| 'SELECT * FROM user WHERE id = ?', (user_id,) | |||||
| ).fetchone() | |||||
| def login_required(view): | |||||
| @functools.wraps(view) | |||||
| def wrapped_view(**kwargs): | |||||
| if g.user is None: | |||||
| return redirect(url_for('auth.login')) | |||||
| return view(**kwargs) | |||||
| return wrapped_view | |||||
| @ -0,0 +1,41 @@ | |||||
| import sqlite3 | |||||
| import click | |||||
| from flask import current_app, g | |||||
| from flask.cli import with_appcontext | |||||
| def get_db(): | |||||
| if 'db' not in g: | |||||
| g.db = sqlite3.connect( | |||||
| current_app.config['DATABASE'], | |||||
| detect_types=sqlite3.PARSE_DECLTYPES | |||||
| ) | |||||
| g.db.row_factory = sqlite3.Row | |||||
| return g.db | |||||
| def close_db(e=None): | |||||
| db = g.pop('db', None) | |||||
| if db is not None: | |||||
| db.close() | |||||
| def init_db(): | |||||
| db = get_db() | |||||
| with current_app.open_resource('schema.sql') as f: | |||||
| db.executescript(f.read().decode('utf8')) | |||||
| @click.command('init-db') | |||||
| @with_appcontext | |||||
| def init_db_command(): | |||||
| """Clear the existing data and create new tables.""" | |||||
| init_db() | |||||
| click.echo('Initialized the database.') | |||||
| def init_app(app): | |||||
| app.teardown_appcontext(close_db) | |||||
| app.cli.add_command(init_db_command) | |||||
| @ -0,0 +1,45 @@ | |||||
| from flask import ( | |||||
| Blueprint, flash, g, redirect, render_template, request, url_for | |||||
| ) | |||||
| from werkzeug.exceptions import abort | |||||
| from interruptor.auth import login_required | |||||
| from interruptor.db import get_db | |||||
| import RPi.GPIO as GPIO | |||||
| bp = Blueprint('interruptor', __name__) | |||||
| @bp.route('/') | |||||
| def index(): | |||||
| db = get_db() | |||||
| status = db.execute( | |||||
| 'SELECT user_id, status, created' | |||||
| ' FROM status JOIN user ON status.user_id = user.id' | |||||
| ' ORDER BY created DESC LIMIT 1' | |||||
| ).fetchone() | |||||
| return render_template('interruptor/index.html.j2', status=status) | |||||
| @bp.route('/toggle') | |||||
| @login_required | |||||
| def toggle(): | |||||
| db = get_db() | |||||
| status = db.execute( | |||||
| 'SELECT status' | |||||
| ' FROM status JOIN user ON status.user_id = user.id' | |||||
| ' ORDER BY created DESC LIMIT 1' | |||||
| ).fetchone() | |||||
| if status['status']: | |||||
| GPIO.output(17, GPIO.LOW) | |||||
| else: | |||||
| GPIO.output(17, GPIO.HIGH) | |||||
| db.execute( | |||||
| 'INSERT INTO status (user_id, status)' | |||||
| ' VALUES (?, ?)', | |||||
| (g.user['id'], not status['status']) | |||||
| ) | |||||
| db.commit() | |||||
| return redirect(url_for('interruptor.index')) | |||||
| @ -0,0 +1,18 @@ | |||||
| DROP TABLE IF EXISTS user; | |||||
| DROP TABLE IF EXISTS status; | |||||
| CREATE TABLE user ( | |||||
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |||||
| username TEXT UNIQUE NOT NULL, | |||||
| password TEXT NOT NULL | |||||
| ); | |||||
| CREATE TABLE status ( | |||||
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |||||
| user_id INTEGER NOT NULL, | |||||
| status BOOLEAN NOT NULL, | |||||
| created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, | |||||
| FOREIGN KEY (user_id) REFERENCES user (id) | |||||
| ); | |||||
| INSERT INTO STATUS (user_id, status) VALUES (1, 1); | |||||
| @ -0,0 +1,134 @@ | |||||
| html { | |||||
| font-family: sans-serif; | |||||
| background: #eee; | |||||
| padding: 1rem; | |||||
| } | |||||
| body { | |||||
| max-width: 960px; | |||||
| margin: 0 auto; | |||||
| background: white; | |||||
| } | |||||
| h1, h2, h3, h4, h5, h6 { | |||||
| font-family: serif; | |||||
| color: #377ba8; | |||||
| margin: 1rem 0; | |||||
| } | |||||
| a { | |||||
| color: #377ba8; | |||||
| } | |||||
| hr { | |||||
| border: none; | |||||
| border-top: 1px solid lightgray; | |||||
| } | |||||
| nav { | |||||
| background: lightgray; | |||||
| display: flex; | |||||
| align-items: center; | |||||
| padding: 0 0.5rem; | |||||
| } | |||||
| nav h1 { | |||||
| flex: auto; | |||||
| margin: 0; | |||||
| } | |||||
| nav h1 a { | |||||
| text-decoration: none; | |||||
| padding: 0.25rem 0.5rem; | |||||
| } | |||||
| nav ul { | |||||
| display: flex; | |||||
| list-style: none; | |||||
| margin: 0; | |||||
| padding: 0; | |||||
| } | |||||
| nav ul li a, nav ul li span, header .action { | |||||
| display: block; | |||||
| padding: 0.5rem; | |||||
| } | |||||
| .content { | |||||
| padding: 0 1rem 1rem; | |||||
| } | |||||
| .content > header { | |||||
| border-bottom: 1px solid lightgray; | |||||
| display: flex; | |||||
| align-items: flex-end; | |||||
| } | |||||
| .content > header h1 { | |||||
| flex: auto; | |||||
| margin: 1rem 0 0.25rem 0; | |||||
| } | |||||
| .flash { | |||||
| margin: 1em 0; | |||||
| padding: 1em; | |||||
| background: #cae6f6; | |||||
| border: 1px solid #377ba8; | |||||
| } | |||||
| .post > header { | |||||
| display: flex; | |||||
| align-items: flex-end; | |||||
| font-size: 0.85em; | |||||
| } | |||||
| .post > header > div:first-of-type { | |||||
| flex: auto; | |||||
| } | |||||
| .post > header h1 { | |||||
| font-size: 1.5em; | |||||
| margin-bottom: 0; | |||||
| } | |||||
| .post .about { | |||||
| color: slategray; | |||||
| font-style: italic; | |||||
| } | |||||
| .post .body { | |||||
| white-space: pre-line; | |||||
| } | |||||
| .content:last-child { | |||||
| margin-bottom: 0; | |||||
| } | |||||
| .content form { | |||||
| margin: 1em 0; | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| } | |||||
| .content label { | |||||
| font-weight: bold; | |||||
| margin-bottom: 0.5em; | |||||
| } | |||||
| .content input, .content textarea { | |||||
| margin-bottom: 1em; | |||||
| } | |||||
| .content textarea { | |||||
| min-height: 12em; | |||||
| resize: vertical; | |||||
| } | |||||
| input.danger { | |||||
| color: #cc2f2e; | |||||
| } | |||||
| input[type=submit] { | |||||
| align-self: start; | |||||
| min-width: 10em; | |||||
| } | |||||
| @ -0,0 +1,15 @@ | |||||
| {% extends 'base.html.j2' %} | |||||
| {% block header %} | |||||
| <h1>{% block title %}Log In{% endblock %}</h1> | |||||
| {% endblock %} | |||||
| {% block content %} | |||||
| <form method="post"> | |||||
| <label for="username">Username</label> | |||||
| <input name="username" id="username" required> | |||||
| <label for="password">Password</label> | |||||
| <input type="password" name="password" id="password" required> | |||||
| <input type="submit" value="Log In"> | |||||
| </form> | |||||
| {% endblock %} | |||||
| @ -0,0 +1,15 @@ | |||||
| {% extends 'base.html.j2' %} | |||||
| {% block header %} | |||||
| <h1>{% block title %}Register{% endblock %}</h1> | |||||
| {% endblock %} | |||||
| {% block content %} | |||||
| <form method="post"> | |||||
| <label for="username">Username</label> | |||||
| <input name="username" id="username" required> | |||||
| <label for="password">Password</label> | |||||
| <input type="password" name="password" id="password" required> | |||||
| <input type="submit" value="Register"> | |||||
| </form> | |||||
| {% endblock %} | |||||
| @ -0,0 +1,24 @@ | |||||
| <!doctype html> | |||||
| <title>{% block title %}{% endblock %} - Interruptor</title> | |||||
| <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> | |||||
| <nav> | |||||
| <h1>Interruptor</h1> | |||||
| <ul> | |||||
| {% if g.user %} | |||||
| <li><span>{{ g.user['username'] }}</span> | |||||
| <li><a href="{{ url_for('auth.logout') }}">Log Out</a> | |||||
| {% else %} | |||||
| <li><a href="{{ url_for('auth.register') }}">Register</a> | |||||
| <li><a href="{{ url_for('auth.login') }}">Log In</a> | |||||
| {% endif %} | |||||
| </ul> | |||||
| </nav> | |||||
| <section class="content"> | |||||
| <header> | |||||
| {% block header %}{% endblock %} | |||||
| </header> | |||||
| {% for message in get_flashed_messages() %} | |||||
| <div class="flash">{{ message }}</div> | |||||
| {% endfor %} | |||||
| {% block content %}{% endblock %} | |||||
| </section> | |||||
| @ -0,0 +1,23 @@ | |||||
| {% extends 'base.html.j2' %} | |||||
| {% block header %} | |||||
| <h1>{% block title %}Status{% endblock %}</h1> | |||||
| {% if g.user %} | |||||
| <a class="action" href="{{ url_for('interruptor.toggle') }}"> | |||||
| {% if status.status %} | |||||
| Desligar | |||||
| {% else %} | |||||
| Ligar | |||||
| {% endif %} | |||||
| </a> | |||||
| {% endif %} | |||||
| {% endblock %} | |||||
| {% block content %} | |||||
| {% if status.status %} | |||||
| Ligado | |||||
| {% else %} | |||||
| Desligado | |||||
| {% endif %} | |||||
| {% endblock %} | |||||
| @ -0,0 +1,13 @@ | |||||
| from setuptools import find_packages, setup | |||||
| setup( | |||||
| name='interruptor', | |||||
| version='1.0.0', | |||||
| packages=find_packages(), | |||||
| include_package_data=True, | |||||
| zip_safe=False, | |||||
| install_requires=[ | |||||
| 'flask', | |||||
| 'RPi.GPIO', | |||||
| ], | |||||
| ) | |||||