diff --git a/.gitignore b/.gitignore index 13d1490..66cce11 100644 --- a/.gitignore +++ b/.gitignore @@ -129,3 +129,6 @@ dmypy.json # Pyre type checker .pyre/ +# Project stuff +fishbowl/fishbowl.db +data/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7872c11 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3 + +RUN mkdir -p /app +WORKDIR /app + +# Install service runtime requirements +COPY ./requirements-minimal.txt /app/requirements.txt +RUN pip install -r ./requirements.txt + +RUN mkdir /data +VOLUME /data +ENV DB_URI sqlite:////data/fishbowl.db + +EXPOSE 3000 + +# Install service as package for alembic +COPY ./fishbowl /app/fishbowl + +# Own app dir and drop root +RUN chown -R www-data:www-data /app +RUN chown -R www-data:www-data /data +USER www-data + + +CMD ["python", "/app/fishbowl/app.py"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..13206af --- /dev/null +++ b/Makefile @@ -0,0 +1,9 @@ +.PHONY: default clean test all + +default: venv + env FLASK_ENV=development ./venv/bin/python ./fishbowl/app.py + + +venv: + virtualenv -p python3 ./venv + ./venv/bin/pip install -r ./requirements-minimal.txt diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..ef8a9da --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,7 @@ +--- +version: '2' +services: + main: + environment: + FLASK_ENV: production + SECRET_KEY: ${SECRET_KEY} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..046c402 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +--- +version: '2' +services: + main: + build: . + ports: + - "3000:3000" + volumes: + - ./data:/data + environment: + FLASK_ENV: development + SECRET_KEY: mysupersecretkey diff --git a/fishbowl/app.py b/fishbowl/app.py new file mode 100644 index 0000000..d7ec7fe --- /dev/null +++ b/fishbowl/app.py @@ -0,0 +1,176 @@ +from uuid import uuid4 +from random import choice +from os import environ +import crypt + +from flask import Flask +from flask import redirect +from flask import render_template +from flask import request +from flask import session +from flask import url_for +from flask_bootstrap import Bootstrap +from flask_sqlalchemy import SQLAlchemy +from flask_wtf import FlaskForm +from wtforms import StringField, SubmitField, BooleanField, PasswordField +from wtforms.validators import DataRequired, Length, Optional +import flask_sqlalchemy + + +app = Flask(__name__) +app.config['SQLALCHEMY_DATABASE_URI'] = environ.get( + 'DB_URI', 'sqlite:///fishbowl.db' +) +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +app.secret_key = environ.get('SECRET_KEY', uuid4().hex) +bootstrap = Bootstrap(app) +db = SQLAlchemy(app) + + +class Game(db.Model): + id = db.Column(db.Integer, primary_key=True) + uuid = db.Column(db.String(32), unique=True, nullable=False) + password = db.Column(db.String(128)) + admin_password = db.Column(db.String(128)) + words = db.relationship( + 'Word', + backref=db.backref('game', lazy=True), + ) + + @classmethod + def new(cls, password, admin_password=None): + game = Game(uuid=uuid4().hex) + salt = crypt.mksalt() + if password: + game.password = crypt.crypt(password, salt) + if admin_password: + admin_password = crypt.crypt(admin_password, salt) + return game + + @classmethod + def by_uuid(cls, uuid): + return cls.query.filter_by( + uuid=uuid, + ).one() + + def get_url(self): + return url_for('.game', game_uuid=self.uuid) + + def get_remaining_words(self): + return [ + word for word in self.words + if not word.is_hidden and not word.is_picked + ] + + +class Word(db.Model): + id = db.Column(db.Integer, primary_key=True) + text = db.Column(db.String(1024)) + game_id = db.Column(db.Integer, db.ForeignKey('game.id')) + is_hidden = db.Column(db.Boolean, default=False) + is_picked = db.Column(db.Boolean, default=False) + + +class NewGameForm(FlaskForm): + game_password = PasswordField( + 'Game password (optional)', validators=[Optional(), Length(5, 128)] + ) + # admin_password = PasswordField( + # 'Admin password', validators=[DataRequired(), Length(5, 128)] + # ) + submit = SubmitField(label='Create') + + +class GameLoginForm(FlaskForm): + game_password = PasswordField( + 'Game password', validators=[DataRequired(), Length(5, 128)] + ) + # admin_password = PasswordField( + # 'Admin password', validators=[DataRequired(), Length(5, 128)] + # ) + submit = SubmitField(label='Login') + + +class AddWordForm(FlaskForm): + new_word = StringField('New word', validators=[Length(3, 1024)]) + submit_new_word = SubmitField(label='Add new word') + + +class PlayGameForm(FlaskForm): + draw_word = SubmitField(label='Draw word') + reset_game = SubmitField(label='Reset game') + reset_confirm = BooleanField(label='Confirm reset') + + +@app.route('/', methods=['GET', 'POST']) +def index(): + form = NewGameForm() + if request.method == 'POST': + if form.validate_on_submit(): + game = Game.new(form.game_password.data) + db.session.add(game) + db.session.commit() + app.logger.info("Created game %i, %s", game.id, game.uuid) + return redirect(game.get_url()) + else: + print("Form was not valid") + + return render_template('index.html', form=form) + + +@app.route('/game/', methods=['GET', 'POST']) +def game(game_uuid): + # Init login tracker + if 'logged_in' not in session: + session['logged_in'] = {} + game_login = GameLoginForm() + word_form = AddWordForm() + play_form = PlayGameForm() + try: + game = Game.by_uuid(game_uuid) + except flask_sqlalchemy.orm.exc.NoResultFound: + return redirect(url_for('.index')) + word = None + if request.method == 'POST': + # Check if trying to login + if game_login.submit.data and game_login.validate(): + if crypt.crypt(game.password, game_login.game_password.data): + if not session['logged_in']: + session['logged_in'] = {} + session['logged_in'][game.uuid] = True + # Check if word is being added + if word_form.submit_new_word.data and word_form.validate(): + word = Word(text=word_form.new_word.data) + game.words.append(word) + # We just drew a new word! + if play_form.draw_word.data: + words = game.get_remaining_words() + if words: + word = choice(words) + word.is_picked = True + else: + word = "There are no words. Add some and try again!" + # Reset the game + if play_form.reset_game.data and play_form.reset_confirm.data: + for unpick_word in game.words: + unpick_word.is_picked = False + + db.session.add(game) + db.session.commit() + + words = game.get_remaining_words() + + return render_template( + 'game.html', + game_login=game_login, + word_form=word_form, + play_form=play_form, + game=game, + word=word, + words=words, + ) + + +if __name__ == '__main__': + db.create_all() + app.run(host='0.0.0.0', port=3000) diff --git a/fishbowl/fishbowl.db b/fishbowl/fishbowl.db new file mode 100644 index 0000000..7f34389 Binary files /dev/null and b/fishbowl/fishbowl.db differ diff --git a/fishbowl/templates/base.html b/fishbowl/templates/base.html new file mode 100644 index 0000000..96b30c7 --- /dev/null +++ b/fishbowl/templates/base.html @@ -0,0 +1,36 @@ +{% from 'bootstrap/nav.html' import render_nav_item %} + + + + + + + Fishbowl + + {{ bootstrap.load_css() }} + + + + +
+ {% block content %}{% endblock %} +
+ + + + {{ bootstrap.load_js() }} + + diff --git a/fishbowl/templates/game.html b/fishbowl/templates/game.html new file mode 100644 index 0000000..c916244 --- /dev/null +++ b/fishbowl/templates/game.html @@ -0,0 +1,18 @@ +{% extends 'base.html' %} +{% from 'bootstrap/form.html' import render_form %} + +{% block content %} +

