diff --git a/.env b/.env new file mode 100644 index 0000000..17601f8 --- /dev/null +++ b/.env @@ -0,0 +1,4 @@ +# Remember to keep in sync with the frontend's .env too +CATEGORIES_PER_GAME=5 +QUESTIONS_PER_CATEGORY=5 +SCORE_TICK=100 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..b009dfb --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +lts/* diff --git a/README.adoc b/README.adoc index 331d918..6e3aabf 100644 --- a/README.adoc +++ b/README.adoc @@ -65,3 +65,14 @@ database where transactions are pushed when the hosts submit the points. This has the flipside requiring games to be finalized before a new one can be started. Make sure that you always push the "Game over" button before reloading to start a new game. + +== Dev + +Use appropriate node version. A `.nvmrc` file exists to ease setup. +Once you have https://github.com/nvm-sh/nvm#nvmrc[installed nvm adequately] (or https://github.com/jorgebucaran/nvm.fish[with fish]): + + nvm use + +=== Front-End + + npm run dev diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/api_routes.py b/api/api_routes.py new file mode 100644 index 0000000..fee7710 --- /dev/null +++ b/api/api_routes.py @@ -0,0 +1,45 @@ +# Ceopardy +# https://github.com/obilodeau/ceopardy/ +# +# Olivier Bilodeau +# Copyright (C) 2024 Olivier Bilodeau +# All rights reserved. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +from flask import Blueprint, current_app as app, jsonify + +api_bp = Blueprint('api', __name__, url_prefix='/api/v1') + +@api_bp.route('/categories', methods=['GET']) +def get_categories(): + categories = app.controller.get_categories() + return jsonify(categories) + + +@api_bp.route('/current_question', methods=['GET']) +def get_active_question(): + return jsonify(app.controller.get_active_question()) + + +@api_bp.route('/questions/grid', methods=['GET']) +def get_game_grid(): + return jsonify(app.controller.get_questions_status_for_viewer()) + + +@api_bp.route('/state', methods=['GET']) +def get_state(): + return jsonify(app.controller.get_complete_state()) + + +@api_bp.route('/scores', methods=['GET']) +def get_scores(): + return jsonify(app.controller.get_teams_score()) diff --git a/ceopardy.py b/ceopardy.py index c0d1125..9cd333a 100644 --- a/ceopardy.py +++ b/ceopardy.py @@ -22,10 +22,12 @@ import sys from flask import g, Flask, render_template, redirect, jsonify, request +from flask_cors import CORS from flask_socketio import SocketIO, emit, disconnect from flask_sqlalchemy import SQLAlchemy import utils +from api.api_routes import api_bp from config import config from forms import TeamNamesForm, TEAM_FIELD_ID @@ -36,9 +38,12 @@ app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + config['DATABASE_FILENAME'] # To supress warnings about a feature we don't use app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +# Register the API Blueprint +app.register_blueprint(api_bp) socketio = SocketIO(app) db = SQLAlchemy(app) +CORS(app) @app.context_processor @@ -50,12 +55,11 @@ def inject_config(): @app.route('/') @app.route('/viewer') def viewer(): - controller = get_controller() - scores = controller.get_teams_score() - categories = controller.get_categories() - questions = controller.get_questions_status_for_viewer() - state = controller.get_complete_state() - active_question = controller.get_active_question() + scores = app.controller.get_teams_score() + categories = app.controller.get_categories() + questions = app.controller.get_questions_status_for_viewer() + state = app.controller.get_complete_state() + active_question = app.controller.get_active_question() return render_template('viewer.html', scores=scores, categories=categories, questions=questions, state=state, active_question=active_question) @@ -67,7 +71,7 @@ def viewer(): # TODO add authentication here @app.route('/host') def host(): - controller = get_controller() + controller = app.controller if not controller.is_game_in_progress(): must_init = controller.is_game_initialized() is False @@ -104,7 +108,7 @@ def host(): @app.route('/init', methods=["POST"]) def init(): """Init can resume a finished game or start a new one""" - controller = get_controller() + controller = app.controller if request.form['action'] == "new": roundfile = request.form['name'] @@ -152,7 +156,7 @@ def init(): @app.route('/setup', methods=["POST"]) def setup(): - controller = get_controller() + controller = app.controller form = TeamNamesForm() # TODO: [LOW] csrf token errors are not logged (and return 200 which contradicts docs) if not form.validate_on_submit(): @@ -169,7 +173,7 @@ def answer(): # FIXME this form isn't CSRF protected app.logger.debug("Answer form has been submitted with: {}", request.form) data = request.form - controller = get_controller() + controller = app.controller app.logger.debug('received data: {}'.format(data["id"])) try: @@ -213,7 +217,7 @@ def answer(): @socketio.on('question', namespace='/host') def handle_question(data): - controller = get_controller() + controller = app.controller if data["action"] == "select": col, row = utils.parse_question_id(data["id"]) question = controller.get_question(col, row) @@ -261,7 +265,7 @@ def handle_question(data): @socketio.on('message', namespace='/host') def handle_message(data): - controller = get_controller() + controller = app.controller # FIXME Temporary XSS!!! if data["action"] == "show": content = "

