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
| Model | Key Fields |
|---|---|
Module | name, slug, description, icon, order_index, is_active |
Section | name, slug, module_id, order_index, is_active |
Theme | title, slug, section_id, markdown_file_path, duration_minutes, order_index |
ThemeProgress | user_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;