Play game

+ {% if game.password is not none and not session['logged_in'][game.uuid] %} + {{ render_form(game_login) }} + {% else %} +

To share this game, just share the url to this page

+ {% if word is not none %} +
Your word is: {{ word.text }}
+ {% endif %} +
Words remaining: {{ words|length }}
+ {{ render_form(play_form) }} +

Add new word

+ {{ render_form(word_form) }} + {% endif %} +{% endblock %} diff --git a/fishbowl/templates/index.html b/fishbowl/templates/index.html new file mode 100644 index 0000000..5ab406b --- /dev/null +++ b/fishbowl/templates/index.html @@ -0,0 +1,8 @@ +{% extends 'base.html' %} +{% from 'bootstrap/form.html' import render_form %} + +{% block content %} +

New game

+ {{ render_form(form) }} +{% endblock %} + diff --git a/requirements-dev-minimal.txt b/requirements-dev-minimal.txt new file mode 100644 index 0000000..ec9ba4d --- /dev/null +++ b/requirements-dev-minimal.txt @@ -0,0 +1,5 @@ +coverage +docker-compose +flake8 +pre-commit +requirements-tools diff --git a/requirements-minimal.txt b/requirements-minimal.txt new file mode 100644 index 0000000..03e324f --- /dev/null +++ b/requirements-minimal.txt @@ -0,0 +1,6 @@ +flask +flask-sqlalchemy +bootstrap-flask +flask-wtf +sqlalchemy +gunicorn