{0}

".format(data["text"]) @@ -279,7 +283,7 @@ def handle_message(data): @socketio.on('team', namespace='/host') def handle_team(data): - controller = get_controller() + controller = app.controller if data["action"] == "select": team = data["id"] data["args"] = team @@ -301,13 +305,12 @@ def handle_team(data): @socketio.on('slider', namespace='/host') def handle_slider(data): - controller = get_controller() - controller.set_state(data["id"], data["value"]) + app.controller.set_state(data["id"], data["value"]) @socketio.on('final', namespace='/host') def move_to_final_round(data): - controller = get_controller() + controller = app.controller if controller.is_final_question(): # TODO implement pass @@ -322,34 +325,35 @@ def move_to_final_round(data): @socketio.on('refresh', namespace='/viewer') def handle_refresh(): - controller = get_controller() # FIXME - #state = controller.dictionize_questions_solved() + #state = app.controller.dictionize_questions_solved() state = {} emit("update-board", state) -if __name__ == '__main__': +def create_app(): - # Logging - file_handler = logging.FileHandler('ceopardy.log') - #file_handler.setLevel(logging.INFO) - file_handler.setLevel(logging.DEBUG) - fmt = logging.Formatter( - '{asctime} {levelname}: {message} [in {pathname}:{lineno}]', style='{') - file_handler.setFormatter(fmt) - app.logger.addHandler(file_handler) - - # Cleaner controller access - # Unsure if required once we have a db back-end with app.app_context(): + # Logging + file_handler = logging.FileHandler('ceopardy.log') + #file_handler.setLevel(logging.INFO) + file_handler.setLevel(logging.DEBUG) + fmt = logging.Formatter( + '{asctime} {levelname}: {message} [in {pathname}:{lineno}]', style='{') + file_handler.setFormatter(fmt) + app.logger.addHandler(file_handler) + + # Database + app.db = db + + # Controller from controller import Controller - def get_controller(): - _ctl = getattr(g, '_ctl', None) - if _ctl is None: - _ctl = g._ctl = Controller() - return _ctl + _ctl = getattr(g, '_ctl', None) + if _ctl is None: + _ctl = g._ctl = Controller() + # TODO would be nice if this could get type hints + app.controller = _ctl @app.teardown_appcontext def teardown_controller(exception): @@ -361,3 +365,7 @@ def teardown_controller(exception): # WARNING: This app is not ready to be exposed on the network. # Game host interface would be exposed. socketio.run(app, host="127.0.0.1", debug=True) + + +if __name__ == '__main__': + create_app() diff --git a/config.py b/config.py index b50114b..6fb67e5 100644 --- a/config.py +++ b/config.py @@ -17,13 +17,13 @@ # import os +from dotenv import dotenv_values + + config = { 'NB_TEAMS': 3, 'VARIABLE_TEAMS': False, - 'CATEGORIES_PER_GAME': 5, - 'QUESTIONS_PER_CATEGORY': 5, 'QUESTIONS_FILENAME': 'data/Questions.cp', - 'SCORE_TICK': 100, 'MESSAGES': [\ {"title": "Game not started", "text": "Please wait while the game is being set up..."}, @@ -40,3 +40,8 @@ 'DAILYDOUBLE_WAIGER_MIN': 5, 'DAILYDOUBLE_WAIGER_MAX_MIN': 500 } + +env_config = dotenv_values(".env") + +# eventually we can replace with Python's 3.9+ config = config | env_config +config = {**config, **env_config} diff --git a/controller.py b/controller.py index ffd430e..02e7117 100644 --- a/controller.py +++ b/controller.py @@ -22,7 +22,6 @@ from flask import current_app as app from sqlalchemy import and_ -from ceopardy import db from config import config from model import Answer, Game, Team, GameState, Question, Response, State, \ FinalQuestion @@ -44,15 +43,15 @@ def _init(): # If there's not a game state, create one if Game.query.one_or_none() is None: game = Game() - db.session.add(game) + app.db.session.add(game) # Default overlay state for a new game - db.session.add(State("overlay-small", "")) - db.session.add(State("overlay-big", "

