Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions process/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,93 @@
html_static_path = ["_assets"]
html_css_files = ["custom.css"]

# Training portals are pre-generated into trainings/_portals/ by build.py.
# This copies the subdirectory tree (e.g. requirements_engineering/) verbatim
# into the Sphinx HTML output root so portals are served alongside the docs.
html_extra_path = ["trainings/_portals"]

# Exclude training source files and generated portal files from Sphinx processing.
# - trainings/*/source/** : Markdown source files for the training portals
# - trainings/trainings_templates/** : Template files, not Sphinx documents
# - trainings/_portals/** : Generated portal HTML (served via html_extra_path)
exclude_patterns = [
"trainings/*/source/**",
"trainings/trainings_templates/**",
"trainings/_portals/**",
]

# :need:`{title}` is used in the needs templates to display the title of the need
needs_role_need_template = "{title}"


# ---------------------------------------------------------------------------
# Training portal build hook
#
# Automatically regenerates all training portals before Sphinx processes the
# documentation. build.py requires the 'markdown' package which is NOT part
# of the docs-as-code Sphinx environment, so it is run via the dedicated
# .venv virtual environment. If that venv is absent it is created
# and the required packages are installed automatically.
# ---------------------------------------------------------------------------

def _build_training_portals(app):
"""Sphinx builder-inited event handler: regenerate all training portals."""
import os, subprocess, sys
from pathlib import Path

conf_dir = Path(app.confdir) # …/process/
repo_root = conf_dir.parent # …/process_description/
venv_dir = repo_root / ".venv"
venv_python = venv_dir / "bin" / "python"

# Locate all training source build scripts
build_scripts = sorted(conf_dir.glob(
"trainings/*/source/build.py"
))
if not build_scripts:
return

# Ensure the venv exists with the required packages
if not venv_python.exists():
reqs_file = build_scripts[0].parent / "requirements.txt"
try:
subprocess.run(
[sys.executable, "-m", "venv", str(venv_dir)],
check=True, capture_output=True,
)
if reqs_file.exists():
subprocess.run(
[str(venv_python), "-m", "pip", "install", "-q",
"-r", str(reqs_file)],
check=True, capture_output=True,
)
except Exception as exc:
import warnings
warnings.warn(
f"[trainings] Could not create .venv_training: {exc}\n"
" Training portals will not be regenerated. "
"Run source/build.py manually.",
stacklevel=1,
)
return

# Run each build script
for script in build_scripts:
try:
subprocess.run(
[str(venv_python), str(script)],
cwd=str(script.parent),
check=True, capture_output=True,
)
print(f"[trainings] Built portal: {script.parent.parent.name}")
except subprocess.CalledProcessError as exc:
import warnings
warnings.warn(
f"[trainings] Portal build failed for {script.parent.parent.name}:\n"
f" {exc.stderr.decode(errors='replace').strip()}",
stacklevel=1,
)


def setup(app):
app.connect("builder-inited", _build_training_portals)
11 changes: 11 additions & 0 deletions process/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,17 @@ and they include the available templates for the work products already in the ri
folder_templates/index.rst


Trainings
---------

Interactive self-paced training portals for the S-CORE process areas:

.. toctree::
:maxdepth: 1

trainings/index.rst


Glossary
--------

Expand Down
188 changes: 188 additions & 0 deletions process/trainings/_portals/requirements_engineering/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
/*
* Copyright (c) 2026 Contributors to the Eclipse Foundation
*
* See the NOTICE file(s) distributed with this work for additional
* information regarding copyright ownership.
*
* This program and the accompanying materials are made available under the
* terms of the Apache License Version 2.0 which is available at
* https://www.apache.org/licenses/LICENSE-2.0
*
* SPDX-License-Identifier: Apache-2.0
*
* AI Disclosure: This file was largely AI-generated. The AI-generated
* portions are made available under CC0-1.0 and not subject to the
* project's license. The human contributor has reviewed and verified
* that the code is correct.
* SPDX-License-Identifier: CC0-1.0
* Assisted-by: Claude Sonnet 4.6
*/

/* S-CORE Requirements Engineering Training Portal — Progress & Quiz Logic */

const MODULES = [
{ id: 'index', label: 'Course Overview', url: 'index.html' },
{ id: 'module-1', label: 'Why Requirements Engineering?', url: 'module-1.html' },
{ id: 'module-2', label: 'Requirement Levels and Types', url: 'module-2.html' },
{ id: 'module-3', label: 'Requirement Attributes and Quality', url: 'module-3.html' },
{ id: 'module-4', label: 'Workflows and Work Products', url: 'module-4.html' },
{ id: 'quiz-1', label: 'Checkpoint Quiz', url: 'quiz-1.html', isQuiz: true },
];

function getProgress() {
const raw = localStorage.getItem('score-re-progress');
return raw ? JSON.parse(raw) : {};
}
function markComplete(id) {
const p = getProgress(); p[id] = true; localStorage.setItem('score-re-progress', JSON.stringify(p));
updateProgressBar();
}
function isComplete(id) { return !!getProgress()[id]; }

