Files
Python/ham-test-gui.py
T
2025-07-23 11:52:32 -07:00

280 lines
9.6 KiB
Python

#!/usr/bin/python3
# Description: A Python GUI interface for the HAM Operator Test.
# Usage: python3 ham-test-gui.py
# Author: Justin Oros
# Source: https://github.com/JustinOros
import json
import os
import random
import requests
import logging
from datetime import datetime
import tkinter as tk
from tkinter import messagebox
# Setup logging
logging.basicConfig(level=logging.WARNING, format='[%(levelname)s] %(message)s')
# URLs and Local file paths
URLS = {
"Technician": "https://raw.githubusercontent.com/russolsen/ham_radio_question_pool/master/technician-2022-2026/technician.json",
"General": "https://raw.githubusercontent.com/russolsen/ham_radio_question_pool/master/general-2023-2027/general.json",
"Extra": "https://raw.githubusercontent.com/russolsen/ham_radio_question_pool/master/extra-2024-2028/extra.json"
}
LOCAL_FILES = {
"Technician": "technician.json",
"General": "general.json",
"Extra": "extra.json"
}
def get_remote_last_modified(url):
try:
r = requests.head(url, timeout=10)
if r.status_code == 200 and 'Last-Modified' in r.headers:
lm = r.headers['Last-Modified']
dt = datetime.strptime(lm, '%a, %d %b %Y %H:%M:%S %Z')
return dt.timestamp()
except Exception as e:
logging.warning(f"Could not get Last-Modified for {url}: {e}")
return None
def download_file(url, local_path):
print(f"Downloading latest questions from {url} ...")
r = requests.get(url, timeout=20)
r.raise_for_status()
with open(local_path, "w", encoding='utf-8') as f:
f.write(r.text)
print(f"Saved to {local_path}")
def is_local_file_up_to_date(local_path, remote_timestamp):
if not os.path.exists(local_path):
return False
local_mtime = os.path.getmtime(local_path)
return local_mtime >= remote_timestamp
def sync_files():
for test_name, url in URLS.items():
local_path = LOCAL_FILES[test_name]
remote_ts = get_remote_last_modified(url)
if remote_ts is None:
if not os.path.exists(local_path):
print(f"No local file for {test_name} and unable to get remote timestamp. Downloading anyway.")
download_file(url, local_path)
else:
print(f"Skipping update for {test_name} (no remote timestamp).")
else:
if not is_local_file_up_to_date(local_path, remote_ts):
print(f"{local_path} is outdated, updating it.")
download_file(url, local_path)
else:
print(f"{local_path} is up to date.")
def load_questions(test_name):
local_path = LOCAL_FILES[test_name]
if not os.path.exists(local_path):
print(f"Error: Local file {local_path} not found.")
return []
with open(local_path, "r", encoding='utf-8') as f:
return json.load(f)
class HamTestGUI:
def __init__(self, master):
self.master = master
self.master.title("HAM Radio Test")
self.center_window(800, 300)
self.master.resizable(False, False)
self.questions = []
self.score = 0
self.total = 0
self.current_index = -1
self.correct_answer = None
self.correct_answer_text = ""
self.next_pending = False
self.history = []
self.in_review_mode = False
self.content_frame = tk.Frame(self.master)
self.content_frame.pack(fill="both", expand=True)
self.show_test_selection()
def center_window(self, width=800, height=600):
self.master.update_idletasks()
screen_width = self.master.winfo_screenwidth()
screen_height = self.master.winfo_screenheight()
x = (screen_width // 2) - (width // 2)
y = (screen_height // 2) - (height // 2)
self.master.geometry(f"{width}x{height}+{x}+{y}")
def clear_content(self):
for widget in self.content_frame.winfo_children():
widget.destroy()
def show_test_selection(self):
self.clear_content()
label = tk.Label(self.content_frame, text="Select the test you want to take:", font=("Arial", 14))
label.pack(padx=10, pady=10)
for test_name in URLS.keys():
btn = tk.Button(self.content_frame, text=test_name, width=20, font=("Arial", 12),
command=lambda tn=test_name: self.start_test(tn))
btn.pack(pady=5)
def start_test(self, test_name):
print(f"Selected test: {test_name}")
print("Syncing question files...")
sync_files()
questions = load_questions(test_name)
if not questions:
messagebox.showerror("Error", f"No questions found for {test_name}.")
return
self.questions = questions
random.shuffle(self.questions)
self.score = 0
self.total = 0
self.current_index = -1
self.history = []
self.in_review_mode = False
self.show_question()
def show_question(self):
self.clear_content()
if not self.in_review_mode:
self.current_index += 1
if self.current_index >= len(self.questions):
pct = (self.score / self.total) * 100 if self.total > 0 else 0
messagebox.showinfo("Quiz Finished", f"Score: {self.score}/{self.total} ({pct:.2f}%)")
self.master.destroy()
return
q = self.questions[self.current_index]
question_text = q.get("question")
answers = q.get("answers", [])
correct_index = q.get("correct")
self.feedback_label = tk.Label(self.content_frame, text="", font=("Arial", 14), wraplength=780, justify="left")
self.feedback_label.pack(pady=5)
self.question_label = tk.Label(self.content_frame, text=question_text, wraplength=780, justify="left", font=("Arial", 12))
self.question_label.pack(padx=10, pady=10)
self.selected_answer = tk.StringVar()
self.answers_frame = tk.Frame(self.content_frame)
self.answers_frame.pack(pady=5)
options = list(enumerate(answers))
random.shuffle(options)
self.correct_answer = None
self.correct_answer_text = ""
for idx, (orig_index, ans_text) in enumerate(options):
rb = tk.Radiobutton(
self.answers_frame,
text=ans_text,
variable=self.selected_answer,
value=ans_text,
wraplength=760,
justify="left",
anchor="w",
padx=10,
font=("Arial", 11)
)
rb.pack(anchor="w", pady=2)
if orig_index == correct_index:
self.correct_answer = ans_text
self.correct_answer_text = ans_text
nav_frame = tk.Frame(self.content_frame)
nav_frame.pack(pady=10)
self.back_button = tk.Button(nav_frame, text="←", command=self.go_back, font=("Arial", 14))
self.back_button.pack(side=tk.LEFT, padx=5)
if self.current_index == 0:
self.back_button.config(state="disabled")
self.quit_button = tk.Button(nav_frame, text="Quit", command=self.quit_quiz)
self.quit_button.pack(side=tk.LEFT, padx=5)
self.next_button = tk.Button(nav_frame, text="→", command=self.check_answer, font=("Arial", 14))
self.next_button.pack(side=tk.LEFT, padx=5)
self.next_pending = False
if self.in_review_mode:
self.next_button.config(text="→", command=self.exit_review_mode, state="normal")
selected = None
was_correct = False
for h in self.history:
if h["index"] == self.current_index:
selected = h["selected"]
was_correct = h["correct"]
break
self.selected_answer.set(selected)
for child in self.answers_frame.winfo_children():
child.config(state="disabled")
if was_correct:
self.feedback_label.config(text="Correct (Previously answered)", fg="green")
else:
self.feedback_label.config(
text=f"Incorrect. The correct answer was: {self.correct_answer_text}", fg="red"
)
def check_answer(self):
if self.next_pending:
return
selected = self.selected_answer.get()
if not selected:
messagebox.showwarning("No Answer", "Please select an answer before proceeding.")
return
self.total += 1
is_correct = selected == self.correct_answer
if is_correct:
self.score += 1
self.feedback_label.config(text="Correct!", fg="green")
delay = 1500
else:
self.feedback_label.config(text=f"Incorrect! The correct answer was: {self.correct_answer_text}", fg="red")
delay = 3500
self.history.append({
"index": self.current_index,
"selected": selected,
"correct": is_correct
})
self.next_pending = True
self.master.after(delay, self.show_question)
def go_back(self):
if self.current_index > 0:
self.current_index -= 1
self.in_review_mode = True
self.show_question()
def exit_review_mode(self):
self.in_review_mode = False
self.show_question()
def quit_quiz(self):
pct = (self.score / self.total) * 100 if self.total > 0 else 0
messagebox.showinfo("Quit Quiz", f"Questions answered: {self.total}\nCorrect: {self.score}\nPercentage: {int(pct)}%")
self.master.destroy()
def main():
root = tk.Tk()
app = HamTestGUI(root)
root.mainloop()
if __name__ == "__main__":
main()