Managementul conținutului cu mai mulți chiriași: Versiune și SCORM
O mare companie de instruire deservește 500 de clienți corporativi, fiecare cu propriile sale angajații, identitatea sa vizuală și cerințele sale de conformitate. Un furnizor de conținut eLearning trebuie să actualizeze un formular de politică pe securitate (care se schimbă în fiecare an) și să propagă modificarea la toți cei 500 de clienți instantaneu, fără ca niciunul dintre ei să fie nevoit să facă nimic. Când un client dorește să personalizeze formularul cu propriul logo și exemple, trebuie să poți faceți fără a întrerupe actualizările automate.
Aceasta este problema cu Managementul conținutului cu mai mulți chiriași pentru EdTech: gestionați conținutul partajat și personalizat, versați-l corect, distribuiți-l eficient și să asigure conformitatea cu DEfilare (Conținut care poate fi partajat Object Reference Model), cel mai răspândit standard pentru pachetele de eLearning corporative.
În acest articol vom construi un sistem complet: de la structura datelor până la conținut multi-chiriaș cu suprascriere pe locatar, versiunea semantică a pachetelor, către punctul final compatibil SCORM pentru comunicarea cu LMS terță parte, până la strategia CDN pentru distribuție globală eficientă.
Ce veți învăța în acest articol
- Model de date multi-locatari pentru conținut partajat cu personalizare pentru fiecare locatar
- Versiune semantică (semver) pentru pachetele de conținut eLearning
- SCORM 1.2 și SCORM 2004: diferențe, structură și comunicare API
- Găzduire centralizată vs distribuită pentru pachete SCORM multi-locatari
- Ambalare de conținut: ZIP, manifest imsmanifest.xml și structură de fișiere
- SCORM API JavaScript wrapper pentru urmărire avansată
- Strategie CDN pentru distribuție globală cu latență scăzută
- Migrarea de la SCORM la xAPI: când și de ce
1. Model de date multi-chiriași pentru conținut
Provocarea managementului de conținut multi-locatari este echilibrarea a două nevoi opuse: partajarea (conținutul actualizat se propagă tuturor chiriașilor) e personalizare (fiecare chiriaș poate suprascrie anumite elemente). Folosim un model a trei niveluri: conținut global (master), depășiri de categorie (grupuri de chiriași cu caracteristici similare) și depășiri specifice chiriașului.
# 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. Versiune și propagare semantică
Să folosim Versiune semantică (mai multe) pentru conținut: MAJOR.MINOR.PATCH. PLASTURE = remedieri (greșeală, eroare): se propagă automat tuturor chiriașilor care nu au versiunea blocată. MINOR = noi secțiuni opționale, îmbunătățiri: se propagă cu notificare către administratorii chiriașilor. MAJOR = modificări fundamentale ale curriculumului: necesită aprobare explicită pentru fiecare chiriaș.
# 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. Structura SCORM și Manifest imsmanifest.xml
SCORM definește un format de pachet ZIP cu o structură precisă.
Dosarul imsmanifest.xml iar punctul de intrare: descrie
structura cursului (organizație), resurse (fișiere HTML, videoclipuri, imagini)
și metadate. Generăm acest manifest în mod programatic pentru a ne asigura
corectitudine și suport pentru ambele versiuni 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 pentru distribuție globală
Pachetele SCORM conțin active grele (videoclipuri, imagini HD). Distribuiți-le de la un singur server provoacă o latență mare pentru studenții îndepărtați. Folosim o strategie CDN pe mai multe niveluri: CloudFront/Cloudflare pentru stocarea în cache globală, cu stocarea activată S3/Cloud Storage cum ar fi originea și URL-uri semnate pentru controlul accesului.
# 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. Când să migrați de la SCORM la xAPI
SCORM este standardul dominant pentru instruirea corporativă, dar are limitări semnificativ în 2025: urmărire limitată (numai finalizată/eșuată/incompletă în versiunea 1.2), dependența de LMS pentru a rula, dificilă pe mobil. xAPI (API-ul Tin Can) depășește toate aceste limitări dar necesită a Magazin de înregistrări de învățare (LRS) separa.
SCORM vs xAPI: Când să alegeți
| Criteriu | SCORM 1.2 / 2004 | xAPI (Cutie de conserve) |
|---|---|---|
| Compatibilitate Legacy LMS | Universal | Numai LMS modern |
| Granularitatea urmăririi | Finalizat/Eșuat/Scor | Orice activitate |
| Suport mobil | Problematic (iframe) | Nativ |
| Asistență offline | Nu este acceptat | Da (cu LRS local) |
| Infrastructura necesara | Numai LMS | LMS + LRS |
| Interoperabilitate | Standard consolidat | Standard emergent |
| Analiză avansată | Limitat | Complet |
| Recomandat pentru | Formare corporativă moștenită | Noi sisteme EdTech |
Anti-modele de evitat
- O singură adresă URL pentru toți chiriașii: Fără adrese URL separate pentru fiecare locatar, nu puteți aplica acces controlat sau personalizări CSS. Utilizați calea S3 cu tenant_id.
- Versiune cu marca temporală: Utilizarea timestamp în loc de semver face imposibilă gestionarea politicilor de actualizare automată.
- SCORM fără wrapper API: Accesarea API-ului SCORM a LMS direct fără un wrapper face ca codul să nu fie portabil și dificil de testat.
- Cache CDN fără invalidare: După o actualizare a conținutului, vechea versiune rămâne în cache dacă nu este invalidată. Invalidați întotdeauna după încărcare.
- Modificări directe la pachetele SCORM: Nu modificați niciodată un pachet SCORM odată ce acesta a fost publicat. Creați întotdeauna o versiune nouă.
- Ignorați SCORM 1.2: În 2025, peste 60% din LMS-urile pentru întreprinderi acceptă doar SCORM 1.2. Nu presupuneți că toată lumea folosește 2004.
Concluziile seriei EdTech Engineering
Am finalizat turul complet al ingineriei EdTech: de la arhitectura multi-locatari a LMS (articolul 1) la algoritmi de învățare adaptive, din streaming video la sistemele de supraveghere AI, de la tutori LLM+RAG la motoarele de gamification, de la analiza învățării până la colaborarea în timp real cu CRDT, mai întâi offline de la mobil la managementul conținutului SCORM.
Numitorul comun al tuturor acestor sisteme este nevoia de a gândi pe scară largă: multi-chiriaș de la început, nu ca după gândire; performanță pentru milioane de utilizatori, nu pentru mii; confidențialitate și conformitate ca constrângeri de proiectare, nu ca o listă de verificare finală.
Piața EdTech continuă să crească rapid: până în 2030, un miliard dintre elevi vor accesa conținut educațional în principal prin intermediul platformelor digitale. Arhitecturile pe care le construim astăzi vor defini modul în care acea generație invata. Merită să le construiți bine.
Seria EdTech Engineering - Rezumat complet
- Arhitectură LMS scalabilă: model multi-chiriași
- Algoritmi de învățare adaptivă: de la teorie la producție
- Streaming video pentru educație: WebRTC vs HLS vs DASH
- Sisteme de supraveghere AI: confidențialitate-în primul rând cu computer Vision
- Tutor personalizat cu LLM: RAG pentru fundamentarea cunoștințelor
- Motor de gamification: Arhitectură și mașină de stat
- Learning Analytics: Data Pipeline cu xAPI și Kafka
- Colaborare în timp real în EdTech: CRDT și WebSocket
- Mobile-First EdTech: Offline-First Architecture
- Gestionarea conținutului cu mai mulți chiriași: Versiune și SCORM (acest articol)







