# Project export: CheatSheet

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: CruzHacks 2026
- Tagline: Personalized feedback is hard to get, especially in large classes. Instructors just don't have enough time! CheatSheet, an AI driven data analytics platform, solves this problem.
- Devpost: https://devpost.com/software/cheatsheet-ule96x
- GitHub: https://github.com/owenarnst/cheat-sheet
- Video: https://www.youtube.com/embed/DSI1CYPNB2c?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 3 GitHub contributor(s) — Arnav Srivastav (6 commits), Owen Arnst (4 commits), arnsri33 (1 commits)

## Devpost submission (written by the team)

### Inspiration

We recognized a significant gap in the education system: students often spend too much time confused about what to study, while instructors lack the bandwidth to provide immediate, personalized guidance to every individual. We wanted to bridge that gap.

### What it does

CheatSheet seamlessly integrates with Canvas to retrieve course data. It leverages Generative AI to produce detailed, granular metrics that visualize a student's actual understanding of the course material, moving beyond simple letter grades.

### How we built it

Synthetic Data Generation: We created a custom course on Canvas and utilized Gemini to generate comprehensive course materials (assignments, quizzes). We then deployed a 2B parameter LLM to simulate realistic student performance and responses. Content Analysis: We used Gemini to parse and analyze the course syllabus, assignment descriptions, and quiz questions. Knowledge Mapping: Using a Gemini embedding model, we semantically mapped assignments and quizzes to specific course topics derived from the syllabus. Algorithmic Scoring: We implemented a custom algorithm leveraging Bayesian methods to calculate proficiency levels for specific topics based on graded assignment data.

### Challenges we ran into

Hardware Failures: One of our key development laptops suffered a critical failure right before the integration and deployment phase, forcing us to scramble for resources. Strategic Pivots: We identified flaws in our initial approach mid-hackathon and had to execute a rapid pivot to a more viable architecture.

### Accomplishments we're proud of

Feature Completeness: Despite the setbacks, we successfully implemented every core feature we initially planned. Seamless Integration: We proved that our system can easily and effectively integrate with the Canvas LMS API.

### What we learned

We gained a deep appreciation for the complexity of educational structuring. Creating a cohesive curriculum—and building software to manage it—is a multifaceted challenge that requires precise data handling.

### What's next

Automated Grading: Integrating directly with auto-graders for real-time feedback loops. Longitudinal Tracking: Implementing features to track student learning trajectories across multiple classes and semesters.

## README (from the GitHub repository)

# CheatSheet 📊

AI-powered course analytics dashboard for Canvas LMS. Generate student insights, identify knowledge gaps, and create personalized practice problems.

## Architecture

CheatSheet consists of two services:

| Service | Port | Description |
|---------|------|-------------|
| **Frontend App** | 5001 | Flask web dashboard for teachers and students |
| **AI Service** | 8001 | FastAPI backend for ML-powered topic analysis |

## Features

- **Teacher Dashboard** - View all your Canvas courses at a glance
- **Student Insights** - AI-analyzed topic mastery based on grading patterns
- **Practice PDFs** - Generate targeted practice problems for struggling areas

## Prerequisites

- Docker & Docker Compose
- Canvas LMS Access Token
- Supabase account (for authentication)
- Gemini API Key (for AI service)

## Environment Setup

### Frontend App (.env)
```env
SUPABASE_URL=your_supabase_project_url
SUPABASE_KEY=your_supabase_anon_key
CANVAS_INSTANCE=canvas.instructure.com
```

### AI Service (aiservice/.env)
```env
GEMINI_API_KEY=your_gemini_api_key
```

## Running with Docker

### Start Frontend App
```bash
docker compose up -d --build
```
App available at **http://localhost:5001**

### Start AI Service
```bash
cd aiservice
docker compose -f docker-compose.prod.yml up -d --build
```
API available at **http://localhost:8001**

### View Logs
```bash
docker compose logs -f           # Frontend
docker compose -f aiservice/docker-compose.prod.yml logs -f  # AI Service
```

