LtiServiceImpl.java
/*
* Licensed to The Apereo Foundation under one or more contributor license
* agreements. See the NOTICE file distributed with this work for additional
* information regarding copyright ownership.
*
*
* The Apereo Foundation licenses this file to you under the Educational
* Community License, Version 2.0 (the "License"); you may not use this file
* except in compliance with the License. You may obtain a copy of the License
* at:
*
* http://opensource.org/licenses/ecl2.txt
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*
*/
package org.opencastproject.lti.service.impl;
import static org.opencastproject.util.MimeType.mimeType;
import static org.opencastproject.workflow.api.ConfiguredWorkflow.workflow;
import static org.opencastproject.workflow.api.WorkflowInstance.WorkflowState.SUCCEEDED;
import org.opencastproject.assetmanager.api.AssetManager;
import org.opencastproject.assetmanager.util.Workflows;
import org.opencastproject.elasticsearch.api.SearchIndexException;
import org.opencastproject.elasticsearch.api.SearchResult;
import org.opencastproject.elasticsearch.api.SearchResultItem;
import org.opencastproject.elasticsearch.index.ElasticsearchIndex;
import org.opencastproject.elasticsearch.index.objects.event.Event;
import org.opencastproject.elasticsearch.index.objects.event.EventSearchQuery;
import org.opencastproject.index.service.api.IndexService;
import org.opencastproject.index.service.exception.IndexServiceException;
import org.opencastproject.index.service.impl.util.EventUtils;
import org.opencastproject.ingest.api.IngestService;
import org.opencastproject.lti.service.api.LtiFileUpload;
import org.opencastproject.lti.service.api.LtiJob;
import org.opencastproject.lti.service.api.LtiService;
import org.opencastproject.mediapackage.MediaPackage;
import org.opencastproject.mediapackage.MediaPackageElement;
import org.opencastproject.mediapackage.MediaPackageElementBuilder;
import org.opencastproject.mediapackage.MediaPackageElementBuilderFactory;
import org.opencastproject.mediapackage.MediaPackageElementFlavor;
import org.opencastproject.mediapackage.MediaPackageElements;
import org.opencastproject.metadata.dublincore.DublinCore;
import org.opencastproject.metadata.dublincore.DublinCoreMetadataCollection;
import org.opencastproject.metadata.dublincore.EventCatalogUIAdapter;
import org.opencastproject.metadata.dublincore.MetadataField;
import org.opencastproject.metadata.dublincore.MetadataJson;
import org.opencastproject.metadata.dublincore.MetadataList;
import org.opencastproject.security.api.AccessControlEntry;
import org.opencastproject.security.api.AccessControlList;
import org.opencastproject.security.api.AclScope;
import org.opencastproject.security.api.AuthorizationService;
import org.opencastproject.security.api.SecurityService;
import org.opencastproject.security.api.UnauthorizedException;
import org.opencastproject.security.api.User;
import org.opencastproject.series.api.SeriesService;
import org.opencastproject.util.NotFoundException;
import org.opencastproject.workflow.api.ConfiguredWorkflow;
import org.opencastproject.workflow.api.WorkflowDatabaseException;
import org.opencastproject.workflow.api.WorkflowDefinition;
import org.opencastproject.workflow.api.WorkflowInstance;
import org.opencastproject.workflow.api.WorkflowListener;
import org.opencastproject.workflow.api.WorkflowService;
import org.opencastproject.workflow.api.WorkflowUtil;
import org.opencastproject.workspace.api.Workspace;
import com.google.gson.Gson;
import com.google.gson.JsonSyntaxException;
import org.apache.commons.lang3.StringUtils;
import org.json.simple.JSONArray;
import org.json.simple.parser.JSONParser;
import org.json.simple.parser.ParseException;
import org.osgi.service.component.ComponentContext;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Modified;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.component.annotations.ReferenceCardinality;
import org.osgi.service.component.annotations.ReferencePolicy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Dictionary;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* The LTI service implementation
*/
@Component(
immediate = true,
service = { LtiService.class },
property = {
"service.description=LTI Service"
}
)
public class LtiServiceImpl implements LtiService {
private static final Logger logger = LoggerFactory.getLogger(LtiServiceImpl.class);
private static final Gson gson = new Gson();
private static final String NEW_MP_ID_KEY = "newMpId";
private IndexService indexService;
private IngestService ingestService;
private SecurityService securityService;
private WorkflowService workflowService;
private AssetManager assetManager;
private Workspace workspace;
private ElasticsearchIndex searchIndex;
private AuthorizationService authorizationService;
private SeriesService seriesService;
private String workflow;
private String workflowConfiguration;
private String retractWorkflowId;
private String copyWorkflowId;
private final List<EventCatalogUIAdapter> catalogUIAdapters = new ArrayList<>();
private boolean listAllJobsInSeries;
/** OSGi DI */
@Reference
public void setAuthorizationService(AuthorizationService authorizationService) {
this.authorizationService = authorizationService;
}
/** OSGI DI */
@Reference
public void setSeriesService(SeriesService seriesService) {
this.seriesService = seriesService;
}
/** OSGi DI */
@Reference
public void setAssetManager(AssetManager assetManager) {
this.assetManager = assetManager;
}
/** OSGi DI */
@Reference
public void setWorkflowService(WorkflowService workflowService) {
this.workflowService = workflowService;
}
/** OSGi DI */
@Reference
public void setWorkspace(Workspace workspace) {
this.workspace = workspace;
}
/** OSGi DI */
@Reference
public void setSearchIndex(ElasticsearchIndex searchIndex) {
this.searchIndex = searchIndex;
}
/** OSGi DI */
@Reference
public void setIndexService(IndexService indexService) {
this.indexService = indexService;
}
/** OSGi DI */
@Reference
public void setIngestService(IngestService ingestService) {
this.ingestService = ingestService;
}
/** OSGi DI */
@Reference
void setSecurityService(SecurityService securityService) {
this.securityService = securityService;
}
/** OSGi DI. */
@Reference(
cardinality = ReferenceCardinality.MULTIPLE,
policy = ReferencePolicy.DYNAMIC,
unbind = "removeCatalogUIAdapter"
)
public void addCatalogUIAdapter(EventCatalogUIAdapter catalogUIAdapter) {
catalogUIAdapters.add(catalogUIAdapter);
}
/** OSGi DI. */
public void removeCatalogUIAdapter(EventCatalogUIAdapter catalogUIAdapter) {
catalogUIAdapters.remove(catalogUIAdapter);
}
@Activate
public void activate(ComponentContext cc) {
workflowService.addWorkflowListener(new WorkflowListener() {
@Override
public void stateChanged(WorkflowInstance workflow) {
if (!workflow.getTemplate().equals(copyWorkflowId)) {
return;
}
if (workflow.getState().equals(SUCCEEDED)) {
final String publishWorkflowName = "publish";
logger.info("workflow '{}' succeeded for media package '{}', starting workflow '{}'", workflow.getTemplate(),
workflow.getMediaPackage().getIdentifier(), publishWorkflowName);
final WorkflowDefinition wfd;
try {
wfd = workflowService.getWorkflowDefinitionById(publishWorkflowName);
final Workflows workflows = new Workflows(assetManager, workflowService);
final ConfiguredWorkflow newWorkflow = workflow(wfd);
final String targetMpId = workflow.getConfiguration(NEW_MP_ID_KEY);
final List<WorkflowInstance> workflowInstances = workflows
.applyWorkflowToLatestVersion(Collections.singleton(targetMpId), newWorkflow);
if (workflowInstances.isEmpty()) {
throw new RuntimeException(
String.format("couldn't start workflow '%s' for event %s", publishWorkflowName, targetMpId));
}
} catch (WorkflowDatabaseException e) {
logger.error(String.format("couldn't instantiate workflow '%s'", publishWorkflowName), e);
} catch (NotFoundException e) {
logger.error(String.format("couldn't find media package while starting workflow workflow '%s'",
publishWorkflowName), e);
}
}
}
});
updated(cc.getProperties());
}
@Modified
public void modified(ComponentContext cc) {
updated(cc.getProperties());
}
@Override
public List<LtiJob> listJobs(String seriesId) {
final User user = securityService.getUser();
final EventSearchQuery query = new EventSearchQuery(securityService.getOrganization().getId(), user)
.withSeriesId(StringUtils.trimToNull(seriesId));
if (!listAllJobsInSeries) {
query.withCreator(user.getName());
}
try {
SearchResult<Event> results = this.searchIndex.getByQuery(query);
ZonedDateTime startOfDay = ZonedDateTime.now().truncatedTo(ChronoUnit.DAYS);
return Arrays.stream(results.getItems())
.map(SearchResultItem::getSource)
.filter(e -> ZonedDateTime.parse(e.getCreated()).compareTo(startOfDay) > 0)
.map(e -> new LtiJob(e.getTitle(), e.getDisplayableStatus(workflowService.getWorkflowStateMappings())))
.collect(Collectors.toList());
} catch (SearchIndexException e) {
throw new RuntimeException("search index exception", e);
}
}
@Override
public void upsertEvent(
final LtiFileUpload file,
final String captions,
final String captionFormat,
final String captionLanguage,
final String eventId,
final String seriesId,
final String metadataJson) throws UnauthorizedException, NotFoundException {
if (eventId != null) {
updateEvent(eventId, metadataJson);
return;
}
if (workflow == null || workflowConfiguration == null) {
throw new RuntimeException("No workflow configured, cannot upload");
}
try {
MediaPackage mediaPackage = ingestService.createMediaPackage();
if (mediaPackage == null) {
throw new RuntimeException("Unable to create media package for event");
}
if (captions != null) {
final MediaPackageElementFlavor captionsFlavor = new MediaPackageElementFlavor(
"captions", "source"
);
final MediaPackageElementBuilder elementBuilder =
MediaPackageElementBuilderFactory.newInstance().newElementBuilder();
final MediaPackageElement captionsMediaPackage = elementBuilder
.newElement(MediaPackageElement.Type.Track, captionsFlavor);
if (!"vtt".equals(captionFormat)) {
throw new IllegalArgumentException("Subtitle format must be vtt, but was " + captionFormat);
}
captionsMediaPackage.setMimeType(mimeType("text", captionFormat));
captionsMediaPackage.addTag("lang:" + captionLanguage);
mediaPackage.add(captionsMediaPackage);
final URI captionsUri = workspace
.put(
mediaPackage.getIdentifier().toString(),
captionsMediaPackage.getIdentifier(),
"captions." + captionFormat,
new ByteArrayInputStream(captions.getBytes(StandardCharsets.UTF_8)));
captionsMediaPackage.setURI(captionsUri);
}
final EventCatalogUIAdapter adapter = getEventCatalogUIAdapter();
final DublinCoreMetadataCollection collection = adapter.getRawFields();
JSONArray metadataJsonArray = (JSONArray) new JSONParser().parse(metadataJson);
JSONArray collectionJsonArray = MetadataJson.extractSingleCollectionfromListJson(metadataJsonArray);
MetadataJson.fillCollectionFromJson(collection, collectionJsonArray);
replaceField(collection, "isPartOf", seriesId);
adapter.storeFields(mediaPackage, collection);
AccessControlList accessControlList = null;
// If series is set and it's ACL is not empty, use series' ACL as default
if (StringUtils.isNotBlank(seriesId)) {
accessControlList = seriesService.getSeriesAccessControl(seriesId);
}
if (accessControlList == null || accessControlList.getEntries().isEmpty()) {
accessControlList = new AccessControlList(
new AccessControlEntry("ROLE_ADMIN", "write", true),
new AccessControlEntry("ROLE_ADMIN", "read", true),
new AccessControlEntry("ROLE_USER", "read", true));
}
this.authorizationService.setAcl(mediaPackage, AclScope.Episode, accessControlList);
mediaPackage = ingestService.addTrack(
file.getStream(),
file.getSourceName(),
MediaPackageElements.PRESENTER_SOURCE,
mediaPackage
);
final Map<String, String> configuration = gson.fromJson(workflowConfiguration, Map.class);
configuration.put("workflowDefinitionId", workflow);
ingestService.ingest(mediaPackage, workflow, configuration);
} catch (Exception e) {
throw new RuntimeException("unable to create event", e);
}
}
private static Map<String, String> createCopyWorkflowConfig(final String seriesId, final String newMpId) {
final Map<String, String> result = new HashMap<>();
result.put("numberOfEvents", "1");
result.put("noSuffix", "true");
result.put("setSeriesId", seriesId);
result.put(NEW_MP_ID_KEY, newMpId);
return result;
}
@Override
public void copyEventToSeries(final String eventId, final String seriesId) {
final String workflowId = copyWorkflowId;
try {
final WorkflowDefinition wfd = workflowService.getWorkflowDefinitionById(workflowId);
final Workflows workflows = new Workflows(assetManager, workflowService);
final ConfiguredWorkflow workflow
= workflow(wfd, createCopyWorkflowConfig(seriesId, UUID.randomUUID().toString()));
final List<WorkflowInstance> workflowInstances = workflows
.applyWorkflowToLatestVersion(Collections.singleton(eventId), workflow);
if (workflowInstances.isEmpty()) {
throw new RuntimeException(String.format("Couldn't start workflow '%s' for event %s", workflowId, eventId));
}
} catch (WorkflowDatabaseException | NotFoundException e) {
logger.error("Unable to get workflow definition {}", workflowId, e);
throw new RuntimeException(e);
}
}
private EventCatalogUIAdapter getEventCatalogUIAdapter() {
final MediaPackageElementFlavor flavor = new MediaPackageElementFlavor("dublincore", "episode");
final EventCatalogUIAdapter adapter = catalogUIAdapters.stream()
.filter(e -> e.getFlavor().equals(flavor))
.findAny()
.orElseThrow(() -> new RuntimeException("no adapter found"));
return adapter;
}
private void updateEvent(final String eventId, final String metadata)
throws NotFoundException, UnauthorizedException {
final EventCatalogUIAdapter adapter = getEventCatalogUIAdapter();
final DublinCoreMetadataCollection collection = adapter.getRawFields();
try {
final MetadataList metadataList = new MetadataList();
metadataList.add(adapter, collection);
MetadataJson.fillListFromJson(metadataList, (JSONArray) new JSONParser().parse(metadata));
this.indexService.updateEventMetadata(eventId, metadataList, searchIndex);
republishMetadata(eventId);
} catch (IndexServiceException | SearchIndexException | ParseException e) {
throw new RuntimeException(e);
}
}
private void republishMetadata(final String eventId) {
final String workflowId = "republish-metadata";
try {
final WorkflowDefinition wfd = workflowService.getWorkflowDefinitionById(workflowId);
final Workflows workflows = new Workflows(assetManager, workflowService);
final ConfiguredWorkflow workflow = workflow(wfd, Collections.emptyMap());
if (workflows.applyWorkflowToLatestVersion(Collections.singleton(eventId), workflow).isEmpty()) {
throw new RuntimeException(String.format("couldn't start workflow '%s' for event %s", workflowId, eventId));
}
} catch (WorkflowDatabaseException | NotFoundException e) {
logger.error("Unable to get workflow definition {}", workflowId, e);
throw new RuntimeException(e);
}
}
@Override
public String getEventMetadata(final String eventId) throws NotFoundException, UnauthorizedException {
final Optional<Event> optEvent;
try {
optEvent = indexService.getEvent(eventId, searchIndex);
} catch (SearchIndexException e) {
throw new RuntimeException(e);
}
if (optEvent.isEmpty()) {
throw new NotFoundException("cannot find event with id '" + eventId + "'");
}
final Event event = optEvent.get();
final MetadataList metadataList = new MetadataList();
final List<EventCatalogUIAdapter> catalogUIAdapters = this.indexService.getEventCatalogUIAdapters();
catalogUIAdapters.remove(this.indexService.getCommonEventCatalogUIAdapter());
final MediaPackage mediaPackage;
try {
mediaPackage = this.indexService.getEventMediapackage(event);
} catch (IndexServiceException e) {
if (e.getCause() instanceof NotFoundException) {
throw new NotFoundException("Cannot retrieve metadata for event with id '" + eventId + "'");
} else if (e.getCause() instanceof UnauthorizedException) {
throw new UnauthorizedException("Not authorized to access event with id '" + eventId + "'");
}
throw new RuntimeException(e);
}
for (EventCatalogUIAdapter catalogUIAdapter : catalogUIAdapters) {
metadataList.add(catalogUIAdapter, catalogUIAdapter.getFields(mediaPackage));
}
final DublinCoreMetadataCollection metadataCollection;
try {
metadataCollection = EventUtils.getEventMetadata(event, this.indexService.getCommonEventCatalogUIAdapter());
} catch (final Exception e) {
throw new RuntimeException(e);
}
metadataList.add(this.indexService.getCommonEventCatalogUIAdapter(), metadataCollection);
final String wfState = event.getWorkflowState();
if (wfState != null && WorkflowUtil.isActive(WorkflowInstance.WorkflowState.valueOf(wfState))) {
metadataList.setLocked(MetadataList.Locked.WORKFLOW_RUNNING);
}
return new Gson().toJson(MetadataJson.listToJson(metadataList, true));
}
@Override
public String getNewEventMetadata() {
final MetadataList metadataList = this.indexService.getMetadataListWithAllEventCatalogUIAdapters();
final DublinCoreMetadataCollection collection = metadataList
.getMetadataByAdapter(this.indexService.getCommonEventCatalogUIAdapter());
if (collection != null) {
if (collection.getOutputFields().containsKey(DublinCore.PROPERTY_CREATED.getLocalName())) {
collection.removeField(collection.getOutputFields().get(DublinCore.PROPERTY_CREATED.getLocalName()));
}
if (collection.getOutputFields().containsKey("duration")) {
collection.removeField(collection.getOutputFields().get("duration"));
}
if (collection.getOutputFields().containsKey(DublinCore.PROPERTY_IDENTIFIER.getLocalName())) {
collection.removeField(collection.getOutputFields().get(DublinCore.PROPERTY_IDENTIFIER.getLocalName()));
}
if (collection.getOutputFields().containsKey(DublinCore.PROPERTY_SOURCE.getLocalName())) {
collection.removeField(collection.getOutputFields().get(DublinCore.PROPERTY_SOURCE.getLocalName()));
}
if (collection.getOutputFields().containsKey("startDate")) {
collection.removeField(collection.getOutputFields().get("startDate"));
}
if (collection.getOutputFields().containsKey("startTime")) {
collection.removeField(collection.getOutputFields().get("startTime"));
}
if (collection.getOutputFields().containsKey("location")) {
collection.removeField(collection.getOutputFields().get("location"));
}
if (collection.getOutputFields().containsKey(DublinCore.PROPERTY_PUBLISHER.getLocalName())) {
final MetadataField publisher
= collection.getOutputFields().get(DublinCore.PROPERTY_PUBLISHER.getLocalName());
final Map<String, String> users
= publisher.getCollection() == null ? new HashMap<>() : publisher.getCollection();
final String loggedInUser = this.securityService.getUser().getName();
if (!users.containsKey(loggedInUser)) {
users.put(loggedInUser, loggedInUser);
}
publisher.setValue(loggedInUser);
}
metadataList.add(this.indexService.getCommonEventCatalogUIAdapter(), collection);
}
return new Gson().toJson(MetadataJson.listToJson(metadataList, true));
}
@Override
public void setEventMetadataJson(final String eventId, final String metadataJson)
throws NotFoundException, UnauthorizedException {
try {
this.indexService.updateAllEventMetadata(eventId, metadataJson, this.searchIndex);
} catch (IndexServiceException | SearchIndexException e) {
throw new RuntimeException(e);
}
}
private void replaceField(DublinCoreMetadataCollection collection, String fieldName, String fieldValue) {
final MetadataField field = collection.getOutputFields().get(fieldName);
collection.removeField(field);
collection.addField(MetadataJson.copyWithDifferentJsonValue(field, fieldValue));
}
@Override
public void delete(String id) {
try {
final Optional<Event> event = indexService.getEvent(id, searchIndex);
if (event.isEmpty()) {
throw new RuntimeException("Event '" + id + "' not found");
}
final IndexService.EventRemovalResult eventRemovalResult = indexService.removeEvent(event.get(),
retractWorkflowId);
if (eventRemovalResult == IndexService.EventRemovalResult.GENERAL_FAILURE) {
throw new RuntimeException("Error deleting event: " + eventRemovalResult);
}
} catch (WorkflowDatabaseException | SearchIndexException | UnauthorizedException | NotFoundException e) {
throw new RuntimeException("Error deleting event", e);
}
}
/** OSGi callback if properties file is present */
private void updated(Dictionary<String, ?> properties) {
// Ensure properties is not null
if (properties == null) {
throw new IllegalArgumentException("No configuration specified for events endpoint");
}
String workflowStr = (String) properties.get("workflow");
if (workflowStr == null) {
throw new IllegalArgumentException("Configuration is missing 'workflow' parameter");
}
String workflowConfigurationStr = (String) properties.get("workflow-configuration");
if (workflowConfigurationStr == null) {
throw new IllegalArgumentException("Configuration is missing 'workflow-configuration' parameter");
}
try {
gson.fromJson(workflowConfigurationStr, Map.class);
workflowConfiguration = workflowConfigurationStr;
workflow = workflowStr;
this.retractWorkflowId = Objects.toString(properties.get("retract-workflow-id"), "retract");
this.copyWorkflowId = Objects.toString(properties.get("copy-workflow-id"), "copy-event-to-series");
} catch (JsonSyntaxException e) {
throw new IllegalArgumentException("Invalid JSON specified for workflow configuration");
}
String listAllJobsInSeriesStr = Objects.toString(properties.get("list-all-jobs-in-series"), "false");
this.listAllJobsInSeries = Boolean.parseBoolean(listAllJobsInSeriesStr);
}
}