899 lines
37 KiB
Python
899 lines
37 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 = ""
|
|
|
|
# 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 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
|
|
|
|
@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 = 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,
|
|
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('/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) |