### Stop Services
```bash
docker compose down
cd aiservice && docker compose -f docker-compose.prod.yml down
```

## AI Service Endpoints

| Endpoint | Method | Description |
|----------|--------|-------------|
| `/topic-extraction` | POST | Extract topics from syllabus |
| `/assessment-processing` | POST | Process assignments into question chunks |
| `/topic-mapping` | POST | Map questions to topics via embeddings |
| `/topic-understanding` | POST | Compute mastery scores (Bayesian inference) |
| `/problem-generation` | POST | Generate practice problems with Gemini |
| `/health` | GET | Health check |

## Project Structure

```
├── app.py                  # Flask frontend application
├── db.py                   # Supabase database operations
├── insights.py             # Insight generation (calls AI service)
├── templates/              # HTML templates
├── static/css/             # Stylesheets
├── Dockerfile.app          # Frontend Docker config
├── docker-compose.yml      # Frontend compose config
└── aiservice/
    ├── backend/
    │   ├── app.py          # FastAPI AI service
    │   ├── src/            # ML services & schemas
    │   └── Dockerfile      # AI service Docker config
    └── docker-compose.prod.yml
```

## Tech Stack

- **Frontend**: Flask, Python 3.12, Glassmorphism CSS
- **AI Backend**: FastAPI, Gemini API
- **Database**: Supabase (PostgreSQL)
- **Auth**: Supabase Auth
- **ML**: Embeddings, Cosine similarity, Bayesian inference
- **PDF Generation**: xhtml2pdf
- **Containerization**: Docker


## Detected evidence (automated analysis)

Indexed codebase: 21 recognized source files, 115 KB.
- CSS (language) — detected in the code
- Flask (technology) — detected in the code
- HTML (language) — detected in the code
- Python (language) — detected in the code
- Supabase (technology) — detected in the code

## Codebase structure (from repository index)

### Files (28 of 28)

```
.dockerignore
.gitignore
app.py
buttonclick.py
db.py
docker-compose.yml
Dockerfile
Dockerfile.app
drillassignment.py
exportgrades.py
givesyllabus.py
insights_cache.json
insights.py
output.txt
README.md
requirements.txt
static/css/style.css
templates/course_detail.html
templates/error.html
templates/index.html
templates/insights.html
templates/login.html
templates/problems.html
templates/set_special_token.html
templates/set_token.html
templates/student_dashboard.html
test.py
verify_student_flow.py
```

### Dependencies

- requirements.txt: beautifulsoup4, flask, markdown2, python-dotenv, requests, supabase, xhtml2pdf

### Recent commits (newest first)

- Final Submission CruzHacks
- Merge pull request #1 from owenarnst/canvasOauth
- Final Submission
- Final Submission
- final
- everything before insights are shown
- logging into the webapp
- init backend
- init backend
- init backend
- Update README.md
- Initial commit

## Key source files (fetched from GitHub, selected and truncated for size)

### requirements.txt

```
flask
requests
python-dotenv
supabase
beautifulsoup4
markdown2
xhtml2pdf

```

### Dockerfile

```
FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .

RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 5000

CMD ["python", "app.py"]

```

### docker-compose.yml

```yaml
version: '3.8'

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile.app
    container_name: cheat-sheet-app
    restart: always
    ports:
      - "5001:5001"
    env_file:
      - .env
    environment:
      - FLASK_ENV=production
      - PYTHONUNBUFFERED=1
    healthcheck:
      test: [ "CMD", "python", "-c", "import requests; requests.get('http://localhost:5001/login', timeout=5)" ]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 10s

```

### app.py

