Zarządzanie treścią dla wielu dzierżawców: wersjonowanie i SCORM
Duża firma szkoleniowa obsługuje 500 klientów korporacyjnych, każdy z własnym pracowników, jego identyfikacja wizualna i wymogi zgodności. Dostawca treści e-learningowych musi zaktualizować formularz zasad o bezpieczeństwie (które zmienia się co roku) i rozpowszechnij tę zmianę wśród wszystkich 500 klientów natychmiast, bez konieczności robienia czegokolwiek przez żadnego z nich. Kiedy klient chcesz dostosować formularz za pomocą własnego logo i przykładów, musisz to zrobić zrobić bez przerywania automatycznych aktualizacji.
Z tym jest problem Zarządzanie treścią dla wielu dzierżawców dla EdTecha: zarządzaj udostępnionymi i spersonalizowanymi treściami, wersjonuj je poprawnie, rozpowszechniaj skutecznie i zapewnić zgodność z ZWÓJ (Udostępniana treść Object Reference Model), najbardziej rozpowszechniony standard dla korporacyjnych pakietów e-learningowych.
W tym artykule zbudujemy kompletny system: od struktury danych po treść multi-tenant z nadpisywaniem poszczególnych dzierżawców, semantyczne wersjonowanie pakietów, do punktu końcowego zgodnego ze SCORM w celu komunikacji z zewnętrznym systemem LMS, aż po strategię CDN dotyczącą efektywnej dystrybucji globalnej.
Czego dowiesz się w tym artykule
- Model danych dla wielu dzierżawców dla treści współdzielonych z możliwością dostosowywania dla poszczególnych dzierżawców
- Wersjonowanie semantyczne (semver) dla pakietów treści e-learningowych
- SCORM 1.2 i SCORM 2004: różnice, struktura i API komunikacji
- Hosting scentralizowany a rozproszony dla pakietów SCORM dla wielu dzierżawców
- Opakowanie treści: ZIP, manifest imsmanifest.xml i struktura pliku
- Opakowanie JavaScript API SCORM do zaawansowanego śledzenia
- Strategia CDN dotycząca globalnej dystrybucji o małych opóźnieniach
- Migracja ze SCORM do xAPI: kiedy i dlaczego
1. Wielodostępny model danych dla treści
Wyzwaniem związanym z zarządzaniem treścią dla wielu dzierżawców jest zrównoważenie dwóch przeciwstawnych potrzeb: partycypujący (zaktualizowana treść jest przekazywana wszystkim najemcom) tj personalizacja (każdy najemca może zastąpić określone elementy). Korzystamy ze wzoru a trzy poziomy: treść globalna (master), nadpisania kategorii (grupy najemców o podobnych cechach) i nadpisywania specyficzne dla najemcy.
# 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. Wersjonowanie i propagacja semantyczna
Użyjmy Wersjonowanie semantyczne (kilka) dla zawartości: MAJOR.MINOR.PATCH. SKRAWEK = poprawki (literówka, błąd): Automatycznie rozprzestrzenia się na wszystkich dzierżawców które nie mają wersji zablokowanej. DROBNY = nowe sekcje opcjonalne, ulepszenia: propagowane z powiadomieniem do administratorów dzierżawy. GŁÓWNY = zasadnicze zmiany w programie nauczania: wymaga wyraźnej zgody każdego lokatora.
# 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. Struktura SCORM i Manifestu imsmanifest.xml
SCORM definiuje format pakietu ZIP o precyzyjnej strukturze.
Plik imsmanifest.xml i punkt wejścia: opisuje
struktura kursu (organizacja), zasoby (pliki HTML, filmy, obrazy)
i metadane. Aby to zapewnić, generujemy ten manifest programowo
poprawność i wsparcie dla obu wersji 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. Strategia CDN dotycząca globalnej dystrybucji
Pakiety SCORM zawierają duże zasoby (wideo, obrazy HD). Rozdaj je z jednego serwera powoduje duże opóźnienia dla odległych studentów. Stosujemy wielopoziomową strategię CDN: CloudFront/Cloudflare do globalnego buforowania brzegowego przy włączonej pamięci masowej S3/przechowywanie w chmurze takie jak pochodzenie i podpisane adresy URL w celu kontroli dostępu.
# 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. Kiedy przeprowadzić migrację ze SCORM do xAPI
SCORM jest dominującym standardem szkoleń korporacyjnych, ma jednak ograniczenia znaczące w 2025 r.: ograniczone śledzenie (tylko zakończone/nieudane/niekompletne). w wersji 1.2), zależność od LMS-a, trudna na urządzeniach mobilnych. xAPI (API puszki) pokonuje wszystkie te ograniczenia, ale wymaga a Sklep z płytami edukacyjnymi (LRS) oddzielny.
SCORM vs xAPI: kiedy wybrać
| Kryterium | SCORM 1.2 / 2004 | xAPI (puszka) |
|---|---|---|
| Kompatybilność ze starszym systemem LMS | Uniwersalny | Tylko nowoczesny LMS |
| Szczegółowość śledzenia | Ukończono/Nie udało się/Wynik | Dowolna aktywność |
| Wsparcie mobilne | Problematyczne (iframe) | Rodzinny |
| Wsparcie offline | Nieobsługiwane | Tak (z lokalnym LRS) |
| Niezbędna infrastruktura | Tylko LMS-y | LMS + LRS |
| Interoperacyjność | Skonsolidowany standard | Pojawiający się standard |
| Zaawansowana analityka | Ograniczony | Kompletny |
| Polecane dla | Starsze szkolenia korporacyjne | Nowe systemy EdTech |
Anty-wzorce, których należy unikać
- Jeden adres URL dla wszystkich dzierżawców: Bez oddzielnych adresów URL dla każdej dzierżawy nie można zastosować kontrolowanego dostępu ani dostosowań CSS. Użyj ścieżki S3 z identyfikatorem najemcy.
- Wersjonowanie ze znacznikiem czasu: Użycie znacznika czasu zamiast semver uniemożliwia zarządzanie zasadami automatycznych aktualizacji.
- SCORM bez opakowania API: Bezpośredni dostęp do interfejsu API SCORM systemu LMS bez opakowania sprawia, że kod jest nieprzenośny i trudny do przetestowania.
- Pamięć podręczna CDN bez unieważnienia: Po aktualizacji zawartości stara wersja pozostaje w pamięci podręcznej, chyba że zostanie unieważniona. Zawsze unieważniaj po przesłaniu.
- Bezpośrednie zmiany w pakietach SCORM: Nigdy nie modyfikuj pakietu SCORM po jego opublikowaniu. Zawsze twórz nową wersję.
- Zignoruj SCORM 1.2: W 2025 r. ponad 60% korporacyjnych systemów LMS obsługuje wyłącznie SCORM 1.2. Nie zakładaj, że wszyscy używają 2004.
Wnioski z serii EdTech Engineering
Zakończyliśmy pełną wycieczkę po inżynierii EdTech: od architektury z wieloma najemcami LMS (art. 1) po algorytmy uczenia się adaptacyjnego, począwszy od strumieniowego przesyłania wideo po systemy nadzorujące AI, od tutorów LLM+RAG po silniki grywalizacji, od analityki uczenia się po współpracę w czasie rzeczywistym z CRDT, od pracy w trybie offline mobile do zarządzania treścią SCORM.
Wspólnym mianownikiem wszystkich tych systemów jest potrzeba myślenia na dużą skalę: wielu najemców od początku, a nie po namyśle; wydajność dla milionów użytkowników, a nie tysięcy; prywatność i zgodność jako ograniczenia projektowe, a nie jako ostateczna lista kontrolna.
Rynek EdTech nadal szybko rośnie: do 2030 roku miliard uczniów będzie miało dostęp do treści edukacyjnych głównie za pośrednictwem platform cyfrowy. Architektury, które dziś budujemy, zdefiniują sposób, w jaki będzie wyglądało to pokolenie uczyć się. Warto je dobrze zbudować.
Seria EdTech Engineering — pełne podsumowanie
- Skalowalna architektura LMS: wzorzec wielu najemców
- Algorytmy uczenia się adaptacyjnego: od teorii do produkcji
- Strumieniowe przesyłanie wideo dla edukacji: WebRTC vs HLS vs DASH
- Systemy AI Proctoring: przede wszystkim prywatność dzięki wizji komputerowej
- Spersonalizowany nauczyciel z LLM: RAG dla uziemienia wiedzy
- Silnik grywalizacji: architektura i maszyna stanu
- Learning Analytics: Potok danych z xAPI i Kafką
- Współpraca w czasie rzeczywistym w EdTech: CRDT i WebSocket
- EdTech zorientowany na urządzenia mobilne: architektura zorientowana na tryb offline
- Zarządzanie treścią dla wielu dzierżawców: wersjonowanie i SCORM (ten artykuł)







