feat: major rewrite of the webserver

gets rid of __init__ and runserver in favor of new modular design
also introduces db model, first api endpoints, as well as their wrappers
This commit is contained in:
2025-05-11 01:30:32 +02:00
parent 96e2c53484
commit c1facf00fb
12 changed files with 532 additions and 72 deletions

9
.gitignore vendored
View File

@@ -364,6 +364,13 @@ FodyWeavers.xsd
# Wirtualne środowisko pythona # Wirtualne środowisko pythona
FlaskWebProject/env FlaskWebProject/env
.venv
# Wersja pythona # Wersja pythona
FlaskWebProject/FlaskWebProject.pyproj FlaskWebProject/FlaskWebProject.pyproj
# Baza sqlite
FlaskWebProject/FlaskWebProject/instance
# Poufne dane
config.toml

View File

@@ -1,41 +0,0 @@
from flask import Flask, render_template
from .lewy_globals import *
app = Flask(__name__)
@app.route('/')
def index():
stats = {
'goals': 38,
'assists': 12,
'matches': 45,
'matches_list': [
{'date': '2024-10-12', 'opponent': 'Real Madrid', 'goals': 2, 'assists': 1, 'minutes': 90},
{'date': '2024-10-19', 'opponent': 'Valencia', 'goals': 1, 'assists': 0, 'minutes': 85},
# Możesz dodać więcej meczów...
]
}
return render_template('index.html', goals=stats['goals'], assists=stats['assists'],
matches=stats['matches'], matches_list=stats['matches_list'],
commit_in_html=lewy_globals.getCommitInFormattedHTML())
@app.route('/mecze')
def mecze():
# Możesz dostarczyć szczegóły dotyczące meczów
matches = [
{'date': '2024-10-12', 'opponent': 'Real Madrid', 'goals': 2, 'assists': 1, 'minutes': 90},
{'date': '2024-10-19', 'opponent': 'Valencia', 'goals': 1, 'assists': 0, 'minutes': 85},
]
return render_template('matches.html', matches=matches)
@app.route('/statystyki')
def statystyki():
stats = {
'goals': 38,
'assists': 12,
'matches': 45,
}
return render_template('stats.html', stats=stats)
if __name__ == '__main__':
app.run(debug=True)

View File

@@ -0,0 +1,12 @@
[general]
db_path_url = "postgresql+psycopg2://user:password@hostname/database_name"
db_prefix = "" # What (if any) prefix will be appended to table names.
is_proxied = false # Will ignore discrepancies between retrieved IP and public-facing URL.
public_facing_url = "http://127.0.0.1:5000/" # Used for URL rewriting. Note the trailing forward slash /.
[api]
# Leave empty to automatically generate API key every launch (insecure).
api_key = ""
[scraper]
user-agent = "" # Leave empty for default (Firefox ESR).

View File

@@ -0,0 +1,15 @@
import requests
import json
class scraper:
headers = {
'x-fsign': 'SW9D1eZo'
}
def __init__:
pass
def pobierzDaneNajlepszegoSportowcaNaSwiecie() -> dict:
response = requests.get('https://3.flashscore.ninja/3/x/feed/plm_MVC8zHZD_0', headers=headers)
return json.loads(response.text)

View File