```python
from flask import Flask, render_template, request, redirect, url_for, session
import requests
import os
# from extractTopics import extract_topics
# from dotenv import load_dotenv # Assuming dotenv loaded by venv or system
from db import supabase, get_user_profile, set_initial_profile, set_user_canvas_token, set_user_special_token
from insights import generate_insights, refresh_course_cache, generate_practice_problems
from flask import send_file
import io

app = Flask(__name__)
app.secret_key = os.urandom(24) # Secure session key

# Default to the public Canvas instance, but allow override
CANVAS_INSTANCE = os.environ.get('CANVAS_INSTANCE', 'canvas.instructure.com')
if not CANVAS_INSTANCE.startswith('http'):
    CANVAS_INSTANCE = f'https://{CANVAS_INSTANCE}'

@app.route('/', methods=['GET'])
def index():
    if 'user' not in session:
        return redirect(url_for('login'))
    
    user_id = session['user']['id']
    profile = get_user_profile(user_id)
    
    if not profile:
        # Should not happen if signup works, but if it does, log them out or show error
        return redirect(url_for('logout'))

    role = profile.get('role', 'teacher') # Default to teacher for legacy users

    if role == 'teacher':
        token = profile.get('canvas_token')
        if not token:
            return redirect(url_for('set_canvas_token'))
            
        # Teacher Dashboard
        headers = {'Authorization': f'Bearer {token}'}
        params = {
            'enrollment_state': 'active',
            'enrollment_type': 'teacher',
            'per_page': 100,
            'include[]': ['term', 'teachers'] 
        }
        
        try:
            response = requests.get(f'{CANVAS_INSTANCE}/api/v1/courses', headers=headers, params=params)
            response.raise_for_status()
            courses = response.json()
            valid_courses = [c for c in courses if 'name' in c]
            return render_template('index.html', courses=valid_courses, user=session['user'], role='teacher')
        except requests.exceptions.HTTPError as e:
            error_msg = f"HTTP Error: {e}"
            if e.response.status_code == 401:
                return render_template('error.html', message="Unauthorized: Invalid Access Token from Canvas.")
            return render_template('error.html', message=error_msg)
        except Exception as e:
            return render_template('error.html', message=f"An error occurred: {e}")

    elif role == 'student':
        token = profile.get('special_token')
        if not token:
            return redirect(url_for('set_special_token'))
        
        # Student Dashboard
        return render_template('student_dashboard.html', token=token, user=session['user'])

    else:
        return render_template('error.html', message="Unknown Role")

@app.route('/course/<int:course_id>')
def course_detail(course_id):
    if 'user' not in session:
        return redirect(url_for('login'))
        
    user_id = session['user']['id']
    profile = get_user_profile(user_id)
    token = profile.get('canvas_token')
    
    if not token or profile.get('role') != 'teacher':
        return redirect(url_for('index')) # Students shouldn't see this yet? Or maybe they can? User said "Teacher... can click". Strict for now.

    headers = {'Authorization': f'Bearer {token}'}
    
    try:
        # 1. Course Details (with Syllabus)
        course_resp = requests.get(f'{CANVAS_INSTANCE}/api/v1/courses/{course_id}', headers=headers, params={'include[]': ['syllabus_body', 'term']})
        course_resp.raise_for_status()
        course = course_resp.json()

        # 2. Students
        students_resp = requests.get(f'{CANVAS_INSTANCE}/api/v1/courses/{course_id}/users', headers=headers, params={'enrollment_type[]': 'student', 'include[]': ['avatar_url', 'email'], 'per_page': 100})
        # Note: raise_for_status might fail if teacher doesn't have permission to see users? usually they do.
        students = students_resp.json() if students_resp.status_code == 200 else []

        return render_template('course_detail.html', course=course, students=students)

    except Exception as e:
         return render_template('error.html', message=f"Error loading course details: {e}")

@app.route('/course/<int:course_id>/insights', methods=['POST'])
def view_insights(course_id):
    if 'user' not in session:
        return redirect(url_for('login'))
        
    student_id = request.form.get('student_id') # Optional: if None, gets all
    
    # Note: Currently generates insights for ALL courses/assignments due to pipeline structure
    # In future, pass course_id to generate_insights to filter.
    insights_data, error = generate_insights(student_id=student_id)
    
    if error:
        return render_template('error.html', message=f"Failed to generate insights: {error}")
        
    return render_template('insights.html', insights=insights_data, student_id=student_id, course_id=course_id)

@app.route('/course/<int:course_id>/problems', methods=['POST'])
def view_problems(course_id):
    if 'user' not in session:
        return redirect(url_for('login'))
        
    student_id = request.form.get('student_id')
    
    # Re-fetch insights (fast due to cache) to identify topics
    insights_data, error = generate_insights(student_id=student_id)
    
    if error:
        return render_template('error.html', message=f"Failed to retrieve data for problems: {error}")
        
    # Generate PDF
    pdf_bytes, error = generate_practice_problems(insights_data)
    
    if error:
         return render_template('error.html', message=f"Failed to generate problems: {error}")
         
    # Return as download
    # FPDF2 output usually returns bytes or bytearray
    return send_file(
        io.BytesIO(pdf_bytes),
        mimetype='application/pdf',
        as_attachment=True,
        download_name='practice_problems.pdf'
    )

@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.me
[truncated — 3622 more characters]
```

