Skip to content

Commit e62c38e

Browse files
committed
Other
2 parents fd3035c + cbcec39 commit e62c38e

8 files changed

Lines changed: 376 additions & 16 deletions

File tree

.github/workflows/tests.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ jobs:
1010

1111
steps:
1212
- uses: actions/checkout@v2
13-
- name: 🔨 Set up Python 3.9.20
13+
- name: 🔨 Set up Python 3.12
1414
uses: actions/setup-python@v2
1515
with:
16-
python-version: 3.9.20
16+
python-version: 3.12
1717

1818
- name: Install dependencies
1919
run: |

SRCweb/settings.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,3 +259,6 @@
259259

260260
SESSION_COOKIE_AGE = 60 * 60 * 24 * 30 # 30 days
261261
MAX_UPLOAD_SIZE = "5242880"
262+
263+
# Discord Webhook URL for Highscores
264+
DISCORD_WEBHOOK_URL = os.getenv("HIGHSCORES_WEBHOOK_URL")

highscores/lib.py

Lines changed: 199 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
from django.http import HttpRequest
22
from django.core.mail import send_mail
3+
from django.utils import timezone
4+
import logging
35

46
from .models import Score, CleanCodeSubmission, ExemptedIP
57
from .forms import ScoreForm
6-
from SRCweb.settings import NEW_AES_KEY, DEBUG, ADMIN_EMAILS, EMAIL_HOST_USER
8+
from SRCweb.settings import NEW_AES_KEY, DEBUG, ADMIN_EMAILS, EMAIL_HOST_USER, DISCORD_WEBHOOK_URL
9+
import os
710

811
from typing import Callable, Union
912
from Crypto.Cipher import AES
1013
from urllib.request import urlopen, Request
14+
import json
1115

1216
USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/35.0.1916.47 Safari/537.36'
1317

@@ -21,6 +25,168 @@
2125
WRONG_AUTO_OR_TELEOP_MESSAGE = 'Incorrect choice for control mode! Ensure you are submitting to the correct leaderboard for autonomous or tele-operated play.'
2226

2327

