Compare commits

...

19 Commits

Author SHA1 Message Date
e54f70eaac fix: correct ordering & remove debug prints
Some checks failed
Container Build for tmnf-replay-server / docker (push) Failing after 12s
2025-07-13 12:12:03 +02:00
a40bf057ee fix: don't use inbuild function name 2025-07-13 11:45:16 +02:00
807860351d whitespaces: trailing spaces 2025-07-13 11:45:03 +02:00
d89ccf42ff fix: current season selector tm2020 2025-07-13 11:44:33 +02:00
7a3ce3ef32 chore: inc version
Some checks failed
Container Build for tmnf-replay-server / docker (push) Failing after 9s
2025-06-21 18:08:50 +02:00
ae92f51201 add: build on schedule 2024-09-27 14:49:45 +02:00
6f80c7bd32 change: use dispatch method any 2024-07-06 16:52:34 +02:00
7479499f78 fix: indicate successful handoff in log 2024-07-06 16:37:33 +02:00
4fa83afe16 feat: use new authentication style for atlantis dispatch 2024-07-06 16:00:26 +02:00
42f292fb7a fix: show tm2020 milliseconds 2024-01-28 08:07:01 +01:00
710d519344 fix: typo fitler 2024-01-13 07:28:20 +01:00
8690c3b7bc feat: user settings map filter & reload 2024-01-13 07:24:05 +01:00
8258f57a09 fix: quick fix layout 2024-01-13 07:03:53 +01:00
430dd53f77 feat: user settings via web interface 2024-01-13 06:59:18 +01:00
b4203095a9 fix: add requests to pip deps 2024-01-13 04:55:00 +01:00
c28049b94c fix: cleanup message & fix user query 2024-01-13 04:49:25 +01:00
fd61cb12a4 feat: add notification trigger condition 2024-01-13 04:29:04 +01:00
6488eebd1b feat: add user settings db 2024-01-13 04:28:52 +01:00
59af2e0ec6 feat: notification connection 2024-01-13 04:27:07 +01:00
9 changed files with 294 additions and 23 deletions

View File

@@ -4,6 +4,8 @@ on:
push: push:
branches: branches:
- "master" - "master"
schedule:
- cron: "0 2 * * 0"
jobs: jobs:
docker: docker:

View File

@@ -1 +1 @@
# v1.0 # v1.01

31
notifications.py Normal file
View File

@@ -0,0 +1,31 @@
import sys
import requests
def send_notification(app, target_user, mapname, old_replay, new_replay):
'''Build notification and handoff to dispatcher'''
url = app.config["DISPATCH_SERVER"]
if not url:
return
# send to event dispatcher #
message = "TM: Record broken on {}\n\n".format(mapname)
message += "Old time: {}\n".format(old_replay.get_human_readable_time())
message += "New time: {}\n".format(new_replay.get_human_readable_time())
message += "\nby {}".format(new_replay.clean_login())
payload = {
"users": [target_user],
"msg" : message,
"method" : "any"
}
url_and_token = "/smart-send?dispatch-access-token={}".format(app.config["DISPATCH_TOKEN"])
r = requests.post(app.config["DISPATCH_SERVER"] + url_and_token, json=payload)
if not r.ok:
msg = "Error handing off notification to dispatch ({} {})".format(r.status_code, r.content)
print(msg, file=sys.stderr)
else:
print("Handed off notification for {} to dispatch".format(target_user), file=sys.stderr)

View File

@@ -3,3 +3,4 @@ flask-sqlalchemy
pygbx pygbx
werkzeug werkzeug
xmltodict xmltodict
requests

153
server.py
View File

