| @ -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', | |||
| ], | |||
| ) | |||