### test.py

```python
from exportgrades import get_all_grades

# Example 1: Get grades WITHOUT comments (Default)
print("\n--- Fetching Grades (No Comments) ---")
grades_simple = get_all_grades(include_comments=False)

# Print details to show we got grades for everything
for course, students in grades_simple.items():
    print(f"\nCourse: {course}")
    for student, assignments in students.items():
        print(f"  Student: {student}")
        for assign in assignments:
            print(f"    - {assign['assignment']} (ID: {assign.get('assignment_id')}): Score={assign['score']} ({assign['percentage']})")




# Example 2: Get grades WITH comments

print("\n--- Fetching Grades (With Comments) ---")
grades_detailed = get_all_grades(include_comments=True)

# Print specific details to verify comments are present
for course, students in grades_detailed.items():
    for student, assignments in students.items():
        for assign in assignments:
            if assign['comments']:
                print(f"Found comments for {student} in '{assign['assignment']}':")
                for c in assign['comments']:
                    print(f"  - {c.get('comment')}")

```

### db.py

```python
import os
from supabase import create_client, Client

url: str = os.environ.get("SUPABASE_URL", "")
key: str = os.environ.get("SUPABASE_KEY", "")

supabase: Client = create_client(url, key)

def get_user_profile(user_id: str):
    """Retrieves the profile (role, tokens) for a given user ID."""
    try:
        response = supabase.table("user_tokens").select("*").eq("user_id", user_id).execute()
        if response.data:
            return response.data[0]
    except Exception as e:
        print(f"Error fetching profile: {e}")
    return None

def set_initial_profile(user_id: str, role: str):
    """Sets the initial role for a user."""
    try:
        # Default empty tokens
        data = {"user_id": user_id, "role": role, "canvas_token": "", "special_token": ""}
        response = supabase.table("user_tokens").upsert(data).execute()
        return response.data
    except Exception as e:
        print(f"Error setting initial profile: {e}")
        # raise e # Don't raise, just log. Setup might be partial.

def set_user_canvas_token(user_id: str, token: str):
    """Sets the Canvas Access Token."""
    try:
        data = {"user_id": user_id, "canvas_token": token}
        # We use upsert to create or update. Note: this might wipe role if not careful if we just pass these fields?
        # Supabase upsert updates existing row if PK matches. We should ideally only update the specific field.
        # But .upsert() requires all non-null fields or it might error if they don't have defaults?
        # Actually, let's use .update() since row should exist from set_initial_profile
        response = supabase.table("user_tokens").update({"canvas_token": token}).eq("user_id", user_id).execute()
        return response.data
    except Exception as e:
        print(f"Error setting canvas token: {e}")
        raise e

def set_user_special_token(user_id: str, token: str):
    """Sets the Special Token."""
    try:
        response = supabase.table("user_tokens").update({"special_token": token}).eq("user_id", user_id).execute()
        return response.data
    except Exception as e:
        print(f"Error setting special token: {e}")
        raise e

```