@@ -0,0 +1,168 @@
from argparse import ArgumentParser
from flask import Flask, Response, render_template
from flask_apscheduler import APScheduler
from lewy_globals import colors as c
import lewy_api
import lewy_db
import lewy_globals
import lewy_routes
import os
import time
app = Flask(__name__)
app_host = "None"
app_port = "None"
def setup():
# sanity check: make sure config is set
# required to make `flask --app lewy run --debug` work
global config, app_host, app_port
try:
if not config['general']:
lewy_globals.setConfig(lewy_globals.configfile)
config = lewy_globals.config
except:
lewy_globals.setConfig(lewy_globals.configfile)
config = lewy_globals.config
# setting all the variables
lewy_globals.starttime = int(time.time())
lewy_globals.realUptime = 0
lewy_globals.apiRequests = 0
lewy_globals.apiFailedRequests = 0
lewy_globals.isProxied = config['general']['is_proxied']
lewy_globals.outsideApiHits = 0
are_we_sure_of_host_and_port = True
if app_host == "None":
app_host = "127.0.0.1"
are_we_sure_of_host_and_port = False
if app_port == "None":
app_port = "5000"
are_we_sure_of_host_and_port = False
public_facing_url = config['general']['public_facing_url']
if len(public_facing_url) >= 4 and public_facing_url[0:5].lower() == "https":
https_str = f"{c.OKNLUE}INFO: {c.ENDC} You're trying to run this web server on HTTPS, but currently it's not possible to do that!\n"
https_str += f" Please consider running this service behind a reverse proxy if you need HTTPS.\n"
print(https_str)
rewrite_sanity_check = public_facing_url.replace(f"{app_host}:{app_port}", "")
if not config['general']['is_proxied'] and public_facing_url == rewrite_sanity_check:
sanity_string = f"{c.OKBLUE}INFO:{c.ENDC} Public facing URL does not match the IP and port the server is running on.\n"
sanity_string += f" Expected: {c.OKCYAN}{config['general']['public_facing_url']}{c.ENDC}, but"
if not are_we_sure_of_host_and_port: sanity_string += " (assuming it's)"
sanity_string += f" running on: {c.OKCYAN}{app_host}:{app_port}{c.ENDC}.\n"
sanity_string += f" This is just a sanity check and may not neccessarily mean bad configuration.\n"
sanity_string += f" If you're running a reverse proxy, set {c.OKCYAN}is_proxied{c.ENDC} to true to silence this message.\n"
print(sanity_string)
app.config['SQLALCHEMY_DATABASE_URI'] = f"{config['general']['db_path_url']}"
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
# Widoki widoczne dla "normalnego" użytkownika:
app.add_url_rule('/', view_func=lewy_routes.index)
app.add_url_rule('/index.html', view_func=lewy_routes.index)
app.add_url_rule('/mecze', view_func=lewy_routes.mecze)
app.add_url_rule('/statystyki', view_func=lewy_routes.statystyki)
app.add_url_rule('/toggle_dark_mode', view_func=lewy_routes.toggle_dark_mode)
# API:
app.add_url_rule('/api/', view_func=lewy_api.api_greeting)
app.add_url_rule('/api/<path:received_request>', view_func=lewy_api.api_global_catchall)
db = lewy_db.initDB(app, config)
with app.app_context():
db.create_all()
# job scheduler for repetetive tasks
scheduler = APScheduler()
scheduler.add_job(func=every5seconds, trigger='interval', id='5sec', seconds=5)
scheduler.add_job(func=every2hours, trigger='interval', id='2hr', hours=2)
scheduler.start()
# gets called every 5 seconds
def every5seconds():
# update the "real" uptime counter
lewy_globals.realUptime += 5
def every2hours():
# zaktualizuj bazę danych scrapując FS
# ...
return
@app.route('/<string:val>', methods=['GET'])
def blank(val):
return Response(f"{val}: not implemented in lewangoalski {lewy_globals.getCommitWithFailsafe()}", mimetype="text/plain")
def main(args):
print(f"{c.BOLD + c.HEADER}Witaj w webaplikacji 'lewangoalski' ({lewy_globals.getCommitWithFailsafe()})!{c.ENDC}")
print(f"Aby uruchomić w trybie deweloperskim (aby włączyć automatyczne przeładowanie zmian), użyj: {c.OKCYAN}flask --app lewy run --debug{c.ENDC}.")
print( "Aby uruchomić lokalnie, użyj adresu IP 127.0.0.1. Aby uruchomić na każdym z interfejsów, użyj 0.0.0.0.\n")
global config, app_host, app_port
try:
# if specified, use custom config file
lewy_globals.configfile = args.config
lewy_globals.setConfig(lewy_globals.configfile)
except:
# if not, try using the default "config.toml"
if os.path.exists("config.toml"):
lewy_globals.configfile = "config.toml"
else:
# unless it's not there, if that's the case then use the dummy file
lewy_globals.configfile = ""
# but try to set the API secret if provided by the user
if args.secret:
lewy_globals.randomly_generated_passcode = args.secret
lewy_globals.setConfig(lewy_globals.configfile)
config = lewy_globals.config
try:
host = args.ip
port = args.port
if not host or not port:
raise Exception
except:
config_ip, config_port = lewy_globals.extractIpAndPortFromPublicUrl()
print(f"Wpisz nazwę_hosta:port, na których należy uruchomić serwer Flask [domyślnie: {config_ip}:{config_port}]:")
try:
host_port = input('> ').split(':')
if host_port == ['']:
host_port = [config_ip, config_port] # defaults
except KeyboardInterrupt:
print(" ...wychodzę z programu."), quit() # handle Ctrl+C
host = host_port[0]
port = host_port[1]
print()
app_host = host
app_port = port
setup()
app.run(host=host, port=int(port))
if __name__ == "__main__":
#app.run(host="127.0.0.1", port=5000)
#app.run(host="0.0.0.0", port=5000)
parser = ArgumentParser(description='Aplikacja webowa do śledzenia statystyk Roberta Lewandowskiego.')
parser.add_argument("-i", "--ip", dest="ip", help="ip address/interface to bind to")
parser.add_argument("-p", "--port", dest="port", help="port on which the flask web backend should be ran")
parser.add_argument("-c", "--config", dest="config", help="path to TOML config file")
parser.add_argument("-s", "--secret", dest="secret", help="API key for resource access") # NOT tested
args = parser.parse_args()
main(args)
else:
app_host = os.getenv("FLASK_RUN_HOST", "None")
app_port = os.getenv("FLASK_RUN_PORT", "None")
setup()

