Strategická: Init web serveru
This commit is contained in:
commit
5f3da84f71
9 changed files with 858 additions and 0 deletions
16
server/config.py.example
Normal file
16
server/config.py.example
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
# Patří do hra/config.py
|
||||||
|
|
||||||
|
SQLALCHEMY_DATABASE_URI = "postgresql:///ksp-strathra"
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
SQLALCHEMY_ECHO = False
|
||||||
|
|
||||||
|
# Vytvořte pomocí python3 -c 'import secrets; print(secrets.token_hex(32))'
|
||||||
|
SECRET_KEY = "8aeffbdf14d441f40359708cbbae9b47926db8b08e4ea9e0cdf78071bee7b788"
|
||||||
|
|
||||||
|
SESSION_COOKIE_NAME = 'ksp_strathra__session'
|
||||||
|
|
||||||
|
# Druh webu (devel/test/pub), z toho CSS třída elementu <header>
|
||||||
|
WEB_FLAVOR = 'devel'
|
||||||
|
|
||||||
|
# Nutné pro registraci
|
||||||
|
CAPTCHA = 'hroch'
|
2
server/flask
Executable file
2
server/flask
Executable file
|
@ -0,0 +1,2 @@
|
||||||
|
#!/bin/sh
|
||||||
|
exec flask --app hra.web --debug "$@"
|
347
server/hra/web/__init__.py
Normal file
347
server/hra/web/__init__.py
Normal file
|
@ -0,0 +1,347 @@
|
||||||
|
from flask import Flask, redirect, flash, render_template, session, g, request
|
||||||
|
from wtforms import Form, BooleanField, StringField, PasswordField, validators, SubmitField, IntegerField, DateTimeField
|
||||||
|
from wtforms.validators import ValidationError
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from flask_bootstrap import Bootstrap
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from sqlalchemy import exc, update
|
||||||
|
import hashlib
|
||||||
|
import bcrypt
|
||||||
|
import os
|
||||||
|
import werkzeug.exceptions
|
||||||
|
import wtforms
|
||||||
|
from wtforms.fields import EmailField
|
||||||
|
from wtforms.widgets import NumberInput
|
||||||
|
|
||||||
|
import hra.config as config
|
||||||
|
|
||||||
|
|
||||||
|
class OptionalIntField(wtforms.IntegerField):
|
||||||
|
widget = NumberInput()
|
||||||
|
|
||||||
|
def process_formdata(self, valuelist):
|
||||||
|
self.data = None
|
||||||
|
if valuelist:
|
||||||
|
if valuelist[0]:
|
||||||
|
try:
|
||||||
|
self.data = int(valuelist[0])
|
||||||
|
except ValueError:
|
||||||
|
raise wtforms.ValidationError('Nejedná se o číslo.')
|
||||||
|
|
||||||
|
|
||||||
|
import logging
|
||||||
|
logging.basicConfig()
|
||||||
|
logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO)
|
||||||
|
|
||||||
|
static_dir = os.path.abspath('static')
|
||||||
|
app = Flask(__name__, static_folder=static_dir)
|
||||||
|
|
||||||
|
app.config.from_object(config)
|
||||||
|
Bootstrap(app)
|
||||||
|
db = SQLAlchemy(app)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class NeedLoginError(werkzeug.exceptions.Forbidden):
|
||||||
|
description = 'Need to log in'
|
||||||
|
|
||||||
|
class MenuItem:
|
||||||
|
url: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
def __init__(self, url: str, name: str):
|
||||||
|
self.url = url
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
|
||||||
|
def init_request():
|
||||||
|
path = request.path
|
||||||
|
# XXX: Když celá aplikace běží v adresáři, request.path je relativní ke kořeni aplikace, ne celého webu
|
||||||
|
if path.startswith('/static/') or path.startswith('/assets/'):
|
||||||
|
# Pro statické soubory v development nasazení nepotřebujeme nastavovat
|
||||||
|
# nic dalšího (v ostrém nasazení je servíruje uwsgi)
|
||||||
|
return
|
||||||
|
|
||||||
|
if 'uid' in session:
|
||||||
|
user = db.session.query(Users).filter_by(id=session['uid']).first()
|
||||||
|
else:
|
||||||
|
user = None
|
||||||
|
path = request.path
|
||||||
|
if path.startswith('/org/'):
|
||||||
|
if not user:
|
||||||
|
raise werkzeug.exceptions.Forbidden()
|
||||||
|
if not user.org:
|
||||||
|
raise werkzeug.exceptions.Forbidden()
|
||||||
|
g.user = user
|
||||||
|
|
||||||
|
g.menu = [
|
||||||
|
MenuItem('/', "Domů"),
|
||||||
|
MenuItem('/bonuses', "Bonusy"),
|
||||||
|
]
|
||||||
|
if g.user and g.user.org:
|
||||||
|
g.menu += [
|
||||||
|
MenuItem('/org/ranking', "Výsledky"),
|
||||||
|
MenuItem('/org/act', "Aktuální kolo"),
|
||||||
|
MenuItem('/org/admin', "Admin"),
|
||||||
|
MenuItem('/org/su', "Vtělování se"),
|
||||||
|
MenuItem('/org/users', "Uživatelé"),
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
g.menu += [
|
||||||
|
MenuItem('/submitted', "Odevzdané kódy"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
app.before_request(init_request)
|
||||||
|
|
||||||
|
|
||||||
|
class Users(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
org = db.Column(db.Boolean)
|
||||||
|
username = db.Column(db.String(80), unique=True, nullable=False)
|
||||||
|
passwd = db.Column(db.String(80), nullable=False)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '<User %r>' % self.username
|
||||||
|
|
||||||
|
|
||||||
|
def code_points(code, time):
|
||||||
|
r = 1
|
||||||
|
if code in bonus:
|
||||||
|
for b in bonus[code]:
|
||||||
|
if not b.is_out(time):
|
||||||
|
r = max(r, b.eval(time))
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
class Findings(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
user = db.Column(db.Integer, db.ForeignKey('users.id'))
|
||||||
|
round = db.Column(db.Integer)
|
||||||
|
code = db.Column(db.String(10))
|
||||||
|
time = db.Column(db.Integer)
|
||||||
|
delete = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
def points(self):
|
||||||
|
return code_points(self.code, self.time)
|
||||||
|
|
||||||
|
class ActualRound(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
|
||||||
|
class Round(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
start_time = db.Column(db.DateTime)
|
||||||
|
|
||||||
|
# db.create_all()
|
||||||
|
|
||||||
|
# if db.session.query(ActualRound).one_or_none() is None:
|
||||||
|
# db.session.add(ActualRound(id=0))
|
||||||
|
# db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrationForm(FlaskForm):
|
||||||
|
username = StringField('Jméno týmu', [validators.Length(min=2, max=25), validators.DataRequired()])
|
||||||
|
captcha = StringField('Captcha', validators=[validators.DataRequired()])
|
||||||
|
passwd = PasswordField('Heslo', [
|
||||||
|
validators.DataRequired(),
|
||||||
|
validators.EqualTo('confirm', message='Passwords must match')
|
||||||
|
])
|
||||||
|
confirm = PasswordField('Heslo znovu', validators=[validators.DataRequired()])
|
||||||
|
submit = SubmitField("Založit")
|
||||||
|
|
||||||
|
def validate_captcha(form, field):
|
||||||
|
if field.data != config.CAPTCHA:
|
||||||
|
raise ValidationError("Chyba!")
|
||||||
|
|
||||||
|
|
||||||
|
class LoginForm(FlaskForm):
|
||||||
|
username = StringField('Jméno týmu', [validators.DataRequired()])
|
||||||
|
passwd = PasswordField('Heslo', [validators.DataRequired()])
|
||||||
|
submit = SubmitField("Přihlásit")
|
||||||
|
|
||||||
|
|
||||||
|
def hash_passwd(a):
|
||||||
|
salt = b'$2b$12$V2aIKSJC/uozaodwYnQX3e'
|
||||||
|
hashed = bcrypt.hashpw(a.encode('utf-8'), salt)
|
||||||
|
return hashed.decode('us-ascii')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/registration", methods=['GET', 'POST'])
|
||||||
|
def registration():
|
||||||
|
f = RegistrationForm()
|
||||||
|
if f.validate_on_submit():
|
||||||
|
u = Users(org=False, username=f.username.data, passwd=hash_passwd(f.passwd.data))
|
||||||
|
try:
|
||||||
|
db.session.add(u)
|
||||||
|
db.session.commit()
|
||||||
|
except exc.IntegrityError:
|
||||||
|
flash("Uživatelské jméno již existuje")
|
||||||
|
return render_template('registration.html', form=f)
|
||||||
|
flash("Přidán nový uživatel.", 'success')
|
||||||
|
return redirect("login")
|
||||||
|
return render_template('registration.html', form=f)
|
||||||
|
|
||||||
|
@app.route("/login", methods=['GET', 'POST'])
|
||||||
|
def login():
|
||||||
|
f = LoginForm()
|
||||||
|
if f.validate_on_submit():
|
||||||
|
p_hash=hash_passwd(f.passwd.data)
|
||||||
|
user = db.session.query(Users).filter_by(username=f.username.data).one_or_none()
|
||||||
|
print(user, p_hash)
|
||||||
|
if user and user.passwd == p_hash:
|
||||||
|
session.clear()
|
||||||
|
session['uid'] = user.id
|
||||||
|
flash("Přihlášení hotovo.", 'success')
|
||||||
|
return redirect("/")
|
||||||
|
flash("Chybné jméno nebo heslo.", 'danger')
|
||||||
|
return render_template('login.html', form=f)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/logout")
|
||||||
|
def logout():
|
||||||
|
session.clear()
|
||||||
|
return redirect('/')
|
||||||
|
|
||||||
|
|
||||||
|
@app.template_filter()
|
||||||
|
def none_as_minus(x):
|
||||||
|
return x if x is not None else '-'
|
||||||
|
|
||||||
|
|
||||||
|
@app.template_filter()
|
||||||
|
def round_points(x):
|
||||||
|
if x is None:
|
||||||
|
return None
|
||||||
|
return round(x,3)
|
||||||
|
|
||||||
|
|
||||||
|
@app.template_filter()
|
||||||
|
def print_time(t):
|
||||||
|
if t == None:
|
||||||
|
return "-"
|
||||||
|
return ("-" if t<0 else "") + str(abs(t)//1000//60) + ":" + ("00{0:d}".format(abs(t)//1000%60))[-2:]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/", methods=['GET', 'POST'])
|
||||||
|
def web_index():
|
||||||
|
return render_template('index.html')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/org/users")
|
||||||
|
def web_users():
|
||||||
|
users = db.session.query(Users).all()
|
||||||
|
return render_template("org_users.html", users=users)
|
||||||
|
|
||||||
|
@app.route("/org/user/<int:user_id>", methods=['GET', 'POST'])
|
||||||
|
def web_org_user(user_id):
|
||||||
|
user = db.session.query(Users).filter_by(id=user_id).one_or_none()
|
||||||
|
f = FindingForm()
|
||||||
|
del f.user
|
||||||
|
if f.validate_on_submit():
|
||||||
|
f.fill_empty()
|
||||||
|
find = Findings(user=user.id, code=f.f_code, time=f.f_time, round=get_round_id())
|
||||||
|
db.session.add(find)
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"Kód {find.code}… přijat", 'success')
|
||||||
|
f.code.data = ""
|
||||||
|
return redirect(f"/org/user/{user_id}")
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise werkzeug.exceptions.NotFound()
|
||||||
|
calc_point(user)
|
||||||
|
findings = db.session.query(Findings).filter_by(user=user.id, round=get_round_id()).order_by(Findings.time).all()
|
||||||
|
return render_template("org_user.html", user=user, findings=findings, form=f)
|
||||||
|
|
||||||
|
@app.route("/org/admin", methods=['GET', 'POST'])
|
||||||
|
def web_admin():
|
||||||
|
obj_round = get_round()
|
||||||
|
f_round = FormRound(obj=obj_round, prefix="r")
|
||||||
|
if f_round.validate_on_submit():
|
||||||
|
f_round.populate_obj(obj_round)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return render_template("org_admin.html", f_round=f_round)
|
||||||
|
|
||||||
|
@app.route("/org/act", methods=['GET', 'POST'])
|
||||||
|
def web_act():
|
||||||
|
obj_act_round = db.session.query(ActualRound).one()
|
||||||
|
f_act_round = FormActRound(obj=obj_act_round, prefix="act")
|
||||||
|
if f_act_round.validate_on_submit():
|
||||||
|
f_act_round.populate_obj(obj_act_round)
|
||||||
|
db.session.commit()
|
||||||
|
return render_template("org_act.html", f_act_round=f_act_round)
|
||||||
|
|
||||||
|
|
||||||
|
class SuForm(FlaskForm):
|
||||||
|
username = StringField('Uživatel')
|
||||||
|
time_move = OptionalIntField('Časový posun')
|
||||||
|
round = OptionalIntField('Kolo')
|
||||||
|
submit = SubmitField("Změnit")
|
||||||
|
|
||||||
|
def validate_username(form, field):
|
||||||
|
form.f_user = db.session.query(Users).filter_by(username=field.data).one_or_none()
|
||||||
|
if form.f_user == None and field.data:
|
||||||
|
raise ValidationError("Uživatel neexistuje.")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/org/su", methods=['GET', 'POST'])
|
||||||
|
def web_su():
|
||||||
|
f = SuForm()
|
||||||
|
if not f.is_submitted():
|
||||||
|
f.username.data = g.user.username
|
||||||
|
|
||||||
|
if f.validate_on_submit():
|
||||||
|
if not f.f_user or session['uid'] != f.f_user.id:
|
||||||
|
session['uid'] = f.f_user.id if f.f_user else None
|
||||||
|
flash("Uživatel vtělen!")
|
||||||
|
if f.time_move.data is not None:
|
||||||
|
session['time_move'] = f.time_move.data * 1000
|
||||||
|
flash("Čas vtělen!")
|
||||||
|
if f.round.data is not None:
|
||||||
|
session['round'] = f.round.data
|
||||||
|
flash("Kolo vtěleno!")
|
||||||
|
return redirect('/')
|
||||||
|
|
||||||
|
return render_template("org_su.html", f=f)
|
||||||
|
|
||||||
|
from hra.web.html import *
|
||||||
|
@app.route("/test", methods=['GET', 'POST'])
|
||||||
|
def test():
|
||||||
|
b = HtmlBuilder()
|
||||||
|
with b.head():
|
||||||
|
b.title()("KSP hra")
|
||||||
|
b.meta(name="viewport", content="width=device-width, initial-scale=1.0")
|
||||||
|
b.link(rel="stylesheet", href=app.url_for('static', filename='bootstrap.min.css'), type='text/css', media="all")
|
||||||
|
b.link(rel="stylesheet", href=app.url_for('static', filename='ksp-mhd.css'), type='text/css', media="all")
|
||||||
|
# b.link(rel="stylesheet", href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css", integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm", crossorigin="anonymous")
|
||||||
|
b.link(rel="icon", type="image/png", sizes="16x16", href="static/favicon.ico")
|
||||||
|
b.link(rel="shortcut icon", href=app.url_for('static', filename='img/favicon.ico'))
|
||||||
|
# b.link(rel="stylesheet", href="https://mo.mff.cuni.cz/osmo/assets/a373a8f2/mo.css", type='text/css', media="all")
|
||||||
|
with b.body():
|
||||||
|
with b.header(_class=f"flavor-{config.WEB_FLAVOR}"):
|
||||||
|
with b.div(_class="content"):
|
||||||
|
with b.a(href="/", title="Na hlavní stránku"):
|
||||||
|
b.img(src=app.url_for('static', filename='hippo.png'), style="width: 60px;height: auto;", alt="KSP")
|
||||||
|
b.h1()("Hra na soustředění KSP")
|
||||||
|
with b.div(id="nav-wrapper"):
|
||||||
|
with b.nav(id="main-menu", _class="content"):
|
||||||
|
for item in g.menu:
|
||||||
|
b.a()(item.name)
|
||||||
|
if g.user:
|
||||||
|
b.a(_class="right", href="/")(f"Přihlášen: {{ g.user.username }}")
|
||||||
|
b.a(_class="right", href="/logout")(f"Odhlásit")
|
||||||
|
else:
|
||||||
|
b.a(_class="right", href="/login")(f"Přihlásit")
|
||||||
|
b.a(_class="right", href="/registration")(f"Registrovat")
|
||||||
|
|
||||||
|
return b._print_file()
|
||||||
|
|
146
server/hra/web/html.py
Normal file
146
server/hra/web/html.py
Normal file
|
@ -0,0 +1,146 @@
|
||||||
|
from flask import Markup, escape
|
||||||
|
from typing import List, Optional, Union, Tuple
|
||||||
|
import threading
|
||||||
|
|
||||||
|
|
||||||
|
class EscapeError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def escape_attribute(x:str) -> str:
|
||||||
|
return x.replace("&", "&ersand;").replace('"', """)
|
||||||
|
|
||||||
|
|
||||||
|
def escape_attribute_name(x:str) -> str:
|
||||||
|
for c in "<>='\" ":
|
||||||
|
if c in x:
|
||||||
|
raise EscapeError
|
||||||
|
return x
|
||||||
|
|
||||||
|
|
||||||
|
def escape_tag_name(x:str) -> str:
|
||||||
|
return escape_attribute_name(x)
|
||||||
|
|
||||||
|
Element = Union[str, Markup, 'Tag']
|
||||||
|
|
||||||
|
|
||||||
|
class Tag:
|
||||||
|
name: str
|
||||||
|
attributes: List[Tuple[str, str]]
|
||||||
|
content: List[Element]
|
||||||
|
is_paired: bool
|
||||||
|
builder: 'Builder'
|
||||||
|
|
||||||
|
def __init__(self, builder, name:str, attributes: List[Tuple[str, str]]):
|
||||||
|
self.builder = builder
|
||||||
|
self.name = name
|
||||||
|
self.attributes = attributes
|
||||||
|
self.is_paired = False
|
||||||
|
self.content = []
|
||||||
|
self.before_tag = None
|
||||||
|
|
||||||
|
def add(self, x: Element):
|
||||||
|
self.is_paired = True
|
||||||
|
self.content.append(x)
|
||||||
|
|
||||||
|
def add_attribute(k, v):
|
||||||
|
self.attributes.append((k,v))
|
||||||
|
|
||||||
|
def __call__(self, *arg, **kvarg):
|
||||||
|
for i in arg:
|
||||||
|
self.add(i)
|
||||||
|
for k, v in kvarg.items():
|
||||||
|
self.add_attribute(remove_leading_underscore(k), v)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def add_tag(self, name:str, attributes: List[Tuple[str, str]]):
|
||||||
|
t = Tag(self.builder, name, attributes)
|
||||||
|
self.add(t)
|
||||||
|
return t
|
||||||
|
|
||||||
|
def format_attributes(self):
|
||||||
|
return " ".join(f'{escape_attribute_name(i[0])}="{escape_attribute(i[1])}"' for i in self.attributes)
|
||||||
|
|
||||||
|
def serialize_append_to_list(self, out, indent):
|
||||||
|
indent_str = " "
|
||||||
|
if self.is_paired:
|
||||||
|
out.append(indent_str*indent + f"<{escape_tag_name(self.name)} {self.format_attributes()}>\n")
|
||||||
|
indent += 1
|
||||||
|
for i in self.content:
|
||||||
|
if isinstance(i, Tag):
|
||||||
|
i.serialize_append_to_list(out, indent)
|
||||||
|
elif isinstance(i, Markup):
|
||||||
|
for j in i.__html__.split("\n"):
|
||||||
|
out.append(indent_str*indent + j + "\n")
|
||||||
|
else:
|
||||||
|
for j in str(i).split("\n"):
|
||||||
|
out.append(indent_str*indent + j + "\n")
|
||||||
|
indent -= 1
|
||||||
|
out.append(indent_str*indent + f"</{escape_tag_name(self.name)}>\n")
|
||||||
|
else:
|
||||||
|
out.append(indent_str*indent + f"<{escape_tag_name(self.name)} {self.format_attributes()} \>\n")
|
||||||
|
|
||||||
|
def print(self):
|
||||||
|
out = []
|
||||||
|
self.serialize_append_to_list(out, 0)
|
||||||
|
return Markup("".join(out))
|
||||||
|
|
||||||
|
def print_file(self):
|
||||||
|
out = ["<!DOCTYPE html>\n"]
|
||||||
|
self.serialize_append_to_list(out, 0)
|
||||||
|
return Markup("".join(out))
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
if self.before_tag is not None:
|
||||||
|
raise RuntimeError("Duplicit __enter__")
|
||||||
|
self.before_tag = self.builder._current_tag
|
||||||
|
self.builder._current_tag = self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_value, exc_traceback):
|
||||||
|
if self.before_tag is None:
|
||||||
|
raise RuntimeError("__exit__ before __enter__")
|
||||||
|
self.builder._current_tag = self.before_tag
|
||||||
|
self.before_tag = None
|
||||||
|
|
||||||
|
class Builder:
|
||||||
|
_current_tag: Tag
|
||||||
|
_root_tag: Tag
|
||||||
|
def __init__(self, tag: Tag):
|
||||||
|
self._root_tag = tag
|
||||||
|
self._current_tag = tag
|
||||||
|
|
||||||
|
def _tag(self, name: str, attributes: List[Tuple[str,str]] = []):
|
||||||
|
return self._current_tag.add_tag(name, attributes)
|
||||||
|
|
||||||
|
def __call__(self, x: Element):
|
||||||
|
self._current_tag.add(x)
|
||||||
|
return x
|
||||||
|
|
||||||
|
def _print(self):
|
||||||
|
return self._root_tag.print()
|
||||||
|
|
||||||
|
def _print_file(self):
|
||||||
|
return self._root_tag.print_file()
|
||||||
|
|
||||||
|
class HtmlBuilder(Builder):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(Tag(self, "html", []))
|
||||||
|
|
||||||
|
|
||||||
|
tag_names = ["a", "abbr", "acronym", "address", "applet", "area", "article", "aside", "audio", "b", "base", "basefont", "bdi", "bdo", "big", "blockquote", "body", "br", "button", "canvas", "caption", "center", "cite", "code", "col", "colgroup", "data", "datalist", "dd", "del", "details", "dfn", "dialog", "dir", "div", "dl", "dt", "em", "embed", "fieldset", "figcaption", "figure", "font", "footer", "form", "frame", "frameset", "head", "header", "hgroup", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "html", "i", "iframe", "img", "input", "ins", "kbd", "keygen", "label", "legend", "li", "link", "main", "map", "mark", "menu", "menuitem", "meta", "meter", "nav", "noframes", "noscript", "object", "ol", "optgroup", "option", "output", "p", "param", "picture", "pre", "progress", "q", "rp", "rt", "ruby", "s", "samp", "script", "section", "select", "small", "source", "span", "strike", "strong", "style", "sub", "summary", "sup", "svg", "table", "tbody", "td", "template", "textarea", "tfoot", "th", "thead", "time", "title", "tr", "track", "tt", "u", "ul", "var", "video", "wbr"]
|
||||||
|
|
||||||
|
|
||||||
|
def remove_leading_underscore(s):
|
||||||
|
if s == "":
|
||||||
|
return s
|
||||||
|
if s[0] == "_":
|
||||||
|
return s[1:]
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
for name in tag_names:
|
||||||
|
def run(name):
|
||||||
|
def l(self, **kvarg):
|
||||||
|
return self._tag(name, [(remove_leading_underscore(k), v) for k, v in kvarg.items()])
|
||||||
|
setattr(HtmlBuilder, name, l)
|
||||||
|
run(name)
|
43
server/setup.py
Normal file
43
server/setup.py
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import setuptools
|
||||||
|
|
||||||
|
setuptools.setup(
|
||||||
|
name='hra',
|
||||||
|
version='1.0',
|
||||||
|
description='Hra na soustředění KSP',
|
||||||
|
packages=['hra', 'hra/web'],
|
||||||
|
scripts=[
|
||||||
|
],
|
||||||
|
include_package_data=True,
|
||||||
|
zip_safe=False,
|
||||||
|
install_requires=[
|
||||||
|
# Udržujte prosím seřazené
|
||||||
|
'Flask',
|
||||||
|
'Flask-WTF',
|
||||||
|
'WTForms',
|
||||||
|
'bcrypt',
|
||||||
|
'bleach',
|
||||||
|
'blinker',
|
||||||
|
'click',
|
||||||
|
'dateutils',
|
||||||
|
'flask_bootstrap',
|
||||||
|
'flask_sqlalchemy',
|
||||||
|
'pikepdf',
|
||||||
|
'pillow',
|
||||||
|
'psycopg2',
|
||||||
|
'pyzbar',
|
||||||
|
'sqlalchemy[mypy]',
|
||||||
|
'uwsgidecorators',
|
||||||
|
# Používáme pro vývoj, ale aby je pylsp našel, musí být ve stejném virtualenvu
|
||||||
|
# jako ostatní knihovny.
|
||||||
|
'sqlalchemy-stubs',
|
||||||
|
'types-Markdown',
|
||||||
|
'types-bleach',
|
||||||
|
'types-flask_sqlalchemy',
|
||||||
|
'types-pillow',
|
||||||
|
'types-python-dateutil',
|
||||||
|
'types-requests',
|
||||||
|
'types-setuptools',
|
||||||
|
],
|
||||||
|
)
|
7
server/static/bootstrap.min.css
vendored
Normal file
7
server/static/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
server/static/favicon.ico
Normal file
BIN
server/static/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.4 KiB |
BIN
server/static/hippo.png
Normal file
BIN
server/static/hippo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 139 KiB |
297
server/static/ksp-mhd.css
Normal file
297
server/static/ksp-mhd.css
Normal file
|
@ -0,0 +1,297 @@
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: white;
|
||||||
|
color: black;
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content, main {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1em;
|
||||||
|
max-width: 1000px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
padding: 10px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header.flavor-test {
|
||||||
|
background-color: #33cc33;
|
||||||
|
}
|
||||||
|
|
||||||
|
header.flavor-devel {
|
||||||
|
background-color: #cc77cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
padding: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .content {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
header img { height: 60px; }
|
||||||
|
|
||||||
|
header h1 { margin: auto 20px 0px; color: #222; }
|
||||||
|
|
||||||
|
#nav-wrapper {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background-color: #222;
|
||||||
|
border: 1px #222 solid;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: red;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.okay {
|
||||||
|
color: green;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: #737373;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.unknown {
|
||||||
|
font-weight: bold;
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.data {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 2ex;
|
||||||
|
margin-bottom: 2ex;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.data.full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.data tbody tr:hover {
|
||||||
|
background-color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.data tr td, table.data tr th {
|
||||||
|
border: 1px solid #222;
|
||||||
|
padding: 0.1ex 0.5ex;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.data.center th, table.data.center td {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.data thead, table.data tfoot {
|
||||||
|
background-color: #aaa;
|
||||||
|
}
|
||||||
|
table.data thead a, table.data tfoot a {
|
||||||
|
color: rgb(0, 22, 121);
|
||||||
|
}
|
||||||
|
|
||||||
|
table.data thead th {
|
||||||
|
padding: 0.4ex 0.5ex;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.data td.sol {
|
||||||
|
background-color: lightgreen;
|
||||||
|
}
|
||||||
|
table.data td.sol a {
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
table.data td.sol-warn {
|
||||||
|
background-color: #ffaaaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
table tr.bonus--1{
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
table tr.bonus-0{
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
table tr.bonus-1{
|
||||||
|
color: blue;
|
||||||
|
}
|
||||||
|
nav#main-menu {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav#main-menu a, nav#main-menu input[type=submit] {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
margin: 0px;
|
||||||
|
color: white;
|
||||||
|
font-size: 1em;
|
||||||
|
text-align: center;
|
||||||
|
padding: 14px 16px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only the first item with .right class does the margin trick */
|
||||||
|
nav#main-menu > .right { margin-left: auto; }
|
||||||
|
nav#main-menu > .right ~ .right { margin-left: 0px; }
|
||||||
|
|
||||||
|
nav#main-menu a:hover:not(.active), nav#main-menu input[type=submit]:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: #555;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav#main-menu a.active {
|
||||||
|
background-color: #ddd;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group.required .control-label:after {
|
||||||
|
content:"*";
|
||||||
|
color:red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-frame {
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px #ddd solid;
|
||||||
|
border-radius: 4px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checked_toggle input.toggle:checked ~ .checked_hide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.checked_toggle input.toggle:not(:checked) ~ .checked_show {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs - source: https://codepen.io/MPDoctor/pen/mpJdYe */
|
||||||
|
.tabbed {
|
||||||
|
margin: 15px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabbed [type="radio"] {
|
||||||
|
/* hiding the inputs */
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 0px;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
.tab > label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
padding: 12px 15px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
background: #eee;
|
||||||
|
color: #666;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.tab:hover label {
|
||||||
|
border-top-color: #333;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
margin: 0px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-top: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* As we cannot replace the numbers with variables or calls to element properties, the number of this selector parts is our tab count limit */
|
||||||
|
.tabbed [type="radio"]:nth-of-type(1):checked ~ .tabs .tab:nth-of-type(1) label,
|
||||||
|
.tabbed [type="radio"]:nth-of-type(2):checked ~ .tabs .tab:nth-of-type(2) label,
|
||||||
|
.tabbed [type="radio"]:nth-of-type(3):checked ~ .tabs .tab:nth-of-type(3) label,
|
||||||
|
.tabbed [type="radio"]:nth-of-type(4):checked ~ .tabs .tab:nth-of-type(4) label,
|
||||||
|
.tabbed [type="radio"]:nth-of-type(5):checked ~ .tabs .tab:nth-of-type(5) label {
|
||||||
|
border-bottom-color: #fff;
|
||||||
|
border-top-color: #999;
|
||||||
|
background: #fff;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabbed [type="radio"]:nth-of-type(1):checked ~ .tab-content:nth-of-type(1),
|
||||||
|
.tabbed [type="radio"]:nth-of-type(2):checked ~ .tab-content:nth-of-type(2),
|
||||||
|
.tabbed [type="radio"]:nth-of-type(3):checked ~ .tab-content:nth-of-type(3),
|
||||||
|
.tabbed [type="radio"]:nth-of-type(4):checked ~ .tab-content:nth-of-type(4) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icons */
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'icomoon';
|
||||||
|
src: url('fonts/icomoon.ttf?azc5ov') format('truetype'),
|
||||||
|
url('fonts/icomoon.woff?azc5ov') format('woff');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
/* Use !important to prevent issues with browser extensions that change fonts */
|
||||||
|
font-family: 'icomoon' !important;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: normal;
|
||||||
|
font-variant: normal;
|
||||||
|
text-transform: none;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
/* Better font rendering */
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapsible */
|
||||||
|
|
||||||
|
.collapsible {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.collapsible input[type="checkbox"].toggle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.collapsible label.toggle {
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 15px;
|
||||||
|
}
|
||||||
|
.collapsible label.toggle::before {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
left: 0px;
|
||||||
|
border-left: 8px solid black;
|
||||||
|
border-top: 8px solid transparent;
|
||||||
|
border-bottom: 8px solid transparent;
|
||||||
|
transition: 0.5s ease;
|
||||||
|
}
|
||||||
|
.collapsible input[type="checkbox"].toggle:checked ~ label.toggle::before {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
.collapsible .collapsible-inner {
|
||||||
|
max-height: 0;
|
||||||
|
overflow-y: hidden;
|
||||||
|
transition: 0.5s ease;
|
||||||
|
}
|
||||||
|
.collapsible input[type="checkbox"].toggle:checked ~ .collapsible-inner {
|
||||||
|
max-height: 100vh;
|
||||||
|
}
|
Loading…
Reference in a new issue