### verify_student_flow.py

```python
import requests
import string
import random

BASE_URL = "http://127.0.0.1:5001"

def get_random_string(length=10):
    letters = string.ascii_lowercase
    return ''.join(random.choice(letters) for i in range(length))

def verify_flow():
    email = f"student_{get_random_string()}@example.com"
    password = "Password123!"
    role = "student"
    
    session = requests.Session()
    
    print(f"1. Attempting Signup with {email} as {role}...")
    signup_payload = {
        "email": email,
        "password": password,
        "action": "signup",
        "role": role
    }
    r = session.post(f"{BASE_URL}/login", data=signup_payload)
    
    # Check if we were redirected to index (meaning success) or stayed on login
    if r.url == f"{BASE_URL}/" or r.url == f"{BASE_URL}/set-special-token":
        print("   Signup Auto-Login Successful!")
    elif "Signup successful" in r.text:
         print("   Signup successful (Manual Login required). Logging in...")
         login_payload = {
            "email": email,
            "password": password,
            "action": "login",
            "role": role # Should not matter for login but standardizing
         }
         r = session.post(f"{BASE_URL}/login", data=login_payload)
    else:
        print(f"   Signup Failed. URL: {r.url}")
        # Debug: Print the error message from the HTML
        if 'class="error-message"' in r.text:
            start = r.text.find('class="error-message">') + len('class="error-message">')
            end = r.text.find('</div>', start)
            print(f"   Error on Page: {r.text[start:end].strip()}")
        else:
            print("   No specific error message found in HTML.")
            print(f"   Status Code: {r.status_code}")
            print(f"   Response Preview: {r.text[:600]}")
        return False

    print(f"2. Current URL after Login: {r.url}")
    
    # We expect to be redirected to /set-special-token because we are a student without a token
    if "/set-special-token" in r.url:
        print("   Correctly redirected to Token Entry page.")
    elif "/set-canvas-token" in r.url:
        print("   FAILURE: Redirected to Teacher Token page!")
        return False
    elif r.url.rstrip('/') == BASE_URL.rstrip('/'):
        # Maybe we are at index? logic says if no token -> redirect. 
        # Let's check if we are actually at index and it didn't redirect (bug?)
        print("   At Index... Checking content...")
    else:
        print("   Unexpected URL.")
        return False

    # 3. Submit Special Token
    print("3. Submitting Special Token...")
    token_val = "SPECIAL_CODE_XYZ"
    r = session.post(f"{BASE_URL}/set-special-token", data={"token": token_val})
    
    print(f"4. Post-Token URL: {r.url}")
    if r.url.rstrip('/') == BASE_URL.rstrip('/'):
        print("   Redirected to Dashboard (Index).")
    else:
         print("   Failed to redirect to dashboard.")
         return False

    # 4. Verify Dashboard Content
    print("5. Verifying Dashboard Content...")
    if "Student Dashboard" in r.text and token_val in r.text:
        print("   SUCCESS: Student Dashboard verified!")
        return True
    else:
        print("   FAILURE: Student content not found.")
        print("   Status Code: ", r.status_code)
        print("   Preview:", r.text[:200])
        return False

if __name__ == "__main__":
    try:
        if verify_flow():
            print("\nVerification PASSED")
        else:
            print("\nVerification FAILED")
    except Exception as e:
        print(f"\nVerification ERROR: {e}")

```

### givesyllabus.py

