You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
320 lines
11 KiB
320 lines
11 KiB
"""
|
|
Course Model - Loads and manages course structure from course.json
|
|
"""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional
|
|
from app.config import COURSE_JSON, LESSONS_DIR
|
|
|
|
|
|
class Lesson:
|
|
"""Represents a single lesson"""
|
|
|
|
def __init__(self, data: dict, part_id: str, section_id: str, order: int = 0):
|
|
self.id = data['id']
|
|
self.filename = data['filename']
|
|
self.title = data['title']
|
|
self.estimated_time = data['estimated_time']
|
|
self.difficulty = data['difficulty']
|
|
self.part_id = part_id
|
|
self.section_id = section_id
|
|
self.order = order # Sequential order in course (1-30)
|
|
self.points = data.get('points', 0) # Points for completion
|
|
|
|
# Construct full path to lesson file
|
|
section_path = LESSONS_DIR / section_id.replace('-', '_').replace('fundamentals', '01-fundamentals').replace('optimization', '02-optimization').replace('spark_physics', '03-spark-physics').replace('advanced_modeling', '04-advanced-modeling')
|
|
self.file_path = section_path / self.filename
|
|
|
|
def __repr__(self):
|
|
return f"<Lesson {self.id}: {self.title}>"
|
|
|
|
|
|
class Section:
|
|
"""Represents a course section (e.g., 'fundamentals')"""
|
|
|
|
def __init__(self, data: dict, part_id: str, lesson_order_start: int = 1):
|
|
self.id = data['id']
|
|
self.title = data['title']
|
|
self.path = data['path']
|
|
self.description = data['description']
|
|
self.part_id = part_id
|
|
|
|
# Load lessons with sequential ordering
|
|
self.lessons = []
|
|
for i, lesson_data in enumerate(data['lessons']):
|
|
lesson = Lesson(lesson_data, part_id, self.id, lesson_order_start + i)
|
|
self.lessons.append(lesson)
|
|
|
|
self.exercises = data.get('exercises', [])
|
|
self.key_concepts = data.get('key_concepts', [])
|
|
|
|
def get_lesson(self, lesson_id: str) -> Optional[Lesson]:
|
|
"""Get lesson by ID"""
|
|
for lesson in self.lessons:
|
|
if lesson.id == lesson_id:
|
|
return lesson
|
|
return None
|
|
|
|
def __repr__(self):
|
|
return f"<Section {self.id}: {len(self.lessons)} lessons>"
|
|
|
|
|
|
class Part:
|
|
"""Represents a course part (e.g., 'Part 1: Fundamentals')"""
|
|
|
|
def __init__(self, data: dict, part_number: int, lesson_order_start: int = 1):
|
|
self.id = data['id']
|
|
self.title = data['title']
|
|
self.description = data['description']
|
|
self.estimated_time = data['estimated_time']
|
|
self.number = part_number # Part number (1-4)
|
|
|
|
# Load sections with sequential lesson ordering
|
|
self.sections = []
|
|
current_order = lesson_order_start
|
|
for section_data in data['sections']:
|
|
section = Section(section_data, self.id, current_order)
|
|
self.sections.append(section)
|
|
current_order += len(section.lessons)
|
|
|
|
# Create a convenience property for accessing all lessons in this part
|
|
self.lessons = self.get_all_lessons()
|
|
|
|
def get_lesson(self, lesson_id: str) -> Optional[Lesson]:
|
|
"""Get lesson by ID from any section in this part"""
|
|
for section in self.sections:
|
|
lesson = section.get_lesson(lesson_id)
|
|
if lesson:
|
|
return lesson
|
|
return None
|
|
|
|
def get_all_lessons(self) -> List[Lesson]:
|
|
"""Get all lessons in this part"""
|
|
lessons = []
|
|
for section in self.sections:
|
|
lessons.extend(section.lessons)
|
|
return lessons
|
|
|
|
def __repr__(self):
|
|
return f"<Part {self.id}: {len(self.sections)} sections>"
|
|
|
|
|
|
class LearningPath:
|
|
"""Represents a learning path (e.g., 'beginner', 'complete')"""
|
|
|
|
def __init__(self, data: dict):
|
|
self.id = data['id']
|
|
self.title = data['title']
|
|
self.description = data['description']
|
|
self.lessons = data.get('lessons', [])
|
|
self.skip = data.get('skip', [])
|
|
|
|
def includes_lesson(self, lesson_id: str) -> bool:
|
|
"""Check if lesson is included in this path"""
|
|
if self.lessons == 'all':
|
|
return lesson_id not in self.skip
|
|
return lesson_id in self.lessons
|
|
|
|
def __repr__(self):
|
|
return f"<LearningPath {self.id}: {self.title}>"
|
|
|
|
|
|
class Course:
|
|
"""Main course model - loads and manages entire course structure"""
|
|
|
|
def __init__(self, course_json_path: Path = None):
|
|
"""
|
|
Load course from course.json
|
|
|
|
Args:
|
|
course_json_path: Path to course.json file (default: from config)
|
|
"""
|
|
if course_json_path is None:
|
|
course_json_path = COURSE_JSON
|
|
|
|
self.json_path = course_json_path
|
|
self._load_course()
|
|
|
|
def _load_course(self):
|
|
"""Load and parse course.json"""
|
|
try:
|
|
with open(self.json_path, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
|
|
# Course metadata
|
|
self.title = data['title']
|
|
self.version = data['version']
|
|
self.author = data['author']
|
|
self.description = data['description']
|
|
self.estimated_total_time = data['estimated_total_time']
|
|
self.total_lessons = data['total_lessons']
|
|
self.total_exercises = data['total_exercises']
|
|
self.total_points = data['total_points']
|
|
|
|
# Prerequisites
|
|
self.prerequisites_required = data['prerequisites']['required']
|
|
self.prerequisites_recommended = data['prerequisites']['recommended']
|
|
|
|
# Load course structure (4 parts) with sequential numbering
|
|
self.parts = []
|
|
current_order = 1
|
|
for i, part_data in enumerate(data['structure']):
|
|
part = Part(part_data, i + 1, current_order)
|
|
self.parts.append(part)
|
|
current_order += len(part.get_all_lessons())
|
|
|
|
# Reference materials
|
|
self.reference_materials = data['reference_materials']
|
|
|
|
# Worked examples
|
|
self.worked_examples = data['worked_examples']
|
|
|
|
# Learning paths
|
|
self.learning_paths = [
|
|
LearningPath(path_data)
|
|
for path_data in data['learning_paths']
|
|
]
|
|
|
|
# Tags
|
|
self.tags = data.get('tags', {})
|
|
|
|
# Metadata
|
|
self.metadata = data.get('metadata', {})
|
|
|
|
# Build lesson index for quick lookup
|
|
self._build_lesson_index()
|
|
|
|
print(f"[Course] Loaded: {self.title}")
|
|
print(f"[Course] {self.total_lessons} lessons across {len(self.parts)} parts")
|
|
|
|
except FileNotFoundError:
|
|
print(f"[Course ERROR] course.json not found: {self.json_path}")
|
|
raise
|
|
except json.JSONDecodeError as e:
|
|
print(f"[Course ERROR] Invalid JSON: {e}")
|
|
raise
|
|
except KeyError as e:
|
|
print(f"[Course ERROR] Missing required field: {e}")
|
|
raise
|
|
|
|
def _build_lesson_index(self):
|
|
"""Build index for fast lesson lookup by ID"""
|
|
self._lesson_index = {}
|
|
for part in self.parts:
|
|
for section in part.sections:
|
|
for lesson in section.lessons:
|
|
self._lesson_index[lesson.id] = lesson
|
|
|
|
def get_lesson(self, lesson_id: str) -> Optional[Lesson]:
|
|
"""Get lesson by ID (fast lookup)"""
|
|
return self._lesson_index.get(lesson_id)
|
|
|
|
def get_all_lessons(self) -> List[Lesson]:
|
|
"""Get all lessons in course order"""
|
|
lessons = []
|
|
for part in self.parts:
|
|
lessons.extend(part.get_all_lessons())
|
|
return lessons
|
|
|
|
def get_lesson_by_index(self, index: int) -> Optional[Lesson]:
|
|
"""Get lesson by sequential index (0-29)"""
|
|
all_lessons = self.get_all_lessons()
|
|
if 0 <= index < len(all_lessons):
|
|
return all_lessons[index]
|
|
return None
|
|
|
|
def get_lesson_index(self, lesson_id: str) -> Optional[int]:
|
|
"""Get sequential index of a lesson (0-29)"""
|
|
all_lessons = self.get_all_lessons()
|
|
for i, lesson in enumerate(all_lessons):
|
|
if lesson.id == lesson_id:
|
|
return i
|
|
return None
|
|
|
|
def get_next_lesson(self, lesson_id: str) -> Optional[Lesson]:
|
|
"""Get next lesson in sequence"""
|
|
index = self.get_lesson_index(lesson_id)
|
|
if index is not None:
|
|
return self.get_lesson_by_index(index + 1)
|
|
return None
|
|
|
|
def get_prev_lesson(self, lesson_id: str) -> Optional[Lesson]:
|
|
"""Get previous lesson in sequence"""
|
|
index = self.get_lesson_index(lesson_id)
|
|
if index is not None and index > 0:
|
|
return self.get_lesson_by_index(index - 1)
|
|
return None
|
|
|
|
def get_part(self, part_id: str) -> Optional[Part]:
|
|
"""Get part by ID"""
|
|
for part in self.parts:
|
|
if part.id == part_id:
|
|
return part
|
|
return None
|
|
|
|
def get_learning_path(self, path_id: str) -> Optional[LearningPath]:
|
|
"""Get learning path by ID"""
|
|
for path in self.learning_paths:
|
|
if path.id == path_id:
|
|
return path
|
|
return None
|
|
|
|
def get_lessons_for_path(self, path_id: str) -> List[Lesson]:
|
|
"""Get all lessons for a specific learning path"""
|
|
path = self.get_learning_path(path_id)
|
|
if not path:
|
|
return []
|
|
|
|
all_lessons = self.get_all_lessons()
|
|
if path.lessons == 'all':
|
|
return [l for l in all_lessons if l.id not in path.skip]
|
|
else:
|
|
return [l for l in all_lessons if l.id in path.lessons]
|
|
|
|
def get_lessons_by_tag(self, tag: str) -> List[Lesson]:
|
|
"""Get all lessons with a specific tag"""
|
|
if tag not in self.tags:
|
|
return []
|
|
|
|
lesson_ids = self.tags[tag]
|
|
return [self.get_lesson(lid) for lid in lesson_ids if self.get_lesson(lid)]
|
|
|
|
def get_part_for_lesson(self, lesson_id: str) -> Optional[Part]:
|
|
"""Get the part that contains a lesson"""
|
|
for part in self.parts:
|
|
if part.get_lesson(lesson_id):
|
|
return part
|
|
return None
|
|
|
|
def search_lessons(self, query: str) -> List[Lesson]:
|
|
"""Simple search by lesson title"""
|
|
query = query.lower()
|
|
results = []
|
|
for lesson in self.get_all_lessons():
|
|
if query in lesson.title.lower() or query in lesson.id.lower():
|
|
results.append(lesson)
|
|
return results
|
|
|
|
def validate(self) -> bool:
|
|
"""Validate that all lesson files exist"""
|
|
all_valid = True
|
|
for lesson in self.get_all_lessons():
|
|
if not lesson.file_path.exists():
|
|
print(f"[Course WARN] Missing lesson file: {lesson.file_path}")
|
|
all_valid = False
|
|
return all_valid
|
|
|
|
def __repr__(self):
|
|
return f"<Course: {self.total_lessons} lessons, {len(self.parts)} parts>"
|
|
|
|
|
|
# Global course instance
|
|
_course_instance = None
|
|
|
|
def get_course() -> Course:
|
|
"""Get global course instance (singleton)"""
|
|
global _course_instance
|
|
if _course_instance is None:
|
|
_course_instance = Course()
|
|
return _course_instance
|