View File

@@ -0,0 +1,43 @@
from flask import Response, request
from lewy_globals import colors as c
from markupsafe import escape
import json
import lewy_globals
import lewy_api_v1
import requests
import time
import traceback
def api_greeting():
string = {'status': 200, 'msg': f"ok (lewangoalski {lewy_globals.version})", 'latest_api': f"v{lewy_globals.apiVersion}"}
string = json.dumps(string)
return Response(string, mimetype='application/json')
def api_global_catchall(received_request):
lewy_globals.apiRequests += 1
if request.environ['REMOTE_ADDR'] != "127.0.0.1" or (lewy_globals.isProxied and request.environ['X-Forwarded-For'] != "127.0.0.1"):
lewy_globals.outsideApiHits += 1
request_list = received_request.split('/')
api_version = request_list[0]
if request_list[0] == 'v1':
# use v1 api
del request_list[0]
# if list is empty, aka /api/v1/, or /api/v1
if request_list == [''] or request_list == []:
resp = api_greeting()
try:
status, received, data = lewy_api_v1.lookup(request_list)
except Exception as e:
lewy_globals.apiFailedRequests += 1
stripped_filename = __file__[max(__file__.rfind("/"), __file__.rfind("\\")) + 1:]
print(f"\n{c.FAIL}Error! /api/{received_request} -> {stripped_filename}:L{e.__traceback__.tb_lineno} -> {type(e).__name__}{c.ENDC}:")
print(traceback.format_exc())
status, received, data = 500, f"internal server error: call ended in failure: {e} ({stripped_filename}:L{e.__traceback__.tb_lineno})", []
resp = Response(json.dumps({'status': status, 'msg': received, 'data': data}), mimetype='application/json', status=status)
else:
lewy_globals.apiFailedRequests += 1
status, received, data = 405, f'error: unsupported api version: "{request_list[0]}". try: "v{lewy_globals.apiVersion}".', []
resp = Response(json.dumps({'status': status, 'msg': received, 'data': data}), mimetype='application/json', status=status)
return resp

View File