```python
import os
import requests
from bs4 import BeautifulSoup
from dotenv import load_dotenv

# Load environment variables
load_dotenv()

CANVAS_ACCESS_TOKEN = os.getenv('CANVAS_ACCESS_TOKEN')
CANVAS_INSTANCE = os.getenv('CANVAS_INSTANCE', 'https://canvas.instructure.com')

# Ensure protocol matches
if not CANVAS_INSTANCE.startswith('http'):
    CANVAS_INSTANCE = f'https://{CANVAS_INSTANCE}'

headers = {
    'Authorization': f'Bearer {CANVAS_ACCESS_TOKEN}'
}

def get_all_pages(url, headers, params=None):
    """
    Generator that handles Canvas API pagination.
    """
    if params is None:
        params = {}
    
    while url:
        response = requests.get(url, headers=headers, params=params)
        response.raise_for_status()
        data = response.json()
        
        for item in data:
            yield item
        
        # Check for next page in Link header
        links = response.links
        if 'next' in links:
            url = links['next']['url']
            params = {} 
        else:
            url = None

def get_syllabus_data():
    if not CANVAS_ACCESS_TOKEN:
        print("Error: CANVAS_ACCESS_TOKEN not found in .env file.")
        return []

    courses_url = f'{CANVAS_INSTANCE}/api/v1/courses'
    
    params = {
        'enrollment_type': 'teacher',
        'enrollment_state': 'active',
        'include[]': 'syllabus_body',
        'per_page': 100
    }

    syllabus_list = []

    try:
        courses_generator = get_all_pages(courses_url, headers, params)
        
        for course in courses_generator:
            if 'name' not in course:
                continue
            
            syllabus_html = course.get('syllabus_body', '')
            
            if syllabus_html:
                soup = BeautifulSoup(syllabus_html, 'html.parser')
                text = soup.get_text(separator=' ', strip=True) # Check if user wanted newlines or just text? 
                # User request: "I want {"sylabus": "syllabuscontent"}"
                # In drillassignment we used separator=' ' for description. 
                # In previous givesyllabus we used '\n'.
                # Let's stick to '\n' for readability if it's a syllabus, or ' ' if they want strict single line string?
                # "second part is all of the content from it". 
                # Converting to string usually implies preserving some structure or just raw content.
                # I'll use separator=' ' to act like the assignment description to be safe for "content", 
                # or maybe keep '\n' but ensure it is a string. The user didn't specify format details beyond "content".
                # Let's use '\n' as it is more "syllabus-like" content, but return it as a string value.
                # Actually, JSON strings with newlines are fine.
                
                # Re-reading: "The first part is what it is, and the second part is all of the content from it."
                # I'll use '\n' to preserve the readable structure.
                text = soup.get_text(separator='\n', strip=True)
                
                syllabus_list.append({
                    "syllabus": text
                })
            # else: 
                # Only adding if there IS a syllabus? Or should we add empty? 
                # "every assingment ... should exist". Probably every course. 
                # But user just said "In give syllabus... retrieve and print".
                # I will skip empty ones to be clean.
    
    except requests.exceptions.RequestException as e:
        print(f"Network Error: {e}")
        return []
    except Exception as e:
        print(f"Error: {e}")
        return []
        
    return syllabus_list

if __name__ == "__main__":
    import json
    data = get_syllabus_data()
    print(json.dumps(data, indent=4))

```

### exportgrades.py

