504 lines
19 KiB
Python
504 lines
19 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
|
|
import glob
|
|
import csv
|
|
import uuid
|
|
from datetime import datetime
|
|
|
|
app = Flask(__name__)
|
|
|
|
# Configuration
|
|
CONTENT_DIR = 'content'
|
|
STATIC_DIR = 'static'
|
|
TEMPLATES_DIR = 'templates'
|
|
TOKENS_FILE = 'tokens.csv'
|
|
|
|
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):
|
|
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:
|
|
if lesson_name in fieldnames:
|
|
row[lesson_name] = status
|
|
updated = True
|
|
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)
|
|
|
|
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 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"""
|
|
lessons = get_ordered_lessons()
|
|
|
|
# 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'])
|
|
|
|
return render_template('index.html', lessons=lessons, home_content=home_content)
|
|
|
|
@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;
|
|
}"""
|
|
|
|
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())
|
|
|
|
@app.route('/compile', methods=['POST'])
|
|
def compile_code():
|
|
"""Compile and run C code submitted by the user"""
|
|
try:
|
|
code = None
|
|
|
|
# Try to get code 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 and 'code' in json_data:
|
|
code = json_data['code']
|
|
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', '')
|
|
|
|
if not code:
|
|
return jsonify({
|
|
'success': False,
|
|
'output': '',
|
|
'error': 'No code provided'
|
|
})
|
|
|
|
# Create a temporary file for the C code
|
|
with tempfile.NamedTemporaryFile(mode='w', suffix='.c', delete=False) as temp_c:
|
|
temp_c.write(code)
|
|
temp_c_path = temp_c.name
|
|
|
|
# Create a temporary file for the executable
|
|
temp_exe_path = temp_c_path.replace('.c', '')
|
|
|
|
# Compile the C code
|
|
compile_result = subprocess.run(
|
|
['gcc', temp_c_path, '-o', temp_exe_path],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10
|
|
)
|
|
|
|
if compile_result.returncode != 0:
|
|
# Compilation failed
|
|
result = {
|
|
'success': False,
|
|
'output': compile_result.stdout, # Include any stdout if available
|
|
'error': compile_result.stderr # Show GCC error messages
|
|
}
|
|
else:
|
|
# Compilation succeeded, run the program
|
|
try:
|
|
run_result = subprocess.run(
|
|
[temp_exe_path],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5
|
|
)
|
|
|
|
result = {
|
|
'success': True,
|
|
'output': run_result.stdout,
|
|
'error': run_result.stderr if run_result.stderr else None
|
|
}
|
|
except subprocess.TimeoutExpired:
|
|
result = {
|
|
'success': False,
|
|
'output': '',
|
|
'error': 'Program execution timed out'
|
|
}
|
|
|
|
# Clean up temporary files
|
|
try:
|
|
os.remove(temp_c_path)
|
|
if os.path.exists(temp_exe_path):
|
|
os.remove(temp_exe_path)
|
|
except:
|
|
pass # Ignore cleanup errors
|
|
|
|
return jsonify(result)
|
|
|
|
except Exception as e:
|
|
return jsonify({
|
|
'success': False,
|
|
'output': '',
|
|
'error': f'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:
|
|
return jsonify({
|
|
'success': True,
|
|
'student_name': student_info['student_name'],
|
|
'message': 'Login successful'
|
|
})
|
|
else:
|
|
return jsonify({'success': False, 'message': 'Invalid token'})
|
|
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'message': f'Error processing login: {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 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': f'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()
|
|
|
|
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:
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Progress updated successfully'
|
|
})
|
|
else:
|
|
return jsonify({'success': False, 'message': 'Failed to update progress'})
|
|
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'message': f'Error tracking progress: {str(e)}'})
|
|
|
|
@app.context_processor
|
|
def inject_functions():
|
|
"""Make get_lessons function available in templates"""
|
|
return dict(get_lessons=get_lessons)
|
|
|
|
# Initialize the tokens file when the app starts
|
|
initialize_tokens_file()
|
|
|
|
if __name__ == '__main__':
|
|
app.run(host='0.0.0.0', port=5000, debug=False) |