Správa obsahu pro více nájemců: Správa verzí a SCORM
Velká školicí společnost obsluhuje 500 firemních klientů, každý má svého zaměstnanců, jeho vizuální identity a požadavků na dodržování předpisů. Poskytovatel eLearningového obsahu musí aktualizovat formulář zásad o bezpečnosti (který se mění každý rok) a propagovat změnu všem 500 zákazníkům okamžitě, aniž by kdokoli z nich musel cokoli dělat. Když zákazník chcete přizpůsobit formulář vlastním logem a příklady, musíte být schopni bez přerušení automatických aktualizací.
S tím je problém Správa obsahu pro více nájemců pro EdTech: spravovat sdílený a personalizovaný obsah, správně jej verzovat, distribuovat efektivně a zajistit dodržování SVITEK (Obsah ke sdílení Object Reference Model), nejrozšířenější standard pro firemní eLearningové balíčky.
V tomto článku postavíme kompletní systém: od datové struktury po obsah multi-tenant s přepsáním na jednoho tenanta, sémantické verzování balíčků, do koncového bodu kompatibilního se SCORM pro komunikaci s LMS třetích stran, až po strategii CDN pro efektivní globální distribuci.
Co se dozvíte v tomto článku
- Datový model pro více nájemců pro sdílený obsah s přizpůsobením podle jednotlivých tenantů
- Sémantické verzování (semver) pro balíčky obsahu eLearning
- SCORM 1.2 a SCORM 2004: rozdíly, struktura a komunikační API
- Centralizovaný vs distribuovaný hosting pro balíčky SCORM pro více nájemců
- Balení obsahu: ZIP, manifest imsmanifest.xml a struktura souborů
- SCORM API JavaScript wrapper pro pokročilé sledování
- Strategie CDN pro globální distribuci s nízkou latencí
- Migrace ze SCORM na xAPI: kdy a proč
1. Datový model pro více nájemců pro obsah
Výzvou správy obsahu pro více nájemců je vyvažování dvou protichůdných potřeb: sdílení (aktualizovaný obsah se šíří všem nájemcům) e přizpůsobení (každý nájemce může přepsat konkrétní položky). Používáme model a tři úrovně: globální obsah (master), přepisy kategorie (skupiny nájemců s podobnými vlastnostmi) a přepisy specifické pro nájemce.
# models/content.py
from dataclasses import dataclass, field
from typing import Optional, Dict, List, Any
from datetime import datetime
from enum import Enum
class ContentStatus(Enum):
DRAFT = "draft"
REVIEW = "review"
PUBLISHED = "published"
DEPRECATED = "deprecated"
ARCHIVED = "archived"
class SCORMVersion(Enum):
SCORM_12 = "1.2"
SCORM_2004_3 = "2004_3rd"
SCORM_2004_4 = "2004_4th"
XAPI = "xapi"
@dataclass
class ContentVersion:
"""Una versione specifica di un pacchetto di contenuto."""
version_id: str
content_id: str
semver: str # "1.2.3" (MAJOR.MINOR.PATCH)
status: ContentStatus
scorm_version: SCORMVersion
title: Dict[str, str] # {"it-IT": "...", "en-US": "..."} - multilingue
description: Dict[str, str]
# Path nel bucket S3/Cloud Storage al pacchetto ZIP
package_path: str
# Hash SHA256 del pacchetto per integrity check
package_hash: str
package_size_bytes: int
# URL del manifest per accesso diretto
manifest_url: str
created_at: datetime
created_by: str
changelog: str # Cosa e cambiato rispetto alla versione precedente
# Metadati curriculum
estimated_duration_minutes: int
topics: List[str]
prerequisites: List[str] # IDs di contenuti prerequisito
difficulty: int # 1-5
@dataclass
class ContentMaster:
"""Il contenuto 'master' condiviso tra tutti i tenant."""
content_id: str
category_id: str
global_id: str # Identificatore universale (es: "compliance-gdpr-intro")
current_version: str # Semver della versione corrente pubblicata
versions: List[ContentVersion] = field(default_factory=list)
auto_update_policy: str = "minor" # "none", "patch", "minor", "major"
tags: List[str] = field(default_factory=list)
@dataclass
class TenantContentOverride:
"""Override specifico per tenant di un contenuto master."""
override_id: str
tenant_id: str
content_id: str # Riferimento al ContentMaster
version_locked: Optional[str] = None # Se impostato, questo tenant usa sempre questa versione
# Personalizzazioni UI
custom_logo_url: Optional[str] = None
custom_colors: Optional[Dict[str, str]] = None # {"primary": "#..."}
custom_css_url: Optional[str] = None
# Override testi (es. esempi locali al posto di quelli globali)
text_overrides: Dict[str, str] = field(default_factory=dict)
# Slides o moduli da nascondere per questo tenant
hidden_modules: List[str] = field(default_factory=list)
# Moduli aggiuntivi solo per questo tenant
additional_modules: List[str] = field(default_factory=list)
enabled: bool = True
created_at: datetime = field(default_factory=datetime.utcnow)
updated_at: datetime = field(default_factory=datetime.utcnow)
@dataclass
class TenantContentAssignment:
"""Assegnazione di un contenuto a un tenant, con configurazione."""
assignment_id: str
tenant_id: str
content_id: str
effective_version: str # Versione effettivamente usata (dopo risoluzione override)
required: bool = False # Formazione obbligatoria?
deadline: Optional[datetime] = None
completion_certificate: bool = False
passing_score_percent: int = 70
max_attempts: int = 3
2. Sémantické verzování a šíření
Pojďme použít Sémantické verzování (několik) pro obsah: MAJOR.MINOR.PATCH. NÁPLAST = opravy (překlep, chyba): Automaticky se šíří všem tenantům které nemají uzamčenou verzi. MENŠÍ = nové volitelné sekce, vylepšení: šíří se oznámením správcům tenantů. HLAVNÍ = zásadní změny osnov: vyžaduje výslovný souhlas každého nájemce.
# services/content_version_manager.py
from dataclasses import dataclass
from typing import List, Optional, Tuple
import semver
from datetime import datetime
@dataclass
class VersionBump:
content_id: str
from_version: str
to_version: str
bump_type: str # "major", "minor", "patch"
changelog: str
affected_tenants: List[str] # Tenant che riceveranno l'aggiornamento automatico
notify_tenants: List[str] # Tenant da notificare ma non aggiornare automaticamente
require_approval: List[str] # Tenant che richiedono approvazione esplicita
class ContentVersionManager:
def __init__(self, db, notification_service, event_bus):
self.db = db
self.notifications = notification_service
self.events = event_bus
async def publish_new_version(
self,
content_id: str,
new_version: str,
changelog: str,
package_path: str,
) -> VersionBump:
"""Pubblica una nuova versione e gestisce la propagazione ai tenant."""
content = await self._get_content(content_id)
current = content.current_version
bump_type = self._classify_bump(current, new_version)
# Recupera tutti i tenant che hanno questo contenuto
assignments = await self._get_tenant_assignments(content_id)
overrides = await self._get_tenant_overrides(content_id)
# Classifica i tenant per politica di aggiornamento
auto_update = []
notify_only = []
require_approval = []
for assignment in assignments:
override = overrides.get(assignment.tenant_id)
# Se la versione e bloccata: non aggiornare
if override and override.version_locked:
continue
# Applica la politica del contenuto master
if bump_type == "patch":
auto_update.append(assignment.tenant_id)
elif bump_type == "minor":
if content.auto_update_policy in ("minor", "major"):
auto_update.append(assignment.tenant_id)
else:
notify_only.append(assignment.tenant_id)
else: # major
if content.auto_update_policy == "major":
auto_update.append(assignment.tenant_id)
else:
require_approval.append(assignment.tenant_id)
# Esegui aggiornamenti automatici
for tenant_id in auto_update:
await self._apply_version_to_tenant(tenant_id, content_id, new_version)
# Notifiche
if notify_only:
await self.notifications.send_bulk(
tenant_ids=notify_only,
subject=f"Disponibile nuova versione: {content.global_id} v{new_version}",
body=f"Una nuova versione minore e disponibile.\nCambiamenti: {changelog}\nApprovazione manuale richiesta.",
action_url=f"/admin/content/{content_id}/versions/{new_version}",
)
if require_approval:
await self._create_approval_requests(require_approval, content_id, new_version, changelog)
# Aggiorna versione corrente del master
await self.db.execute(
"UPDATE content_masters SET current_version = :v WHERE content_id = :cid",
{"v": new_version, "cid": content_id},
)
await self.db.commit()
# Pubblica evento
await self.events.publish({
"type": "content.version.published",
"content_id": content_id,
"version": new_version,
"bump_type": bump_type,
"auto_updated_tenants": len(auto_update),
})
return VersionBump(
content_id=content_id,
from_version=current,
to_version=new_version,
bump_type=bump_type,
changelog=changelog,
affected_tenants=auto_update,
notify_tenants=notify_only,
require_approval=require_approval,
)
def _classify_bump(self, current: str, new_version: str) -> str:
try:
v_current = semver.VersionInfo.parse(current)
v_new = semver.VersionInfo.parse(new_version)
if v_new.major > v_current.major:
return "major"
if v_new.minor > v_current.minor:
return "minor"
return "patch"
except ValueError:
return "major" # Default conservativo se parsing fallisce
async def _apply_version_to_tenant(self, tenant_id: str, content_id: str, version: str) -> None:
await self.db.execute(
"""UPDATE tenant_content_assignments
SET effective_version = :v, updated_at = NOW()
WHERE tenant_id = :tid AND content_id = :cid""",
{"v": version, "tid": tenant_id, "cid": content_id},
)
await self.db.commit()
3. SCORM a Manifest struktura imsmanifest.xml
SCORM definuje formát balíčku ZIP s přesnou strukturou.
Soubor imsmanifest.xml a vstupní bod: popisuje
struktura kurzu (organizace), zdroje (soubory HTML, videa, obrázky)
a metadata. Tento manifest generujeme programově, abychom zajistili
správnost a podporu pro obě verze SCORM.
# packaging/scorm_packager.py
import zipfile
import xml.etree.ElementTree as ET
from io import BytesIO
from dataclasses import dataclass
from typing import List, Optional
from pathlib import Path
import hashlib
@dataclass
class SCORMModule:
identifier: str # Univoco nel pacchetto
title: str
launch_url: str # Es: "content/module01/index.html"
scorm_type: str = "sco" # "sco" o "asset"
mastery_score: int = 70 # Punteggio minimo per superamento (%)
time_limit_minutes: Optional[int] = None
@dataclass
class SCORMPackageConfig:
course_id: str
course_title: str
course_description: str
scorm_version: str # "1.2" o "2004_4th"
language: str # "it-IT", "en-US"
modules: List[SCORMModule]
tenant_id: str
version: str
class SCORMPackager:
"""Genera pacchetti SCORM conformi allo standard ADL."""
SCORM_12_SCHEMA = "http://www.adlnet.org/xsd/adlcp_rootv1p2"
SCORM_2004_SCHEMA = "http://www.adlnet.org/xsd/adlcp_v1p3"
def package(self, config: SCORMPackageConfig, content_dir: Path) -> bytes:
"""
Genera un pacchetto SCORM ZIP con imsmanifest.xml.
content_dir: directory con i file HTML/CSS/JS del corso.
"""
zip_buffer = BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
# Aggiungi il manifest
manifest = self._generate_manifest(config)
zf.writestr('imsmanifest.xml', manifest)
# Aggiungi API wrapper SCORM JavaScript
scorm_api = self._get_scorm_api_js(config.scorm_version)
zf.writestr('scorm_api.js', scorm_api)
# Aggiungi tutti i file del corso
for file_path in content_dir.rglob('*'):
if file_path.is_file():
relative = file_path.relative_to(content_dir)
zf.write(file_path, str(relative))
return zip_buffer.getvalue()
def _generate_manifest(self, config: SCORMPackageConfig) -> str:
"""Genera il file imsmanifest.xml secondo lo standard SCORM."""
if config.scorm_version == "1.2":
return self._manifest_12(config)
return self._manifest_2004(config)
def _manifest_12(self, config: SCORMPackageConfig) -> str:
"""Manifest per SCORM 1.2."""
return f"""<?xml version="1.0" encoding="UTF-8"?>
<manifest identifier="{config.course_id}" version="1.0"
xmlns="http://www.imsproject.org/xsd/imscp_rootv1p1p2"
xmlns:adlcp="{self.SCORM_12_SCHEMA}"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.imsproject.org/xsd/imscp_rootv1p1p2
imscp_rootv1p1p2.xsd {self.SCORM_12_SCHEMA} adlcp_rootv1p2.xsd">
<metadata>
<schema>ADL SCORM</schema>
<schemaversion>1.2</schemaversion>
</metadata>
<organizations default="ORG-{config.course_id}">
<organization identifier="ORG-{config.course_id}" structure="hierarchical">
{config.course_title}
{self._generate_items_12(config.modules)}
</organization>
</organizations>
<resources>
{self._generate_resources_12(config.modules)}
</resources>
</manifest>"""
def _generate_items_12(self, modules: List[SCORMModule]) -> str:
items = []
for module in modules:
mastery = f'<adlcp:masteryscore>{module.mastery_score}</adlcp:masteryscore>' if module.scorm_type == 'sco' else ''
items.append(f"""
<item identifier="ITEM-{module.identifier}" identifierref="RES-{module.identifier}" isvisible="true">
{module.title}
{mastery}
</item>""")
return "".join(items)
def _generate_resources_12(self, modules: List[SCORMModule]) -> str:
resources = []
for module in modules:
resources.append(f"""
<resource identifier="RES-{module.identifier}" type="webcontent"
adlcp:scormtype="{module.scorm_type}" href="{module.launch_url}">
<file href="{module.launch_url}" />
</resource>""")
return "".join(resources)
def _get_scorm_api_js(self, version: str) -> str:
"""
SCORM API wrapper JavaScript per comunicazione con LMS.
Semplificato: in produzione usa scorm-again o pipwerks-scorm-api.
"""
if version == "1.2":
return """
// SCORM 1.2 API Wrapper - Simplified
var API = null;
function findAPI(win) {
var searchLimit = 7;
var currentWindow = win;
while ((currentWindow.API == null) && (currentWindow.parent != null) && (currentWindow.parent != currentWindow)) {
searchLimit--;
if (searchLimit <= 0) return null;
currentWindow = currentWindow.parent;
}
return currentWindow.API;
}
function getAPI() {
if (API == null) {
API = findAPI(window);
if (API == null && window.opener != null) {
API = findAPI(window.opener);
}
}
return API;
}
var SCORM = {
init: function() {
var api = getAPI();
if (api) return api.LMSInitialize("") === "true";
return false;
},
finish: function() {
var api = getAPI();
if (api) {
api.LMSCommit("");
return api.LMSFinish("") === "true";
}
return false;
},
getValue: function(element) {
var api = getAPI();
if (api) return api.LMSGetValue(element);
return "";
},
setValue: function(element, value) {
var api = getAPI();
if (api) return api.LMSSetValue(element, value) === "true";
return false;
},
commit: function() {
var api = getAPI();
if (api) return api.LMSCommit("") === "true";
return false;
},
setProgress: function(percent) {
this.setValue("cmi.core.score.raw", percent.toString());
if (percent >= 70) {
this.setValue("cmi.core.lesson_status", "passed");
} else {
this.setValue("cmi.core.lesson_status", "failed");
}
this.commit();
},
setCompleted: function() {
this.setValue("cmi.core.lesson_status", "completed");
this.commit();
}
};"""
return "// SCORM 2004 API wrapper"
4. Strategie CDN pro globální distribuci
Balíčky SCORM obsahují těžké prostředky (videa, HD obrázky). Distribuujte je z jednoho serveru způsobuje u vzdálených studentů vysokou latenci. Používáme víceúrovňovou strategii CDN: CloudFront/Cloudflare pro globální okrajovou mezipaměť se zapnutým úložištěm S3/Cloudové úložiště jako je původ a podepsané adresy URL pro řízení přístupu.
# cdn/content_delivery.py
import boto3
from botocore.signers import CloudFrontSigner
import rsa
from datetime import datetime, timedelta
from typing import Optional
import json
import hashlib
class SCORMContentDelivery:
"""
Gestisce distribuzione sicura di pacchetti SCORM via CDN.
Usa URL firmati CloudFront per controllo accesso.
"""
def __init__(
self,
cloudfront_domain: str,
s3_bucket: str,
private_key_path: str,
key_pair_id: str,
):
self.cdn_domain = cloudfront_domain
self.s3_bucket = s3_bucket
self.key_pair_id = key_pair_id
self.s3_client = boto3.client('s3')
self.cf_client = boto3.client('cloudfront')
with open(private_key_path, 'rb') as f:
self._private_key = rsa.PrivateKey.load_pkcs1(f.read())
def get_signed_launch_url(
self,
content_id: str,
tenant_id: str,
version: str,
module_path: str,
expires_in_hours: int = 8,
) -> str:
"""
Genera un URL firmato CloudFront per un modulo SCORM specifico.
L'URL scade dopo expires_in_hours ore per sicurezza.
"""
s3_path = f"tenants/{tenant_id}/content/{content_id}/{version}/{module_path}"
cdn_url = f"https://{self.cdn_domain}/{s3_path}"
expire_date = datetime.utcnow() + timedelta(hours=expires_in_hours)
signer = CloudFrontSigner(self.key_pair_id, self._rsa_signer)
signed_url = signer.generate_presigned_url(
cdn_url,
date_less_than=expire_date,
)
return signed_url
def _rsa_signer(self, message: bytes) -> bytes:
return rsa.sign(message, self._private_key, 'SHA-1')
async def upload_package(
self,
content_id: str,
tenant_id: str,
version: str,
package_bytes: bytes,
) -> dict:
"""
Carica un pacchetto SCORM su S3 e invalida la cache CDN.
"""
prefix = f"tenants/{tenant_id}/content/{content_id}/{version}"
# Calcola hash per integrity check
package_hash = hashlib.sha256(package_bytes).hexdigest()
# Carica il pacchetto ZIP
package_key = f"{prefix}/package.zip"
self.s3_client.put_object(
Bucket=self.s3_bucket,
Key=package_key,
Body=package_bytes,
ContentType='application/zip',
Metadata={
'content-id': content_id,
'tenant-id': tenant_id,
'version': version,
'sha256': package_hash,
},
)
# Invalida cache CDN per questo contenuto
self.cf_client.create_invalidation(
DistributionId='YOUR_DISTRIBUTION_ID',
InvalidationBatch={
'Paths': {
'Quantity': 1,
'Items': [f"/tenants/{tenant_id}/content/{content_id}/{version}/*"],
},
'CallerReference': f"{content_id}-{version}-{int(datetime.utcnow().timestamp())}",
},
)
return {
"package_key": package_key,
"package_hash": package_hash,
"cdn_url": f"https://{self.cdn_domain}/{prefix}/",
}
async def generate_tenant_manifest_url(
self,
content_id: str,
tenant_id: str,
version: str,
) -> str:
"""URL del manifest SCORM per un tenant specifico."""
return self.get_signed_launch_url(
content_id=content_id,
tenant_id=tenant_id,
version=version,
module_path="imsmanifest.xml",
)
5. Kdy přejít ze SCORM na xAPI
SCORM je dominantním standardem pro firemní školení, ale má svá omezení významné v roce 2025: omezené sledování (pouze dokončeno/neúspěšně/nedokončeno ve verzi 1.2), závislost na LMS spuštění, obtížné na mobilu. xAPI (rozhraní API plechovky) překonává všechna tato omezení, ale vyžaduje a Store Record Store (LRS) samostatný.
SCORM vs xAPI: Kdy zvolit
| Kritérium | SCORM 1.2 / 2004 | xAPI (plechovka) |
|---|---|---|
| Kompatibilita se staršími LMS | Univerzální | Pouze moderní LMS |
| Podrobnost sledování | Dokončeno/Neúspěšně/Skóre | Jakákoli činnost |
| Mobilní podpora | Problematické (iframe) | Rodák |
| Podpora offline | Není podporováno | Ano (s místním LRS) |
| Nezbytná infrastruktura | Pouze LMS | LMS + LRS |
| Interoperabilita | Konsolidovaný standard | Vznikající standard |
| Pokročilá analytika | Omezený | Kompletní |
| Doporučeno pro | Starší firemní školení | Nové systémy EdTech |
Anti-vzory, kterým je třeba se vyhnout
- Jedna adresa URL pro všechny nájemce: Bez samostatných adres URL na klienta nemůžete použít řízený přístup nebo přizpůsobení CSS. Použijte cestu S3 s tenant_id.
- Verze s časovým razítkem: Použití časového razítka místo semver znemožňuje správu zásad automatické aktualizace.
- SCORM bez obálky API: Přímý přístup k SCORM API LMS bez obálky činí kód nepřenosným a obtížně testovatelným.
- Mezipaměť CDN bez ověření: Po aktualizaci obsahu zůstane stará verze v mezipaměti, pokud není zrušena. Po nahrání vždy zneplatnit.
- Přímé změny balíčků SCORM: Nikdy neupravujte balíček SCORM, jakmile byl publikován. Vždy vytvořte novou verzi.
- Ignorovat SCORM 1.2: V roce 2025 více než 60 % podnikových LMS podporuje pouze SCORM 1.2. Nepředpokládejte, že všichni používají 2004.
Závěry EdTech Engineering Series
Dokončili jsme kompletní prohlídku inženýrství EdTech: od architektury pro více nájemců LMS (článek 1) k algoritmům adaptivního učení, od streamování videa po systémy pro proctoring AI, od lektorů LLM+RAG po gamifikační nástroje, od učení analytiky po spolupráci v reálném čase s CRDT, od offline nejprve z mobilu na správu obsahu SCORM.
Společným jmenovatelem všech těchto systémů je potřeba přemýšlet ve velkém měřítku: multi-nájemník od začátku, ne jako dodatečně; výkon pro miliony uživatelů, nikoli tisíce; soukromí a dodržování předpisů jako konstrukční omezení, nikoli jako konečný kontrolní seznam.
Trh EdTech nadále rychle roste: do roku 2030 jedna miliarda studentů bude mít ke vzdělávacímu obsahu přístup především prostřednictvím platforem digitální. Architektury, které dnes budujeme, budou definovat, jak tato generace učit se. Stojí za to je dobře postavit.
EdTech Engineering Series - Kompletní shrnutí
- Škálovatelná architektura LMS: Vzor pro více nájemců
- Algoritmy adaptivního učení: Od teorie k produkci
- Streamování videa pro vzdělávání: WebRTC vs HLS vs DASH
- AI Proctoring Systems: Privacy-first with Computer Vision
- Personalizovaný lektor s LLM: RAG pro ukotvení znalostí
- Gamification Engine: Architektura a státní stroj
- Learning Analytics: Data Pipeline s xAPI a Kafka
- Spolupráce v reálném čase v EdTech: CRDT a WebSocket
- Mobile-First EdTech: Offline-First Architecture
- Správa obsahu pro více nájemců: verzování a SCORM (tento článek)







