feat: Add Quiz Application

This commit is contained in:
Neil Revin 2025-06-28 03:14:14 +05:30
parent 0bb9a452a7
commit 63fc0244d7
Signed by: Neil
GPG key ID: 9C54D0F528D78694
4 changed files with 796 additions and 0 deletions

7
maths/.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
# Local development files (uv)
quiz_questions.json
pyproject.toml
.venv
.python-version
# As this code is for internal review only, dependencies will not be included.

3
maths/README.md Normal file
View file

@ -0,0 +1,3 @@
# All parts of this folder are subject to the Will of Steel Proprietary License Agreement. For more info visit https://willofsteel.me/license
Last updated: 28-06-2025

371
maths/db.py Normal file
View file

@ -0,0 +1,371 @@
#
# Will of Steel Proprietary License (WOSPL)
#
# Copyright (c) 2025-present Will of Steel
#
# This software and its accompanying source code ("the Software") are proprietary and confidential. Unauthorized actions are strictly prohibited. Specifically, you are not permitted to:
#
# - Use the Software for any purpose other than internal review, unless explicitly authorized in writing by the Licensor.
# - Modify, adapt, translate, or create derivative works based on the Software.
# - Distribute, disclose, sublicense, lease, rent, or otherwise make available the Software to any third party.
# - Reverse engineer, decompile, disassemble, or attempt to derive the source code, object code, or underlying structure, ideas, or algorithms of the Software.
# - Remove, alter, or obscure any proprietary notices, labels, or marks on the Software.
# - Use the Software for any commercial purposes or in any commercial environment.
# - Claim ownership or authorship of the Software or any part thereof.
# - Use the Software in any manner that violates applicable laws or regulations.
#
# The Software is provided "as is" without warranty of any kind, express or implied. The Licensor shall not be liable for any damages arising out of or in connection with the use or performance of the Software.
#
# Any violation of these terms will result in immediate termination of your rights to use the Software and may subject you to legal action.
#
# For permissions beyond the scope of this license, please contact neil@willofsteel.me
# For more information, please visit https://willofsteel.me/license
# This file is in compliance with Pep 8 and PEP 257 standards. -- Neil 28-06-2025
import json
import os
class QuestionManager:
def __init__(self, filename: str = "quiz_questions.json"):
self.filename = filename
self.questions = []
self.load_questions()
def load_questions(self):
"""Load questions from JSON file"""
if os.path.exists(self.filename):
try:
with open(self.filename, "r", encoding="utf-8") as file:
data = json.load(file)
self.questions = data.get("questions", [])
except json.JSONDecodeError:
print(f"Error: Invalid JSON format in {self.filename}")
self.questions = []
else:
print(f"File {self.filename} not found. Starting with empty question list.")
self.questions = []
def save_questions(self):
"""Save questions to JSON file"""
data = {"questions": self.questions}
try:
with open(self.filename, "w", encoding="utf-8") as file:
json.dump(data, file, indent=2, ensure_ascii=False)
print(f"Questions saved to {self.filename}")
except Exception as e:
print(f"Error saving questions: {e}")
def add_question(self):
"""Add a new question interactively"""
print("\n" + "=" * 50)
print("ADD NEW QUESTION")
print("=" * 50)
question_text = input("Enter the question: ").strip()
if not question_text:
print("Question cannot be empty!")
return
# Ask for question image
question_image = input(
"Enter question image path (optional, press Enter to skip): "
).strip()
if question_image and not os.path.exists(question_image):
print(
f"Warning: Image file '{question_image}' not found. Continuing without image."
)
question_image = ""
print("\nEnter 4 answer options:")
options = []
for i in range(4):
option = input(f"Option {i+1}: ").strip()
if not option:
print("Option cannot be empty!")
return
options.append(option)
print("\nOptions:")
for i, option in enumerate(options, 1):
print(f"{i}. {option}")
while True:
try:
correct_num = int(input("\nWhich option is correct? (1-4): "))
if 1 <= correct_num <= 4:
correct_answer = options[correct_num - 1]
break
else:
print("Please enter a number between 1 and 4.")
except ValueError:
print("Please enter a valid number.")
explanation = input("Enter explanation (optional): ").strip()
# Ask for explanation image
explanation_image = input(
"Enter explanation image path (optional, press Enter to skip): "
).strip()
if explanation_image and not os.path.exists(explanation_image):
print(
f"Warning: Image file '{explanation_image}' not found. Continuing without image."
)
explanation_image = ""
# Create question object
new_question = {
"question": question_text,
"options": options,
"correct_answer": correct_answer,
}
if question_image:
new_question["question_image"] = question_image
if explanation:
new_question["explanation"] = explanation
if explanation_image:
new_question["explanation_image"] = explanation_image
self.questions.append(new_question)
print(
f"\n✅ Question added successfully! Total questions: {len(self.questions)}"
)
print("\nQuestion Summary:")
print(f"Question: {question_text}")
if question_image:
print(f"Question Image: {question_image}")
print(f"Correct Answer: {correct_answer}")
if explanation:
print(f"Explanation: {explanation}")
if explanation_image:
print(f"Explanation Image: {explanation_image}")
def view_questions(self):
"""Display all questions"""
if not self.questions:
print("\nNo questions available.")
return
print(f"\n{'='*50}")
print(f"ALL QUESTIONS ({len(self.questions)} total)")
print("=" * 50)
for i, q in enumerate(self.questions, 1):
print(f"\n{i}. {q['question']}")
if q.get("question_image"):
print(f" 📷 Question Image: {q['question_image']}")
for j, option in enumerate(q["options"], 1):
marker = "" if option == q["correct_answer"] else " "
print(f" {marker} {j}. {option}")
if "explanation" in q:
print(f" 💡 Explanation: {q['explanation']}")
if q.get("explanation_image"):
print(f" 📷 Explanation Image: {q['explanation_image']}")
print("-" * 40)
def edit_question(self):
"""Edit an existing question"""
if not self.questions:
print("\nNo questions available to edit.")
return
self.view_questions()
while True:
try:
question_num = int(
input(
f"\nEnter question number to edit (1-{len(self.questions)}): "
)
)
if 1 <= question_num <= len(self.questions):
break
else:
print(f"Please enter a number between 1 and {len(self.questions)}.")
except ValueError:
print("Please enter a valid number.")
question_index = question_num - 1
old_question = self.questions[question_index]
print(f"\nEditing question {question_num}:")
print(f"Current: {old_question['question']}")
new_question_text = input(
"Enter new question (press Enter to keep current): "
).strip()
if new_question_text:
old_question["question"] = new_question_text
# Ask about question image
current_q_image = old_question.get("question_image", "")
print(
f"\nCurrent question image: {current_q_image if current_q_image else 'None'}"
)
new_q_image = input(
"Enter new question image path (press Enter to keep current, 'none' to remove): "
).strip()
if new_q_image.lower() == "none":
old_question.pop("question_image", None)
elif new_q_image:
if os.path.exists(new_q_image):
old_question["question_image"] = new_q_image
else:
print(f"Warning: Image file '{new_q_image}' not found.")
print("\nCurrent options:")
for i, option in enumerate(old_question["options"], 1):
marker = "" if option == old_question["correct_answer"] else " "
print(f"{marker} {i}. {option}")
edit_options = input("\nEdit options? (y/n): ").lower().strip()
if edit_options == "y":
new_options = []
for i in range(4):
current = (
old_question["options"][i]
if i < len(old_question["options"])
else ""
)
new_option = input(f"Option {i+1} (current: '{current}'): ").strip()
if new_option:
new_options.append(new_option)
else:
new_options.append(current)
old_question["options"] = new_options
print("\nNew options:")
for i, option in enumerate(new_options, 1):
print(f"{i}. {option}")
while True:
try:
correct_num = int(input("\nWhich option is correct? (1-4): "))
if 1 <= correct_num <= 4:
old_question["correct_answer"] = new_options[correct_num - 1]
break
else:
print("Please enter a number between 1 and 4.")
except ValueError:
print("Please enter a valid number.")
current_explanation = old_question.get("explanation", "")
print(f"\nCurrent explanation: {current_explanation}")
new_explanation = input(
"Enter new explanation (press Enter to keep current): "
).strip()
if new_explanation:
old_question["explanation"] = new_explanation
# Ask about explanation image
current_exp_image = old_question.get("explanation_image", "")
print(
f"\nCurrent explanation image: {current_exp_image if current_exp_image else 'None'}"
)
new_exp_image = input(
"Enter new explanation image path (press Enter to keep current, 'none' to remove): "
).strip()
if new_exp_image.lower() == "none":
old_question.pop("explanation_image", None)
elif new_exp_image:
if os.path.exists(new_exp_image):
old_question["explanation_image"] = new_exp_image
else:
print(f"Warning: Image file '{new_exp_image}' not found.")
print("✅ Question updated successfully!")
def delete_question(self):
"""Delete a question"""
if not self.questions:
print("\nNo questions available to delete.")
return
self.view_questions()
while True:
try:
question_num = int(
input(
f"\nEnter question number to delete (1-{len(self.questions)}): "
)
)
if 1 <= question_num <= len(self.questions):
break
else:
print(f"Please enter a number between 1 and {len(self.questions)}.")
except ValueError:
print("Please enter a valid number.")
question_index = question_num - 1
question_to_delete = self.questions[question_index]
print(f"\nQuestion to delete:")
print(f"{question_to_delete['question']}")
if question_to_delete.get("question_image"):
print(f"Question Image: {question_to_delete['question_image']}")
if question_to_delete.get("explanation"):
print(f"Explanation: {question_to_delete['explanation']}")
if question_to_delete.get("explanation_image"):
print(f"Explanation Image: {question_to_delete['explanation_image']}")
confirm = (
input("\nAre you sure you want to delete this question? (y/n): ")
.lower()
.strip()
)
if confirm == "y":
self.questions.pop(question_index)
print("✅ Question deleted successfully!")
else:
print("Delete cancelled.")
def run(self):
"""Main menu loop"""
while True:
print(f"\n{'='*50}")
print("QUIZ QUESTION MANAGER")
print("=" * 50)
print(f"Current file: {self.filename}")
print(f"Total questions: {len(self.questions)}")
print("\nOptions:")
print("1. Add new question")
print("2. View all questions")
print("3. Edit question")
print("4. Delete question")
print("5. Save questions")
print("6. Exit")
choice = input("\nEnter your choice (1-6): ").strip()
if choice == "1":
self.add_question()
elif choice == "2":
self.view_questions()
elif choice == "3":
self.edit_question()
elif choice == "4":
self.delete_question()
elif choice == "5":
self.save_questions()
elif choice == "6":
save_choice = (
input("Save changes before exiting? (y/n): ").lower().strip()
)
if save_choice == "y":
self.save_questions()
print("Goodbye!")
break
else:
print("Invalid choice. Please enter a number between 1 and 6.")
if __name__ == "__main__":
manager = QuestionManager()
manager.run()

