elemes/app.py

929 lines
38 KiB
Python

#!/usr/bin/env python3
"""
C Programming Learning Management System
A web-based application for learning C programming with exercise compilation
"""
import os
import subprocess
import tempfile
import markdown
from flask import Flask, render_template, request, jsonify, send_from_directory
from flask_talisman import Talisman
import glob
import csv
import uuid
import os
from datetime import datetime
import logging
app = Flask(__name__)
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
# Import compiler module after app initialization to avoid circular imports
from compiler import compiler_factory
# Load configuration from environment variables with defaults
CONTENT_DIR = os.environ.get('CONTENT_DIR', 'content')
STATIC_DIR = os.environ.get('STATIC_DIR', 'static')
TEMPLATES_DIR = os.environ.get('TEMPLATES_DIR', 'templates')
TOKENS_FILE = os.environ.get('TOKENS_FILE', 'tokens.csv')
# Get application titles from environment variables
APP_BAR_TITLE = os.environ.get('APP_BAR_TITLE', 'C Programming Learning System')
COPYRIGHT_TEXT = os.environ.get('COPYRIGHT_TEXT', 'C Programming Learning System © 2025')
PAGE_TITLE_SUFFIX = os.environ.get('PAGE_TITLE_SUFFIX', 'C Programming Learning System')
# Log the values to ensure they are loaded correctly
print(f"APP_BAR_TITLE: {APP_BAR_TITLE}")
print(f"COPYRIGHT_TEXT: {COPYRIGHT_TEXT}")
print(f"PAGE_TITLE_SUFFIX: {PAGE_TITLE_SUFFIX}")
# Security configuration using Talisman
Talisman(app,
force_https=False, # Set to True if using SSL
strict_transport_security=True,
strict_transport_security_max_age=31536000,
frame_options='DENY',
content_security_policy={
'default-src': "'self'",
'script-src': "'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net",
'style-src': "'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com",
'img-src': "'self' data: https:",
'font-src': "'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com",
'connect-src': "'self'",
'frame-ancestors': "'none'",
},
referrer_policy='strict-origin-when-cross-origin'
)
def get_lessons():
"""Get all lesson files from the content directory"""
lesson_files = glob.glob(os.path.join(CONTENT_DIR, "*.md"))
lessons = []
for file_path in lesson_files:
filename = os.path.basename(file_path)
# Skip home.md as it's not a lesson
if filename == "home.md":
continue
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# Extract title from first line if it starts with #
lines = content.split('\n')
title = "Untitled"
description = "Learn C programming concepts with practical examples."
for i, line in enumerate(lines):
if line.startswith('# '):
title = line[2:].strip()
# Look for a line after the title that might be a description
elif title != "Untitled" and line.strip() != "" and not line.startswith('#') and i < 10:
# Take the first substantial line as description
clean_line = line.strip().replace('#', '').strip()
if len(clean_line) > 10: # Only if it's a meaningful description
description = clean_line
break
lessons.append({
'filename': filename,
'title': title,
'description': description,
'path': file_path
})
return lessons
def get_lesson_names():
"""Get all lesson names from the content directory (excluding home.md)"""
lesson_files = glob.glob(os.path.join(CONTENT_DIR, "*.md"))
lesson_names = []
for file_path in lesson_files:
filename = os.path.basename(file_path)
# Skip home.md as it's not a lesson
if filename == "home.md":
continue
lesson_names.append(filename.replace('.md', ''))
return lesson_names
def initialize_tokens_file():
"""Initialize the tokens CSV file with headers and lesson columns"""
lesson_names = get_lesson_names()
# Check if file exists
if not os.path.exists(TOKENS_FILE):
# Create the file with headers
headers = ['token', 'nama_siswa'] + lesson_names
with open(TOKENS_FILE, 'w', newline='', encoding='utf-8') as csvfile:
writer = csv.writer(csvfile, delimiter=';')
writer.writerow(headers)
print(f"Created new tokens file: {TOKENS_FILE} with headers: {headers}")
def validate_token(token):
"""Validate if a token exists in the CSV file and return student info"""
if not os.path.exists(TOKENS_FILE):
return None
with open(TOKENS_FILE, 'r', newline='', encoding='utf-8') as csvfile:
reader = csv.DictReader(csvfile, delimiter=';')
for row in reader:
if row['token'] == token:
return {
'token': row['token'],
'student_name': row['nama_siswa']
}
return None
def get_student_progress(token):
"""Get the progress of a student based on their token"""
if not os.path.exists(TOKENS_FILE):
return None
with open(TOKENS_FILE, 'r', newline='', encoding='utf-8') as csvfile:
reader = csv.DictReader(csvfile, delimiter=';')
for row in reader:
if row['token'] == token:
# Return the entire row as progress data
return row
return None
def update_student_progress(token, lesson_name, status="completed"):
"""Update the progress of a student for a specific lesson"""
if not os.path.exists(TOKENS_FILE):
logging.warning(f"Tokens file {TOKENS_FILE} does not exist")
return False
# Read all rows
rows = []
with open(TOKENS_FILE, 'r', newline='', encoding='utf-8') as csvfile:
reader = csv.DictReader(csvfile, delimiter=';')
fieldnames = reader.fieldnames
rows = list(reader)
# Find and update the specific student's lesson status
updated = False
for row in rows:
if row['token'] == token:
# Check if the lesson_name exists in fieldnames
if lesson_name in fieldnames:
row[lesson_name] = status
updated = True
logging.info(f"Updating progress for token {token}, lesson {lesson_name}, status {status}")
else:
logging.warning(f"Lesson '{lesson_name}' not found in CSV columns: {fieldnames}")
break
# Write the updated data back to the file
if updated:
with open(TOKENS_FILE, 'w', newline='', encoding='utf-8') as csvfile:
writer = csv.DictWriter(csvfile, fieldnames=fieldnames, delimiter=';')
writer.writeheader()
writer.writerows(rows)
logging.info(f"Updated progress for token {token}, lesson {lesson_name}, status {status}")
else:
logging.warning(f"Failed to update progress for token {token}, lesson {lesson_name}")
return updated
def get_ordered_lessons():
"""Get lessons in the order specified in home.md if available"""
# Read home content to check for lesson order
home_file_path = os.path.join(CONTENT_DIR, "home.md")
if os.path.exists(home_file_path):
with open(home_file_path, 'r', encoding='utf-8') as f:
home_content = f.read()
# Split content to get only the lesson list part
parts = home_content.split('---Available_Lessons---')
if len(parts) > 1:
lesson_list_content = parts[1]
# Find lesson links in the lesson list content
import re
# Look for markdown links that point to lessons
lesson_links = re.findall(r'\[([^\]]+)\]\(lesson/([^\)]+)\)', lesson_list_content)
if lesson_links:
# Create ordered list based on home.md
all_lessons = get_lessons()
ordered_lessons = []
for link_text, filename in lesson_links:
for lesson in all_lessons:
if lesson['filename'] == filename:
# Update title to use the link text from home.md if needed
lesson_copy = lesson.copy()
lesson_copy['title'] = link_text
ordered_lessons.append(lesson_copy)
break
# Add any remaining lessons not mentioned in home.md
for lesson in all_lessons:
if not any(l['filename'] == lesson['filename'] for l in ordered_lessons):
ordered_lessons.append(lesson)
return ordered_lessons
# If no specific order is defined in home.md, return lessons in default order
return get_lessons()
def get_lessons_with_learning_objectives():
"""Get all lesson files from the content directory with learning objectives as descriptions"""
lesson_files = glob.glob(os.path.join(CONTENT_DIR, "*.md"))
lessons = []
for file_path in lesson_files:
filename = os.path.basename(file_path)
# Skip home.md as it's not a lesson
if filename == "home.md":
continue
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# Extract title from first line if it starts with #
lines = content.split('\n')
title = "Untitled"
description = "Learn C programming concepts with practical examples."
# Look for lesson info section to extract learning objectives
lesson_info_start = content.find('---LESSON_INFO---')
lesson_info_end = content.find('---END_LESSON_INFO---')
if lesson_info_start != -1 and lesson_info_end != -1:
lesson_info_section = content[lesson_info_start + len('---LESSON_INFO---'):lesson_info_end]
# Extract learning objectives
objectives_start = lesson_info_section.find('**Learning Objectives:**')
if objectives_start != -1:
objectives_section = lesson_info_section[objectives_start:]
# Find the objectives list
import re
# Look for bullet points after Learning Objectives
objective_matches = re.findall(r'- ([^\n]+)', objectives_section)
if objective_matches:
# Combine first few objectives as description
description = '; '.join(objective_matches[:3]) # Take first 3 objectives
else:
# If no bullet points found, take a few lines after the heading
lines_after = lesson_info_section[objectives_start:].split('\n')[1:4]
description = ' '.join(line.strip() for line in lines_after if line.strip())
# Look for the main title after the lesson info section
# Find the content after END_LESSON_INFO
content_after_info = content[lesson_info_end + len('---END_LESSON_INFO---'):].strip()
content_lines = content_after_info.split('\n')
# Find the first line that starts with # (the main title)
for line in content_lines:
if line.startswith('# '):
title = line[2:].strip()
break
else:
# If no lesson info section, use the original method
for i, line in enumerate(lines):
if line.startswith('# '):
title = line[2:].strip()
break
lessons.append({
'filename': filename,
'title': title,
'description': description,
'path': file_path
})
return lessons
def get_ordered_lessons_with_learning_objectives(progress=None):
"""Get lessons in the order specified in home.md if available with learning objectives as descriptions"""
# Read home content to check for lesson order
home_file_path = os.path.join(CONTENT_DIR, "home.md")
if os.path.exists(home_file_path):
with open(home_file_path, 'r', encoding='utf-8') as f:
home_content = f.read()
# Split content to get only the lesson list part
parts = home_content.split('---Available_Lessons---')
if len(parts) > 1:
lesson_list_content = parts[1]
# Find lesson links in the lesson list content
import re
# Look for markdown links that point to lessons
lesson_links = re.findall(r'\[([^\]]+)\]\((?:lesson/)?([^\)]+)\)', lesson_list_content)
if lesson_links:
# Create ordered list based on home.md
all_lessons = get_lessons_with_learning_objectives()
ordered_lessons = []
for link_text, filename in lesson_links:
for lesson in all_lessons:
if lesson['filename'] == filename:
# Update title to use the link text from home.md if needed
lesson_copy = lesson.copy()
lesson_copy['title'] = link_text
# Add completion status if progress is provided
if progress:
lesson_key = filename.replace('.md', '')
if lesson_key in progress:
lesson_copy['completed'] = progress[lesson_key] == 'completed'
else:
lesson_copy['completed'] = False
else:
lesson_copy['completed'] = False
ordered_lessons.append(lesson_copy)
break
# Add any remaining lessons not mentioned in home.md
for lesson in all_lessons:
if not any(l['filename'] == lesson['filename'] for l in ordered_lessons):
# Add completion status if progress is provided
if progress:
lesson_key = lesson['filename'].replace('.md', '')
if lesson_key in progress:
lesson['completed'] = progress[lesson_key] == 'completed'
else:
lesson['completed'] = False
else:
lesson['completed'] = False
ordered_lessons.append(lesson)
return ordered_lessons
# If no specific order is defined in home.md, return lessons in default order
all_lessons = get_lessons_with_learning_objectives()
# Add completion status if progress is provided
if progress:
for lesson in all_lessons:
lesson_key = lesson['filename'].replace('.md', '')
if lesson_key in progress:
lesson['completed'] = progress[lesson_key] == 'completed'
else:
lesson['completed'] = False
else:
for lesson in all_lessons:
lesson['completed'] = False
return all_lessons
def render_markdown_content(file_path):
"""Render markdown content to HTML"""
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# Check if there's additional lesson info at the beginning
lesson_info = ""
lesson_content = content
initial_code = ""
solution_code = ""
expected_output = ""
key_text = ""
# Look for expected output separator - format: [content] ---EXPECTED_OUTPUT--- [output] ---END_EXPECTED_OUTPUT--- [content]
if '---EXPECTED_OUTPUT---' in lesson_content and '---END_EXPECTED_OUTPUT---' in lesson_content:
start_idx = lesson_content.find('---EXPECTED_OUTPUT---')
end_idx = lesson_content.find('---END_EXPECTED_OUTPUT---')
if start_idx != -1 and end_idx != -1 and end_idx > start_idx:
# Extract the expected output between the separators
expected_start = start_idx + len('---EXPECTED_OUTPUT---')
expected_output = lesson_content[expected_start:end_idx].strip()
# Remove the expected output section from the lesson content
lesson_content = lesson_content[:start_idx] + lesson_content[end_idx + len('---END_EXPECTED_OUTPUT---'):]
# Look for key text separator - format: [content] ---KEY_TEXT--- [keywords] ---END_KEY_TEXT--- [content]
if '---KEY_TEXT---' in lesson_content and '---END_KEY_TEXT---' in lesson_content:
start_idx = lesson_content.find('---KEY_TEXT---')
end_idx = lesson_content.find('---END_KEY_TEXT---')
if start_idx != -1 and end_idx != -1 and end_idx > start_idx:
# Extract the key text between the separators
key_start = start_idx + len('---KEY_TEXT---')
key_text = lesson_content[key_start:end_idx].strip()
# Remove the key text section from the lesson content
lesson_content = lesson_content[:start_idx] + lesson_content[end_idx + len('---END_KEY_TEXT---'):]
# Look for lesson info separator - format: ---LESSON_INFO--- [info] ---END_LESSON_INFO--- [content]
if '---LESSON_INFO---' in lesson_content and '---END_LESSON_INFO---' in lesson_content:
start_idx = lesson_content.find('---LESSON_INFO---') + len('---LESSON_INFO---')
end_idx = lesson_content.find('---END_LESSON_INFO---')
if start_idx != -1 and end_idx != -1 and end_idx > start_idx:
lesson_info = lesson_content[start_idx:end_idx].strip()
lesson_content = lesson_content[end_idx + len('---END_LESSON_INFO---'):].strip()
else:
# If the format is not correct, treat as before
pass # lesson_content remains the same
elif '---LESSON_INFO---' in lesson_content:
# Fallback for old format: content before first separator is info, after is lesson
parts = lesson_content.split('---LESSON_INFO---', 1)
if len(parts) == 2:
lesson_info = parts[0].strip()
lesson_content = parts[1].strip()
# Look for solution code separator - format: [content] ---SOLUTION_CODE--- [code] ---END_SOLUTION_CODE---
if '---SOLUTION_CODE---' in lesson_content and '---END_SOLUTION_CODE---' in lesson_content:
start_idx = lesson_content.find('---SOLUTION_CODE---')
end_idx = lesson_content.find('---END_SOLUTION_CODE---')
if start_idx != -1 and end_idx != -1 and end_idx > start_idx:
# Extract the code between the separators
code_start = start_idx + len('---SOLUTION_CODE---')
solution_code = lesson_content[code_start:end_idx].strip()
# Remove the solution code section from the lesson content
lesson_content = lesson_content[:start_idx] + lesson_content[end_idx + len('---END_SOLUTION_CODE---'):]
# Look for initial code separator - format: [content] ---INITIAL_CODE--- [code] ---END_INITIAL_CODE---
if '---INITIAL_CODE---' in lesson_content and '---END_INITIAL_CODE---' in lesson_content:
start_idx = lesson_content.find('---INITIAL_CODE---')
end_idx = lesson_content.find('---END_INITIAL_CODE---')
if start_idx != -1 and end_idx != -1 and end_idx > start_idx:
# Extract the code between the separators
code_start = start_idx + len('---INITIAL_CODE---')
initial_code = lesson_content[code_start:end_idx].strip()
# Remove the initial code section from the lesson content
lesson_content = lesson_content[:start_idx] + lesson_content[end_idx + len('---END_INITIAL_CODE---'):]
# Split content into lesson and exercise parts
parts = lesson_content.split('---EXERCISE---')
lesson_content = parts[0] if len(parts) > 0 else lesson_content
exercise_content = parts[1] if len(parts) > 1 else ""
lesson_html = markdown.markdown(lesson_content, extensions=['fenced_code', 'codehilite', 'tables'])
exercise_html = markdown.markdown(exercise_content, extensions=['fenced_code', 'codehilite', 'tables']) if exercise_content else ""
lesson_info_html = markdown.markdown(lesson_info, extensions=['fenced_code', 'codehilite', 'tables']) if lesson_info else ""
return lesson_html, exercise_html, expected_output, lesson_info_html, initial_code, solution_code, key_text
@app.route('/')
def index():
"""Main page showing all lessons"""
print("Index function called") # Logging for debugging
print(f"APP_BAR_TITLE value: {APP_BAR_TITLE}") # Logging for debugging
print(f"COPYRIGHT_TEXT value: {COPYRIGHT_TEXT}") # Logging for debugging
print(f"PAGE_TITLE_SUFFIX value: {PAGE_TITLE_SUFFIX}") # Logging for debugging
# Get token from session or request (for now, we'll pass it in the template context)
token = request.args.get('token', '') # This would typically come from session after login
# If no token provided in URL, try to get from cookie
if not token:
token = request.cookies.get('student_token', '')
# Get student progress if token is provided
progress = None
if token:
progress = get_student_progress(token)
print(f"Progress for token {token}: {progress}") # Logging for debugging
lessons = get_ordered_lessons_with_learning_objectives(progress)
# Read home content from a home.md file if it exists
home_content = ""
home_file_path = os.path.join(CONTENT_DIR, "home.md")
if os.path.exists(home_file_path):
with open(home_file_path, 'r', encoding='utf-8') as f:
full_content = f.read()
# Split content to get only the main content part (before the lesson list)
parts = full_content.split('---Available_Lessons---')
main_content = parts[0] if len(parts) > 0 else full_content
home_content = markdown.markdown(main_content, extensions=['fenced_code', 'codehilite', 'tables'])
print(f"Sending to template - token: {token}, progress: {progress}, lessons count: {len(lessons)}") # Logging for debugging
for lesson in lessons:
print(f"Lesson: {lesson['title']}, completed: {lesson.get('completed', 'N/A')}") # Logging for debugging
try:
result = render_template('index.html',
lessons=lessons,
home_content=home_content,
token=token,
progress=progress,
app_bar_title=APP_BAR_TITLE,
copyright_text=COPYRIGHT_TEXT,
page_title_suffix=PAGE_TITLE_SUFFIX)
print("Template rendered successfully")
return result
except Exception as e:
print(f"Error rendering template: {str(e)}")
import traceback
traceback.print_exc()
# Return a simple error page
return f"Error rendering template: {str(e)}"
@app.route('/lesson/<filename>')
def lesson(filename):
"""Display a specific lesson"""
file_path = os.path.join(CONTENT_DIR, filename)
if not os.path.exists(file_path):
return "Lesson not found", 404
lesson_html, exercise_html, expected_output, lesson_info, initial_code, solution_code, key_text = render_markdown_content(file_path)
# If no initial code is provided, use a default template
if not initial_code:
initial_code = """#include <stdio.h>
int main() {
// Write your code here
printf("Hello, World!\\n");
return 0;
}"""
# Get token from session or request (for now, we'll pass it in the template context)
token = request.args.get('token', '') # This would typically come from session after login
# If no token provided in URL, try to get from cookie
if not token:
token = request.cookies.get('student_token', '')
# Get student progress if token is provided
progress = None
lesson_completed = False
if token:
progress = get_student_progress(token)
if progress and filename.replace('.md', '') in progress:
lesson_completed = progress[filename.replace('.md', '')] == 'completed'
# Get ordered lessons to determine next and previous lessons
all_lessons = get_ordered_lessons_with_learning_objectives(progress)
# Find current lesson index
current_lesson_idx = -1
for i, lesson in enumerate(all_lessons):
if lesson['filename'] == filename:
current_lesson_idx = i
break
# Determine previous and next lessons
prev_lesson = None
next_lesson = None
if current_lesson_idx != -1:
if current_lesson_idx > 0:
prev_lesson = all_lessons[current_lesson_idx - 1]
if current_lesson_idx < len(all_lessons) - 1:
next_lesson = all_lessons[current_lesson_idx + 1]
# Get ordered lessons for the sidebar
ordered_lessons = get_ordered_lessons_with_learning_objectives(progress)
# Get the programming language from environment variable
programming_language = os.environ.get('DEFAULT_PROGRAMMING_LANGUAGE', 'c').lower()
language_display_name = compiler_factory.get_language_display_name(programming_language)
return render_template('lesson.html',
lesson_content=lesson_html,
exercise_content=exercise_html,
expected_output=expected_output,
lesson_info=lesson_info,
initial_code=initial_code,
solution_code=solution_code,
key_text=key_text,
lesson_title=filename.replace('.md', '').replace('_', ' ').title(),
token=token,
progress=progress,
lesson_completed=lesson_completed,
prev_lesson=prev_lesson,
next_lesson=next_lesson,
ordered_lessons=ordered_lessons,
app_bar_title=APP_BAR_TITLE,
copyright_text=COPYRIGHT_TEXT,
page_title_suffix=PAGE_TITLE_SUFFIX,
language=programming_language,
language_display_name=language_display_name)
@app.route('/get-key-text/<filename>')
def get_key_text(filename):
"""Get the key text for a specific lesson"""
file_path = os.path.join(CONTENT_DIR, filename)
if not os.path.exists(file_path):
return jsonify({'success': False, 'error': 'Lesson not found'}), 404
# Extract key text from the lesson file
_, _, _, _, _, _, key_text = render_markdown_content(file_path)
return jsonify({
'success': True,
'key_text': key_text
})
@app.route('/compile', methods=['POST'])
def compile_code():
"""Compile and run code submitted by the user in the selected programming language"""
try:
code = None
language = None
# Try to get code and language from JSON data
if request.content_type and 'application/json' in request.content_type:
try:
json_data = request.get_json(force=True)
if json_data:
code = json_data.get('code', '')
language = json_data.get('language', '') # Get language from request
except Exception as e:
# Log the error for debugging
print(f"JSON parsing error: {e}")
pass # If JSON parsing fails, continue to try form data
# If not found in JSON, try form data
if not code:
code = request.form.get('code', '')
language = request.form.get('language', '') # Get language from form
if not code:
return jsonify({
'success': False,
'output': '',
'error': 'No code provided'
})
# Get the appropriate compiler based on the language
compiler = compiler_factory.get_compiler(language)
# Compile and run the code using the selected compiler
result = compiler.compile_and_run(code)
return jsonify(result)
except Exception as e:
return jsonify({
'success': False,
'output': '',
'error': 'An error occurred: ' + str(e)
})
@app.route('/static/<path:path>')
def send_static(path):
"""Serve static files"""
return send_from_directory('static', path)
@app.route('/assets/<path:path>')
def send_assets(path):
"""Serve asset files (images, etc.)"""
return send_from_directory('assets', path)
@app.route('/login', methods=['POST'])
def login():
"""Handle student login with token"""
try:
data = request.get_json()
token = data.get('token', '').strip()
if not token:
return jsonify({'success': False, 'message': 'Token is required'})
# Validate the token
student_info = validate_token(token)
if student_info:
response = jsonify({
'success': True,
'student_name': student_info['student_name'],
'message': 'Login successful'
})
# Set token in cookie with expiration
response.set_cookie('student_token', token, httponly=True, secure=False, samesite='Lax', max_age=86400) # 24 hours
return response
else:
return jsonify({'success': False, 'message': 'Invalid token'})
except Exception as e:
return jsonify({'success': False, 'message': 'Error processing login: ' + str(e)})
@app.route('/logout', methods=['POST'])
def logout():
"""Handle student logout"""
try:
# Create response that clears the cookie
response = jsonify({
'success': True,
'message': 'Logout successful'
})
# Clear the student_token cookie
response.set_cookie('student_token', '', expires=0)
return response
except Exception as e:
return jsonify({'success': False, 'message': 'Error processing logout: ' + str(e)})
@app.route('/validate-token', methods=['POST'])
def validate_token_route():
"""Validate a token without logging in"""
try:
data = request.get_json()
token = data.get('token', '').strip()
# If no token provided in request, try to get from cookie
if not token:
token = request.cookies.get('student_token', '').strip()
if not token:
return jsonify({'success': False, 'message': 'Token is required'})
# Validate the token
student_info = validate_token(token)
if student_info:
return jsonify({
'success': True,
'student_name': student_info['student_name']
})
else:
return jsonify({'success': False, 'message': 'Invalid token'})
except Exception as e:
return jsonify({'success': False, 'message': 'Error validating token: ' + str(e)})
@app.route('/track-progress', methods=['POST'])
def track_progress():
"""Track student progress for a lesson"""
try:
data = request.get_json()
token = data.get('token', '').strip()
lesson_name = data.get('lesson_name', '').strip()
status = data.get('status', 'completed').strip()
logging.info(f"Received track-progress request: token={token}, lesson_name={lesson_name}, status={status}")
if not token or not lesson_name:
return jsonify({'success': False, 'message': 'Token and lesson name are required'})
# Validate the token first
student_info = validate_token(token)
if not student_info:
return jsonify({'success': False, 'message': 'Invalid token'})
# Update progress
updated = update_student_progress(token, lesson_name, status)
if updated:
logging.info(f"Progress updated successfully for token {token}, lesson {lesson_name}")
return jsonify({
'success': True,
'message': 'Progress updated successfully'
})
else:
logging.warning(f"Failed to update progress for token {token}, lesson {lesson_name}")
return jsonify({'success': False, 'message': 'Failed to update progress'})
except Exception as e:
logging.error("Error in track-progress: " + str(e))
return jsonify({'success': False, 'message': 'Error tracking progress: ' + str(e)})
def calculate_student_completion(student_data, all_lessons):
"""Calculate the number of completed lessons for a student"""
completed_count = 0
for lesson in all_lessons:
# Handle both the new structure and the old structure for compatibility
if isinstance(lesson, dict) and 'filename' in lesson:
lesson_key = lesson['filename'].replace('.md', '')
else:
# If lesson is just a string (fallback)
lesson_key = lesson.replace('.md', '')
if lesson_key in student_data and student_data[lesson_key] == 'completed':
completed_count += 1
return completed_count
@app.route('/progress-report')
def progress_report():
"""Display a report of all students' progress (from teacher's perspective)"""
# Get all students' progress from the CSV file
all_students_progress = []
lesson_headers = [] # Store the lesson column headers in the correct order
if os.path.exists(TOKENS_FILE):
with open(TOKENS_FILE, 'r', newline='', encoding='utf-8') as csvfile:
reader = csv.DictReader(csvfile, delimiter=';')
# Get the fieldnames to determine the correct order of lessons
lesson_headers = [field for field in reader.fieldnames if field not in ['token', 'nama_siswa']]
# Get all lessons to map filenames to titles
all_lessons_dict = {}
for lesson in get_lessons_with_learning_objectives():
lesson_key = lesson['filename'].replace('.md', '')
all_lessons_dict[lesson_key] = lesson
# Create ordered lesson list based on CSV column order
ordered_lessons = []
for lesson_header in lesson_headers:
if lesson_header in all_lessons_dict:
ordered_lessons.append(all_lessons_dict[lesson_header])
else:
# If lesson not found in content directory, create a minimal entry
ordered_lessons.append({
'filename': f"{lesson_header}.md",
'title': lesson_header.replace('_', ' ').title(),
'description': 'Lesson information not available'
})
for row in reader:
# Create a copy of the row without the token
student_data = dict(row)
del student_data['token'] # Don't include token in the displayed data
# Add completion count to student data
student_data['completed_count'] = calculate_student_completion(student_data, ordered_lessons)
all_students_progress.append(student_data)
return render_template('progress_report.html',
all_students_progress=all_students_progress,
all_lessons=ordered_lessons,
app_bar_title=APP_BAR_TITLE,
copyright_text=COPYRIGHT_TEXT,
page_title_suffix=PAGE_TITLE_SUFFIX)
@app.route('/progress-report/export-csv')
def export_progress_csv():
"""Export the progress report as CSV"""
# Get all students' progress from the CSV file
all_students_progress = []
lesson_headers = [] # Store the lesson column headers in the correct order
if os.path.exists(TOKENS_FILE):
with open(TOKENS_FILE, 'r', newline='', encoding='utf-8') as csvfile:
reader = csv.DictReader(csvfile, delimiter=';')
# Get the fieldnames to determine the correct order of lessons
lesson_headers = [field for field in reader.fieldnames if field not in ['token', 'nama_siswa']]
# Get all lessons to map filenames to titles
all_lessons_dict = {}
for lesson in get_lessons_with_learning_objectives():
lesson_key = lesson['filename'].replace('.md', '')
all_lessons_dict[lesson_key] = lesson
# Create ordered lesson list based on CSV column order
ordered_lessons = []
for lesson_header in lesson_headers:
if lesson_header in all_lessons_dict:
ordered_lessons.append(all_lessons_dict[lesson_header])
else:
# If lesson not found in content directory, create a minimal entry
ordered_lessons.append({
'filename': f"{lesson_header}.md",
'title': lesson_header.replace('_', ' ').title(),
'description': 'Lesson information not available'
})
for row in reader:
# Create a copy of the row without the token
student_data = dict(row)
del student_data['token'] # Don't include token in the exported data
# Add completion count to student data
student_data['completed_count'] = calculate_student_completion(student_data, ordered_lessons)
all_students_progress.append(student_data)
# Create CSV response
import io
from flask import Response
output = io.StringIO()
if all_students_progress:
# Write CSV header
fieldnames = list(all_students_progress[0].keys())
writer = csv.DictWriter(output, fieldnames=fieldnames, delimiter=';')
writer.writeheader()
writer.writerows(all_students_progress)
# Create response with CSV content
response = Response(
output.getvalue(),
mimetype='text/csv',
headers={"Content-Disposition": "attachment; filename=progress_report.csv"}
)
return response
@app.context_processor
def inject_functions():
"""Make functions available in templates"""
return dict(
get_lessons=get_lessons,
get_ordered_lessons_with_learning_objectives=get_ordered_lessons_with_learning_objectives
)
# Initialize the tokens file when the app starts
initialize_tokens_file()
if __name__ == '__main__':
import os
debug_mode = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true'
app.run(host='0.0.0.0', port=5000, debug=debug_mode)