~muirrum/fllscoring

bd74d5624dd17efc303cfbf43b0b6fc8821da461 — Cara Salter 25 days ago
Initial Commit
A  => .gitignore +236 -0
@@ 1,236 @@

# Created by https://www.toptal.com/developers/gitignore/api/flask,python,vscode
# Edit at https://www.toptal.com/developers/gitignore?templates=flask,python,vscode

### Flask ###
instance/*
!instance/.gitignore
.webassets-cache
.env

### Flask.Python Stack ###
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class

# C extensions
*.so

# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# PyInstaller
#  Usually these files are written by a python script from a template
#  before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec

# Installer logs
pip-log.txt
pip-delete-this-directory.txt

# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
pytestdebug.log

# Translations
*.mo
*.pot

# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal

# Flask stuff:
instance/

# Scrapy stuff:
.scrapy

# Sphinx documentation
docs/_build/
doc/_build/

# PyBuilder
target/

# Jupyter Notebook
.ipynb_checkpoints

# IPython
profile_default/
ipython_config.py

# pyenv
.python-version

# pipenv
#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
#   However, in case of collaboration, if having platform-specific dependencies or dependencies
#   having no cross-platform support, pipenv may install dependencies that don't work, or not
#   install all needed dependencies.
#Pipfile.lock

# poetry
#poetry.lock

# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/

# Celery stuff
celerybeat-schedule
celerybeat.pid

# SageMath parsed files
*.sage.py

# Environments
# .env
.env/
.venv/
env/
venv/
ENV/
env.bak/
venv.bak/
pythonenv*

# Spyder project settings
.spyderproject
.spyproject

# Rope project settings
.ropeproject

# mkdocs documentation
/site

# mypy
.mypy_cache/
.dmypy.json
dmypy.json

# Pyre type checker
.pyre/

# pytype static type analyzer
.pytype/

# operating system-related files
# file properties cache/storage on macOS
*.DS_Store
# thumbnail cache on Windows
Thumbs.db

# profiling data
.prof


### Python ###
# Byte-compiled / optimized / DLL files

# C extensions

# Distribution / packaging

# PyInstaller
#  Usually these files are written by a python script from a template
#  before PyInstaller builds the exe, so as to inject date/other infos into it.

# Installer logs

# Unit test / coverage reports

# Translations

# Django stuff:

# Flask stuff:

# Scrapy stuff:

# Sphinx documentation

# PyBuilder

# Jupyter Notebook

# IPython

# pyenv

# pipenv
#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
#   However, in case of collaboration, if having platform-specific dependencies or dependencies
#   having no cross-platform support, pipenv may install dependencies that don't work, or not
#   install all needed dependencies.

# poetry

# PEP 582; used by e.g. github.com/David-OConnor/pyflow

# Celery stuff

# SageMath parsed files

# Environments
# .env

# Spyder project settings

# Rope project settings

# mkdocs documentation

# mypy

# Pyre type checker

# pytype static type analyzer

# operating system-related files
# file properties cache/storage on macOS
# thumbnail cache on Windows

# profiling data


### vscode ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace

# End of https://www.toptal.com/developers/gitignore/api/flask,python,vscode

A  => fllscoring/__init__.py +44 -0
@@ 1,44 @@
from flask import Flask

from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from flask_migrate import Migrate

db = SQLAlchemy()
login_manager = LoginManager()
migrator = Migrate()

def create_app():
    app = Flask(__name__)

    app.config.from_mapping(
        SQLALCHEMY_DATABASE_URI='postgresql://fllscoring:fllscoring@172.20.32.1/fllscoring',
        SECRET_KEY='badkey'
    )

    # Health check
    @app.route('/hello')
    def hello():
        return 'Hello!'

    # Init extensions
    db.init_app(app)
    login_manager.init_app(app)
    migrator.init_app(app, db)

    # Configure login manager
    login_manager.login_view = "auth.login"
    login_manager.login_message = "You need to be logged in to view that page"

    # Init blueprints
    import fllscoring.auth
    app.register_blueprint(auth.bp)

    import fllscoring.meta
    app.register_blueprint(meta.bp)

    import fllscoring.profile
    app.register_blueprint(profile.bp)


    return app
\ No newline at end of file

A  => fllscoring/auth/__init__.py +49 -0
@@ 1,49 @@
from flask import Blueprint, render_template, redirect, url_for, flash
from werkzeug.security import generate_password_hash, check_password_hash

import flask_login

from fllscoring import login_manager, db
from fllscoring.models import Users

from fllscoring.auth import forms

bp = Blueprint('auth', __name__, url_prefix="/auth")

@bp.route('/login', methods=["GET", "POST"])
def login():
    form = forms.LoginForm()

    if form.validate_on_submit():
        user = Users.query.filter_by(username=form.username.data).first()
        if user is not None:
            if check_password_hash(user.password_hash, form.password.data):
                flask_login.login_user(user)
                return redirect(url_for("profile.profile"))
            else:
                flash("Incorrect password")
        else:
            flash("Incorrect username")
    return render_template('auth/login.html', form=form, title="Login")

@bp.route('/register', methods=["GET", "POST"])
def register():
    form = forms.RegisterForm()

    if form.validate_on_submit():
        user = Users(username=form.username.data, email=form.email.data, password_hash=generate_password_hash(form.password.data))
        db.session.add(user)
        db.session.commit()
        flask_login.login_user(user)
        return redirect(url_for('profile.profile'))

    return render_template('auth/register.html', form=form, title="Register")

@bp.route('/logout')
def logout():
    flask_login.logout_user()
    return redirect(url_for("meta.home"))

@login_manager.user_loader
def user_loader(user_id):
    return Users.query.filter_by(user_id=user_id).first()
\ No newline at end of file

A  => fllscoring/auth/forms.py +13 -0
@@ 1,13 @@
from flask_wtf import FlaskForm
from wtforms.fields import StringField, PasswordField
from wtforms.validators import DataRequired, Email, EqualTo

class RegisterForm(FlaskForm):
    username = StringField(label="Username", validators=[DataRequired()])
    email = StringField(label="Email Address", validators=[DataRequired(), Email()])
    password = PasswordField(label="Password", validators=[DataRequired()])
    password_confirm = PasswordField(label="Confirm Password", validators=[DataRequired(), EqualTo("password")])

class LoginForm(FlaskForm):
    username = StringField(label="Username", validators=[DataRequired()])
    password = PasswordField(label="Password", validators=[DataRequired()])
\ No newline at end of file

A  => fllscoring/meta/__init__.py +11 -0
@@ 1,11 @@
from flask import Blueprint, render_template

bp = Blueprint('meta', __name__)

@bp.route('/')
def home():
    return render_template('home.html')

@bp.route('/about')
def about():
    return render_template('about.html')
\ No newline at end of file

A  => fllscoring/models.py +16 -0
@@ 1,16 @@
from fllscoring import db

from flask_login import UserMixin

class Users(db.Model,UserMixin):
    user_id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String, nullable=False, unique=True)
    email = db.Column(db.String, nullable=False)
    password_hash = db.Column(db.String, nullable=False)
    is_superadmin = db.Column(db.Boolean, default=False, nullable=False)

    def __repr__(self):
        return f"<User {self.username}"

    def get_id(self):
        return self.user_id
\ No newline at end of file

A  => fllscoring/profile/__init__.py +10 -0
@@ 1,10 @@
from flask import Blueprint, render_template

from flask_login import login_required

bp = Blueprint('profile', __name__, url_prefix="/profile")

@bp.route('/')
@login_required
def profile():
    return render_template("profile/profile.html", title="Profile")
\ No newline at end of file

A  => fllscoring/static/main.css +165 -0
@@ 1,165 @@
html,
body {
    margin: auto;
    background: #f6f6f6;
    --paper-accent: #ED1C24;
    max-width: 720px;
}

#page-heading {
    --paper-accent: #231F20;
}

html,
body,
input,
textarea,
button {
    font-family: 'Barlow', system-ui, sans-serif;
    font-size: 1em;
}

main {
    max-width: 720px;
    margin: 0 auto;
    padding: 16px 0 60px 0;
}

h1,
h2,
h3 {
    font-weight: normal;
}

a.paper {
    text-decoration: none;
}

ul .card {
    list-style: none;
}

a {
    color: var(--paper-accent);
}

p {
    line-height: 1.5em;
}

pre,
code {
    background: #eee;
    font-family: 'Menlo', 'Monaco', 'Courier', monospace;
}

pre {
    box-sizing: border-box;
    width: 100%;
    overflow-x: auto;
    padding: .6em;
}

code {
    padding: .1em .4em;
    font-size: 1em;
}

header,
section,
details {
    margin-bottom: .8em;
}

summary {
    cursor: pointer;
}

h1,
h2 {
    margin-top: .6rem;
}

summary h2 {
    display: inline-block;
    margin-bottom: .4em;
}

.buttons,
.cards,
.hbar,
.buttons .left,
.buttons .right {
    display: flex;
    flex-direction: row;
    align-items: center;
    flex-wrap: wrap;
}

.buttons,
.hbar {
    justify-content: space-between;
}

.cards>div {
    max-width: 25%;
    margin: auto;
}

.buttons .right .paper {
    margin-left: .6em;
    margin-right: 0;
}

.buttons .paper {
    margin-right: .6em;
}

.border-cards {
    overflow: hidden;
    width: calc(100% + 16px);
    padding: 8px;
    box-sizing: border-box;
    transform: translateX(-8px);
}

.border-card {
    float: left;
    margin-right: 8px;
    margin-bottom: 8px;
    min-height: 80px;
    width: calc(50% - 8px);
}

.footer {
    margin-top: 30px;
}

.mobile {
    display: none !important;
}

.flash {
    margin: 1em 0;
    padding: 1em;
    background: #cae6f6;
}

input {
    margin-bottom: 10px;
}

@media only screen and (max-width: 520px) {
    .mobile {
        display: initial !important;
    }
    .desktop {
        display: none !important;
    }
}

@media only screen and (max-width: 750px) {
    main {
        width: 96%;
    }
}
\ No newline at end of file

A  => fllscoring/templates/about.html +18 -0
@@ 1,18 @@
{% extends 'base.html' %}

{% block content %}
<small>This page gives an overview of the system. Documentation can be found within the respective modules.</small>

<h3>Overview</h3>
<p>This system provides a fully-featured scorekeeping system for the FIRST Lego League. It is designed as a "plug-n-play" system, that can be distributed as part of a hardware package serving as a server platform for an event.</p>

<h3>Scope</h3>
<p>This project has a defined scope that limits it to scorekeeping. If you need a software package for judging or other aspects of event planning, there are options out there.</p>

<p>This project is designed specifically for <i>scorekeeping</i>. This includes:</p>
<ul>
    <li>Automatic scoring of matches by referees</li>
    <li>Leaderboard displaying top teams</li>
</ul>
<p>Anything beyond this is <i>out of scope</i>, and will not be included in the main package. If you need something more, you are more than welcome to create your own fork of the software.</p>
{% endblock %}
\ No newline at end of file

A  => fllscoring/templates/auth/login.html +10 -0
@@ 1,10 @@
{% extends 'base.html' %}

{% block content %}
<form method="POST">
    {{ form.csrf_token }}
    {{ form.username.label }}<br>{{ form.username(class_="paper") }}
    {{ form.password.label }}<br>{{ form.password(class_="paper") }}
    <input type="submit" class="paper accent movable" value="Login">
</form>
{% endblock %}
\ No newline at end of file

A  => fllscoring/templates/auth/register.html +14 -0
@@ 1,14 @@
{% extends 'base.html' %}

{% block content %}
    <form method="POST">
        {{ form.csrf_token }}
        {{ form.username.label }}<br>{{ form.username(class_="paper")}}
        {{ form.email.label }}<br>{{ form.email(class_="paper")}}
        <span>
            {{ form.password.label }}<br>{{ form.password(class_="paper") }}
            {{ form.password_confirm.label }}<br>{{ form.password_confirm(class_="paper") }}
        </span>
        <input class="paper accent movable" type="submit" value="Register">
    </form>
{% endblock %}
\ No newline at end of file

A  => fllscoring/templates/base.html +36 -0
@@ 1,36 @@
<!DOCTYPE html>
<title>{% block title %}{% if title%}{{ title }} - FLL-Scoring{% else %}FLL-Scoring{% endif %}{% endblock %}</title>

<link rel="stylesheet" href="https://unpkg.com/@thesephist/paper.css/dist/paper.min.css" />
<link rel="stylesheet" href="{{ url_for('static', filename='main.css') }}">


<header class="accent paper" id="page-heading">
    <h1>{% block header %}{% if title %}{{ title }}{% else %}FLL-Scoring{% endif %}{% endblock %}</h1>
</header>

<section class="buttons">
    <div class="left">
        <a class="paper movable paper-border-left" href="{{ url_for('meta.home') }}">Home</a>
        <a class="paper movable" href="{{ url_for('meta.about') }}">About</a>
        {% if current_user.is_authenticated %}
        <a class="paper movable" href="{{ url_for('profile.profile') }}">Profile</a>
        {% endif %}
    </div>
    <div class="right">
        {% if current_user.is_authenticated %}
        <a class="paper movable paper-border-right" href="{{ url_for('auth.logout') }}">Log out</a>
        {% else %}
        <a href="{{ url_for('auth.register') }}" class="paper movable">Register</a>
        <a href="{{ url_for('auth.login') }}" class="paper movable paper-border-right">Login</a> {% endif %}
    </div>
</section>
</nav>


<section class="content paper">

    {% for message in get_flashed_messages() %}
    <div class="flash">{{ message }}</div>
    {% endfor %} {% block content %}{% endblock %}
</section>
\ No newline at end of file

A  => fllscoring/templates/home.html +5 -0
@@ 1,5 @@
{% extends 'base.html' %}

{% block content %}
<h1>Test</h1>
{% endblock %}
\ No newline at end of file

A  => fllscoring/templates/profile/profile.html +7 -0
@@ 1,7 @@
{% extends 'base.html' %}

{% block content %}
<h1>Welcome, {{ current_user.username }}</h1>
<p>Here you can access our services. If this is a local installation, please see the documentation for how to register yourself as a super-admin.</p>

{% endblock %}
\ No newline at end of file

A  => migrations/README +1 -0
@@ 1,1 @@
Generic single-database configuration.
\ No newline at end of file

A  => migrations/alembic.ini +50 -0
@@ 1,50 @@
# A generic, single database configuration.

[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s

# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false


# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARN
handlers = console
qualname =

[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

A  => migrations/env.py +90 -0
@@ 1,90 @@
from __future__ import with_statement

import logging
from logging.config import fileConfig

from flask import current_app

from alembic import context

# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config

# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')

# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
config.set_main_option(
    'sqlalchemy.url',
    str(current_app.extensions['migrate'].db.engine.url).replace('%', '%%'))
target_metadata = current_app.extensions['migrate'].db.metadata

# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.


def run_migrations_offline():
    """Run migrations in 'offline' mode.

    This configures the context with just a URL
    and not an Engine, though an Engine is acceptable
    here as well.  By skipping the Engine creation
    we don't even need a DBAPI to be available.

    Calls to context.execute() here emit the given string to the
    script output.

    """
    url = config.get_main_option("sqlalchemy.url")
    context.configure(
        url=url, target_metadata=target_metadata, literal_binds=True
    )

    with context.begin_transaction():
        context.run_migrations()


def run_migrations_online():
    """Run migrations in 'online' mode.

    In this scenario we need to create an Engine
    and associate a connection with the context.

    """

    # this callback is used to prevent an auto-migration from being generated
    # when there are no changes to the schema
    # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
    def process_revision_directives(context, revision, directives):
        if getattr(config.cmd_opts, 'autogenerate', False):
            script = directives[0]
            if script.upgrade_ops.is_empty():
                directives[:] = []
                logger.info('No changes in schema detected.')

    connectable = current_app.extensions['migrate'].db.engine

    with connectable.connect() as connection:
        context.configure(
            connection=connection,
            target_metadata=target_metadata,
            process_revision_directives=process_revision_directives,
            **current_app.extensions['migrate'].configure_args
        )

        with context.begin_transaction():
            context.run_migrations()


if context.is_offline_mode():
    run_migrations_offline()
else:
    run_migrations_online()

A  => migrations/script.py.mako +24 -0
@@ 1,24 @@
"""${message}

Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}

"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}

# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}


def upgrade():
    ${upgrades if upgrades else "pass"}


def downgrade():
    ${downgrades if downgrades else "pass"}

A  => migrations/versions/0bc190d5b794_.py +29 -0
@@ 1,29 @@
"""empty message

Revision ID: 0bc190d5b794
Revises: 797223d54f92
Create Date: 2021-04-15 12:01:20.946867

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = '0bc190d5b794'
down_revision = None
branch_labels = None
depends_on = None


def upgrade():
    op.create_table('users',
        sa.Column('user_id', sa.Integer, primary_key=True),
        sa.Column('username', sa.String, nullable=False),
        sa.Column('email', sa.String, nullable=False),
        sa.Column('password_hash', sa.String, nullable=False)
    )


def downgrade():
    op.drop_table('users')

A  => migrations/versions/142a0c4e80c0_.py +28 -0
@@ 1,28 @@
"""empty message

Revision ID: 142a0c4e80c0
Revises: 0bc190d5b794
Create Date: 2021-04-15 15:45:40.658499

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = '142a0c4e80c0'
down_revision = '0bc190d5b794'
branch_labels = None
depends_on = None


def upgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    op.add_column('users', sa.Column('is_superadmin', sa.Boolean(), nullable=True))
    # ### end Alembic commands ###


def downgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    op.drop_column('users', 'is_superadmin')
    # ### end Alembic commands ###

A  => migrations/versions/8cba3105ef08_.py +34 -0
@@ 1,34 @@
"""empty message

Revision ID: 8cba3105ef08
Revises: 142a0c4e80c0
Create Date: 2021-04-15 15:47:29.688491

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = '8cba3105ef08'
down_revision = '142a0c4e80c0'
branch_labels = None
depends_on = None


def upgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    op.alter_column('users', 'is_superadmin',
               existing_type=sa.BOOLEAN(),
               nullable=False)
    op.create_unique_constraint(None, 'users', ['username'])
    # ### end Alembic commands ###


def downgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    op.drop_constraint(None, 'users', type_='unique')
    op.alter_column('users', 'is_superadmin',
               existing_type=sa.BOOLEAN(),
               nullable=True)
    # ### end Alembic commands ###

A  => requirements.txt +27 -0
@@ 1,27 @@
alembic==1.5.8
click==7.1.2
dnspython==2.1.0
dominate==2.6.0
email-validator==1.1.2
Flask==1.1.2
Flask-Login==0.5.0
Flask-Migrate==2.7.0
Flask-SQLAlchemy==2.5.1
Flask-WTF==0.14.3
greenlet==1.0.0
idna==3.1
importlib-metadata==3.10.1
itsdangerous==1.1.0
Jinja2==2.11.3
Mako==1.1.4
MarkupSafe==1.1.1
psycopg2==2.8.6
python-dateutil==2.8.1
python-editor==1.0.4
six==1.15.0
SQLAlchemy==1.4.7
typing-extensions==3.7.4.3
visitor==0.1.3
Werkzeug==1.0.1
WTForms==2.3.3
zipp==3.4.1

A  => setup.py +11 -0
@@ 1,11 @@
from setuptools import setup,find_packages

setup(
    name="fllscoring",
    desc="FIRST Lego League scoring server",
    author="Cara Salter",
    author_email="cara@devcara.com",
    packages=find_packages(),
    include_package_data=True,
    zip_safe=False
)