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

"""
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