28+
def send_world_record_webhook(new_score: Score, previous_record: Score = None) -> None:
29+
"""Send Discord webhook notification for new world record"""
30+
if not DISCORD_WEBHOOK_URL:
31+
logging.error("Discord webhook URL not configured")
32+
return
33+
34+
try:
35+
# Calculate duration and get previous record holder info
36+
if previous_record is None:
37+
# Get the current world record (which will become the previous one)
38+
previous_record = Score.objects.filter(
39+
leaderboard=new_score.leaderboard,
40+
approved=True
41+
).order_by('-score', 'time_set').first()
42+
43+
if previous_record and previous_record.score < new_score.score:
44+
# Calculate how long the previous record stood
45+
duration_diff = new_score.time_set - previous_record.time_set
46+
47+
# Calculate duration in a readable format
48+
total_seconds = int(duration_diff.total_seconds())
49+
days = total_seconds // 86400
50+
hours = (total_seconds % 86400) // 3600
51+
minutes = (total_seconds % 3600) // 60
52+
53+
if days > 0:
54+
duration_text = f"{days} day{'s' if days != 1 else ''}, {hours} hour{'s' if hours != 1 else ''}"
55+
elif hours > 0:
56+
duration_text = f"{hours} hour{'s' if hours != 1 else ''}, {minutes} minute{'s' if minutes != 1 else ''}"
57+
elif minutes > 0:
58+
duration_text = f"{minutes} minute{'s' if minutes != 1 else ''}"
59+
else:
60+
duration_text = "less than a minute"
61+
62+
previous_record_info = f"**{previous_record.player.username}**'s record ({previous_record.score:,} points) stood for **{duration_text}**"
63+
else:
64+
previous_record_info = "**First record set for this category!**"
65+
66+
# Create the embed message
67+
embed = {
68+
"title": "🏆 NEW WORLD RECORD ACHIEVED! 🏆",
69+
"description": f"**{new_score.player.username}** has set a new world record!",
70+
"color": 0xFFD700, # Gold color
71+
"fields": [
72+
{
73+
"name": "🎮 Game & Robot",
74+
"value": f"**{new_score.leaderboard.game}**\n`{new_score.leaderboard.name}`",
75+
"inline": True
76+
},
77+
{
78+
"name": "🎯 Score",
79+
"value": f"**{new_score.score:,} points**",
80+
"inline": True
81+
},
82+
{
83+
"name": "⏱️ Previous Record",
84+
"value": previous_record_info,
85+
"inline": False
86+
},
87+
{
88+
"name": "📎 Proof",
89+
"value": f"[View Submission]({new_score.source})",
90+
"inline": False
91+
}
92+
],
93+
"footer": {
94+
"text": f"Record set on {new_score.time_set.strftime('%B %d, %Y at %I:%M %p UTC')}",
95+
"icon_url": "https://cdn.discordapp.com/emojis/1306393882618114139.png"
96+
},
97+
"author": {
98+
"name": "Second Robotics Competition",
99+
"url": "https://secondrobotics.org",
100+
"icon_url": "https://secondrobotics.org/static/images/logo.png"
101+
},
102+
"timestamp": new_score.time_set.isoformat()
103+
}
104+
105+
payload = {
106+
"embeds": [embed],
107+
"username": "World Record Bot"
108+
}
109+
110+
# Send the webhook
111+
data = json.dumps(payload).encode('utf-8')
112+
req = Request(DISCORD_WEBHOOK_URL, data=data, headers={
113+
'Content-Type': 'application/json',
114+
'User-Agent': USER_AGENT
115+
})
116+
117+
response = urlopen(req)
118+
if response.status != 204:
119+
logging.error(
120+
f"Discord webhook failed with status: {response.status}")
121+
122+
except Exception as e:
123+
logging.error(f"Failed to send Discord webhook: {e}")
124+
125+
126+
def test_world_record_webhook(player_name: str, score: int, game: str, robot: str, previous_player: str = "TestPlayer", previous_score: int = 95000, duration: str = "2 days, 3 hours") -> bool:
127+
"""Test function for Discord webhook - returns True if successful"""
128+
if not DISCORD_WEBHOOK_URL:
129+
logging.error("Discord webhook URL not configured")
130+
return False
131+
132+
try:
133+
embed = {
134+
"title": "🧪 TEST WORLD RECORD NOTIFICATION 🧪",
135+
"description": f"**{player_name}** has set a new world record! *(This is a test)*",
136+
"color": 0x00FF00, # Green color for test
137+
"fields": [
138+
{
139+
"name": "🎮 Game & Robot",
140+
"value": f"**{game}**\n`{robot}`",
141+
"inline": True
142+
},
143+
{
144+
"name": "🎯 Score",
145+
"value": f"**{score:,} points**",
146+
"inline": True
147+
},
148+
{
149+
"name": "⏱️ Previous Record",
150+
"value": f"**{previous_player}**'s record ({previous_score:,} points) stood for **{duration}**",
151+
"inline": False
152+
},
153+
{
154+
"name": "📎 Proof",
155+
"value": "[Test Submission](https://secondrobotics.org)",
156+
"inline": False
157+
}
158+
],
159+
"footer": {
160+
"text": f"TEST - Record set on {timezone.now().strftime('%B %d, %Y at %I:%M %p UTC')}",
161+
"icon_url": "https://cdn.discordapp.com/emojis/1306393882618114139.png"
162+
},
163+
"author": {
164+
"name": "Second Robotics Competition (TEST MODE)",
165+
"url": "https://secondrobotics.org",
166+
"icon_url": "https://secondrobotics.org/static/images/logo.png"
167+
},
168+
"timestamp": timezone.now().isoformat()
169+
}
170+
171+
payload = {
172+
"embeds": [embed],
173+
"username": "World Record Bot (TEST)"
174+
}
175+
176+
data = json.dumps(payload).encode('utf-8')
177+
req = Request(DISCORD_WEBHOOK_URL, data=data, headers={
178+
'Content-Type': 'application/json',
179+
'User-Agent': USER_AGENT
180+
})
181+
182+
response = urlopen(req)
183+
return response.status == 204
184+
185+
except Exception as e:
186+
logging.error(f"Failed to send test Discord webhook: {e}")
187+
return False
188+
189+
24190
def submit_score(score_obj: Score, clean_code_check_func: Callable[[Score], Union[str, None]]) -> Union[str, None]:
25191
# Check to ensure image / video is proper
26192
res = submission_screenshot_check(score_obj)
@@ -118,6 +284,10 @@ def submit_reefscape(score_obj: Score) -> Union[str, None]:
118284
return submit_score(score_obj, reefscape_clean_code_check)
119285

