Skip to main content

Lesson System

Lessons are stored as Markdown files in a private GitLab repository. The database stores only the structure (metadata); actual content is fetched on demand.


Three-Tier Structure

Module  (e.g. "Linux Fundamentals")
└── Section (e.g. "File Systems")
└── Theme (e.g. "Introduction to inodes")
└── markdown_file_path → "linux/file-systems/intro.md" in GitLab repo

Database Models

ModelKey Fields
Modulename, slug, description, icon, order_index, is_active
Sectionname, slug, module_id, order_index, is_active
Themetitle, slug, section_id, markdown_file_path, duration_minutes, order_index
ThemeProgressuser_id, theme_id, completed, completed_at

Markdown Content Fetching

Flow

GET /api/lessons/themes/{id}/content

├─ Fetch Theme from DB → get markdown_file_path
├─ LessonsService.get_content(path)
│ ├─ Clone/pull private GitLab repo via SSH → /tmp/lessons_repo
│ └─ Read file at path from local clone
└─ Return markdown string

LessonsService

# backend/app/services/lessons_service.py

class LessonsService:
def get_content(self, file_path: str) -> str:
self._ensure_repo() # clone if not exists, pull if stale
full_path = os.path.join(REPO_DIR, file_path)
with open(full_path) as f:
return f.read()

def _ensure_repo(self):
env = {"GIT_SSH_COMMAND": f"ssh -i {SSH_KEY_PATH} -o StrictHostKeyChecking=no"}
if not os.path.exists(REPO_DIR):
subprocess.run(["git", "clone", LESSONS_REPO_URL, REPO_DIR], env=env)
else:
subprocess.run(["git", "-C", REPO_DIR, "pull"], env=env)

Required Environment Variables

LESSONS_REPO_URL=git@gitlab.com:zsaidov1988/lessons-content.git
LESSONS_SSH_KEY_PATH=/app/private_key # SSH key with read access to the repo

Progress Tracking

When a student marks a theme complete:

POST /api/lessons/themes/{id}/complete
→ Upsert ThemeProgress (user_id, theme_id, completed=True, completed_at=now)
→ Log activity: ActivityLog(type="lesson_complete", ...)

Progress is displayed on the dashboard and in the lesson sidebar.


Frontend — Lesson Viewer

/lessons                  → Module grid
/lessons/{moduleSlug} → Section list
/lessons/{module}/{section}/{theme} → Theme content with sidebar

Markdown is sanitized with DOMPurify before rendering with v-html:

import DOMPurify from 'dompurify'
import { marked } from 'marked'

const rendered = DOMPurify.sanitize(marked(rawMarkdown))

Admin — Managing Content

Structure is managed via the admin panel:

# Create module
POST /api/admin/lessons/modules { name, description, icon }

# Create section
POST /api/admin/lessons/sections { name, module_id, order_index }

# Create theme (links to markdown file)
POST /api/admin/lessons/themes {
title, section_id, order_index,
markdown_file_path: "linux/file-systems/intro.md",
duration_minutes: 15
}

The markdown_file_path is a relative path inside the private lessons repo.


Repository Refresh

To force a repo pull without waiting:

POST /api/lessons/refresh-repository
Authorization: Bearer {admin-token}

Or via Makefile:

make lessons-refresh

Troubleshooting

Lesson content not loading

# Check SSH key is present
docker-compose exec backend ls -la /app/private_key

# Check if repo is cloned
docker-compose exec backend ls -la /tmp/lessons_repo

# Force refresh
curl -X POST http://localhost:8000/api/lessons/refresh-repository \
-H "Authorization: Bearer {token}"

"File not found" error

Verify the markdown_file_path on the Theme record matches the actual file path in the GitLab repo:

make db-shell
SELECT title, markdown_file_path FROM themes WHERE id = 5;