@@ -0,0 +1,44 @@
# API is expected to return:
# - HTTP status code,
# - human-readable status message,
# - json with appropriate data
import flask, json, time
import lewy_globals
def incrementBadRequests():
lewy_globals.apiFailedRequests += 1
def notImplemented(data):
# TODO: change list to string -> data, not data[0]
return 501, f"not recognised/implemented: {data[0]}", []
def stub_hello():
return 200, 'hello from v1! stats are at /api/v1/stats', []
def stats():
data_to_send = {
"start_time": lewy_globals.starttime,
"uptime": lewy_globals.getUptime(),
"real_uptime": lewy_globals.realUptime,
"total_api_requests": lewy_globals.apiRequests,
"failed_api_requests": lewy_globals.apiFailedRequests,
"outside_api_requests": lewy_globals.outsideApiHits,
"local_api_requests": lewy_globals.apiRequests - lewy_globals.outsideApiHits
}
return 200, "OK", data_to_send
def lookup(data):
if data == []:
return stub_hello()
match data[0].lower():
case 'stats' | '':
return stats()
case 'user':
return stub_hello()
case 'info':
return stub_hello()
case _:
incrementBadRequests()
return notImplemented(data)

View File

@@ -0,0 +1,86 @@
from flask_sqlalchemy import SQLAlchemy
import toml
global db
def initDB(app, config):
tablenameprefix = config['general']['db_prefix'] + "_lewangoalski_"
db = SQLAlchemy(app)
class sportowcy(db.Model):
__tablename__ = tablenameprefix + "sportowcy"
id_zawodnika = db.Column(db.Integer, primary_key=True)
data_urodzenia = db.Column(db.String(10))
czy_aktywny = db.Column(db.Boolean)
klub = db.Column(db.String(63))
narodowosc = db.Column(db.String(3))
ilosc_trofeow = db.Column(db.Integer)
ostatnie_trofeum = db.Column(db.Integer)
pierwszy_mecz = db.Column(db.Integer)
# ostatni_mecz = db.Column(db.Integer) # statystyki_sportowcow już to przechowuje
wycena = db.Column(db.BigInteger)
ostatni_gol_dla = db.Column(db.String(3))
statystyka = db.Column(db.Integer)
class trofea(db.Model):
__tablename__ = tablenameprefix + "trofea"
id_trofeum = db.Column(db.Integer, primary_key=True)
id_zawodnika = db.Column(db.Integer) # != None
nazwa = db.Column(db.String(127))
sezon = db.Column(db.String(9))
rok = db.Column(db.String(4))
class sportowcy_w_meczach(db.Model):
__tablename__ = tablenameprefix + "sportowcy_w_meczach"
id_rekordu = db.Column(db.Integer, primary_key=True)
id_zawodnika = db.Column(db.Integer) # != None
zewnetrzne_id_meczu = db.Column(db.Integer) # != None
czas_gry = db.Column(db.Integer)
goli = db.Column(db.Integer)
asyst = db.Column(db.Integer)
interwencje_bramkarza = db.Column(db.Integer)
suma_interwencji_na_bramke = db.Column(db.Integer)
zolte_kartki = db.Column(db.Integer)
czerwone_kartki = db.Column(db.Integer)
wygrana = db.Column(db.Integer)
wynik = db.Column(db.Float)
class statystyki_sportowcow(db.Model):
__tablename__ = tablenameprefix + "statystyki_sportowcow"
id_statystyki = db.Column(db.Integer, primary_key=True)
ostatni_mecz = db.Column(db.Integer)
ilosc_wystapien = db.Column(db.Integer)
minut_gry = db.Column(db.BigInteger)
gier_sum = db.Column(db.Integer)
goli_sum = db.Column(db.Integer)
asyst_sum = db.Column(db.Integer)
interwencji_sum = db.Column(db.Integer)
nieobronionych_interwencji_sum = db.Column(db.Integer)
zoltych_kartek_sum = db.Column(db.Integer)
czerwonych_kartek_sum = db.Column(db.Integer)
wygranych_sum = db.Column(db.Integer)
wynik_sum = db.Column(db.Integer)
meczow_do_wynikow_sum = db.Column(db.Integer)
class kluby(db.Model):
__tablename__ = tablenameprefix + "kluby"
id_klubu = db.Column(db.String(63), primary_key=True)
pelna_nazwa = db.Column(db.String(63))
skrocona_nazwa = db.Column(db.String(3))
class mecze(db.Model):
__tablename__ = tablenameprefix + "mecze"
id_meczu = db.Column(db.Integer, primary_key=True)
zewnetrzne_id_meczu = db.Column(db.String(15)) # != None
data = db.Column(db.DateTime)
gospodarze = db.Column(db.String(3))
goscie = db.Column(db.String(3))
gosp_wynik = db.Column(db.Integer)
gosc_wynik = db.Column(db.Integer)
sezon = db.Column(db.String(9))
nazwa_turnieju = db.Column(db.String(127))
skrocona_nazwa_turnieju = db.Column(db.String(15))
flaga = db.Column(db.Integer)
return db

