From 63b2fc084fb0ddc8a0314005daec238ccb71bc92 Mon Sep 17 00:00:00 2001 From: Ian Fijolek Date: Sun, 5 Apr 2020 10:48:11 -0700 Subject: [PATCH] Add working code --- .gitignore | 3 + Dockerfile | 25 +++++ Makefile | 9 ++ docker-compose.prod.yml | 7 ++ docker-compose.yml | 12 +++ fishbowl/app.py | 176 ++++++++++++++++++++++++++++++++++ fishbowl/fishbowl.db | Bin 0 -> 16384 bytes fishbowl/templates/base.html | 36 +++++++ fishbowl/templates/game.html | 18 ++++ fishbowl/templates/index.html | 8 ++ requirements-dev-minimal.txt | 5 + requirements-minimal.txt | 6 ++ 12 files changed, 305 insertions(+) create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 fishbowl/app.py create mode 100644 fishbowl/fishbowl.db create mode 100644 fishbowl/templates/base.html create mode 100644 fishbowl/templates/game.html create mode 100644 fishbowl/templates/index.html create mode 100644 requirements-dev-minimal.txt create mode 100644 requirements-minimal.txt 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 0000000000000000000000000000000000000000..7f343893c9531ca3868ccc0a9a445068d47c3e63 GIT binary patch literal 16384 zcmeI(J#P~+7yw}3<%1+N7=$XsfUpEo6A93L<#HzmNW;;FrYSuVzLv;7pW#Hm;I4ud z#1BA-m4Cp_zz;x7Y)D9q`~oI6u+yY15+xH1=(XhJ-q)|~f#x6&uQLAQI3RLWWWBqKLlVSBL^)=a~4>MDDWuE)c@87h^e!}|&6@dlY+S?LC? z-Z;6HL=R%VKY2auEVsftWGT2qY6%}Lcy%QVItx9OtBn`0lQ6g%gh8(z+#o8qTijj@ z+DkIp-LcanHRBvH>!Y>yhR1vF4Oi+#DB*VzRT_fhODA@~uxyY^(kY z*cMIGk?Ewsw7k*FqI^Bs7#=wMJC#ANvpNV!O>J1O)aUf#iHoxuN;Y`>Bz?Fh@9l?l zJKG$^L-nGErh3fDuUbic-Kw$|j>voS5BL>k*p`>jKmY_l00ck)1V8`;KmY_l00cnb ze-p3@1++YegkjL8&3z`Uh9g|+F)!ib9`p_uIbs#6$UdKkrPwO z1?8!rDHpb`X4IXA8#NmyvurDJIBoi#8QaWn#-_!rf9H690|`C zuERaYbE(ZeS8iofVMa~0jjR`ZJg*`@(E?sXcpZPiZ}Cfc2@M2500ck)1V8`;KmY_l z00ck)1VG>bfwQ?~ZQ3b9K^09qHpr`@X@>AlTP{03|@C$^$;jj1; zevjYbH~6(oKm!2~009sH0T2KI5C8!X009sH0TB3W0*6aQG^>^5pDNNUUp-VrC&vMy z`D$?|yqRpS#hG3y6wz@x+MyeIHNP`-El$%U)@O1uESJiCxeAG9^nDSV(e_33CwqM^ AiU0rr literal 0 HcmV?d00001 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 %} +
+ +
+ {% block footer %} + {% 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