From fccd75d6c55c88746870f60d04384bc3f49328b9 Mon Sep 17 00:00:00 2001 From: luxick Date: Mon, 14 Oct 2019 19:02:25 +0200 Subject: [PATCH] Rewrite data layer using SqlAlchemy Part 1 --- .editorconfig | 37 ++ app.py | 530 ------------------ config.py | 9 - environment | 6 - estusshots/__init__.py | 36 ++ choices.py => estusshots/choices.py | 26 +- estusshots/config.ini | 6 + estusshots/config.py | 16 + db.py => estusshots/db.py | 65 ++- estusshots/estus-shots.ts | 17 + forms.py => estusshots/forms.py | 28 +- models.py => estusshots/models.py | 64 ++- estusshots/orm.py | 154 +++++ schema.sql => estusshots/schema.sql | 44 +- .../static}/OptimusPrinceps.ttf | Bin .../static}/OptimusPrincepsSemiBold.ttf | Bin {static => estusshots/static}/custom.css | 6 +- {static => estusshots/static}/dark_theme.css | 0 {static => estusshots/static}/favicon.ico | Bin {static => estusshots/static}/img/brand.png | Bin {static => estusshots/static}/img/elite.png | Bin {static => estusshots/static}/img/estus.png | Bin {static => estusshots/static}/img/solaire.png | Bin .../static}/img/solaire300x300.png | Bin estusshots/static/js/estus-shots.js | 20 + estusshots/static/js/estus-shots.js.map | 1 + .../static}/moment-with-locals.js | 0 {static => estusshots/static}/moment.js | 0 {templates => estusshots/templates}/base.html | 4 + .../templates}/drink_list.html | 4 +- .../templates}/enemies.html | 14 +- .../templates}/episode_details.html | 35 +- .../templates}/episode_list.html | 0 estusshots/templates/event_editor.html | 95 ++++ .../templates}/generic_form.html | 0 .../templates}/login.html | 2 +- .../templates}/player_list.html | 0 .../templates}/season_list.html | 0 .../templates}/season_overview.html | 0 estusshots/util.py | 120 ++++ estusshots/views/drinks.py | 72 +++ estusshots/views/enemies.py | 72 +++ estusshots/views/episodes.py | 137 +++++ estusshots/views/events.py | 56 ++ estusshots/views/login.py | 28 + estusshots/views/players.py | 71 +++ estusshots/views/seasons.py | 94 ++++ requirements.txt | 1 + templates/event_editor.html | 25 - tsconfig.json | 13 + util.py | 47 -- 51 files changed, 1297 insertions(+), 658 deletions(-) create mode 100644 .editorconfig delete mode 100644 app.py delete mode 100644 config.py delete mode 100644 environment create mode 100644 estusshots/__init__.py rename choices.py => estusshots/choices.py (70%) create mode 100644 estusshots/config.ini create mode 100644 estusshots/config.py rename db.py => estusshots/db.py (81%) create mode 100644 estusshots/estus-shots.ts rename forms.py => estusshots/forms.py (73%) rename models.py => estusshots/models.py (67%) create mode 100644 estusshots/orm.py rename schema.sql => estusshots/schema.sql (63%) rename {static => estusshots/static}/OptimusPrinceps.ttf (100%) rename {static => estusshots/static}/OptimusPrincepsSemiBold.ttf (100%) rename {static => estusshots/static}/custom.css (97%) rename {static => estusshots/static}/dark_theme.css (100%) rename {static => estusshots/static}/favicon.ico (100%) rename {static => estusshots/static}/img/brand.png (100%) rename {static => estusshots/static}/img/elite.png (100%) rename {static => estusshots/static}/img/estus.png (100%) rename {static => estusshots/static}/img/solaire.png (100%) rename {static => estusshots/static}/img/solaire300x300.png (100%) create mode 100644 estusshots/static/js/estus-shots.js create mode 100644 estusshots/static/js/estus-shots.js.map rename {static => estusshots/static}/moment-with-locals.js (100%) rename {static => estusshots/static}/moment.js (100%) rename {templates => estusshots/templates}/base.html (94%) rename {templates => estusshots/templates}/drink_list.html (96%) rename {templates => estusshots/templates}/enemies.html (79%) rename {templates => estusshots/templates}/episode_details.html (71%) rename {templates => estusshots/templates}/episode_list.html (100%) create mode 100644 estusshots/templates/event_editor.html rename {templates => estusshots/templates}/generic_form.html (100%) rename {templates => estusshots/templates}/login.html (98%) rename {templates => estusshots/templates}/player_list.html (100%) rename {templates => estusshots/templates}/season_list.html (100%) rename {templates => estusshots/templates}/season_overview.html (100%) create mode 100644 estusshots/util.py create mode 100644 estusshots/views/drinks.py create mode 100644 estusshots/views/enemies.py create mode 100644 estusshots/views/episodes.py create mode 100644 estusshots/views/events.py create mode 100644 estusshots/views/login.py create mode 100644 estusshots/views/players.py create mode 100644 estusshots/views/seasons.py delete mode 100644 templates/event_editor.html create mode 100644 tsconfig.json delete mode 100644 util.py diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..40e6aeb --- /dev/null +++ b/.editorconfig @@ -0,0 +1,37 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true + +# Matches multiple files with brace expansion notation +# Set default charset +[*.{js,py}] +charset = utf-8 + +# 4 space indentation +[*.py] +indent_style = space +indent_size = 4 + +# Tab indentation (no size specified) +[Makefile] +indent_style = tab + +[*.{js,ts}] +indent_style = space +indent_size = 2 + +# Matches the exact files either package.json or .travis.yml +[{package.json,.travis.yml}] +indent_style = space +indent_size = 2 + +[*.html] +indent_style = space +indent_size = 2 + diff --git a/app.py b/app.py deleted file mode 100644 index 269192a..0000000 --- a/app.py +++ /dev/null @@ -1,530 +0,0 @@ -import functools -import logging -import os - -from flask import Flask, g, render_template, request, redirect, session, url_for -from flask_bootstrap import Bootstrap - -import db -import forms -import models -import util -from config import Config - - -logging.basicConfig(filename=Config.LOG_PATH, level=logging.DEBUG) - -logging.info(f"Starting in working dir: {os.getcwd()}") - - -def create_app(): - app = Flask(__name__) - Bootstrap(app) - return app - - -app = create_app() - -app.config.from_object(Config) - - -@app.template_filter("format_time") -def format_time(value): - """Make the datetime to time string formatting available to jinja2""" - if value is None: - return "" - return util.datetime_time_str(value) - - -@app.template_filter("format_timedelta") -def format_timedelta(value): - """Make formatting for timedeltas available to jinja2""" - if value is None: - return "" - return util.timedelta_to_str(value) - - -@app.cli.command("initdb") -def init_db_command(): - """Initializes the database.""" - db.init_db() - - -@app.teardown_appcontext -def close_connection(exception): - db = getattr(g, "_database", None) - if db is not None: - db.close() - - -def set_user_role(data): - """Set the users role in the flask g object for later usage""" - g.is_editor = data == "editor" - - -def authorize(func): - @functools.wraps(func) - def wrapper(*args, **kwargs): - try: - set_user_role(session["user_type"]) - except KeyError: - return redirect("/login") - return func(*args, **kwargs) - - return wrapper - - -def get_user_type(password): - # TODO password hashing? - if password == Config.WRITE_PW: - return "editor" - if password == Config.READ_PW: - return "readonly" - return False - - -@app.route("/login", methods=["GET", "POST"]) -def login(): - if request.method == "GET": - return render_template("login.html") - else: - user_type = get_user_type(request.form.get("password")) - if not user_type: - return redirect("/login") - session["user_type"] = user_type - return redirect("/") - - -@app.route("/logout") -def logout(): - session.pop("role", None) - return redirect("login") - - -@app.route("/") -@authorize -def landing(): - return redirect("/season") - - -@app.route("/season") -@authorize -def season_list(): - sql, args = db.load_season() - results = db.query_db(sql, args, cls=models.Season) - model = { - "seasons": results, - "columns": [ - ("code", "#"), - ("game", "Game"), - ("description", "Season Description"), - ("start", "Started At"), - ("end", "Ended At"), - ], - } - return render_template("season_list.html", model=model) - - -@app.route("/season/new", methods=["GET"]) -@authorize -def season_new(): - form = forms.SeasonForm() - model = models.GenericFormModel( - page_title="New Season", - form_title="Create New Season", - post_url="/season/edit/null", - ) - return render_template("generic_form.html", model=model, form=form) - - -@app.route("/season/edit/", methods=["GET", "POST"]) -@authorize -def season_edit(season_id: int): - model = models.GenericFormModel( - page_title="Seasons", - form_title="Edit Season", - post_url=f"/season/edit/{season_id}", - ) - - if request.method == "GET": - sql, args = db.load_season(season_id) - season: models.Season = db.query_db(sql, args, one=True, cls=models.Season) - - form = forms.SeasonForm() - form.season_id.data = season.id - form.code.data = season.code - form.game_name.data = season.game - form.description.data = season.description - form.start.data = season.start - form.end.data = season.end - - model.form_title = f"Edit Season '{season.code}: {season.game}'" - return render_template("generic_form.html", model=model, form=form) - else: - form = forms.SeasonForm() - - if not form.validate_on_submit(): - model.errors = form.errors - return render_template("generic_form.html", model=model, form=form) - - season = models.Season.from_form(form) - sql, args = db.save_season_query(season) - errors = db.update_db(sql, args) - return redirect(url_for("season_list")) - - -@app.route("/season/", methods=["GET"]) -@authorize -def season_overview(season_id: int): - sql, args = db.load_season(season_id) - season = db.query_db(sql, args, one=True, cls=models.Season) - - sql, args = db.load_episodes(season.id) - episodes = db.query_db(sql, args, cls=models.Episode) - - infos = { - "Number": season.code, - "Game": season.game, - "Start Date": season.start, - "End Date": season.end if season.end else "Ongoing", - } - model = { - "title": f"{season.code} {season.game}", - "season_info": infos, - "episodes": episodes, - } - return render_template("season_overview.html", model=model) - - -@app.route("/season//episode/") -@authorize -def episode_detail(season_id: int, episode_id: int): - sql, args = db.load_season(season_id) - season = db.query_db(sql, args, one=True, cls=models.Season) - sql, args = db.load_episode(episode_id) - episode = db.query_db(sql, args, one=True, cls=models.Episode) - sql, args = db.load_episode_players(episode_id) - ep_players = db.query_db(sql, args, cls=models.Player) - - events = {"entries": [], "victory_count": 0, "defeat_count": 0} - - model = { - "title": f"{season.code}{episode.code}", - "episode": episode, - "season": season, - "players": ep_players, - "events": events, - } - - return render_template("episode_details.html", model=model) - - -@app.route("/season//episode", methods=["GET"]) -@authorize -def episode_list(season_id: int): - sql, args = db.load_season(season_id) - season = db.query_db(sql, args, one=True, cls=models.Season) - sql, args = db.load_episodes(season_id) - episodes = db.query_db(sql, args, cls=models.Episode) - - model = {"season_id": season_id, "season_code": season.code} - return render_template("episode_list.html", model=model) - - -@app.route("/season//episode/new", methods=["GET"]) -@authorize -def episode_new(season_id: int): - model = models.GenericFormModel( - page_title="New Episode", - form_title="Create New Episode", - post_url=f"/season/{season_id}/episode/null/edit", - ) - - form = forms.EpisodeForm(request.form) - form.season_id.data = season_id - return render_template("generic_form.html", model=model, form=form) - - -@app.route("/season//episode//edit", methods=["GET", "POST"]) -@authorize -def episode_edit(season_id: int, episode_id: int): - model = models.GenericFormModel( - page_title="Edit Episode", - form_title="Edit Episode", - post_url=f"/season/{season_id}/episode/{episode_id}/edit", - ) - - if request.method == "GET": - sql, args = db.load_episode(episode_id) - episode: models.Episode = db.query_db(sql, args, one=True, cls=models.Episode) - - sql, args = db.load_episode_players(episode_id) - ep_players = db.query_db(sql, args, cls=models.Player) - - form = forms.EpisodeForm() - form.season_id.data = episode.season_id - form.episode_id.data = episode.id - form.code.data = episode.code - form.date.data = episode.date - form.start.data = episode.start - form.end.data = episode.end - form.title.data = episode.title - form.players.data = [p.id for p in ep_players] - - model.form_title = f"Edit Episode '{episode.code}: {episode.title}'" - return render_template("generic_form.html", model=model, form=form) - else: - form = forms.EpisodeForm() - - if not form.validate_on_submit(): - model.errors = form.errors - return render_template("generic_form.html", model=model, form=form) - - errors = False - episode = models.Episode.from_form(form) - sql, args = db.save_episode(episode) - - last_key = db.update_db(sql, args, return_key=True) - - episode_id = episode.id if episode.id else last_key - - form_ids = form.players.data - - sql, args = db.load_episode_players(episode_id) - ep_players = db.query_db(sql, args, cls=models.Player) - pids = [p.id for p in ep_players] - - new_ids = [pid for pid in form_ids if pid not in pids] - removed_ids = [pid for pid in pids if pid not in form_ids] - - if removed_ids: - sql, args = db.remove_episode_player(episode_id, removed_ids) - errors = db.update_db(sql, args) - - if new_ids: - sql, args = db.save_episode_players(episode_id, new_ids) - errors = db.update_db(sql, args) - - if errors: - model.errors = {"Error saving episode": [errors]} - return render_template("generic_form.html", model=model, form=form) - return redirect(url_for("season_overview", season_id=season_id)) - - -@app.route("/season//episode//event/new", methods=["GET"]) -@authorize -def event_new(s_id: int, ep_id: int): - model = { - "page_title": "New Event", - "form_title": "Create New Event", - "post_url": f"/seasons/{s_id}/episodes/{ep_id}/events/null/edit", - } - - return render_template("event_editor.html", model=model) - - -@app.route( - "/season//episode//event//edit", methods=["GET", "POST"] -) -@authorize -def event_edit(s_id: int, ep_id: int, ev_id: int): - model = { - "page_title": "Edit Event", - "form_title": "Edit Event", - "post_url": f"/season/{s_id}/episode/{ep_id}/event/{ev_id}/edit", - } - if request.method == "GET": - return render_template("event_editor.html", model=model) - else: - pass - - -@app.route("/player/new", methods=["GET"]) -@authorize -def player_new(): - form = forms.PlayerForm() - model = models.GenericFormModel( - page_title="Players", - form_title="Create a new Player", - post_url="/player/null/edit", - ) - return render_template("generic_form.html", model=model, form=form) - - -@app.route("/player//edit", methods=["GET", "POST"]) -@authorize -def player_edit(player_id: int): - model = models.GenericFormModel( - page_title="Players", - form_title=f"Edit Player", - post_url=f"/player/{player_id}/edit", - ) - # Edit Existing Player - if request.method == "GET": - sql, args = db.load_players(player_id) - player = db.query_db(sql, args, one=True, cls=models.Player) - - form = forms.PlayerForm() - form.player_id.data = player.id - form.anonymize.data = player.anon - form.real_name.data = player.real_name - form.alias.data = player.alias - form.hex_id.data = player.hex_id - - model.form_title = f'Edit Player "{player.name}"' - return render_template("generic_form.html", model=model, form=form) - - # Save POSTed data - else: - form = forms.PlayerForm() - if form.validate_on_submit(): - player = models.Player.from_form(form) - res = db.save_player(player) - return redirect("/player") - - model.form_title = "Incorrect Data" - return render_template("generic_form.html", model=model, form=form) - - -@app.route("/player") -@authorize -def player_list(): - sql, args = db.load_players() - players = db.query_db(sql, args, cls=models.Player) - model = { - "player_list": players, - "columns": [ - ("id", "ID"), - ("name", "Player Name"), - ("alias", "Alias"), - ("hex_id", "Hex ID"), - ], - } - return render_template("player_list.html", model=model) - - -@app.route("/drink") -@authorize -def drink_list(): - sql, args = db.load_drinks() - drinks = db.query_db(sql, args, cls=models.Drink) - model = { - "drinks": drinks, - "columns": [("id", "ID"), ("name", "Drink Name"), ("vol", "Alcohol %")], - "controls": [("edit", "Edit")], - } - return render_template("drink_list.html", model=model) - - -@app.route("/drink//edit", methods=["GET"]) -@authorize -def drink_edit(drink_id: int): - sql, args = db.load_drinks(drink_id) - drink = db.query_db(sql, args, one=True, cls=models.Drink) - - form = forms.DrinkForm() - form.drink_id.data = drink.id - form.name.data = drink.name - form.vol.data = drink.vol - - model = models.GenericFormModel( - page_title="Edit Drink", - form_title=f'Edit Drink "{drink.name}"', - post_url="/drink/save", - ) - - return render_template("generic_form.html", model=model, form=form) - - -@app.route("/drink/new", methods=["GET"]) -@authorize -def new_drink(): - form = forms.DrinkForm() - - model = models.GenericFormModel( - page_title="New Drink", - form_title=f"Create a new Drink", - post_url="/drinks/save", - ) - return render_template("generic_form.html", model=model, form=form) - - -@app.route("/drink/save", methods=["POST"]) -@authorize -def drink_save(): - form = forms.DrinkForm() - if form.validate_on_submit(): - drink = models.Drink.from_form(form) - res = db.save_drink(drink) - return redirect("/drink") - - model = models.GenericFormModel( - page_title="Drinks", form_title="Edit Drink", post_url="/drink/save" - ) - return render_template("generic_form.html", model=model, form=form) - - -@app.route("/enemy") -@authorize -def enemy_list(): - sql, args = db.load_enemies() - enemies = db.query_db(sql, args, cls=models.Enemy) - model = {"enemies": enemies} - return render_template("enemies.html", model=model) - - -@app.route("/enemy/new", methods=["GET"]) -@authorize -def enemy_new(preselect_season=None): - form = forms.EnemyForm() - - if preselect_season: - form.season_id.default = preselect_season - - model = models.GenericFormModel( - page_title="Enemies", - form_title="Create a new Enemy", - post_url=f"/enemy/null/edit", - ) - return render_template("generic_form.html", model=model, form=form) - - -@app.route("/enemy//edit", methods=["GET", "POST"]) -@authorize -def enemy_edit(enemy_id: int): - model = models.GenericFormModel( - page_title="Enemies", - form_title="Edit Enemy", - post_url=f"/enemy/{enemy_id}/edit", - ) - - if request.method == "GET": - sql, args = db.load_enemies(enemy_id) - enemy = db.query_db(sql, args, one=True, cls=models.Enemy) - - form = forms.EnemyForm() - form.season_id.data = enemy.season_id if enemy.season_id else -1 - form.name.data = enemy.name - form.is_boss.data = enemy.boss - form.enemy_id.data = enemy_id - - model.form_title = f'Edit Enemy "{enemy.name}"' - return render_template("generic_form.html", model=model, form=form) - else: - form = forms.EnemyForm() - if form.validate_on_submit(): - enemy = models.Enemy.from_form(form) - sql, args = db.save_enemy(enemy) - errors = db.update_db(sql, args) - - if form.submit_continue_button.data: - form.name.data = None - return enemy_new(preselect_season=enemy.season_id) - return redirect("/enemy") - - model.form_title = "Incorrect Data" - return render_template("generic_form.html", model=model, form=form) - - -if __name__ == "__main__": - app.run() diff --git a/config.py b/config.py deleted file mode 100644 index 79513d9..0000000 --- a/config.py +++ /dev/null @@ -1,9 +0,0 @@ -import os - - -class Config: - SECRET_KEY = os.environ.get("ES_SECRET_KEY") - WRITE_PW = os.environ.get("ES_WRITE_PW") - READ_PW = os.environ.get("ES_READ_PW") - DATABASE_PATH = os.environ.get("ES_DATABASE_PATH") - LOG_PATH = os.environ.get("ES_LOG_PATH") diff --git a/environment b/environment deleted file mode 100644 index 0d9b97d..0000000 --- a/environment +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -export ES_READ_PW="" -export ES_WRITE_PW="" -export ES_SECRET_KEY="" -export ES_DATABASE_PATH="" -export ES_LOG_PATH="" \ No newline at end of file diff --git a/estusshots/__init__.py b/estusshots/__init__.py new file mode 100644 index 0000000..cfd76e4 --- /dev/null +++ b/estusshots/__init__.py @@ -0,0 +1,36 @@ +import os +import logging +import sys + +from flask import Flask +from flask_bootstrap import Bootstrap + + +from estusshots.config import config + + +def create_app(): + app = Flask(__name__) + Bootstrap(app) + return app + + +if not config.SECRET_KEY: + logging.error( + "No secret key provided for app. Are the environment variables set correctly?" + ) + sys.exit(1) + +app = create_app() + +logging.basicConfig(filename=config.LOG_PATH, level=logging.DEBUG) +logging.info(f"Starting in working dir: {os.getcwd()}") +app.config.from_object(config) + +import estusshots.views.drinks +import estusshots.views.enemies +import estusshots.views.episodes +import estusshots.views.events +import estusshots.views.login +import estusshots.views.players +import estusshots.views.seasons diff --git a/choices.py b/estusshots/choices.py similarity index 70% rename from choices.py rename to estusshots/choices.py index 9f16bf6..b6ff8f3 100644 --- a/choices.py +++ b/estusshots/choices.py @@ -1,13 +1,17 @@ -import models -import db +from estusshots import orm, models, db +from estusshots.orm import Season + + +def event_choices(): + return [(member.value, member.name) for member in models.EventType] def season_choices(): """ Query the database for available seasons. This returns a list of tuples with the season ID and a display string. """ - sql, args = db.load_season() - seasons = db.query_db(sql, args, cls=models.Season) + db = orm.new_session() + seasons = db.query(Season).order_by(Season.code).all() choices = [(s.id, f"{s.code}: {s.game}") for s in seasons] choices.insert(0, (-1, "No Season")) return choices @@ -33,6 +37,15 @@ def drink_choice(): return choices +def enemy_choice_for_season(season_id: int): + """ + Query database for all available enemies in this season + """ + sql, args = db.load_enemies_for_season(season_id) + enemies = db.query_db(sql, args, cls=models.Enemy) + return [(e.id, e.name) for e in enemies] + + class IterableBase: """ This is used to declare choices for WTForms SelectFields at class definition time. @@ -60,3 +73,8 @@ class PlayerChoiceIterable(IterableBase): class DrinkChoiceIterable(IterableBase): def __init__(self): self._loader = drink_choice + + +class EventChoiceIterable(IterableBase): + def __init__(self): + self._loader = event_choices diff --git a/estusshots/config.ini b/estusshots/config.ini new file mode 100644 index 0000000..3cf7668 --- /dev/null +++ b/estusshots/config.ini @@ -0,0 +1,6 @@ +[Default] +ES_READ_PW = 123 +ES_WRITE_PW = 1234 +ES_SECRET_KEY = 1234 +ES_DATABASE_PATH = ../databases/debug.db +ES_LOG_PATH = ../logs/debug.log diff --git a/estusshots/config.py b/estusshots/config.py new file mode 100644 index 0000000..8611630 --- /dev/null +++ b/estusshots/config.py @@ -0,0 +1,16 @@ +from configparser import ConfigParser + + +class Config: + def __init__(self): + parser = ConfigParser() + parser.read("config.ini") + self.SECRET_KEY = parser.get("Default", "ES_SECRET_KEY") + self.WRITE_PW = parser.get("Default", "ES_WRITE_PW") + self.READ_PW = parser.get("Default", "ES_READ_PW") + self.DATABASE_PATH = parser.get("Default", "ES_DATABASE_PATH") + self.LOG_PATH = parser.get("Default", "ES_LOG_PATH") + + +config = Config() + diff --git a/db.py b/estusshots/db.py similarity index 81% rename from db.py rename to estusshots/db.py index 14c5166..5c40f31 100644 --- a/db.py +++ b/estusshots/db.py @@ -4,8 +4,8 @@ from typing import Sequence from flask import g -import models -from config import Config +from estusshots import models +from estusshots.config import config class DataBaseError(Exception): @@ -16,8 +16,8 @@ def connect_db(): """Create a new sqlite3 connection and register it in 'g._database'""" db = getattr(g, "_database", None) if db is None: - log.info(f"Connecting {Config.DATABASE_PATH}") - db = g._database = sqlite3.connect(Config.DATABASE_PATH) + log.info(f"Connecting {config.DATABASE_PATH}") + db = g._database = sqlite3.connect(config.DATABASE_PATH) db.row_factory = sqlite3.Row return db @@ -119,7 +119,9 @@ def save_drink_query(drink): sql = "insert into drink values (?, ?, ?)" args = (None, drink.name, drink.vol) else: - sql = "update drink " "set name=?, vol=? " "where id==?" + sql = "update drink " \ + "set name=?, vol=? " \ + "where id==?" args = (drink.name, drink.vol, drink.id) return sql, args @@ -139,12 +141,22 @@ def load_enemies(id=None): return sql, args +def load_enemies_for_season(season_id: int): + sql = "select * from enemy " \ + "where season_id = ? or season_id = 'None'" \ + "order by enemy.id" + args = (season_id, ) + return sql, args + + def save_enemy(enemy: models.Enemy): if not enemy.id: sql = "insert into enemy values (?, ?, ?, ?)" args = (None, enemy.name, enemy.boss, enemy.season_id) else: - sql = "update enemy " "set name=?, boss=?, season_id=? " "where id==?" + sql = "update enemy " \ + "set name=?, boss=?, season_id=? " \ + "where id==?" args = (enemy.name, enemy.boss, enemy.season_id, enemy.id) return sql, args @@ -214,10 +226,12 @@ def load_episode_player_links(episode_id: int): def load_episode_players(episode_id: int): - sql = "select player.* " \ - "from player " \ - "left join episode_player ep on player.id = ep.player_id " \ - "where ep.episode_id = ?" + sql = ( + "select player.* " + "from player " + "left join episode_player ep on player.id = ep.player_id " + "where ep.episode_id = ?" + ) args = (episode_id,) return sql, args @@ -229,8 +243,7 @@ def save_episode_players(episode_id: int, player_ids: Sequence[int]): def remove_episode_player(episode_id: int, player_ids: Sequence[int]): - sql = "delete from episode_player " \ - "where episode_id = ? and player_id = ?" + sql = "delete from episode_player " "where episode_id = ? and player_id = ?" args = tuple((episode_id, pid) for pid in player_ids) return sql, args @@ -263,3 +276,31 @@ def save_episode(episode: models.Episode): episode.id, ) return sql, args + + +def save_event(event: models.Event): + args = [ + None, + event.episode_id, + event.player_id, + event.enemy_id, + event.type.name, + event.time.timestamp(), + event.comment, + ] + if not event.event_id: + sql = "insert into event values (?, ?, ?, ?, ?, ?, ?)" + else: + sql = ( + "where id==? " + "update event " + "set episode_id=?, player_id=?, enemy_id=?, type=?, time=?, comment=?" + ) + args[0] = event.event_id + return sql, args + + +def load_events(episode_id: int): + sql = "select * from event where episode_id = ?" + args = (episode_id,) + return sql, args diff --git a/estusshots/estus-shots.ts b/estusshots/estus-shots.ts new file mode 100644 index 0000000..55d3840 --- /dev/null +++ b/estusshots/estus-shots.ts @@ -0,0 +1,17 @@ +class EditorModule{ + + setCurrentTime = (elemId: string) => { + const elem = document.getElementById(elemId) as HTMLInputElement; + if (!elem) return; + elem.value = this.currentTimeHHMM() + }; + + currentTimeHHMM = () => { + const d = new Date(); + const hours = (d.getHours()<10 ? '0' : '') + d.getHours(); + const minutes = (d.getMinutes()<10 ? '0' : '') + d.getMinutes(); + return `${hours}:${minutes}`; + }; +} + +const editorModule = new EditorModule(); diff --git a/forms.py b/estusshots/forms.py similarity index 73% rename from forms.py rename to estusshots/forms.py index 7655aca..3730453 100644 --- a/forms.py +++ b/estusshots/forms.py @@ -8,11 +8,13 @@ from wtforms import ( DecimalField, SelectField, SelectMultipleField, - HiddenField + HiddenField, + FieldList, + FormField, ) from wtforms.validators import DataRequired, Optional -import choices +from estusshots import choices class SeasonForm(FlaskForm): @@ -64,3 +66,25 @@ class EnemyForm(FlaskForm): is_boss = BooleanField("Is Boss") submit_button = SubmitField("Submit") submit_continue_button = SubmitField("Submit and Continue") + + +class PenaltyFrom(FlaskForm): + penalty_id = HiddenField("Penalty ID") + player_id = HiddenField("Player ID") + player = HiddenField("Player") + drink = SelectField("Drink", choices=choices.DrinkChoiceIterable(), coerce=int) + + +class EventForm(FlaskForm): + event_id = HiddenField("Event ID") + episode_id = HiddenField("Episode ID") + event_type = SelectField( + "Type", choices=choices.EventChoiceIterable(), coerce=int, + validators=[DataRequired()] + ) + time = TimeField("Time", format="%H:%M", validators=[DataRequired()]) + player = SelectField("Player", choices=choices.PlayerChoiceIterable(), coerce=int) + enemy = SelectField("Enemy", coerce=int) + comment = StringField("Comment") + penalties = FieldList(FormField(PenaltyFrom)) + submit_button = SubmitField("Submit") diff --git a/models.py b/estusshots/models.py similarity index 67% rename from models.py rename to estusshots/models.py index 2203b58..a574a1b 100644 --- a/models.py +++ b/estusshots/models.py @@ -1,10 +1,18 @@ import datetime +import enum from numbers import Rational -from typing import Dict, List, Union +from typing import Dict, List, Union, Optional from dataclasses import dataclass -import forms -import util +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from estusshots import forms, util + + +class EventType(enum.Enum): + Pause = 0 + Death = 1 + Victory = 2 @dataclass @@ -28,7 +36,7 @@ class Player: return self.real_name if self.real_name and not self.anon else self.alias @classmethod - def from_form(cls, form: forms.PlayerForm): + def from_form(cls, form: "forms.PlayerForm"): id = int(form.player_id.data) if form.player_id.data else None real_name = str(form.real_name.data) if form.real_name.data else None alias = str(form.alias.data) @@ -44,7 +52,7 @@ class Drink: vol: float @classmethod - def from_form(cls, form: forms.DrinkForm): + def from_form(cls, form: "forms.DrinkForm"): id = int(form.drink_id.data) if form.drink_id.data else None name = str(form.name.data) vol = float(form.vol.data) @@ -61,7 +69,7 @@ class Enemy: season_id: int @classmethod - def from_form(cls, form: forms.EnemyForm): + def from_form(cls, form: "forms.EnemyForm"): id = int(form.enemy_id.data) if form.enemy_id.data else None name = str(form.name.data) boss = bool(form.is_boss.data) @@ -91,7 +99,7 @@ class Season: pass @classmethod - def from_form(cls, form: forms.SeasonForm): + def from_form(cls, form: "forms.SeasonForm"): season_id = int(form.season_id.data) if form.season_id.data else None code = str(form.code.data) game = str(form.game_name.data) @@ -126,7 +134,7 @@ class Episode: self.end = datetime.datetime.fromtimestamp(self.end) @classmethod - def from_form(cls, form: forms.EpisodeForm): + def from_form(cls, form: "forms.EpisodeForm"): episode_id = int(form.episode_id.data) if form.episode_id.data else None season_id = int(form.season_id.data) code = str(form.code.data) @@ -139,3 +147,43 @@ class Episode: end = end + datetime.timedelta(days=1) return cls(episode_id, season_id, title, date, start, end, code) + + +@dataclass +class Penalty: + penalty_id: int + player_id: int + drink_id: int + + +@dataclass +class Event: + event_id: int + episode_id: int + type: EventType + time: datetime.datetime + comment: str + player_id: Optional[int] + enemy_id: Optional[int] + penalties: List[Penalty] + + @classmethod + def from_form(cls, form: "forms.EventForm"): + event_id = int(form.event_id.data) if form.event_id.data else None + episode_id = int(form.episode_id.data) + event_type = EventType(form.event_type.data) + time = util.combine_datetime(datetime.datetime.today(), form.time.data) + comment = str(form.comment.data) if form.comment.data else None + player_id = int(form.player.data) if form.player.data else None + enemy_id = int(form.enemy.data) if form.enemy.data else None + + penalties = [] + for entry in form.penalties: + penalties.append(Penalty( + penalty_id=int(entry.penalty_id.data) if entry.penalty_id.data else None, + player_id=int(entry.player_id.data), + drink_id=int(entry.drink.data) + )) + + return cls(event_id, episode_id, event_type, time, comment, player_id, enemy_id, + penalties) diff --git a/estusshots/orm.py b/estusshots/orm.py new file mode 100644 index 0000000..630053e --- /dev/null +++ b/estusshots/orm.py @@ -0,0 +1,154 @@ +import enum +import sqlalchemy +from sqlalchemy import create_engine, ForeignKey, Table +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy import Column, Integer, String, Boolean, Float, Enum, Date, Time +from sqlalchemy.orm import sessionmaker, relationship + +from estusshots import util, forms + +engine = create_engine('sqlite:///../databases/test.db') +Base = declarative_base() + + +class EventType(enum.Enum): + Pause = 0 + Death = 1 + Victory = 2 + + +class Player(Base): + __tablename__ = "players" + + id = Column(Integer, primary_key=True) + real_name = Column(String) + alias = Column(String) + hex_id = Column(String) + anon = Column(Boolean, default=False) + + events = relationship("Event", back_populates="player") + + @property + def name(self) -> str: + return self.real_name if self.real_name and not self.anon else self.alias + + def populate_from_form(self, form: "forms.PlayerForm"): + self.real_name = str(form.real_name.data) if form.real_name.data else None + self.alias = str(form.alias.data) + self.hex_id = str(form.hex_id.data) if form.hex_id.data else None + self.anon = bool(form.anonymize.data) + + +class Drink(Base): + __tablename__ = "drinks" + + id = Column(Integer, primary_key=True) + name = Column(String) + vol = Column(Float) + + def populate_from_form(self, form: "forms.DrinkForm"): + self.name = str(form.name.data) + self.vol = float(form.vol.data) + + +class Season(Base): + __tablename__ = "seasons" + + id = Column(Integer, primary_key=True) + code = Column(String, default='SXX') + game = Column(String) + description = Column(String) + start = Column(Date) + end = Column(Date) + + episodes = relationship("Episode", back_populates="season") + enemies = relationship("Enemy", back_populates="season") + + def populate_from_form(self, form: "forms.SeasonForm"): + self.code = str(form.code.data) + self.game = str(form.game_name.data) + self.description = str(form.description.data) if form.description.data else None + self.start = form.start.data + self.end = form.end.data + + +class Enemy(Base): + __tablename__ = "enemies" + + id = Column(Integer, primary_key=True) + name = Column(String) + boss = Column(Boolean, default=True) + + season_id = Column(Integer, ForeignKey('seasons.id')) + season = relationship("Season", back_populates="enemies") + + events = relationship('Event', back_populates="enemy") + + def populate_from_form(self, form: "forms.EnemyForm"): + self.name = str(form.name.data) + self.boss = bool(form.is_boss.data) + self.season_id = int(form.season_id.data) + + +class Episode(Base): + __tablename__ = "episodes" + + id = Column(Integer, primary_key=True) + code = Column(String, default='EXX') + title = Column(String) + date = Column(Date) + start = Column(Time) + end = Column(Time) + + season_id = Column(Integer, ForeignKey('seasons.id')) + season = relationship("Season", back_populates="episodes") + + events = relationship('Event', back_populates='episode') + + @property + def playtime(self): + return util.timedelta(self.start, self.end) + + +class Event(Base): + __tablename__ = 'events' + + id = Column(Integer, primary_key=True) + type = Column(Enum(EventType)) + time = Column(Time) + comment = Column(String) + + episode_id = Column(Integer, ForeignKey('episodes.id')) + episode = relationship('Episode', back_populates='events') + + player_id = Column(Integer, ForeignKey('players.id')) + player = relationship('Player', back_populates='events') + + enemy_id = Column(Integer, ForeignKey('enemies.id')) + enemy = relationship('Enemy', back_populates='events') + + penalties = relationship('Penalty', back_populates='event') + + +class Penalty(Base): + __tablename__ = 'penalties' + + id = Column(Integer, primary_key=True) + + player_id = Column(Integer, ForeignKey('players.id')) + player = relationship('Player') + + drink_id = Column(Integer, ForeignKey('drinks.id')) + drink = relationship('Drink') + + event_id = Column(Integer, ForeignKey('events.id')) + event = relationship('Event', back_populates='penalties') + + +Base.metadata.create_all(engine) +Session = sessionmaker(bind=engine) + + +def new_session() -> sqlalchemy.orm.Session: + """Open up a new session. This function exists for ease of use, as the return type is hinted for the IDE.""" + return Session() diff --git a/schema.sql b/estusshots/schema.sql similarity index 63% rename from schema.sql rename to estusshots/schema.sql index 97d4711..9e685dc 100644 --- a/schema.sql +++ b/estusshots/schema.sql @@ -87,7 +87,47 @@ create table if not exists episode_player references player ); -create unique index if not exists episode_player_link_id_uindex - on episode_player (link_id); +create table if not exists penalty +( + id integer not null + constraint penalty_pk + primary key autoincrement, + episode_id integer not null + references episode, + drink_id integer not null + references drink +); +create unique index if not exists penalty_id_uindex + on penalty (id); +create table if not exists event +( + id integer not null + constraint event_pk + primary key autoincrement, + episode_id integer not null + constraint event_episode_id_fk + references season, + player_id integer not null + constraint event_player_id_fk + references player, + enemy_id integer + references enemy, + type text not null, + time timestamp not null, + comment text +); +create unique index if not exists event_id_uindex + on event (id); + +create table if not exists event_penalty +( + link_id integer not null + constraint event_punishment_pk + primary key autoincrement, + event_id integer not null + references event, + punishment_id integer not null + references punishment +); diff --git a/static/OptimusPrinceps.ttf b/estusshots/static/OptimusPrinceps.ttf similarity index 100% rename from static/OptimusPrinceps.ttf rename to estusshots/static/OptimusPrinceps.ttf diff --git a/static/OptimusPrincepsSemiBold.ttf b/estusshots/static/OptimusPrincepsSemiBold.ttf similarity index 100% rename from static/OptimusPrincepsSemiBold.ttf rename to estusshots/static/OptimusPrincepsSemiBold.ttf diff --git a/static/custom.css b/estusshots/static/custom.css similarity index 97% rename from static/custom.css rename to estusshots/static/custom.css index 2bfbe83..33a68be 100644 --- a/static/custom.css +++ b/estusshots/static/custom.css @@ -67,4 +67,8 @@ a:hover { .list-group-item:hover { color: inherit; background-color: rgb(34, 34, 34); -} \ No newline at end of file +} + +h1 { + font-size: 2.5rem; +} diff --git a/static/dark_theme.css b/estusshots/static/dark_theme.css similarity index 100% rename from static/dark_theme.css rename to estusshots/static/dark_theme.css diff --git a/static/favicon.ico b/estusshots/static/favicon.ico similarity index 100% rename from static/favicon.ico rename to estusshots/static/favicon.ico diff --git a/static/img/brand.png b/estusshots/static/img/brand.png similarity index 100% rename from static/img/brand.png rename to estusshots/static/img/brand.png diff --git a/static/img/elite.png b/estusshots/static/img/elite.png similarity index 100% rename from static/img/elite.png rename to estusshots/static/img/elite.png diff --git a/static/img/estus.png b/estusshots/static/img/estus.png similarity index 100% rename from static/img/estus.png rename to estusshots/static/img/estus.png diff --git a/static/img/solaire.png b/estusshots/static/img/solaire.png similarity index 100% rename from static/img/solaire.png rename to estusshots/static/img/solaire.png diff --git a/static/img/solaire300x300.png b/estusshots/static/img/solaire300x300.png similarity index 100% rename from static/img/solaire300x300.png rename to estusshots/static/img/solaire300x300.png diff --git a/estusshots/static/js/estus-shots.js b/estusshots/static/js/estus-shots.js new file mode 100644 index 0000000..078b191 --- /dev/null +++ b/estusshots/static/js/estus-shots.js @@ -0,0 +1,20 @@ +var EditorModule = (function () { + function EditorModule() { + var _this = this; + this.setCurrentTime = function (elemId) { + var elem = document.getElementById(elemId); + if (!elem) + return; + elem.value = _this.currentTimeHHMM(); + }; + this.currentTimeHHMM = function () { + var d = new Date(); + var hours = (d.getHours() < 10 ? '0' : '') + d.getHours(); + var minutes = (d.getMinutes() < 10 ? '0' : '') + d.getMinutes(); + return hours + ":" + minutes; + }; + } + return EditorModule; +}()); +var editorModule = new EditorModule(); +//# sourceMappingURL=estus-shots.js.map \ No newline at end of file diff --git a/estusshots/static/js/estus-shots.js.map b/estusshots/static/js/estus-shots.js.map new file mode 100644 index 0000000..c273638 --- /dev/null +++ b/estusshots/static/js/estus-shots.js.map @@ -0,0 +1 @@ +{"version":3,"file":"estus-shots.js","sourceRoot":"","sources":["../../estus-shots.ts"],"names":[],"mappings":"AAAA;IAAA;QAAA,iBAcC;QAZC,mBAAc,GAAG,UAAC,MAAc;YAC9B,IAAM,IAAI,GAAG,QAAQ,CAAC,cAAc,CAAC,MAAM,CAAqB,CAAC;YACjE,IAAI,CAAC,IAAI;gBAAE,OAAO;YAClB,IAAI,CAAC,KAAK,GAAG,KAAI,CAAC,eAAe,EAAE,CAAA;QACrC,CAAC,CAAC;QAEF,oBAAe,GAAG;YAChB,IAAM,CAAC,GAAG,IAAI,IAAI,EAAE,CAAC;YACrB,IAAM,KAAK,GAAG,CAAC,CAAC,CAAC,QAAQ,EAAE,GAAC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,CAAC;YAC1D,IAAM,OAAO,GAAG,CAAC,CAAC,CAAC,UAAU,EAAE,GAAC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,UAAU,EAAE,CAAC;YAChE,OAAU,KAAK,SAAI,OAAS,CAAC;QAC/B,CAAC,CAAC;IACJ,CAAC;IAAD,mBAAC;AAAD,CAAC,AAdD,IAcC;AAED,IAAM,YAAY,GAAG,IAAI,YAAY,EAAE,CAAC"} \ No newline at end of file diff --git a/static/moment-with-locals.js b/estusshots/static/moment-with-locals.js similarity index 100% rename from static/moment-with-locals.js rename to estusshots/static/moment-with-locals.js diff --git a/static/moment.js b/estusshots/static/moment.js similarity index 100% rename from static/moment.js rename to estusshots/static/moment.js diff --git a/templates/base.html b/estusshots/templates/base.html similarity index 94% rename from templates/base.html rename to estusshots/templates/base.html index cb16dae..044edae 100644 --- a/templates/base.html +++ b/estusshots/templates/base.html @@ -2,6 +2,10 @@ {% block title %}- Estus Shots{% endblock %} +{% block scripts %} + +{% endblock %} + {% block styles %} {{ super() }} diff --git a/templates/drink_list.html b/estusshots/templates/drink_list.html similarity index 96% rename from templates/drink_list.html rename to estusshots/templates/drink_list.html index 6f152fd..d04e9e4 100644 --- a/templates/drink_list.html +++ b/estusshots/templates/drink_list.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "base.html" %}drink_save {% set active_page = "drinks" %} {% block title %}Drinks {{ super() }}{% endblock %} @@ -45,4 +45,4 @@ {% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/enemies.html b/estusshots/templates/enemies.html similarity index 79% rename from templates/enemies.html rename to estusshots/templates/enemies.html index 15ffce7..0aff03e 100644 --- a/templates/enemies.html +++ b/estusshots/templates/enemies.html @@ -16,8 +16,8 @@ - + {% if g.is_editor %} @@ -26,19 +26,19 @@ - {% for item in model.enemies %} + {% for enemy in model.enemies %} - - + + {% if g.is_editor %} @@ -49,4 +49,4 @@
ID NameSeason Boss Enemy
{{ item.id }}{{ item.name }}{{ enemy.name }}{{ enemy.season.game }} - {% if item.boss %} + {% if enemy.boss %} {% endif %} - +
{% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/episode_details.html b/estusshots/templates/episode_details.html similarity index 71% rename from templates/episode_details.html rename to estusshots/templates/episode_details.html index afdcc5b..b6c7b38 100644 --- a/templates/episode_details.html +++ b/estusshots/templates/episode_details.html @@ -88,15 +88,46 @@ -
+ + {% if model.events %}
Event List
+ + + + + + + + + + + {% for entry in model.events.entries %} + + + + + + + {% endfor %} + +
TimeTypePlayerEnemy
{{ entry.time }}{{ entry.type.name }}{{ entry.type.player_name }}{{ entry.type.enemy_name }}
+ {% else %} +
+
+ Event List +
+
+
Nothing did happen yet
+
+
+ {% endif %}
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/episode_list.html b/estusshots/templates/episode_list.html similarity index 100% rename from templates/episode_list.html rename to estusshots/templates/episode_list.html diff --git a/estusshots/templates/event_editor.html b/estusshots/templates/event_editor.html new file mode 100644 index 0000000..82d4c0a --- /dev/null +++ b/estusshots/templates/event_editor.html @@ -0,0 +1,95 @@ +{% extends "base.html" %} +{% import "bootstrap/wtf.html" as wtf %} +{% set active_page = "seasons" %} +{% block title %}{{ model.page_title }} {{ super() }}{% endblock %} + +{% block page %} +
+

{{ model.form_title }}

+ + {% if model.errors %} +
+ {% for field, errors in model.errors.items() %} +
+ {{ field }}: + {{ errors|join(', ') }} +
+ {% endfor %} +
+ {% endif %} + +
+ + {{ form.hidden_tag() }} + +
+
+ {{ form.event_type.label(class_="form-control-label") }} +
+
+ {{ form.event_type(class_="form-control") }} +
+
+ +
+
+ {{ form.time.label(class_="form-control-label") }} +
+
+
+ {{ form.time(class_="form-control") }} +
+
+ +
+
+
+ + +
+
+ {{ form.player.label(class_="form-control-label") }} +
+
+ {{ form.player(class_="form-control") }} +
+
+ +
+
+ {{ form.enemy.label(class_="form-control-label") }} +
+
+ {{ form.enemy(class_="form-control") }} +
+
+ +
+
+ {{ form.comment.label(class_="form-control-label") }} +
+
+ {{ form.comment(class_="form-control") }} +
+
+ +
+ {% for penalty in form.penalties %} + {{ penalty.hidden_tag() }} +
+ {{ penalty.penalty_id }} + {{ penalty.player_id }} + {{ penalty.player }} + + {{ penalty.drink }} +
+ {% endfor %} +
+ +
+ {{ form.submit_button(class_="btn btn-primary") }} +
+
+
+{% endblock %} diff --git a/templates/generic_form.html b/estusshots/templates/generic_form.html similarity index 100% rename from templates/generic_form.html rename to estusshots/templates/generic_form.html diff --git a/templates/login.html b/estusshots/templates/login.html similarity index 98% rename from templates/login.html rename to estusshots/templates/login.html index b4dd4df..37a8516 100644 --- a/templates/login.html +++ b/estusshots/templates/login.html @@ -34,4 +34,4 @@ -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/player_list.html b/estusshots/templates/player_list.html similarity index 100% rename from templates/player_list.html rename to estusshots/templates/player_list.html diff --git a/templates/season_list.html b/estusshots/templates/season_list.html similarity index 100% rename from templates/season_list.html rename to estusshots/templates/season_list.html diff --git a/templates/season_overview.html b/estusshots/templates/season_overview.html similarity index 100% rename from templates/season_overview.html rename to estusshots/templates/season_overview.html diff --git a/estusshots/util.py b/estusshots/util.py new file mode 100644 index 0000000..6ab2e3f --- /dev/null +++ b/estusshots/util.py @@ -0,0 +1,120 @@ +import functools +from datetime import datetime, time, date, timedelta + +from flask import g, session, redirect + +from estusshots import config, app, db + +TIME_FMT = "%H:%M" +DATE_FMT = "%Y-%m-%d" + + +def str_to_datetime(data: str) -> datetime: + """ + Convert %H:%M formatted string into a python datetime object + """ + data = ":".join(data.split(":")[:2]) + return datetime.strptime(data, TIME_FMT) + + +def datetime_time_str(data: datetime) -> str: + """ + Convert a datetime object into a formatted string for display + :param data: datetime + :return: str + """ + return data.strftime(TIME_FMT) + + +def timedelta_to_str(data: timedelta) -> str: + """ + Remove second and microsecond portion from timedeltas for display + :param data: datetime.timedelta + :return: str + """ + return str( + data - timedelta(seconds=data.seconds, microseconds=data.microseconds) + ) + + +def timedelta(start: time, end: time) -> float: + startDateTime = datetime.combine(date.today(), start) + # Check if the the end is still on the same day + if start.hour > end.hour: + base = date.today() + timedelta(days=1) + else: + base = date.today() + endDateTime = datetime.combine(base, end) + difference = startDateTime - endDateTime + difference_hours = difference.total_seconds() / 3600 + return difference_hours + + +def combine_datetime(date: datetime.date, time: datetime.time): + """ + Combine a date and time object into a datetime object + """ + return datetime( + date.year, + date.month, + date.day, + time.hour, + time.minute, + time.second, + time.microsecond, + ) + + +def get_user_type(password): + # TODO password hashing? + if password == config.WRITE_PW: + return "editor" + if password == config.READ_PW: + return "readonly" + return False + + +def set_user_role(data): + """Set the users role in the flask g object for later usage""" + g.is_editor = data == "editor" + + +def authorize(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + try: + set_user_role(session["user_type"]) + except KeyError: + return redirect("/login") + return func(*args, **kwargs) + + return wrapper + + +@app.template_filter("format_time") +def format_time(value): + """Make the datetime to time string formatting available to jinja2""" + if value is None: + return "" + return datetime_time_str(value) + + +@app.template_filter("format_timedelta") +def format_timedelta(value): + """Make formatting for timedeltas available to jinja2""" + if value is None: + return "" + return timedelta_to_str(value) + + +@app.cli.command("initdb") +def init_db_command(): + """Initializes the database.""" + db.init_db() + + +@app.teardown_appcontext +def close_connection(exception): + db = getattr(g, "_database", None) + if db is not None: + db.close() diff --git a/estusshots/views/drinks.py b/estusshots/views/drinks.py new file mode 100644 index 0000000..f005b85 --- /dev/null +++ b/estusshots/views/drinks.py @@ -0,0 +1,72 @@ +from flask import render_template, redirect + +from estusshots import app +from estusshots import forms, models, orm +from estusshots.util import authorize +from estusshots.orm import Drink + + +@app.route("/drink") +@authorize +def drink_list(): + db = orm.new_session() + drinks = db.query(Drink).order_by(Drink.name).all() + model = { + "drinks": drinks, + "columns": [("name", "Drink Name"), ("vol", "Alcohol %")], + "controls": [("edit", "Edit")], + } + return render_template("drink_list.html", model=model) + + +@app.route("/drink//edit", methods=["GET"]) +@authorize +def drink_edit(drink_id: int): + db = orm.new_session() + drink = db.query(Drink).filter(Drink.id == drink_id).first() + form = forms.DrinkForm() + form.drink_id.data = drink.id + form.name.data = drink.name + form.vol.data = drink.vol + + model = models.GenericFormModel( + page_title="Edit Drink", + form_title=f'Edit Drink "{drink.name}"', + post_url="/drink/save", + ) + + return render_template("generic_form.html", model=model, form=form) + + +@app.route("/drink/new", methods=["GET"]) +@authorize +def new_drink(): + form = forms.DrinkForm() + model = models.GenericFormModel( + page_title="New Drink", + form_title=f"Create a new Drink", + post_url="/drink/save", + ) + return render_template("generic_form.html", model=model, form=form) + + +@app.route("/drink/save", methods=["POST"]) +@authorize +def drink_save(): + form = forms.DrinkForm() + if form.validate_on_submit(): + drink_id = int(form.drink_id.data) if form.drink_id.data else None + db = orm.new_session() + if drink_id: + drink = db.query(Drink).filter(Drink.id == drink_id).first() + else: + drink = Drink() + db.add(drink) + drink.populate_from_form(form) + err = db.commit() + return redirect("/drink") + + model = models.GenericFormModel( + page_title="Drinks", form_title="Edit Drink", post_url="/drink/save" + ) + return render_template("generic_form.html", model=model, form=form) diff --git a/estusshots/views/enemies.py b/estusshots/views/enemies.py new file mode 100644 index 0000000..ee0479d --- /dev/null +++ b/estusshots/views/enemies.py @@ -0,0 +1,72 @@ +from flask import render_template, request, redirect + +from estusshots import app +from estusshots import forms, models, orm +from estusshots.util import authorize +from estusshots.orm import Enemy +from sqlalchemy.orm import subqueryload + + +@app.route("/enemy") +@authorize +def enemy_list(): + db = orm.new_session() + enemies = db.query(Enemy).options(subqueryload(Enemy.season)).order_by(Enemy.name).all() + model = {"enemies": enemies} + return render_template("enemies.html", model=model) + + +@app.route("/enemy/new", methods=["GET"]) +@authorize +def enemy_new(preselect_season=None): + form = forms.EnemyForm() + + if preselect_season: + form.season_id.default = preselect_season + + model = models.GenericFormModel( + page_title="Enemies", + form_title="Create a new Enemy", + post_url=f"/enemy/null/edit", + ) + return render_template("generic_form.html", model=model, form=form) + + +@app.route("/enemy//edit", methods=["GET", "POST"]) +@authorize +def enemy_edit(enemy_id: int): + model = models.GenericFormModel( + page_title="Enemies", + form_title="Edit Enemy", + post_url=f"/enemy/{enemy_id}/edit", + ) + + if request.method == "GET": + db = orm.new_session() + enemy = db.query(Enemy).filter(Enemy.id == enemy_id).first() + + form = forms.EnemyForm() + form.season_id.data = enemy.season_id if enemy.season_id else -1 + form.name.data = enemy.name + form.is_boss.data = enemy.boss + form.enemy_id.data = enemy_id + + model.form_title = f'Edit Enemy "{enemy.name}"' + return render_template("generic_form.html", model=model, form=form) + else: + form = forms.EnemyForm() + if form.validate_on_submit(): + db = orm.new_session() + enemy = db.query(Enemy).filter(Enemy.id == enemy_id).first() + if not enemy: + enemy = Enemy() + db.add(enemy) + enemy.populate_from_form(form) + db.commit() + if form.submit_continue_button.data: + form.name.data = None + return enemy_new(preselect_season=enemy.season_id) + return redirect("/enemy") + + model.form_title = "Incorrect Data" + return render_template("generic_form.html", model=model, form=form) diff --git a/estusshots/views/episodes.py b/estusshots/views/episodes.py new file mode 100644 index 0000000..bb6074b --- /dev/null +++ b/estusshots/views/episodes.py @@ -0,0 +1,137 @@ +from typing import List + +from flask import render_template, request, redirect + +from estusshots import app +from estusshots import forms, models, db +from estusshots.util import authorize + + +@app.route("/season//episode/") +@authorize +def episode_detail(season_id: int, episode_id: int): + sql, args = db.load_season(season_id) + season = db.query_db(sql, args, one=True, cls=models.Season) + sql, args = db.load_episode(episode_id) + episode = db.query_db(sql, args, one=True, cls=models.Episode) + sql, args = db.load_episode_players(episode_id) + ep_players = db.query_db(sql, args, cls=models.Player) + sql, args = db.load_events(episode_id) + ep_events: List[models.Event] = db.query_db(sql, args, cls=models.Event) + sql, args = db.load_enemies(season_id) + enemies = db.query_db(sql, args, cls=models.Enemy) + + deaths = [ev for ev in ep_events if ev.type == models.EventType.Death] + entries = [] + for death in deaths: + entries.append({ + "time": death.time.time(), + "type": death.type, + "player_name": [p.name for p in ep_players if p.id == death.player_id], + "enemy_name": [e.name for e in enemies if e.id == death.enemy_id] + }) + events = None + if ep_events: + events = {"entries": death, "victory_count": 0, "defeat_count": 0} + + model = { + "title": f"{season.code}{episode.code}", + "episode": episode, + "season": season, + "players": ep_players, + "events": events, + } + + return render_template("episode_details.html", model=model) + + +@app.route("/season//episode", methods=["GET"]) +@authorize +def episode_list(season_id: int): + sql, args = db.load_season(season_id) + season = db.query_db(sql, args, one=True, cls=models.Season) + sql, args = db.load_episodes(season_id) + episodes = db.query_db(sql, args, cls=models.Episode) + + model = {"season_id": season_id, "season_code": season.code} + return render_template("episode_list.html", model=model) + + +@app.route("/season//episode/new", methods=["GET"]) +@authorize +def episode_new(season_id: int): + model = models.GenericFormModel( + page_title="New Episode", + form_title="Create New Episode", + post_url=f"/season/{season_id}/episode/null/edit", + ) + + form = forms.EpisodeForm(request.form) + form.season_id.data = season_id + return render_template("generic_form.html", model=model, form=form) + + +@app.route("/season//episode//edit", methods=["GET", "POST"]) +@authorize +def episode_edit(season_id: int, episode_id: int): + model = models.GenericFormModel( + page_title="Edit Episode", + form_title="Edit Episode", + post_url=f"/season/{season_id}/episode/{episode_id}/edit", + ) + + if request.method == "GET": + sql, args = db.load_episode(episode_id) + episode: models.Episode = db.query_db(sql, args, one=True, cls=models.Episode) + + sql, args = db.load_episode_players(episode_id) + ep_players = db.query_db(sql, args, cls=models.Player) + + form = forms.EpisodeForm() + form.season_id.data = episode.season_id + form.episode_id.data = episode.id + form.code.data = episode.code + form.date.data = episode.date + form.start.data = episode.start + form.end.data = episode.end + form.title.data = episode.title + form.players.data = [p.id for p in ep_players] + + model.form_title = f"Edit Episode '{episode.code}: {episode.title}'" + return render_template("generic_form.html", model=model, form=form) + else: + form = forms.EpisodeForm() + + if not form.validate_on_submit(): + model.errors = form.errors + return render_template("generic_form.html", model=model, form=form) + + errors = False + episode = models.Episode.from_form(form) + sql, args = db.save_episode(episode) + + last_key = db.update_db(sql, args, return_key=True) + + episode_id = episode.id if episode.id else last_key + + form_ids = form.players.data + + sql, args = db.load_episode_players(episode_id) + ep_players = db.query_db(sql, args, cls=models.Player) + pids = [p.id for p in ep_players] + + new_ids = [pid for pid in form_ids if pid not in pids] + removed_ids = [pid for pid in pids if pid not in form_ids] + + if removed_ids: + sql, args = db.remove_episode_player(episode_id, removed_ids) + errors = db.update_db(sql, args) + + if new_ids: + sql, args = db.save_episode_players(episode_id, new_ids) + errors = db.update_db(sql, args) + + if errors: + model.errors = {"Error saving episode": [errors]} + return render_template("generic_form.html", model=model, form=form) + return redirect(url_for("season_overview", season_id=season_id)) diff --git a/estusshots/views/events.py b/estusshots/views/events.py new file mode 100644 index 0000000..e2bac8e --- /dev/null +++ b/estusshots/views/events.py @@ -0,0 +1,56 @@ +from collections import namedtuple + +from flask import render_template, request, redirect + +from estusshots import app +from estusshots import forms, models, db, choices +from estusshots.util import authorize + + +@app.route("/season//episode//event/new", methods=["GET"]) +@authorize +def event_new(s_id: int, ep_id: int): + model = { + "page_title": "New Event", + "form_title": "Create New Event", + "post_url": f"/season/{s_id}/episode/{ep_id}/event/null/edit", + } + sql, args = db.load_episode(ep_id) + episode: models.Episode = db.query_db(sql, args, one=True, cls=models.Episode) + + sql, args = db.load_episode_players(ep_id) + ep_players = db.query_db(sql, args, cls=models.Player) + + form = forms.EventForm() + form.episode_id.data = ep_id + form.enemy.choices = choices.enemy_choice_for_season(s_id) + form.event_type.data = 1 + + Penalty = namedtuple("Penalty", ["penalty_id", "player_id", "player", "drink"]) + for player in ep_players: + form.penalties.append_entry(Penalty(None, player.id, player.name, 1)) + + return render_template("event_editor.html", model=model, form=form) + + +@app.route("/season//episode//event//edit", methods=["GET", "POST"]) +@authorize +def event_edit(s_id: int, ep_id: int, ev_id: int): + model = { + "page_title": "Edit Event", + "form_title": "Edit Event", + "post_url": f"/season/{s_id}/episode/{ep_id}/event/{ev_id}/edit", + } + if request.method == "GET": + return render_template("event_editor.html", model=model) + else: + form = forms.EventForm() + form.enemy.choices = choices.enemy_choice_for_season(s_id) + if not form.validate_on_submit(): + model["errors"] = form.errors + return render_template("event_editor.html", model=model, form=form) + + event = models.Event.from_form(form) + sql, args = db.save_event(event) + errors = db.update_db(sql, args) + return redirect(f"/season/{s_id}/episode/{ep_id}") diff --git a/estusshots/views/login.py b/estusshots/views/login.py new file mode 100644 index 0000000..23fe2e0 --- /dev/null +++ b/estusshots/views/login.py @@ -0,0 +1,28 @@ +from flask import render_template, request, redirect, session + +from estusshots import app +from estusshots.util import authorize, get_user_type + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "GET": + return render_template("login.html") + else: + user_type = get_user_type(request.form.get("password")) + if not user_type: + return redirect("/login") + session["user_type"] = user_type + return redirect("/") + + +@app.route("/logout") +def logout(): + session.pop("role", None) + return redirect("login") + + +@app.route("/") +@authorize +def landing(): + return redirect("/season") diff --git a/estusshots/views/players.py b/estusshots/views/players.py new file mode 100644 index 0000000..4fb2db0 --- /dev/null +++ b/estusshots/views/players.py @@ -0,0 +1,71 @@ +from flask import render_template, request, redirect + +from estusshots import app +from estusshots import forms, models, orm +from estusshots.util import authorize +from estusshots.orm import Player + + +@app.route("/player/new", methods=["GET"]) +@authorize +def player_new(): + form = forms.PlayerForm() + model = models.GenericFormModel( + page_title="Players", + form_title="Create a new Player", + post_url="/player/null/edit", + ) + return render_template("generic_form.html", model=model, form=form) + + +@app.route("/player//edit", methods=["GET", "POST"]) +@authorize +def player_edit(player_id: int): + model = models.GenericFormModel( + page_title="Players", + form_title=f"Edit Player", + post_url=f"/player/{player_id}/edit", + ) + # Edit Existing Player + if request.method == "GET": + db = orm.new_session() + player = db.query(Player).filter(Player.id == player_id).first() + + form = forms.PlayerForm() + form.player_id.data = player.id + form.anonymize.data = player.anon + form.real_name.data = player.real_name + form.alias.data = player.alias + form.hex_id.data = player.hex_id + + model.form_title = f'Edit Player "{player.name}"' + return render_template("generic_form.html", model=model, form=form) + + # Save POSTed data + else: + form = forms.PlayerForm() + if form.validate_on_submit(): + db = orm.new_session() + player = db.query(Player).filter(Player.id == player_id).first() + player.populate_from_form(form) + db.commit() + return redirect("/player") + + model.form_title = "Incorrect Data" + return render_template("generic_form.html", model=model, form=form) + + +@app.route("/player") +@authorize +def player_list(): + db = orm.new_session() + players = db.query(Player) + model = { + "player_list": players, + "columns": [ + ("name", "Player Name"), + ("alias", "Alias"), + ("hex_id", "Hex ID"), + ], + } + return render_template("player_list.html", model=model) diff --git a/estusshots/views/seasons.py b/estusshots/views/seasons.py new file mode 100644 index 0000000..e935a69 --- /dev/null +++ b/estusshots/views/seasons.py @@ -0,0 +1,94 @@ +from flask import render_template, request, redirect, url_for + +from estusshots import app +from estusshots import forms, models, orm +from estusshots.util import authorize +from estusshots.orm import Season + + +@app.route("/season") +@authorize +def season_list(): + db = orm.new_session() + seasons = db.query(Season).order_by(Season.code).all() + model = { + "seasons": seasons, + "columns": [ + ("code", "#"), + ("game", "Game"), + ("description", "Season Description"), + ("start", "Started At"), + ("end", "Ended At"), + ], + } + return render_template("season_list.html", model=model) + + +@app.route("/season/new", methods=["GET"]) +@authorize +def season_new(): + form = forms.SeasonForm() + model = models.GenericFormModel( + page_title="New Season", + form_title="Create New Season", + post_url="/season/edit/null", + ) + return render_template("generic_form.html", model=model, form=form) + + +@app.route("/season/edit/", methods=["GET", "POST"]) +@authorize +def season_edit(season_id: int): + model = models.GenericFormModel( + page_title="Seasons", + form_title="Edit Season", + post_url=f"/season/edit/{season_id}", + ) + db = orm.new_session() + season = db.query(Season).filter(Season.id == season_id).first() + + if request.method == "GET": + form = forms.SeasonForm() + form.season_id.data = season.id + form.code.data = season.code + form.game_name.data = season.game + form.description.data = season.description + form.start.data = season.start + form.end.data = season.end + + model.form_title = f"Edit Season '{season.code}: {season.game}'" + return render_template("generic_form.html", model=model, form=form) + else: + form = forms.SeasonForm() + + if not form.validate_on_submit(): + model.errors = form.errors + return render_template("generic_form.html", model=model, form=form) + + if not season: + season = Season() + db.add(season) + + season.populate_from_form(form) + db.commit() + return redirect(url_for("season_list")) + + +@app.route("/season/", methods=["GET"]) +@authorize +def season_overview(season_id: int): + db = orm.new_session() + season = db.query(Season).filter(Season.id == season_id).first() + + infos = { + "Number": season.code, + "Game": season.game, + "Start Date": season.start, + "End Date": season.end if season.end else "Ongoing", + } + model = { + "title": f"{season.code} {season.game}", + "season_info": infos, + "episodes": season.episodes, + } + return render_template("season_overview.html", model=model) diff --git a/requirements.txt b/requirements.txt index 3a48d24..e14623d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +sqlalchemy Click==7.0 dataclasses==0.6 dominate==2.3.5 diff --git a/templates/event_editor.html b/templates/event_editor.html deleted file mode 100644 index f4cddfc..0000000 --- a/templates/event_editor.html +++ /dev/null @@ -1,25 +0,0 @@ -{% extends "base.html" %} -{% import "bootstrap/wtf.html" as wtf %} -{% set active_page = "seasons" %} -{% block title %}{{ model.page_title }} {{ super() }}{% endblock %} - -{% block page %} -
-

{{ model.form_title }}

- - {% if model.errors %} -
- {% for field, errors in model.errors.items() %} -
- {{ field }}: - {{ errors|join(', ') }} -
- {% endfor %} -
- {% endif %} - -
- -
-
-{% endblock %} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7277d56 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "commonjs", + "noImplicitAny": true, + "removeComments": true, + "preserveConstEnums": true, + "sourceMap": true, + "outDir": "./estusshots/static/js" + }, + "include": [ + "**/*.ts" + ], +} diff --git a/util.py b/util.py deleted file mode 100644 index c3b944c..0000000 --- a/util.py +++ /dev/null @@ -1,47 +0,0 @@ -import datetime - -TIME_FMT = "%H:%M" -DATE_FMT = "%Y-%m-%d" - - -def str_to_datetime(data: str) -> datetime.datetime: - """ - Convert %H:%M formatted string into a python datetime object - """ - data = ":".join(data.split(":")[:2]) - return datetime.datetime.strptime(data, TIME_FMT) - - -def datetime_time_str(data: datetime) -> str: - """ - Convert a datetime object into a formatted string for display - :param data: datetime - :return: str - """ - return data.strftime(TIME_FMT) - - -def timedelta_to_str(data: datetime.timedelta) -> str: - """ - Remove second and microsecond portion from timedeltas for display - :param data: datetime.timedelta - :return: str - """ - return str( - data - datetime.timedelta(seconds=data.seconds, microseconds=data.microseconds) - ) - - -def combine_datetime(date: datetime.date, time: datetime.time): - """ - Combine a date and time object into a datetime object - """ - return datetime.datetime( - date.year, - date.month, - date.day, - time.hour, - time.minute, - time.second, - time.microsecond, - )