View File

@@ -1,9 +1,37 @@
from git import Repo # hash ostatniego commitu from git import Repo # hash ostatniego commitu
import os
import time
import toml
global config, randomly_generated_passcode
class colors:
HEADER = '\033[95m'
OKBLUE = '\033[94m'
OKCYAN = '\033[96m'
OKGREEN = '\033[92m'
WARNING = '\033[93m'
FAIL = '\033[91m'
ENDC = '\033[0m'
BOLD = '\033[1m'
UNDERLINE = '\033[4m'
ENDL = '\n'
def safeTraverse(obj: dict, path: list, default=None):
result = obj
try:
for x in path:
result = result[x]
except KeyError:
result = default
# print(f"error reading: {' -> '.join(path)} - returning: {default}")
finally:
return result
def getCommit(): def getCommit():
try: try:
return Repo(search_parent_directories=True).head.object.hexsha return Repo(search_parent_directories=True).head.object.hexsha
except: except Exception as e:
return None return None
def getCommitInFormattedHTML(): def getCommitInFormattedHTML():
@@ -13,4 +41,85 @@ def getCommitInFormattedHTML():
if commit is not None: if commit is not None:
repo = f"<p>Commit: <a href='https://gitea.7o7.cx/roberteam/lewangoalski/commit/{commit}'>{commit[:11]}</a></p>" repo = f"<p>Commit: <a href='https://gitea.7o7.cx/roberteam/lewangoalski/commit/{commit}'>{commit[:11]}</a></p>"
return repo return repo
def getCommitWithFailsafe():
commit = getCommit()
if commit is None:
commit = "(unknown commit)"
else:
commit = "#" + commit
return commit[:12]
def ensureRandomlyGeneratedPassword():
global randomly_generated_passcode
# iff the passcode is 0, as we manually set it elsewhere!
if randomly_generated_passcode == 0:
# generate a pseudorandom one and use it in the temporary config
randomly_generated_passcode = str(int(time.time() * 1337 % 899_999 + 100_000))
print(f"{colors.WARNING}WARNING{colors.ENDC}: Default config populated with one-time, insecure pseudorandom API key: {colors.OKCYAN}{randomly_generated_passcode}{colors.ENDC}.\n"
f" The API key is not the Flask debugger PIN. You need to provide a config file for persistence!{colors.ENDL}")
def getConfig(configfile):
global randomly_generated_passcode
if not os.path.exists(configfile):
dummy_config = {'general': {'db_path_url': 'sqlite:///lewangoalski.sqlite', 'is_proxied': False, 'public_facing_url': 'http://127.0.0.1:5000/', db_prefix: 'lewy_sqlite'}, 'api': {'api_key': 'CHANGEME'}, 'scraper': {'user-agent': ''}}
# if a passcode has not been provided by the user (config file doesn't exist, and user didn't specify it using an argument)
print(f"{colors.WARNING}WARNING{colors.ENDC}: Using default, baked in config data. {colors.ENDL}"
f" Consider copying and editing the provided example file ({colors.OKCYAN}config.example.toml{colors.ENDC}).")
ensureRandomlyGeneratedPassword()
dummy_config['api']['api_key'] = str(randomly_generated_passcode)
return dummy_config
else:
return toml.load(configfile)
def setConfig(configfile):
global config
config = getConfig(configfile)
if safeTraverse(config['api']['api_key'], []) is None or not config['api']['api_key']:
ensureRandomlyGeneratedPassword()
config['api']['api_key'] = str(randomly_generated_passcode)
def getHeaders():
# NOTE: use ESR user-agent
# user_agent = 'Mozilla/5.0 (Windows NT 10.0; rv:130.0) Gecko/20100101 Firefox/130.0'
user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0'
if safeTraverse(config[scraper]['user-agent'], []) is not None:
user_agent = config[scraper]['user-agent']
return user_agent
def getUptime():
return int(time.time()) - starttime
def extractIpAndPortFromPublicUrl() -> tuple:
ip, port = "127.0.0.1", "5000"
try:
url = config['general']['public_facing_url'].replace(":/", "")
url_parts = url.split('/')
ip_and_port = url_parts[1]
ip, port = ip_and_port.split(':')
except:
pass
return ip, port
# Please leave at the bottom of this file.
config = {}
configfile = "config.toml"
version = getCommitWithFailsafe()
apiVersion = "1"
randomly_generated_passcode = 0