@@ -10,6 +10,7 @@ import datetime
from pygbx import Gbx, GbxType from pygbx import Gbx, GbxType
import tm2020parser import tm2020parser
import notifications
import sqlalchemy import sqlalchemy
from sqlalchemy import Column, Integer, String, Boolean, or_, and_, asc, desc from sqlalchemy import Column, Integer, String, Boolean, or_, and_, asc, desc
@@ -21,6 +22,27 @@ app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get("SQLITE_LOCATION") or "sqlite:///sqlite.db" app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get("SQLITE_LOCATION") or "sqlite:///sqlite.db"
db = SQLAlchemy(app) db = SQLAlchemy(app)
SEASON_ORDERING = ["Winter", "Spring", "Summer", "Fall"]
def filter_for_current_season(maps):
maps = list(maps)
year = str(datetime.datetime.now().year)
season = 0
for m in maps:
if year not in m.mapname:
continue
else:
for i, season_name in enumerate(SEASON_ORDERING):
if season_name in m.mapname and i > season:
season = i
filter_func = lambda m: SEASON_ORDERING[season] in m.mapname and year in m.mapname
maps_season = list(filter(filter_func, maps))
return maps_season
class Map(db.Model): class Map(db.Model):
__tablename__ = "maps" __tablename__ = "maps"
@@ -77,6 +99,79 @@ class Map(db.Model):
delta = datetime.datetime.now() - parsed delta = datetime.datetime.now() - parsed
return delta.days return delta.days
class UserSettings(db.Model):
__tablename__ = "user_settings"
user = Column(String, primary_key=True)
show_tm_2020 = Column(Boolean)
show_tmnf = Column(Boolean)
show_tm_2020_current = Column(Boolean)
notifications_all = Column(Boolean)
notifications_self = Column(Boolean)
@app.route("/update-user-settings", methods=["GET", "POST"])
def update_user_settings():
user = flask.request.headers.get("X-Forwarded-Preferred-Username")
user_helper = user or "anonymous"
settings = db.session.query(UserSettings).filter(UserSettings.user==user_helper).first()
# handle new settings #/
if not settings:
settings = UserSettings(user=user_helper, show_tm_2020=False, show_tmnf=False,
show_tm_2020_current=True, notifications_self=True,
notifications_all=False)
db.session.add(settings)
db.session.commit()
settings = db.session.query(UserSettings).filter(UserSettings.user==user_helper).first()
if flask.request.method == "GET":
key = flask.request.args.get("key")
# some sanity checks #
if not key:
return ("key missing in args", 422)
if key == "user" or key.startswith("_") or "sql" in key:
return ("key {} is not allowed".format(key), 422)
# return attribute #
return (str(getattr(settings, key)), 200)
elif flask.request.method == "POST":
json_dict = flask.request.json
key_value_list = json_dict.get("payload")
if not key_value_list:
return ("'payload' field empty", 422)
for el in key_value_list:
key = el.get("key")
value = el.get("value")
if key == "user" or key.startswith("_") or "sql" in key:
return ("key {} is not allowed".format(key), 422)
if key is None or value is None:
return ("element in payload list does not contain key and value", 422)
try:
getattr(settings, key)
setattr(settings, key, bool(value))
db.session.merge(settings)
db.session.commit()
return ("", 204)
except AttributeError:
return ("key {} not part of user settings".format(key), 422)
else:
raise AssertionError("Unsupported Method: {}".format(flask.request.method))
class ParsedReplay(db.Model): class ParsedReplay(db.Model):
__tablename__ = "replays" __tablename__ = "replays"
@@ -109,7 +204,10 @@ class ParsedReplay(db.Model):
if t.seconds < 60*60: if t.seconds < 60*60:
t_string = t_string[2:] t_string = t_string[2:]
if t.microseconds != 0: if t.microseconds != 0:
return t_string[:-4] if self.game == "tmnf":
return t_string[:-4]
else:
return t_string[:-3]
return t_string + ".00" return t_string + ".00"
def __repr__(self): def __repr__(self):
@@ -311,7 +409,7 @@ def ranks():
@app.route("/map-info") @app.route("/map-info")
def list(): def map_info():
player = flask.request.headers.get("X-Forwarded-Preferred-Username") player = flask.request.headers.get("X-Forwarded-Preferred-Username")
header_col = ["Player", "Time", "Date", "Replay"] header_col = ["Player", "Time", "Date", "Replay"]
map_uid = flask.request.args.get("map_uid") map_uid = flask.request.args.get("map_uid")
@@ -323,17 +421,24 @@ def mapnames():
'''Index Location''' '''Index Location'''
# TODO list by user # TODO list by user
player = flask.request.headers.get("X-Forwarded-Preferred-Username") player = flask.request.headers.get("X-Forwarded-Preferred-Username") or "anonymous"
maps_query = db.session.query(Map).order_by(asc(Map.mapname)) maps_query = db.session.query(Map).order_by(asc(Map.mapname))
# limit leaderboard to game # # limit leaderboard to game #
game = flask.request.args.get("game") settings = db.session.query(UserSettings).filter(UserSettings.user==player).first()
if game == "tm2020": if settings:
maps_query = maps_query.filter(Map.game=="tm2020") if not settings.show_tm_2020 and not settings.show_tmnf:
elif game=="tmnf": maps_query = maps_query.filter(Map.game=="tm2020")
maps_query = maps_query.filter(Map.game!="tm2020") latest_season = tm2020parser.get_latest_season_from_maps(maps_query.all())
else: # handle no replays #
pass if latest_season:
maps_query = maps_query.filter(Map.map_uid.like("{}%".format(latest_season)))
elif settings.show_tm_2020 and not settings.show_tmnf:
maps_query = maps_query.filter(Map.game=="tm2020")
elif not settings.show_tm_2020 and settings.show_tmnf:
maps_query = maps_query.filter(Map.game=="tmnf")
else:
pass
maps = maps_query.all() maps = maps_query.all()
@@ -341,6 +446,9 @@ def mapnames():
allowed = ("A", "B", "C", "D", "E", "Fall", "Winter", "Spring", "Summer") allowed = ("A", "B", "C", "D", "E", "Fall", "Winter", "Spring", "Summer")
maps_filtered = filter(lambda m: m.mapname.startswith(allowed), maps) maps_filtered = filter(lambda m: m.mapname.startswith(allowed), maps)
if settings.show_tm_2020_current:
maps_filtered = filter_for_current_season(maps_filtered)
return flask.render_template("index.html", maps=maps_filtered, player=player) return flask.render_template("index.html", maps=maps_filtered, player=player)
@app.route("/open-info") @app.route("/open-info")
@@ -390,6 +498,7 @@ def upload():
replay = replay_from_path(fullpath, uploader=uploader) replay = replay_from_path(fullpath, uploader=uploader)
db.session.add(replay) db.session.add(replay)
db.session.commit() db.session.commit()
check_replay_trigger(replay)
except ValueError as e: except ValueError as e:
results += [(fname, str(e))] results += [(fname, str(e))]
continue continue
@@ -405,9 +514,33 @@ def upload():
else: else:
return flask.render_template("upload.html") return flask.render_template("upload.html")
def check_replay_trigger(replay):
map_obj = db.session.query(Map).filter(Map.map_uid == replay.map_uid).first()
best = map_obj.get_best_replay()
second = map_obj.get_second_best_replay()
if not second:
return
if replay.filehash != best.filehash:
return
if second.uploader == replay.uploader:
return
settings = db.session.query(UserSettings).filter(UserSettings.user == second.uploader).first()
if settings and settings.notifications_self:
notifications.send_notification(app, settings.user, map_obj.map_uid, second, replay)
def create_app(): def create_app():
db.create_all() db.create_all()
app.config["DISPATCH_SERVER"] = os.environ.get("DISPATCH_SERVER")
if app.config["DISPATCH_SERVER"]:
app.config["DISPATCH_TOKEN"] = os.environ["DISPATCH_TOKEN"]
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description='TM Replay Server', parser = argparse.ArgumentParser(description='TM Replay Server',

View File

@@ -27,6 +27,15 @@ table{
border-color: transparent; border-color: transparent;
} }
.user-settings{
display: inline-flex;
color: white;
}
.form-check{
margin-right: 15px;
}
.m-auto{ .m-auto{
margin: auto; margin: auto;
} }