function updateProgressBar() {
const p = getProgress();
const total = MODULES.filter(m => m.id !== 'index').length;
const done = MODULES.filter(m => m.id !== 'index' && p[m.id]).length;
const pct = Math.round((done / total) * 100);
document.querySelectorAll('.progress-bar-inner').forEach(el => el.style.width = pct + '%');
document.querySelectorAll('.progress-pct').forEach(el => el.textContent = pct + '%');
document.querySelectorAll('.progress-count').forEach(el => el.textContent = done + ' / ' + total + ' complete');
MODULES.forEach(m => {
const a = document.querySelector(`#sidebar nav a[data-id="${m.id}"]`);
if (!a) return;
const icon = a.querySelector('.nav-icon');
if (p[m.id]) { a.classList.add('completed'); icon.textContent = '✓'; }
});
}

function buildSidebar(activeId) {
const p = getProgress();
const sb = document.getElementById('sidebar');
if (!sb) return;

const total = MODULES.filter(m => m.id !== 'index').length;
const done = MODULES.filter(m => m.id !== 'index' && p[m.id]).length;
const pct = Math.round((done / total) * 100);

const modules = MODULES.filter(m => m.id !== 'index');
const navGroup = (label, items, startIdx) => `
<div class="nav-section-label">${label}</div>
<nav>
${items.map((m, i) => {
const done_m = p[m.id];
const active = m.id === activeId;
const num = m.isQuiz ? '?' : (startIdx + i);
return `<a href="${m.url}" class="${active ? 'active' : ''} ${done_m ? 'completed' : ''}" data-id="${m.id}">
<span class="nav-icon">${done_m ? '✓' : num}</span>
<span>${m.label}</span>
</a>`;
}).join('')}
</nav>
`;

sb.innerHTML = `
<div class="brand">
<h1>S-CORE<br>Requirements<br>Engineering</h1>
<p>Eclipse Foundation</p>
</div>
<div class="progress-area">
<div class="progress-label">
<span class="progress-count">${done} / ${total} complete</span>
<span class="progress-pct">${pct}%</span>
</div>
<div class="progress-bar-outer"><div class="progress-bar-inner" style="width:${pct}%"></div></div>
</div>
<a href="index.html" style="display:flex;align-items:center;gap:10px;padding:10px 20px;text-decoration:none;color:#c8d6e5;font-size:.84rem;border-left:3px solid transparent;" data-id="index">
<span class="nav-icon">⌂</span><span>Course Overview</span>
</a>
<hr class="nav-sep">
${navGroup('Modules', modules, 1)}
`;
}

/* ── Collapsible Sections ─────────────────────── */
function initCollapsibles() {
document.querySelectorAll('.collapsible-header').forEach(header => {
header.addEventListener('click', () => {
header.classList.toggle('open');
const body = header.nextElementSibling;
body.classList.toggle('open');
});
});
}

/* ── Quiz Engine ──────────────────────────────── */
function initQuiz(quizId, passMark, onPass) {
const form = document.getElementById(quizId);
if (!form) return;

const submitBtn = form.querySelector('.quiz-submit');
const resultEl = form.querySelector('.quiz-result');

form.querySelectorAll('.options li').forEach(opt => {
opt.addEventListener('click', () => {
const questionBlock = opt.closest('.question-block');
if (questionBlock.dataset.answered) return;
questionBlock.querySelectorAll('.options li').forEach(o => o.classList.remove('selected'));
opt.classList.add('selected');
});
});

if (submitBtn) {
submitBtn.addEventListener('click', () => {
let correct = 0;
let total = 0;
let unanswered = false;

form.querySelectorAll('.question-block').forEach(qb => {
total++;
const selected = qb.querySelector('.options li.selected');
if (!selected) { unanswered = true; return; }
qb.dataset.answered = '1';
const isCorrect = selected.dataset.correct === 'true';
if (isCorrect) {
correct++;
selected.classList.replace('selected', 'correct');
} else {
selected.classList.replace('selected', 'wrong');
qb.querySelectorAll('.options li').forEach(o => {
if (o.dataset.correct === 'true') o.classList.add('reveal-correct');
});
}
const fb = qb.querySelector('.feedback');
if (fb) {
fb.style.display = 'block';
fb.classList.add(isCorrect ? 'correct-fb' : 'wrong-fb');
}
});

if (unanswered) { alert('Please answer all questions before submitting.'); return; }

submitBtn.disabled = true;
const pct = Math.round((correct / total) * 100);
resultEl.style.display = 'block';
if (pct >= passMark) {
resultEl.className = 'quiz-result pass';
resultEl.innerHTML = `✓ Passed! You scored ${correct}/${total} (${pct}%). ${onPass ? 'Module marked as complete.' : ''}`;
if (onPass) { markComplete(onPass); }
} else {
resultEl.className = 'quiz-result fail';
resultEl.innerHTML = `✗ Score: ${correct}/${total} (${pct}%). Pass mark is ${passMark}%. Review the sections above and try again.`;
}
});
}
}

/* ── Auto-mark module visited ─────────────────── */
function autoMarkVisited(id) {
if (id && id !== 'index' && !id.startsWith('quiz')) {
markComplete(id);
}
}

document.addEventListener('DOMContentLoaded', () => {
const pageId = document.body.dataset.page;
autoMarkVisited(pageId);
buildSidebar(pageId);
initCollapsibles();
});
Loading
Loading