con un clic
openolat-dev
// Use this skill when developing OpenOlat features, fixing bugs, or writing new controllers, services, forms, or templates. Provides architecture knowledge, patterns, and conventions for the OpenOlat LMS codebase.
// Use this skill when developing OpenOlat features, fixing bugs, or writing new controllers, services, forms, or templates. Provides architecture knowledge, patterns, and conventions for the OpenOlat LMS codebase.
| name | openolat-dev |
| description | Use this skill when developing OpenOlat features, fixing bugs, or writing new controllers, services, forms, or templates. Provides architecture knowledge, patterns, and conventions for the OpenOlat LMS codebase. |
| allowed-tools | Read, Grep, Glob, Bash(mvn *) |
You are an expert OpenOlat developer. Use the architecture knowledge below and the reference files to help developers write correct, idiomatic OpenOlat code.
For compressed architecture knowledge, read .claude/openolat-architecture-knowledge.md
For detailed architecture documentation, read: doc/openolat-architecture.md
mvn compile -pl :openolat-lms -qOpenOlat is a server-centric web framework. All UI state lives on the server. The browser receives HTML fragments via AJAX — there is no client-side framework (no React/Angular/Vue).
*Manager interfaces, Spring beans)DB facade, VFSHTTP Request → Servlet → Dispatcher → Window (synchronized)
→ Find Component by ID → Fire event to Controller
→ Controller executes business logic (DB, services)
→ Controller may fire events to parent controllers
→ Controller may fire MultiUserEvents to EventBus
→ Controller updates component tree (dirty flags)
→ RENDER PHASE (no more business logic, no DB by convention)
→ Dirty components rendered → JSON/HTML response
DefaultController (base, dispose lifecycle)
└─ BasicController (UI controller, Velocity rendering)
└─ FormBasicController (FlexiForm support)
└─ FormLayoutContainer (pure layout)
public class MyController extends BasicController {
@Autowired private MyService myService;
public MyController(UserRequest ureq, WindowControl wControl) {
super(ureq, wControl);
VelocityContainer vc = createVelocityContainer("my_template");
Link btn = LinkFactory.createButton("save", vc, this);
putInitialPanel(vc);
}
@Override
protected void event(UserRequest ureq, Component source, Event event) {
if (source instanceof Link link && "save".equals(link.getCommand())) {
doSave(ureq);
}
}
}
public class MyFormController extends FormBasicController {
private TextElement nameEl;
public MyFormController(UserRequest ureq, WindowControl wControl) {
super(ureq, wControl);
initForm(ureq);
}
@Override
protected void initForm(FormItemContainer layout, Controller listener, UserRequest ureq) {
nameEl = uifactory.addTextElement("name", "form.name", 255, "", layout);
nameEl.setMandatory(true);
uifactory.addFormSubmitButton("save", layout);
}
@Override
protected boolean validateFormLogic(UserRequest ureq) {
boolean ok = super.validateFormLogic(ureq);
nameEl.clearError();
if (!StringHelper.containsNonWhitespace(nameEl.getValue())) {
nameEl.setErrorKey("form.mandatory");
ok = false;
}
return ok;
}
@Override
protected void formOK(UserRequest ureq) {
String name = nameEl.getValue();
// save logic
fireEvent(ureq, Event.DONE_EVENT);
}
}
// ALWAYS use listenTo() — ensures automatic disposal
detailCtrl = new DetailController(ureq, getWindowControl(), item);
listenTo(detailCtrl);
// To replace: remove first, then create new
removeAsListenerAndDispose(detailCtrl);
detailCtrl = new DetailController(ureq, getWindowControl(), newItem);
listenTo(detailCtrl);
@Override
protected void event(UserRequest ureq, Controller source, Event event) {
if (source == detailCtrl) {
if (event == Event.DONE_EVENT) {
// handle success
} else if (event == Event.CANCELLED_EVENT) {
// handle cancel
}
}
}
| Type | Scope | Example |
|---|---|---|
| Component → Controller | Same controller | Button click, link click |
| Controller → Parent | Parent via listenTo() | fireEvent(ureq, Event.DONE_EVENT) |
| Form events | FormBasicController | formOK(), formCancelled(), formInnerEvent() |
| Multi-user (EventBus) | Cross-session, cluster-wide | coordinatorManager.getCoordinator().getEventBus() |
EventBus pattern:
// Register
OLATResourceable ores = OresHelper.createOLATResourceableInstance("CourseModule", courseId);
coordinatorManager.getCoordinator().getEventBus().registerFor(this, ureq.getIdentity(), ores);
// Fire
coordinatorManager.getCoordinator().getEventBus().fireEventToListenersOf(new MultiUserEvent("changed"), ores);
// MUST deregister in doDispose()!
coordinatorManager.getCoordinator().getEventBus().deregisterFor(this, ores);
Templates in _content/ directory, colocated with the controller's package.
## Render a child component
$r.render("myComponent")
## Translate
$r.translate("my.key")
$r.translate("greeting", $userName)
## Escape user content (CRITICAL for XSS prevention)
$r.escapeHtml($userText)
## Conditional rendering
#if($showDetails)
<div>$r.render("detailPanel")</div>
#end
## Loop
#foreach($item in $items)
<div>$r.escapeHtml($item.name)</div>
#end
Important: $r.render() is safe (components handle escaping), but $myVar from contextPut() is NOT auto-escaped — always use $r.escapeHtml($myVar) for user-provided text.
// In initForm():
FlexiTableColumnModel columnsModel = FlexiTableDataModelFactory.createFlexiTableColumnModel();
columnsModel.addFlexiColumnModel(new DefaultFlexiColumnModel(Cols.name));
columnsModel.addFlexiColumnModel(new DefaultFlexiColumnModel(Cols.date,
new DateFlexiCellRenderer(getLocale())));
columnsModel.addFlexiColumnModel(new DefaultFlexiColumnModel("edit",
translate("action.edit"), "edit")); // action column
tableModel = new MyTableModel(columnsModel);
tableEl = uifactory.addTableElement(getWindowControl(), "table", tableModel, getTranslator(), layout);
tableEl.setSearchEnabled(true);
tableEl.setSelectAllEnable(true);
tableEl.setEmptyTableSettings("icon", "empty.message", null, "create.button");
// Service method pattern — EntityManager via ThreadLocal
public MyEntity loadByKey(Long key) {
return dbInstance.getCurrentEntityManager().find(MyEntity.class, key);
}
// JPQL query
public List<MyEntity> findByName(String name) {
return dbInstance.getCurrentEntityManager()
.createQuery("select e from MyEntity e where e.name = :name", MyEntity.class)
.setParameter("name", name)
.getResultList();
}
// Persist
public void save(MyEntity entity) {
dbInstance.getCurrentEntityManager().persist(entity);
}
dbInstance.commitAndCloseSession() explicitlydbInstance.intermediateCommit() every ~100 itemssrc/main/resources/META-INF/persistence.xml// In Spring-managed beans: use @Autowired
@Service
public class MyManagerImpl implements MyManager {
@Autowired private DB dbInstance;
}
// In controllers (not Spring-managed): use CoreSpringFactory
MyManager mgr = CoreSpringFactory.getImpl(MyManager.class);
src/main/resources/serviceconfig/olat.propertiesolat.local.propertiesAbstractSpringModule for feature toggles_i18n/LocalStrings_XX.properties colocated with UI package (Java .properties format)de_CH__customizing → de_CH → de__customizing → de → en__customizing → en (default) → en (fallback){userData}/customizing/lang/overlay/{package}/_i18n/LocalStrings_XX__customizing.properties$r.translate("key"), $r.translate("key", $arg1)translate("key") (in controllers), translate("key", new String[]{arg})$\:other.key — references another key in the same .properties file$org.olat.other.package:other.key or ${org.olat.other.package:other.key}org.olat.core (core), org.olat (application) — checked when key not found in primary bundle{0}, {1} etc. via MessageFormat. Escape single quotes as ''Benutzer{in} → converted per locale config (star *, colon :, etc.)I18nModule (config), I18nManager (resolution/caching), PackageTranslator (per-controller)OpenOLAT-docs/sites/manual_user/docs/general/glossary.md (EN) and glossary.de.md (DE). Translator-facing canonical term mappings (EN/DE/FR/ES/IT) live in doc/i18n-translation-reference.md in this repo.LocalStrings_XX.properties files must be sorted alphabetically. When adding new keys, insert them at the correct alphabetical position. When modifying existing keys, keep them in place. Do not reorder existing keys unless explicitly told to do so.doc/i18n-translation-reference.md first. Every term listed there must be translated exactly as defined. If existing translations use different words, flag the inconsistency to the user and offer to fix it.OpenOLAT-docs/sites/manual_user/docs/general/glossary.md and glossary.de.md, and (if the term is in scope) the canonical mapping in doc/i18n-translation-reference.md.Never access bcroot/ directly. Always use VFS classes:
VFSContainer folder = VFSManager.olatRootContainer("/course/" + courseId + "/files");
VFSLeaf file = folder.createChildLeaf("report.pdf");
VFSManager.copyContent(inputStream, file, identity);
Every new Java file must include the standard license header and a class-level Javadoc with the initial date and @author tag. The username is mandatory; the email is optional. The author for AI-generated code is AI, ai@frentix.com, https://www.frentix.com.
/**
* <a href="https://www.openolat.org">
* OpenOLAT - Online Learning and Training</a><br>
* <p>
* Licensed under the Apache License, Version 2.0 (the "License"); <br>
* you may not use this file except in compliance with the License.<br>
* You may obtain a copy of the License at the
* <a href="https://www.apache.org/licenses/LICENSE-2.0">Apache homepage</a>
* <p>
* Unless required by applicable law or agreed to in writing,<br>
* software distributed under the License is distributed on an "AS IS" BASIS, <br>
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. <br>
* See the License for the specific language governing permissions and <br>
* limitations under the License.
* <p>
* Initial code contributed and copyrighted by<br>
* frentix GmbH, https://www.frentix.com
* <p>
*/
package org.olat.modules.example;
import ...;
/**
* Initial date: 9 Apr 2026<br>
* @author AI, ai@frentix.com, https://www.frentix.com
*/
public class ExampleController extends BasicController {
java.io.File, java.util.HashMap<>(), org.olat.core.util.vfs.VFSLeaf etc. inline in method bodies. Add the import statement and use the class name only. The only exception is when two classes from different packages have the same name — in that case, use the FQN for the less-frequently-used one.All outbound HTTP requests must use HttpClientService (org.olat.core.util.httpclient.HttpClientService). Never use java.net.http.HttpClient, other HTTP client libraries, or instantiate Apache HttpClient directly. The service provides centralized proxy configuration, standardized timeouts, and frees the DB connection before outbound calls.
// In Spring-managed beans
@Autowired
private HttpClientService httpClientService;
// In controllers
HttpClientService httpClientService = CoreSpringFactory.getImpl(HttpClientService.class);
// Simple request
try (CloseableHttpClient httpClient = httpClientService.createHttpClient()) {
HttpGet request = new HttpGet("https://api.example.com/data");
try (CloseableHttpResponse response = httpClient.execute(request)) {
// handle response
}
}
// Thread-safe pooled client for concurrent use
try (CloseableHttpClient httpClient = httpClientService.createThreadSafeHttpClient(true)) {
// use for multiple concurrent requests
}
Methods: createHttpClient(), createHttpClientBuilder(), createThreadSafeHttpClient(redirect), plus variants with (host, port, user, password) for basic auth.
$r.escapeHtml() for all user text in templates. $r.render() is safe.Form generates tokens automatically. Always use FormBasicController for forms.:paramName), never string concatenation.OWASPAntiSamyXSSFilter for rich-text content.Override doDispose() when your controller:
eventBus.deregisterFor(this, ores)locker.releaseLock(lockEntry)nullYou do NOT need to manually dispose:
listenTo() (automatic)registerMapper() (automatic)BreadcrumbedStackedPanel for drill-down viewswControl.pushAsModalDialog(component)wControl.pushAsCallout(component)showInfo("key"), showError("key")AbstractSpringModule with isEnabled() and persisted configtoolbarPanel.addTool(link) for create/export/import buttonsorg.olat.upgrade)For data migrations between versions, create an upgrade class:
public class OLATUpgrade_20_4_0 extends OLATUpgrade {
private static final String VERSION = "OLAT_20.4.0";
private static final String MIGRATE_DATA = "MIGRATE DATA";
@Autowired
private MyService myService;
@Override
public String getVersion() { return VERSION; }
@Override
public boolean doPostSystemInitUpgrade(UpgradeManager upgradeManager) {
UpgradeHistoryData uhd = upgradeManager.getUpgradesHistory(VERSION);
if (uhd == null) {
uhd = new UpgradeHistoryData();
} else if (uhd.isInstallationComplete()) {
return false;
}
boolean allOk = true;
allOk &= migrateData(upgradeManager, uhd);
uhd.setInstallationComplete(allOk);
upgradeManager.setUpgradesHistory(uhd, VERSION);
return allOk;
}
private boolean migrateData(UpgradeManager upgradeManager, UpgradeHistoryData uhd) {
if (uhd.getBooleanDataValue(MIGRATE_DATA)) {
return true; // Already done
}
// ... migration logic, use @Autowired services ...
uhd.setBooleanDataValue(MIGRATE_DATA, true);
upgradeManager.setUpgradesHistory(uhd, VERSION);
return true;
}
}
Register in org/olat/upgrade/_spring/upgradeContext.xml (append to the list). For SQL schema changes, add ALTER scripts to /database/mysql/ and /database/postgresql/ and register in databaseUpgradeContext.xml.
Important: Upgrades run after all modules are initialized. Changes to AbstractSpringModule configs may need module re-initialization since the module's init() has already executed.
| Level | Base Class | What it tests |
|---|---|---|
| Unit | Plain JUnit | Pure logic, no Spring |
| Integration | OlatTestCase | Spring context + DB with rollback |
| REST API | OlatRestTestCase | Full HTTP stack |
| Selenium | @RunWith(Arquillian.class) | Browser UI tests |
o_sel_ for automationJunitTestHelper for creating users, courses, fixturesOOGraphene for wait helpers (waitBusy(), waitElement())