47
static/user_settings.js Normal file
View File

@@ -0,0 +1,47 @@
/* event listener for all sliders */
const sliders = Array.from(document.getElementsByClassName("form-check-input"))
/* safety switch -> never run set before get */
var sliders_set = false
/* defer */
sliders_load_all()
/* get initial values & set listeners */
function sliders_load_all(){
Promise.all(sliders.map(s => {
fetch("/update-user-settings?key=" + s.id, { credentials: "include" }).then(response => {
response.text().then(data => {
if(data == "True"){
s.checked = true
}
})
})
s.addEventListener("change", submit)
})).then(
sliders_set = true
)
}
/* submit settings */
function submit(e){
console.log("submit")
if(!sliders_set){
return
}
const s = e.target
const json_data = JSON.stringify({ payload : [ { key : s.id, value : s.checked } ] })
fetch("/update-user-settings", {
method: "POST",
credentials: "include",
headers: {'Content-Type': 'application/json'},
body: json_data
}
).then(response => {
if(s.id.startsWith("show")){
window.location.reload()
}
})
}

View File

@@ -3,15 +3,28 @@
</head> </head>
<body> <body>
{% include "upload-button.html" %} {% include "upload-button.html" %}
<button class="ml-4 mt-4 mb-4 btn btn-info" onclick="window.location.href='/?game=tmnf'">
TMNF <div class="user-settings">
</button> <div class="form-check form-switch">
<button class="mt-4 mb-4 btn btn-info" onclick="window.location.href='/?game=tm2020'"> <input class="form-check-input" type="checkbox" role="switch" id="show_tm_2020_current">
TM2020 <label class="form-check-label" for="show_tm_2020_current">Only current TM2020</label>
</button> </div>
<button class="mt-4 mb-4 btn btn-info" onclick="window.location.href='/'"> <div class="form-check form-switch">
All <input class="form-check-input" type="checkbox" role="switch" id="show_tm_2020">
</button> <label class="form-check-label" for="show_tm_2020">Show All TM2020</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="show_tmnf">
<label class="form-check-label" for="show_tmnf">Show All TMNF</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="notifications_self">
<label class="form-check-label" for="notifications_self">Send notifcations</label>
</div>
</div>
<script src="/static/user_settings.js" defer></script>
<table class="m-auto"> <table class="m-auto">
<thead> <thead>
<tr> <tr>

View File

@@ -5,6 +5,41 @@ import hashlib
import pygbx import pygbx
import xmltodict import xmltodict
def get_latest_season_from_maps(maps):
'''Determine the latest season in DB'''
order = ["Winter", "Spring", "Summer", "Fall"]
max_year = 0
max_season = "Winter"
if not maps:
return
for m in maps:
splitted = m.map_uid.split(" ")
# not a campain map #
if len(splitted) < 3:
return
season = splitted[0]
year = splitted[1]
# also not a campaing map #
if not season in order:
return
try:
year = int(year)
except ValueError:
return
if year > max_year or year == max_year and order.index(season) >= order.index(max_season):
max_year = year
max_season = season
return "{} {}".format(max_season, max_year)
class GhostWrapper(): class GhostWrapper():
def __init__(self, fullpath, uploader): def __init__(self, fullpath, uploader):