There is currently no host running the show!

")) + app.db.session.add(State("overlay-small", "")) + app.db.session.add(State("overlay-big", "

There is currently no host running the show!

")) # No question is selected, the game hasn't started - db.session.add(State("question", "")) - db.session.add(State("container-header", "slide-down")) - db.session.add(State("container-footer", "slide-up")) - db.session.commit() + app.db.session.add(State("question", "")) + app.db.session.add(State("container-header", "slide-down")) + app.db.session.add(State("container-footer", "slide-up")) + app.db.session.commit() @staticmethod @@ -76,8 +75,8 @@ def setup_teams(teamnames): if game.state == GameState.uninitialized: for _tid, _tn in teamnames.items(): team = Team(_tid, _tn) - db.session.add(team) - db.session.commit() + app.db.session.add(team) + app.db.session.commit() else: raise GameProblem("Trying to setup a game that is already started") return True @@ -88,8 +87,8 @@ def update_teams(teamnames): """Teamnames is {teamid: team_name} dict""" app.logger.info("Update teams: {}".format(teamnames)) for _id, _name in teamnames.items(): - db.session.query(Team).filter_by(tid=_id).update({"name": _name}) - db.session.commit() + app.db.session.query(Team).filter_by(tid=_id).update({"name": _name}) + app.db.session.commit() @staticmethod @@ -113,18 +112,18 @@ def setup_questions(round_file, q_file=config['QUESTIONS_FILENAME']): question = Question(_q, score, _cat, _row, _col, double = daily_double) - db.session.add(question) + app.db.session.add(question) # Add final question if final is not None: final = FinalQuestion(**final) question = Question(final.question, 0, final.category, 0, 0, final=True) - db.session.add(question) + app.db.session.add(question) # Once everything loaded successfully, identify round file and commit game.round_filename = round_file - db.session.add(game) - db.session.commit() + app.db.session.add(game) + app.db.session.commit() else: raise GameProblem("Trying to setup a game that is already started") @@ -139,7 +138,7 @@ def start_game(): # Yes, mark game as started game = Game.query.one() game.state = GameState.in_round - db.session.commit() + app.db.session.commit() return True else: @@ -154,7 +153,7 @@ def finish_game(): # Mark as finished game = Game.query.one() game.state = GameState.finished - db.session.commit() + app.db.session.commit() return True @@ -171,7 +170,7 @@ def resume_game(): game = Game.query.one() game.state = GameState.in_round - db.session.commit() + app.db.session.commit() scores = Controller.get_teams_score() app.logger.info("A game has been resumed. Current teams / scores: {}" .format(scores)) @@ -186,14 +185,14 @@ def get_config(): @staticmethod def get_teams_score(): if Controller.is_game_initialized(): - answers = db.session.query(Team.id, Team.name, Answer.response, + answers = app.db.session.query(Team.id, Team.name, Answer.response, Answer.score_attributed)\ .join(Answer).order_by(Team.id).all() results = OrderedDict() # Handle case when there are no answers: names with 0 score if not answers: - for _team in db.session.query(Team).order_by(Team.id).all(): + for _team in app.db.session.query(Team).order_by(Team.id).all(): results[_team.name] = 0 return results @@ -218,14 +217,14 @@ def get_teams_score(): # Temporary fix, find a way to merge with the function above! @staticmethod def get_teams_score_by_tid(): - answers = db.session.query(Team.id, Team.tid, Answer.response, + answers = app.db.session.query(Team.id, Team.tid, Answer.response, Answer.score_attributed)\ .join(Answer).order_by(Team.id).all() results = OrderedDict() # Handle case when there are no answers: names with 0 score if not answers: - for _team in db.session.query(Team).order_by(Team.id).all(): + for _team in app.db.session.query(Team).order_by(Team.id).all(): results[_team.tid] = 0 return results @@ -246,7 +245,7 @@ def get_good_answer_team(col, row): """ Returns the team id of the team who correctly answered the specified question """ - team = db.session.query(Team).join(Answer).join(Question).filter( + team = app.db.session.query(Team).join(Answer).join(Question).filter( and_(Question.col == col, Question.row == row, Answer.response == Response.good)).first() if team: @@ -297,7 +296,7 @@ def get_nb_teams(): @staticmethod def get_categories(): return [_q.category for _q in - db.session.query(Question.category).distinct() + app.db.session.query(Question.category).distinct() .filter(Question.final == False) .order_by(Question.col)] @@ -366,7 +365,7 @@ def answer_normal(column, row, answers): if prev_answers: for _answer in prev_answers: _answer.response = Response(int(answers[_answer.team.tid])) - db.session.add(_answer) + app.db.session.add(_answer) # Otherwise create new ones else: @@ -374,9 +373,9 @@ def answer_normal(column, row, answers): team = Team.query.filter(Team.tid == tid).one() question = Question.query.get(_q.id) response = Response(int(points)) - db.session.add(Answer(response, team, question)) + app.db.session.add(Answer(response, team, question)) - db.session.commit() + app.db.session.commit() return True @@ -396,15 +395,15 @@ def answer_dailydouble(column, row, team, answer, waiger): response = Response(int(answer)) _answer = Answer(response, team, question) _answer.score_attributed = waiger - db.session.add(_answer) + app.db.session.add(_answer) - db.session.commit() + app.db.session.commit() return True @staticmethod def _get_questions_status(): """Full status about all questions""" - questions = db.session.query(Question.row, Question.col, Answer)\ + questions = app.db.session.query(Question.row, Question.col, Answer)\ .outerjoin(Answer).all() return questions @@ -476,8 +475,8 @@ def set_state(name, value): else: result.value = value else: - db.session.add(State(name, value)) - db.session.commit() + app.db.session.add(State(name, value)) + app.db.session.commit() @staticmethod @@ -491,11 +490,11 @@ def db_backup_and_create_new(): _bkp = 'ceopardy_{}_{}.db'.format(datetime.now().strftime('%Y-%m-%d_%H%M'), previous_roundfile) app.logger.info('Backing up current game to {}'.format(_bkp)) - db.engine.dispose() + app.db.engine.dispose() os.rename(config['BASE_DIR'] + config['DATABASE_FILENAME'], config['BASE_DIR'] + _bkp) - db.session = db.create_scoped_session() - db.create_all() + app.db.session = app.db.create_scoped_session() + app.db.create_all() Controller._init() app.logger.info('SQL Engine reconnected, empty database recreated.' + 'We are ready to go!') diff --git a/frontend/.env b/frontend/.env new file mode 100644 index 0000000..6fcef0c --- /dev/null +++ b/frontend/.env @@ -0,0 +1,4 @@ +# Remember to keep in sync with Flask's .env too +VITE_CATEGORIES_PER_GAME=5 +VITE_QUESTIONS_PER_CATEGORY=5 +VITE_SCORE_TICK=100 diff --git a/model.py b/model.py index 944ed67..3d62a73 100644 --- a/model.py +++ b/model.py @@ -20,7 +20,7 @@ from flask import current_app as app -from ceopardy import db, VERSION +from ceopardy import VERSION SCHEMA_VERSION = 1 @@ -36,12 +36,12 @@ class GameState(Enum): finished = 3 -class Game(db.Model): - id = db.Column(db.Integer, primary_key=True) - state = db.Column(db.Enum(GameState)) - ceopardy_version = db.Column(db.String(16)) - schema_version = db.Column(db.Integer) - round_filename = db.Column(db.String(255)) +class Game(app.db.Model): + id = app.db.Column(app.db.Integer, primary_key=True) + state = app.db.Column(app.db.Enum(GameState)) + ceopardy_version = app.db.Column(app.db.String(16)) + schema_version = app.db.Column(app.db.Integer) + round_filename = app.db.Column(app.db.String(255)) def __init__(self): self.state = GameState.uninitialized @@ -52,13 +52,13 @@ def __repr__(self): return '' % self.state -class Team(db.Model): - id = db.Column(db.Integer, primary_key=True) - tid = db.Column(db.String(7), unique=True) - name = db.Column(db.String(80), unique=True) - handicap = db.Column(db.Integer) +class Team(app.db.Model): + id = app.db.Column(app.db.Integer, primary_key=True) + tid = app.db.Column(app.db.String(7), unique=True) + name = app.db.Column(app.db.String(80), unique=True) + handicap = app.db.Column(app.db.Integer) - answers = db.relationship('Answer', back_populates='team') + answers = app.db.relationship('Answer', back_populates='team') def __init__(self, tid, name, handicap=0): self.tid = tid @@ -71,17 +71,17 @@ def __repr__(self): return '' % self.name -class Question(db.Model): - id = db.Column(db.Integer, primary_key=True) - text = db.Column(db.String(255)) - score_original = db.Column(db.Integer) - category = db.Column(db.String(80)) - final = db.Column(db.Boolean) - double = db.Column(db.Boolean) - row = db.Column(db.Integer) - col = db.Column(db.Integer) +class Question(app.db.Model): + id = app.db.Column(app.db.Integer, primary_key=True) + text = app.db.Column(app.db.String(255)) + score_original = app.db.Column(app.db.Integer) + category = app.db.Column(app.db.String(80)) + final = app.db.Column(app.db.Boolean) + double = app.db.Column(app.db.Boolean) + row = app.db.Column(app.db.Integer) + col = app.db.Column(app.db.Integer) - answers = db.relationship('Answer', back_populates="question") + answers = app.db.relationship('Answer', back_populates="question") def __init__(self, text, score_original, category, row, col, final=False, double=False): @@ -110,16 +110,16 @@ class Response(Enum): good = 1 -class Answer(db.Model): - id = db.Column(db.Integer, primary_key=True) - score_attributed = db.Column(db.Integer) - response = db.Column(db.Enum(Response)) +class Answer(app.db.Model): + id = app.db.Column(app.db.Integer, primary_key=True) + score_attributed = app.db.Column(app.db.Integer) + response = app.db.Column(app.db.Enum(Response)) - team_id = db.Column(db.Integer, db.ForeignKey('team.id')) - team = db.relationship('Team', back_populates="answers") + team_id = app.db.Column(app.db.Integer, app.db.ForeignKey('team.id')) + team = app.db.relationship('Team', back_populates="answers") - question_id = db.Column(db.Integer, db.ForeignKey('question.id')) - question = db.relationship('Question', back_populates="answers") + question_id = app.db.Column(app.db.Integer, app.db.ForeignKey('question.id')) + question = app.db.relationship('Question', back_populates="answers") def __init__(self, response, team, question): """Meant to be used on normal questions where score is not changeable""" @@ -140,10 +140,10 @@ def __init__(self, value, column): self.column = column -class State(db.Model): - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(10), unique=True) - value = db.Column(db.String(4096)) +class State(app.db.Model): + id = app.db.Column(app.db.Integer, primary_key=True) + name = app.db.Column(app.db.String(10), unique=True) + value = app.db.Column(app.db.String(4096)) def __init__(self, name, value): self.name = name @@ -154,4 +154,4 @@ def __repr__(self): # This creates the database if it doesn't already exist -db.create_all() +app.db.create_all() diff --git a/requirements.txt b/requirements.txt index a6f7483..2e1d5d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,8 @@ Flask==2.3.3 +Flask-CORS==5.0.0 Flask-SocketIO==5.3.7 Flask-SQLAlchemy==2.5.1 Flask-WTF==1.2.1 +python-dotenv==0.19.2 SQLAlchemy==1.4 Werkzeug==3.0.4 diff --git a/templates/viewer.html b/templates/viewer.html index dd96c55..400cc77 100644 --- a/templates/viewer.html +++ b/templates/viewer.html @@ -2,7 +2,7 @@ {% block title %}Game Board{% endblock %} {% block content %} -{% set height = (100 / QUESTIONS_PER_CATEGORY)|int %} +{% set height = (100 / (QUESTIONS_PER_CATEGORY | int)) | int %}
@@ -28,13 +28,13 @@
- {% for _r in range(1, QUESTIONS_PER_CATEGORY + 1) %} + {% for _r in range(1, (QUESTIONS_PER_CATEGORY | int) + 1) %}
- {% for _c in range(1, CATEGORIES_PER_GAME + 1) %} + {% for _c in range(1, (CATEGORIES_PER_GAME | int) + 1) %} {% set answered = questions['c{}q{}'.format(_c, _r)] == True %}
-

${{ _r * SCORE_TICK }}

+

${{ _r * (SCORE_TICK | int) }}

{% endfor %}