```python
import os
import requests
from dotenv import load_dotenv
from drillassignment import get_all_pages

# Load environment variables
load_dotenv()

CANVAS_ACCESS_TOKEN = os.getenv('CANVAS_ACCESS_TOKEN')
CANVAS_INSTANCE = os.getenv('CANVAS_INSTANCE', 'https://canvas.instructure.com')

# Ensure protocol matches
if not CANVAS_INSTANCE.startswith('http'):
    CANVAS_INSTANCE = f'https://{CANVAS_INSTANCE}'

headers = {
    'Authorization': f'Bearer {CANVAS_ACCESS_TOKEN}'
}

def get_all_grades(include_comments=False):
    """
    Fetches all grades for all active courses.
    Args:
        include_comments (bool): If True, includes submission comments in the response.
    Returns:
        dict: A dictionary where keys are course names and values are dicts of student grades.
              Example: { "Course Name": { "Student Name": [ { "assignment": "...", "score": ... } ] } }
    """
    if not CANVAS_ACCESS_TOKEN:
        print("Error: CANVAS_ACCESS_TOKEN not found in .env file.")
        return {}
    
    # Structure: { CourseName: { StudentName: [Assignments] } }
    all_course_data = {}

    courses_url = f'{CANVAS_INSTANCE}/api/v1/courses'
    courses_params = {
        'enrollment_type': 'teacher',
        'enrollment_state': 'active',
        'per_page': 100
    }

    try:
        courses_generator = get_all_pages(courses_url, headers, courses_params)
        
        for course in courses_generator:
            if 'name' not in course:
                continue
                
            course_id = course['id']
            course_name = course['name']
            
            # Initialize course entry
            all_course_data[course_name] = {}
            
            # Fetch submissions for all students in this course
            submissions_url = f'{CANVAS_INSTANCE}/api/v1/courses/{course_id}/students/submissions'
            
            includes = ['assignment', 'user']
            if include_comments:
                includes.append('submission_comments')
                
            submissions_params = {
                'student_ids[]': 'all',
                'include[]': includes,
                'per_page': 100
            }
            
            submissions_generator = get_all_pages(submissions_url, headers, submissions_params)
            
            # Organize by student for cleaner output
            student_grades = {} 

            for sub in submissions_generator:
                user = sub.get('user')
                assignment = sub.get('assignment')
                
                if not user or not assignment:
                    continue
                
                user_name = user.get('name', 'Unknown Student')
                assignment_name = assignment.get('name', 'Unknown Assignment')
                score = sub.get('score')
                grade = sub.get('grade')
                points_possible = assignment.get('points_possible')
                comments = sub.get('submission_comments', []) if include_comments else []
                
                percentage = "N/A"
                if score is not None and points_possible and points_possible > 0:
                    try:
                        pct = (float(score) / float(points_possible)) * 100
                        percentage = f"{pct:.2f}%"
                    except ValueError:
                        pass
                
                if user_name not in student_grades:
                    student_grades[user_name] = []
                
                student_grades[user_name].append({
                    'assignment': assignment_name,
                    'assignment_id': assignment.get('id'),
                    'user_id': user.get('id'),
                    'score': score,
                    'grade': grade,
                    'percentage': percentage,
                    'comments': comments
                })
            
            all_course_data[course_name] = student_grades

    except requests.exceptions.RequestException as e:
        print(f"Network Error: {e}")
        return {}
    except Exception as e:
        print(f"Error: {e}")
        return {}
        
    return all_course_data

if __name__ == "__main__":
    # Example usage: print with comments
    data = get_all_grades(include_comments=True)
    
    for course_name, students in data.items():
        print(f"\n{'='*60}")
        print(f"COURSE: {course_name}")
        print(f"{'='*60}")
        
        if not students:
            print("No submissions found.")
            continue

        for student, grades in students.items():
            print(f"\nStudent: {student}")
            print("-" * 40)
            for g in grades:
                score_display = g['score'] if g['score'] is not None else "-"
                grade_display = g['grade'] if g['grade'] is not None else "-"
                print(f"  Assignment: {g['assignment']}")
                print(f"    Score: {score_display}")
                print(f"    Grade: {grade_display}")
                print(f"    Percentage: {g['percentage']}")
                
                if g['comments']:
                    print(f"    Comments:")
                    for c in g['comments']:
                        author = c.get('author_name', 'Unknown')
                        comment_text = c.get('comment', '').replace('\n', ' ')
                        print(f"      - [{author}]: {comment_text}")

```

### drillassignment.py