415
maths/main.py Normal file
View file

@ -0,0 +1,415 @@
#
# Will of Steel Proprietary License (WOSPL)
#
# Copyright (c) 2025-present Will of Steel
#
# This software and its accompanying source code ("the Software") are proprietary and confidential. Unauthorized actions are strictly prohibited. Specifically, you are not permitted to:
#
# - Use the Software for any purpose other than internal review, unless explicitly authorized in writing by the Licensor.
# - Modify, adapt, translate, or create derivative works based on the Software.
# - Distribute, disclose, sublicense, lease, rent, or otherwise make available the Software to any third party.
# - Reverse engineer, decompile, disassemble, or attempt to derive the source code, object code, or underlying structure, ideas, or algorithms of the Software.
# - Remove, alter, or obscure any proprietary notices, labels, or marks on the Software.
# - Use the Software for any commercial purposes or in any commercial environment.
# - Claim ownership or authorship of the Software or any part thereof.
# - Use the Software in any manner that violates applicable laws or regulations.
#
# The Software is provided "as is" without warranty of any kind, express or implied. The Licensor shall not be liable for any damages arising out of or in connection with the use or performance of the Software.
#
# Any violation of these terms will result in immediate termination of your rights to use the Software and may subject you to legal action.
#
# For permissions beyond the scope of this license, please contact neil@willofsteel.me
# For more information, please visit https://willofsteel.me/license
# This file is in compliance with Pep 8 and PEP 257 standards. -- Neil 28-06-2025
import customtkinter as ctk
import json
import random
import os
from tkinter import messagebox
from PIL import Image
class QuizApp:
def __init__(self):
ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("blue")
self.root = ctk.CTk()
self.root.title("Python Quiz Application")
self.root.geometry("1000x800")
self.root.resizable(True, True)
self.questions = []
self.current_question = 0
self.score = 0
self.selected_answer = ctk.StringVar()
self.load_questions()
self.create_widgets()
if self.questions:
self.display_question()
def load_questions(self):
"""Load questions from JSON file"""
try:
with open("quiz_questions.json", "r", encoding="utf-8") as file:
data = json.load(file)
self.questions = data.get("questions", [])
random.shuffle(self.questions)
except FileNotFoundError:
self.load_questions()
except json.JSONDecodeError:
messagebox.showerror("Error", "Invalid JSON format in quiz_questions.json")
self.questions = []
def load_image(self, image_path, max_width=500, max_height=400):
"""Load and resize an image"""
try:
if not os.path.exists(image_path):
print(f"Image not found: {image_path}")
return None
pil_image = Image.open(image_path)
width_ratio = max_width / pil_image.width
height_ratio = max_height / pil_image.height
ratio = min(width_ratio, height_ratio)
new_width = int(pil_image.width * ratio)
new_height = int(pil_image.height * ratio)
pil_image = pil_image.resize(
(new_width, new_height), Image.Resampling.LANCZOS
)
ctk_image = ctk.CTkImage(
light_image=pil_image,
dark_image=pil_image,
size=(new_width, new_height),
)
return ctk_image
except Exception as e:
print(f"Error loading image {image_path}: {e}")
return None
def create_widgets(self):
"""Create and arrange GUI widgets with scrollable content"""
self.scrollable_frame = ctk.CTkScrollableFrame(self.root, width=950, height=750)
self.scrollable_frame.pack(fill="both", expand=True, padx=20, pady=20)
self.title_label = ctk.CTkLabel(
self.scrollable_frame,
text="Python Quiz",
font=ctk.CTkFont(size=28, weight="bold"),
)
self.title_label.pack(pady=(20, 10))
self.progress_label = ctk.CTkLabel(
self.scrollable_frame, text="", font=ctk.CTkFont(size=14)
)
self.progress_label.pack(pady=(0, 20))
self.question_frame = ctk.CTkFrame(self.scrollable_frame)
self.question_frame.pack(fill="x", padx=20, pady=(0, 20))
self.question_label = ctk.CTkLabel(
self.question_frame,
text="",
font=ctk.CTkFont(size=16, weight="bold"),
wraplength=800,
justify="left",
)
self.question_label.pack(pady=(20, 10), padx=20)
self.question_image_label = ctk.CTkLabel(
self.question_frame, text="", image=None
)
self.options_frame = ctk.CTkFrame(self.scrollable_frame)
self.options_frame.pack(fill="x", padx=20, pady=(0, 20))
self.option_buttons = []
self.feedback_frame = ctk.CTkFrame(self.scrollable_frame)
self.feedback_label = ctk.CTkLabel(
self.feedback_frame,
text="",
font=ctk.CTkFont(size=14),
wraplength=800,
justify="left",
anchor="w",
)
self.feedback_label.pack(pady=(15, 5), padx=20, fill="x")
self.feedback_image_label = ctk.CTkLabel(
self.feedback_frame, text="", image=None
)
self.buttons_frame = ctk.CTkFrame(self.scrollable_frame)
self.buttons_frame.pack(fill="x", padx=20, pady=(0, 20))
self.submit_button = ctk.CTkButton(
self.buttons_frame,
text="Submit Answer",
command=self.submit_answer,
font=ctk.CTkFont(size=14, weight="bold"),
height=40,
)
self.submit_button.pack(side="left", padx=(20, 10), pady=20)
self.next_button = ctk.CTkButton(
self.buttons_frame,
text="Next Question",
command=self.next_question,
font=ctk.CTkFont(size=14, weight="bold"),
height=40,
state="disabled",
)
self.next_button.pack(side="left", padx=10, pady=20)
self.restart_button = ctk.CTkButton(
self.buttons_frame,
text="Restart Quiz",
command=self.restart_quiz,
font=ctk.CTkFont(size=14, weight="bold"),
height=40,
)
self.restart_button.pack(side="right", padx=(10, 20), pady=20)
self.score_label = ctk.CTkLabel(
self.scrollable_frame,
text="Score: 0/0",
font=ctk.CTkFont(size=16, weight="bold"),
)
self.score_label.pack(pady=(0, 20))
def display_question(self):
"""Display the current question and options"""
if self.current_question < len(self.questions):
question_data = self.questions[self.current_question]
self.progress_label.configure(
text=f"Question {self.current_question + 1} of {len(self.questions)}"
)
self.question_label.configure(text=question_data["question"])
self.question_image_label.pack_forget()
if "question_image" in question_data:
image = self.load_image(question_data["question_image"])
if image:
self.question_image_label.configure(image=image)
self.question_image_label.pack(pady=(0, 20), padx=20)
for button in self.option_buttons:
button.destroy()
self.option_buttons.clear()
self.selected_answer.set("")
for i, option in enumerate(question_data["options"]):
radio_btn = ctk.CTkRadioButton(
self.options_frame,
text=option,
variable=self.selected_answer,
value=option,
font=ctk.CTkFont(size=14),
)
radio_btn.pack(anchor="w", padx=20, pady=5)
self.option_buttons.append(radio_btn)
self.score_label.configure(
text=f"Score: {self.score}/{len(self.questions)}"
)
self.submit_button.configure(state="normal")
self.next_button.configure(state="disabled")
self.next_button.configure(text="Next Question")
self.feedback_frame.pack_forget()
self.feedback_image_label.pack_forget()
self.feedback_label.configure(text="")
self.scrollable_frame._parent_canvas.yview_moveto(0)
self.root.update_idletasks()
else:
self.show_final_results()
def submit_answer(self):
"""Check the selected answer and provide feedback"""
if not self.selected_answer.get():
messagebox.showwarning("Warning", "Please select an answer!")
return
question_data = self.questions[self.current_question]
correct_answer = question_data["correct_answer"]
selected = self.selected_answer.get()
if selected == correct_answer:
self.score += 1
feedback_text = "✅ Correct!"
else:
feedback_text = f"❌ Incorrect! The correct answer is: {correct_answer}"
if "explanation" in question_data:
feedback_text += f"\n\nExplanation: {question_data['explanation']}"
self.feedback_label.configure(text=feedback_text)
if selected == correct_answer:
self.feedback_label.configure(text_color=("green", "lightgreen"))
else:
self.feedback_label.configure(text_color=("red", "lightcoral"))
self.feedback_image_label.pack_forget()
if "explanation_image" in question_data:
image = self.load_image(question_data["explanation_image"])
if image:
self.feedback_image_label.configure(image=image)
self.feedback_image_label.pack(pady=(5, 15), padx=20)
self.feedback_frame.pack(
fill="x", padx=20, pady=(0, 20), before=self.buttons_frame
)
self.root.update_idletasks()
self.feedback_frame.update()
self.submit_button.configure(state="disabled")
if self.current_question < len(self.questions) - 1:
self.next_button.configure(state="normal")
else:
self.next_button.configure(text="Show Results", state="normal")
self.score_label.configure(text=f"Score: {self.score}/{len(self.questions)}")
self.root.after(100, self.scroll_to_feedback)
def scroll_to_feedback(self):
"""Scroll to show the feedback section"""
try:
feedback_y = self.feedback_frame.winfo_y()
scrollable_height = self.scrollable_frame.winfo_height()
if feedback_y > 0:
scroll_position = min(1.0, feedback_y / scrollable_height)
self.scrollable_frame._parent_canvas.yview_moveto(scroll_position)
except:
pass
def next_question(self):
"""Move to the next question"""
self.current_question += 1
if self.current_question < len(self.questions):
self.display_question()
else:
self.show_final_results()
def show_final_results(self):
"""Display final quiz results"""
percentage = (self.score / len(self.questions)) * 100 if self.questions else 0
for widget in self.scrollable_frame.winfo_children():
widget.destroy()
results_frame = ctk.CTkFrame(self.scrollable_frame)
results_frame.pack(fill="both", expand=True, padx=20, pady=20)
results_title = ctk.CTkLabel(
results_frame,
text="Quiz Complete!",
font=ctk.CTkFont(size=32, weight="bold"),
)
results_title.pack(pady=(40, 20))
score_text = (
f"Your Score: {self.score}/{len(self.questions)} ({percentage:.1f}%)"
)
final_score = ctk.CTkLabel(
results_frame, text=score_text, font=ctk.CTkFont(size=24, weight="bold")
)
final_score.pack(pady=20)
if percentage >= 80:
message = "Excellent! 🎉"
color = "green"
elif percentage >= 60:
message = "Good job! 👍"
color = "blue"
else:
message = "Keep practicing! 📚"
color = "orange"
performance_label = ctk.CTkLabel(
results_frame, text=message, font=ctk.CTkFont(size=20), text_color=color
)
performance_label.pack(pady=20)
restart_final = ctk.CTkButton(
results_frame,
text="Take Quiz Again",
command=self.restart_quiz,
font=ctk.CTkFont(size=16, weight="bold"),
height=50,
width=200,
)
restart_final.pack(pady=30)
exit_button = ctk.CTkButton(
results_frame,
text="Exit",
command=self.root.quit,
font=ctk.CTkFont(size=16, weight="bold"),
height=50,
width=200,
)
exit_button.pack(pady=10)
# Scroll to top
self.scrollable_frame._parent_canvas.yview_moveto(0)
def restart_quiz(self):
"""Restart the quiz"""
self.current_question = 0
self.score = 0
self.selected_answer.set("")
self.load_questions()
for widget in self.scrollable_frame.winfo_children():
widget.destroy()
self.create_widgets()
if self.questions:
self.display_question()
else:
messagebox.showerror("Error", "No questions available!")
error_label = ctk.CTkLabel(
self.scrollable_frame,
text="No questions found!\nPlease check quiz_questions.json",
font=ctk.CTkFont(size=16),
)
error_label.pack(expand=True)
def run(self):
"""Start the application"""
if not self.questions:
messagebox.showerror(
"Error", "No questions loaded! Check quiz_questions.json"
)
return
self.root.mainloop()
if __name__ == "__main__":
app = QuizApp()
app.run()