120286

287+
def submit_push_back(score_obj: Score) -> Union[str, None]:
288+
return submit_score(score_obj, push_back_clean_code_check)
289+
290+
121291
def decode_time_data(in_string: str) -> str:
122292
out_bytes = ""
123293

@@ -158,17 +328,22 @@ def extract_form_data(form: ScoreForm, request: HttpRequest) -> Score:
158328

159329

160330
def approve_score(score_obj: Score, prev_submissions, time_data_issue=None):
161-
# Delete previous submissions with lower or equal scores
331+
# Check if this is a new world record before deleting previous submissions
332+
current_world_record = Score.objects.filter(
333+
leaderboard=score_obj.leaderboard,
334+
approved=True
335+
).order_by('-score', 'time_set').first()
336+
337+
is_world_record = (current_world_record is None or
338+
score_obj.score > current_world_record.score)
339+
340+
# Delete previous submissions with lower or equal scores in the category
162341
prev_submissions.filter(score__lte=score_obj.score).delete()
163342

164343
# Save the new submission
165344
score_obj.approved = True
166345
score_obj.save()
167346

168-
# Send time data warning email if there was an issue
169-
if time_data_issue:
170-
send_time_data_warning_email(score_obj, time_data_issue)
171-
172347
code_obj = CleanCodeSubmission()
173348
code_obj.clean_code = score_obj.clean_code
174349
code_obj.player = score_obj.player
@@ -337,6 +512,10 @@ def reefscape_clean_code_check(score_obj: Score) -> Union[str, None]:
337512
return clean_code_check(score_obj, check_reefscape_game_settings, check_subtraction_score)
338513

339514

515+
def push_back_clean_code_check(score_obj: Score) -> Union[str, None]:
516+
return clean_code_check(score_obj, check_push_back_game_settings, check_skills_challenge_score)
517+
518+
340519
def extract_clean_code_info(score_obj: Score) -> tuple[str, list[str], str, str, str, str, str, str]:
341520
""" Extracts the relevant information from the clean code.
342521
:param score_obj: Score object to extract from
@@ -606,6 +785,18 @@ def check_reefscape_game_settings(game_options: list, restart_option: str, game_
606785
return None # No error
607786

608787

788+
def check_push_back_game_settings(game_options: list, restart_option: str, game_index: str) -> Union[str, None]:
789+
""" Checks if the Push Back game settings are valid.
790+
:return: None if the settings are valid, or a response with an error message if they are not.
791+
"""
792+
if (game_index != '20'):
793+
return 'Wrong game! This form is for Push Back.'
794+
if (restart_option != '2'):
795+
return 'You must use restart option 2 (skills challenge) for Push Back high score submissions.'
796+
797+
return None # No error
798+
799+
609800
def check_robot_type(score_obj: Score, robot_model: str) -> Union[str, None]:
610801
""" Checks if the robot model is valid.
611802
:return: None if the robot model is valid, or a response with an error message if it is not.
@@ -795,6 +986,7 @@ def get_game_length(game_index: str):
795986
"ro": submit_rover_ruckus,
796987
"ss": submit_skystone,
797988
"rs": submit_reefscape,
989+
"pb": submit_push_back,
798990
}
799991

800992
game_to_submit_func = {
@@ -814,4 +1006,5 @@ def get_game_length(game_index: str):
8141006
"Rover Ruckus": submit_rover_ruckus,
8151007
"Skystone": submit_skystone,
8161008
"REEFSCAPE": submit_reefscape,
1009+
"Push Back": submit_push_back,
8171010
}

0 commit comments

Comments
 (0)