Browse Source

Strategická: Init web serveru

master
Jiří Kalvoda 2 years ago
commit
5f3da84f71
  1. 16
      server/config.py.example
  2. 2
      server/flask
  3. 347
      server/hra/web/__init__.py
  4. 146
      server/hra/web/html.py
  5. 43
      server/setup.py
  6. 7
      server/static/bootstrap.min.css
  7. BIN
      server/static/favicon.ico
  8. BIN
      server/static/hippo.png
  9. 297
      server/static/ksp-mhd.css

16
server/config.py.example

@ -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

@ -0,0 +1,2 @@
#!/bin/sh
exec flask --app hra.web --debug "$@"

347
server/hra/web/__init__.py

@ -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

@ -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("&", "&ampersand;").replace('"', "&quot;")
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

@ -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

File diff suppressed because one or more lines are too long

BIN
server/static/favicon.ico

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
server/static/hippo.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

297
server/static/ksp-mhd.css

@ -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…
Cancel
Save