import streamlit as st
import pandas as pd
from datetime import datetime, timedelta
import requests
from io import StringIO
import time
# Page configuration
st.set_page_config(
page_title="Exam Practice App",
page_icon="π",
layout="wide",
initial_sidebar_state="collapsed"
)
# Updated CSS with consistent text colors
st.markdown("""
<style>
.main-header {
text-align: center;
color: #2E86C1;
font-size: 2.5rem;
font-weight: bold;
margin-bottom: 2rem;
}
.paper-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 1.5rem;
border-radius: 10px;
text-align: center;
margin: 1rem 0;
color: white;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.timer-box {
background: #e74c3c;
color: white;
padding: 15px;
border-radius: 10px;
font-size: 1.5rem;
font-weight: bold;
text-align: center;
margin: 1rem 0;
}
.progress-box {
background: #f8f9fa;
color: #333333;
padding: 15px;
border-radius: 10px;
border-left: 5px solid #2E86C1;
margin: 1rem 0;
}
.question-box {
background: white;
color: #333333;
padding: 2rem;
border-radius: 10px;
border: 1px solid #ddd;
margin: 1rem 0;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.question-box h3 {
color: #333333;
}
.question-box p {
color: #333333;
}
.nav-grid {
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 5px;
margin: 1rem 0;
}
.nav-btn {
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px solid #ddd;
background: white;
color: #333333;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-weight: bold;
}
.nav-attempted { background: #27ae60; color: white; }
.nav-current { background: #3498db; color: white; border: 2px solid #2980b9; }
.nav-unattempted { background: #95a5a6; color: white; }
.score-card {
background: linear-gradient(135deg, #00b09b, #96c93d);
color: white;
padding: 1.5rem;
border-radius: 10px;
text-align: center;
margin: 1rem 0;
}
.error-box {
background: #f8d7da;
color: #721c24;
padding: 1rem;
border-radius: 5px;
border: 1px solid #f5c6cb;
margin: 1rem 0;
}
.success-box {
background: #d4edda;
color: #155724;
padding: 1rem;
border-radius: 5px;
border: 1px solid #c3e6cb;
margin: 1rem 0;
}
/* Additional styles for consistent text colors on light backgrounds */
.stRadio > div {
color: #333333;
}
.stRadio label {
color: #333333;
}
.stTextInput input {
color: #333333;
}
.stSelectbox div {
color: #333333;
}
/* Ensure all content in white/light containers has proper text color */
div[data-testid="stVerticalBlock"] > div > div > div {
color: #333333;
}
/* Style for any remaining light background elements */
.element-container div {
color: #333333;
}
</style>
""", unsafe_allow_html=True)
class ExamApp:
def __init__(self):
self.initialize_state()
def initialize_state(self):
"""Initialize all session state variables with defaults"""
defaults = {
'page': 'dashboard',
'exam_data': None,
'selected_paper': None,
'current_question': 1, # 1-based indexing throughout
'answers': {}, # {question_number: selected_option}
'start_time': None,
'exam_duration': 90, # minutes
'time_up': False,
'timer_placeholder': None
}
for key, value in defaults.items():
if key not in st.session_state:
st.session_state[key] = value
def validate_exam_data(self, df):
"""Validate that the loaded data has required columns"""
required_columns = [
'paper_id', 'question_number', 'question_text',
'option_A', 'option_B', 'option_C', 'option_D', 'option_E',
'correct_option', 'section'
]
missing_columns = [col for col in required_columns if col not in df.columns]
if missing_columns:
raise ValueError(f"Missing required columns: {', '.join(missing_columns)}")
# Validate data types and ranges
if not pd.api.types.is_numeric_dtype(df['question_number']):
raise ValueError("question_number must be numeric")
if df['question_number'].min() < 1 or df['question_number'].max() > 60:
raise ValueError("question_number must be between 1 and 60")
valid_options = ['A', 'B', 'C', 'D', 'E']
if not df['correct_option'].isin(valid_options).all():
raise ValueError("correct_option must be one of: A, B, C, D, E")
return True
def load_exam_data(self, sheet_url):
"""Load and validate exam data from Google Sheets"""
try:
# Convert Google Sheets URL to CSV export URL
if 'docs.google.com/spreadsheets' in sheet_url:
if '/d/' not in sheet_url:
raise ValueError("Invalid Google Sheets URL format")
sheet_id = sheet_url.split('/d/')[1].split('/')[0]
csv_url = f"https://docs.google.com/spreadsheets/d/{sheet_id}/export?format=csv"
else:
csv_url = sheet_url
# Load data with timeout
response = requests.get(csv_url, timeout=10)
response.raise_for_status()
# Parse CSV
df = pd.read_csv(StringIO(response.text))
if df.empty:
raise ValueError("The spreadsheet appears to be empty")
# Validate data structure
self.validate_exam_data(df)
return df
except requests.exceptions.Timeout:
raise Exception("Request timed out. Please check your internet connection.")
except requests.exceptions.RequestException as e:
raise Exception(f"Failed to load data: {str(e)}")
except Exception as e:
raise Exception(f"Error processing data: {str(e)}")
def get_paper_questions(self, paper_id):
"""Get questions for a specific paper, sorted by question number"""
if st.session_state.exam_data is None:
return pd.DataFrame()
df = st.session_state.exam_data
paper_questions = df[df['paper_id'] == paper_id].copy()
if paper_questions.empty:
return pd.DataFrame()
return paper_questions.sort_values('question_number').reset_index(drop=True)
def get_remaining_time(self):
"""Calculate remaining time in seconds"""
if not st.session_state.start_time:
return st.session_state.exam_duration * 60
elapsed = datetime.now() - st.session_state.start_time
total_seconds = st.session_state.exam_duration * 60
remaining = total_seconds - elapsed.total_seconds()
return max(0, remaining)
def format_time(self, seconds):
"""Format seconds into HH:MM:SS"""
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
seconds = int(seconds % 60)
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
def calculate_score(self, paper_questions):
"""Calculate total and section-wise scores"""
total_score = 0
section_scores = {}
section_questions = {}
attempted_count = 0
correct_count = 0
for _, question in paper_questions.iterrows():
q_num = question['question_number']
section = question['section']
correct_answer = question['correct_option']
# Initialize section counters
if section not in section_scores:
section_scores[section] = 0
section_questions[section] = 0
section_questions[section] += 1
# Check user answer
user_answer = st.session_state.answers.get(q_num)
if user_answer: # Question was attempted
attempted_count += 1
if user_answer == correct_answer:
total_score += 4
section_scores[section] += 4
correct_count += 1
else:
total_score -= 1
section_scores[section] -= 1
return {
'total_score': total_score,
'section_scores': section_scores,
'section_questions': section_questions,
'attempted_count': attempted_count,
'correct_count': correct_count
}
def show_error(self, message):
"""Display error message"""
st.markdown(f'<div class="error-box">β {message}</div>', unsafe_allow_html=True)
def show_success(self, message):
"""Display success message"""
st.markdown(f'<div class="success-box">β
{message}</div>', unsafe_allow_html=True)
def show_dashboard(self):
"""Dashboard page - paper selection"""
st.markdown('<h1 class="main-header">π Exam Practice Portal</h1>', unsafe_allow_html=True)
# Load data section
if st.session_state.exam_data is None:
st.markdown("### π Load Exam Data")
st.markdown('<p style="color: #333333;">Enter your Google Sheets URL below. Make sure the sheet is publicly viewable.</p>', unsafe_allow_html=True)
sheet_url = st.text_input(
"Google Sheets URL:",
placeholder="https://docs.google.com/spreadsheets/d/your-sheet-id/edit",
help="The spreadsheet must be publicly accessible"
)
col1, col2 = st.columns([1, 3])
with col1:
if st.button("π₯ Load Data", disabled=not sheet_url):
try:
with st.spinner("Loading exam data..."):
st.session_state.exam_data = self.load_exam_data(sheet_url)
self.show_success("Data loaded successfully!")
st.rerun()
except Exception as e:
self.show_error(str(e))
with col2:
if st.button("π Use Sample Data"):
# Create sample data for testing
sample_data = []
for paper in [1, 2]:
for q in range(1, 61):
sample_data.append({
'paper_id': paper,
'question_number': q,
'question_text': f'Sample question {q} for paper {paper}?',
'option_A': 'Option A',
'option_B': 'Option B',
'option_C': 'Option C',
'option_D': 'Option D',
'option_E': 'Option E',
'correct_option': 'A',
'section': ['Math', 'English', 'C', 'QL'][q % 4]
})
st.session_state.exam_data = pd.DataFrame(sample_data)
self.show_success("Sample data loaded!")
st.rerun()
# Show available papers
if st.session_state.exam_data is not None:
try:
df = st.session_state.exam_data
papers = sorted(df['paper_id'].unique())
st.markdown("### π Available Papers")
cols = st.columns(min(3, len(papers)))
for i, paper_id in enumerate(papers):
with cols[i % 3]:
st.markdown(f"""
<div class="paper-card">
<h3>Paper {paper_id}</h3>
<p>60 Questions β’ 90 Minutes</p>
</div>
""", unsafe_allow_html=True)
if st.button(f"π Start Paper {paper_id}", key=f"paper_{paper_id}"):
st.session_state.selected_paper = paper_id
st.session_state.page = 'instructions'
st.rerun()
# Reset data option
st.markdown("---")
if st.button("π Load Different Data"):
st.session_state.exam_data = None
st.rerun()
except Exception as e:
self.show_error(f"Error displaying papers: {str(e)}")
if st.button("π Reset Data"):
st.session_state.exam_data = None
st.rerun()
def show_instructions(self):
"""Instructions page"""
paper_id = st.session_state.selected_paper
st.markdown(f'<h1 class="main-header">π Paper {paper_id} - Instructions</h1>', unsafe_allow_html=True)
st.markdown("""
<div style="color: #333333;">
<h3>π Exam Guidelines</h3>
<p><strong>Duration:</strong> 90 minutes | <strong>Questions:</strong> 60 | <strong>Pattern:</strong> Multiple Choice</p>
<h4>π Marking Scheme:</h4>
<ul>
<li>β
<strong>Correct Answer:</strong> +4 marks</li>
<li>β <strong>Wrong Answer:</strong> -1 mark</li>
<li>βͺ <strong>Unattempted:</strong> 0 marks</li>
</ul>
<h4>π Section Distribution:</h4>
<ul>
<li><strong>Mathematics:</strong> 30% (18 questions)</li>
<li><strong>C Programming:</strong> 30% (18 questions)</li>
<li><strong>Quantitative & Logical Reasoning:</strong> 20% (12 questions)</li>
<li><strong>English Comprehension:</strong> 20% (12 questions)</li>
</ul>
<h4>β οΈ Important Notes:</h4>
<ul>
<li>Timer runs continuously once started</li>
<li>You can navigate between questions freely</li>
<li>Changes are saved automatically</li>
<li>Submit before time expires to avoid auto-submission</li>
</ul>
</div>
""", unsafe_allow_html=True)
col1, col2 = st.columns(2)
with col1:
if st.button("β¬
οΈ Back to Dashboard", use_container_width=True):
st.session_state.page = 'dashboard'
st.rerun()
with col2:
if st.button("π― Begin Exam", type="primary", use_container_width=True):
# Reset exam state
st.session_state.page = 'exam'
st.session_state.start_time = datetime.now()
st.session_state.current_question = 1
st.session_state.answers = {}
st.session_state.time_up = False
st.rerun()
def show_exam(self):
"""Main exam interface"""
paper_id = st.session_state.selected_paper
paper_questions = self.get_paper_questions(paper_id)
if paper_questions.empty:
self.show_error(f"No questions found for Paper {paper_id}")
return
# Check if time is up
remaining_seconds = self.get_remaining_time()
if remaining_seconds <= 0 and not st.session_state.time_up:
st.session_state.time_up = True
st.session_state.page = 'results'
st.rerun()
# Header with timer and progress
col1, col2, col3 = st.columns([1, 2, 1])
with col1:
attempted = len(st.session_state.answers)
unattempted = 60 - attempted
st.markdown(f"""
<div class="progress-box">
<strong>Progress:</strong><br>
β
Attempted: {attempted}<br>
βͺ Remaining: {unattempted}
</div>
""", unsafe_allow_html=True)
with col2:
st.markdown(f'<h2 style="text-align: center; color: #2E86C1;">Paper {paper_id}</h2>', unsafe_allow_html=True)
with col3:
time_str = self.format_time(remaining_seconds)
color = "#e74c3c" if remaining_seconds < 600 else "#27ae60" # Red if < 10 mins
st.markdown(f"""
<div class="timer-box" style="background: {color};">
β° {time_str}
</div>
""", unsafe_allow_html=True)
# Main content area
main_col, nav_col = st.columns([3, 1])
with main_col:
# Get current question
current_q = paper_questions[paper_questions['question_number'] == st.session_state.current_question]
if current_q.empty:
self.show_error("Question not found")
return
current_q = current_q.iloc[0]
q_num = current_q['question_number']
# Question display
st.markdown(f"""
<div class="question-box">
<h3>Question {q_num}</h3>
<p style="font-size: 1.1rem; line-height: 1.6; color: #333333;">{current_q['question_text']}</p>
</div>
""", unsafe_allow_html=True)
# Answer options using radio button
options = ['A', 'B', 'C', 'D', 'E']
option_texts = [f"{opt}. {current_q[f'option_{opt}']}" for opt in options]
# Get current answer
current_answer = st.session_state.answers.get(q_num)
current_index = options.index(current_answer) if current_answer in options else None
# Answer selection with custom styling
st.markdown('<div style="color: #333333;"><h4><strong>Select your answer:</strong></h4></div>', unsafe_allow_html=True)
selected = st.radio(
"",
options=option_texts,
index=current_index,
key=f"q_{q_num}_radio"
)
# Save answer
if selected:
selected_option = selected.split('.')[0] # Extract A, B, C, D, or E
st.session_state.answers[q_num] = selected_option
# Clear answer button
col_clear, col_submit = st.columns([1, 1])
with col_clear:
if st.button("ποΈ Clear Answer", key=f"clear_{q_num}"):
if q_num in st.session_state.answers:
del st.session_state.answers[q_num]
st.rerun()
with col_submit:
if st.button("π€ Submit Exam", type="primary"):
st.session_state.page = 'results'
st.rerun()
with nav_col:
st.markdown('<h3 style="color: #333333;">π§ Navigation</h3>', unsafe_allow_html=True)
# Question navigation grid
st.markdown('<div class="nav-grid">', unsafe_allow_html=True)
for q_num in range(1, 61):
is_attempted = q_num in st.session_state.answers
is_current = q_num == st.session_state.current_question
if is_current:
css_class = "nav-current"
elif is_attempted:
css_class = "nav-attempted"
else:
css_class = "nav-unattempted"
if st.button(str(q_num), key=f"nav_{q_num}"):
st.session_state.current_question = q_num
st.rerun()
st.markdown('</div>', unsafe_allow_html=True)
# Navigation buttons
st.markdown("---")
col_prev, col_next = st.columns(2)
with col_prev:
if st.button("β¬
οΈ Previous", disabled=st.session_state.current_question <= 1):
st.session_state.current_question -= 1
st.rerun()
with col_next:
if st.button("β‘οΈ Next", disabled=st.session_state.current_question >= 60):
st.session_state.current_question += 1
st.rerun()
# Quit button
st.markdown("---")
if st.button("πͺ Quit Exam", help="Return to dashboard"):
if st.session_state.answers:
st.warning("You have attempted some questions. Are you sure you want to quit?")
col_yes, col_no = st.columns(2)
with col_yes:
if st.button("Yes, Quit"):
st.session_state.page = 'dashboard'
st.rerun()
with col_no:
st.button("Cancel")
else:
st.session_state.page = 'dashboard'
st.rerun()
def show_results(self):
"""Results and analysis page"""
paper_id = st.session_state.selected_paper
paper_questions = self.get_paper_questions(paper_id)
if paper_questions.empty:
self.show_error(f"No questions found for Paper {paper_id}")
return
st.markdown(f'<h1 class="main-header">π Results - Paper {paper_id}</h1>', unsafe_allow_html=True)
# Calculate scores
scores = self.calculate_score(paper_questions)
# Time taken
if st.session_state.start_time:
if st.session_state.time_up:
time_taken = timedelta(minutes=st.session_state.exam_duration)
else:
time_taken = datetime.now() - st.session_state.start_time
hours = int(time_taken.total_seconds() // 3600)
minutes = int((time_taken.total_seconds() % 3600) // 60)
seconds = int(time_taken.total_seconds() % 60)
time_str = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
else:
time_str = "00:00:00"
# Overall score card
st.markdown(f"""
<div class="score-card">
<h2>π― Final Score: {scores['total_score']}/240</h2>
<p><strong>Time Taken:</strong> {time_str} | <strong>Attempted:</strong> {scores['attempted_count']}/60 | <strong>Correct:</strong> {scores['correct_count']}</p>
</div>
""", unsafe_allow_html=True)
# Section-wise performance
st.markdown('<h3 style="color: #333333;">π Section-wise Performance</h3>', unsafe_allow_html=True)
sections = list(scores['section_scores'].keys())
if sections:
cols = st.columns(len(sections))
for i, section in enumerate(sections):
with cols[i]:
max_score = scores['section_questions'][section] * 4
percentage = (scores['section_scores'][section] / max_score * 100) if max_score > 0 else 0
st.markdown(f"""
<div style="background: white; padding: 1rem; border-radius: 8px; border-left: 4px solid #2E86C1; margin: 0.5rem 0;">
<h4 style="color: #2E86C1; margin: 0;">{section}</h4>
<p style="margin: 0.5rem 0; color: #333333;"><strong>Score:</strong> {scores['section_scores'][section]}/{max_score}</p>
<p style="margin: 0; color: #333333;"><strong>Accuracy:</strong> {percentage:.1f}%</p>
</div>
""", unsafe_allow_html=True)
# Detailed question review
st.markdown('<h3 style="color: #333333;">π Question Review</h3>', unsafe_allow_html=True)
tab1, tab2, tab3 = st.tabs(["β
Correct", "β Incorrect", "βͺ Unattempted"])
with tab1:
correct_questions = []
for _, q in paper_questions.iterrows():
q_num = q['question_number']
if st.session_state.answers.get(q_num) == q['correct_option']:
correct_questions.append(q)
if correct_questions:
for q in correct_questions:
st.markdown(f"""
<div style="background: #d4edda; padding: 1rem; border-radius: 8px; margin: 0.5rem 0; border-left: 4px solid #28a745; color: #333333;">
<h5>Q{q['question_number']}: {q['question_text']}</h5>
<p><strong>Your Answer:</strong> {st.session_state.answers[q['question_number']]}. {q[f'option_{st.session_state.answers[q["question_number"]]}']}</p>
</div>
""", unsafe_allow_html=True)
else:
st.info("No correct answers")
with tab2:
incorrect_questions = []
for _, q in paper_questions.iterrows():
q_num = q['question_number']
user_answer = st.session_state.answers.get(q_num)
if user_answer and user_answer != q['correct_option']:
incorrect_questions.append(q)
if incorrect_questions:
for q in incorrect_questions:
user_ans = st.session_state.answers[q['question_number']]
st.markdown(f"""
<div style="background: #f8d7da; padding: 1rem; border-radius: 8px; margin: 0.5rem 0; border-left: 4px solid #dc3545; color: #333333;">
<h5>Q{q['question_number']}: {q['question_text']}</h5>
<p><strong>Your Answer:</strong> {user_ans}. {q[f'option_{user_ans}']}</p>
<p><strong>Correct Answer:</strong> {q['correct_option']}. {q[f'option_{q["correct_option"]}']}</p>
</div>
""", unsafe_allow_html=True)
else:
st.info("No incorrect answers")
with tab3:
unattempted_questions = []
for _, q in paper_questions.iterrows():
if q['question_number'] not in st.session_state.answers:
unattempted_questions.append(q)
if unattempted_questions:
for q in unattempted_questions:
st.markdown(f"""
<div style="background: #f8f9fa; padding: 1rem; border-radius: 8px; margin: 0.5rem 0; border-left: 4px solid #6c757d; color: #333333;">
<h5>Q{q['question_number']}: {q['question_text']}</h5>
<p><strong>Correct Answer:</strong> {q['correct_option']}. {q[f'option_{q["correct_option"]}']}</p>
</div>
""", unsafe_allow_html=True)
else:
st.info("All questions attempted!")
# Back to dashboard
st.markdown("---")
if st.button("π Back to Dashboard", use_container_width=True):
# Reset all exam-related state
st.session_state.page = 'dashboard'
st.session_state.selected_paper = None
st.session_state.current_question = 1
st.session_state.answers = {}
st.session_state.start_time = None
st.session_state.time_up = False
st.rerun()
def run(self):
"""Main application runner"""
try:
if st.session_state.page == 'dashboard':
self.show_dashboard()
elif st.session_state.page == 'instructions':
self.show_instructions()
elif st.session_state.page == 'exam':
self.show_exam()
elif st.session_state.page == 'results':
self.show_results()
else:
st.session_state.page = 'dashboard'
st.rerun()
except Exception as e:
st.error("An unexpected error occurred!")
st.exception(e)
if st.button("π Reset Application"):
for key in list(st.session_state.keys()):
del st.session_state[key]
st.rerun()
# Run the application
if __name__ == "__main__":
app = ExamApp()
app.run()