```python
import requests
import os
from dotenv import load_dotenv
from bs4 import BeautifulSoup

# Load environment variables
load_dotenv()

def get_all_pages(url, headers, params=None):
    """
    Generator that yields items from all pages of a Canvas API paginated response.
    """
    while url:
        response = requests.get(url, headers=headers, params=params)
        response.raise_for_status()
        
        items = response.json()
        for item in items:
            yield item
        
        # Canvas pagination uses the 'Link' header
        # Link: <https://<canvas>/api/v1/...&page=2>; rel="next", ...
        links = response.links
        if 'next' in links:
            url = links['next']['url']
            params = None # Params are already encoded in the next URL
        else:
            url = None

def get_canvas_data():
    # Get configuration from environment
    token = os.environ.get('CANVAS_ACCESS_TOKEN')
    if not token:
        print("Error: CANVAS_ACCESS_TOKEN not found in environment variables.")
        return

    canvas_instance = os.environ.get('CANVAS_INSTANCE', 'canvas.instructure.com')
    if not canvas_instance.startswith('http'):
        canvas_instance = f'https://{canvas_instance}'

    headers = {'Authorization': f'Bearer {token}'}

    print(f"Connecting to Canvas at: {canvas_instance}")

    try:
        # 1. Get Teacher's Courses (Paginated)
        print("Fetching data from Canvas...")
        courses_url = f'{canvas_instance}/api/v1/courses'
        courses_params = {
            'enrollment_type': 'teacher',
            'enrollment_state': 'active',
            'per_page': 100
        }
        
        courses_generator = get_all_pages(courses_url, headers, courses_params)
        
        output_data = []

        for course in courses_generator:
            if 'name' not in course:
                continue
            
            course_id = course['id']
            # course_name = course['name'] 

            # 2. Get Assignments for the Course (Paginated)
            assignments_url = f'{canvas_instance}/api/v1/courses/{course_id}/assignments'
            assignments_params = {'per_page': 100}
            
            assignments_generator = get_all_pages(assignments_url, headers, assignments_params)
            
            for assignment in assignments_generator:
                description_html = assignment.get('description', '')
                quiz_id = assignment.get('quiz_id')
                
                # Check if it is a quiz first
                if quiz_id:
                    # It is a Quiz
                    quiz_content = []
                    questions_url = f'{canvas_instance}/api/v1/courses/{course_id}/quizzes/{quiz_id}/questions'
                    questions_params = {'per_page': 100}
                    
                    try:

                        questions_generator = get_all_pages(questions_url, headers, questions_params)
                        for q in questions_generator:
                            q_name = q.get('question_name', 'Question')
                            q_text_html = q.get('question_text', '')
                            
                            if q_text_html:
                                q_soup = BeautifulSoup(q_text_html, 'html.parser')
                                q_text = q_soup.get_text(separator=' ', strip=True)
                            else:
                                q_text = "No question text."
                            
                            question_obj = {
                                'name': q_name,
                                'text': q_text,
                                'answer': ''
                            }

                            answers = q.get('answers', [])
                            if answers:
                                # Helper to clean answer text
                                def clean_ans(ans_raw):
                                     # Same check for answer text
                                     if ans_raw and len(ans_raw) < 255 and (os.path.sep in ans_raw or '/' in ans_raw):
                                         ans_soup = BeautifulSoup(str(ans_raw), 'html.parser')
                                     else:
                                         ans_soup = BeautifulSoup(str(ans_raw), 'html.parser')
                                     return ans_soup.get_text(separator=' ', strip=True)

                                for ans in answers:
                                    if ans.get('weight', 0) == 100:
                                        ans_text_html = ans.get('text', '') or ans.get('html', '')
                                        ans_text = clean_ans(ans_text_html)
                                        question_obj['answer'] = ans_text
                            
                            quiz_content.append(question_obj)

                    except Exception as e:
                        print(f"Error fetching quiz questions: {e}")
                    
                    output_data.append({
                        'type': 'quiz',
                        'id': quiz_id,
                        'content': quiz_content
                    })

                else:
                    # It is an Assignment
                    if description_html:
                        soup = BeautifulSoup(description_html, 'html.parser')
                        description_text = soup.get_text(separator=' ', strip=True)
                    else:
                        description_text = 'No description provided.'
                    
                    output_data.append({
                        'type': 'assignment',
                        'id': assignment.get('id'),
                        'content': description_text
                    })
        
        return output_data

    except requests.exceptions.RequestException as e:
        print(f"Network error occurred: {e}")
        return []
  
[truncated — 206 more characters]
```

[11 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]