View File

@@ -0,0 +1,43 @@
from flask import render_template, request, make_response
import lewy_globals
def index():
dark_mode = request.cookies.get('darkMode', 'disabled')
stats = {
'goals': 38,
'assists': 12,
'matches': 45,
'matches_list': [
{'date': '2024-10-12', 'opponent': 'Real Madrid', 'goals': 2, 'assists': 1, 'minutes': 90},
{'date': '2024-10-19', 'opponent': 'Valencia', 'goals': 1, 'assists': 0, 'minutes': 85},
# Możesz dodać więcej meczów...
]
}
return render_template('index.html', goals=stats['goals'], assists=stats['assists'],
matches=stats['matches'], matches_list=stats['matches_list'],
commit_in_html=lewy_globals.getCommitInFormattedHTML(),
dark_mode=dark_mode)
def mecze():
# Możesz dostarczyć szczegóły dotyczące meczów
matches = [
{'date': '2024-10-12', 'opponent': 'Real Madrid', 'goals': 2, 'assists': 1, 'minutes': 90},
{'date': '2024-10-19', 'opponent': 'Valencia', 'goals': 1, 'assists': 0, 'minutes': 85},
]
return render_template('matches.html', matches=matches)
def statystyki():
stats = {
'goals': 38,
'assists': 12,
'matches': 45,
}
return render_template('stats.html', stats=stats)
def toggle_dark_mode():
# Przełącz tryb i zapisz w ciasteczku
dark_mode = request.cookies.get('darkMode', 'disabled')
new_mode = 'enabled' if dark_mode == 'disabled' else 'disabled'
response = make_response("OK")
response.set_cookie('darkMode', new_mode, max_age=31536000) # Ustawienie ciasteczka na 1 rok
return response

View File

@@ -1,17 +0,0 @@
from flask import render_template, request, make_response
from FlaskWebProject import app
@app.route('/')
def index():
# Odczyt ciasteczka "darkMode" domyślnie "disabled"
dark_mode = request.cookies.get('darkMode', 'disabled')
return render_template('index.html', dark_mode=dark_mode)
@app.route('/toggle_dark_mode')
def toggle_dark_mode():
# Przełącz tryb i zapisz w ciasteczku
dark_mode = request.cookies.get('darkMode', 'disabled')
new_mode = 'enabled' if dark_mode == 'disabled' else 'disabled'
response = make_response("OK")
response.set_cookie('darkMode', new_mode, max_age=31536000) # Ustawienie ciasteczka na 1 rok
return response

View File

@@ -1,14 +1,5 @@
""" """
This script runs the FlaskWebProject application using a development server. Please see README.md for more tips on how to get your server running.
""" """
from os import environ print("runserver.py is obsolete. Please run your server with lewy.py.")
from FlaskWebProject import app
if __name__ == '__main__':
HOST = environ.get('SERVER_HOST', 'localhost')
try:
PORT = int(environ.get('SERVER_PORT', '5555'))
except ValueError:
PORT = 5555
app.run(HOST, PORT)