View Javadoc
1   /*
2    * Licensed to The Apereo Foundation under one or more contributor license
3    * agreements. See the NOTICE file distributed with this work for additional
4    * information regarding copyright ownership.
5    *
6    *
7    * The Apereo Foundation licenses this file to you under the Educational
8    * Community License, Version 2.0 (the "License"); you may not use this file
9    * except in compliance with the License. You may obtain a copy of the License
10   * at:
11   *
12   *   http://opensource.org/licenses/ecl2.txt
13   *
14   * Unless required by applicable law or agreed to in writing, software
15   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
16   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
17   * License for the specific language governing permissions and limitations under
18   * the License.
19   *
20   */
21  
22  package org.opencastproject.adminui.endpoint;
23  
24  import static java.lang.String.format;
25  import static javax.servlet.http.HttpServletResponse.SC_ACCEPTED;
26  import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
27  import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
28  import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
29  import static javax.servlet.http.HttpServletResponse.SC_NO_CONTENT;
30  import static javax.servlet.http.HttpServletResponse.SC_OK;
31  import static javax.servlet.http.HttpServletResponse.SC_SERVICE_UNAVAILABLE;
32  import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
33  import static javax.ws.rs.core.Response.Status.NOT_FOUND;
34  import static org.apache.commons.lang3.StringUtils.trimToNull;
35  import static org.opencastproject.adminui.endpoint.EndpointUtil.transformAccessControList;
36  import static org.opencastproject.index.service.impl.util.EventUtils.internalChannelFilter;
37  import static org.opencastproject.index.service.util.JSONUtils.arrayToJsonArray;
38  import static org.opencastproject.index.service.util.JSONUtils.collectionToJsonArray;
39  import static org.opencastproject.index.service.util.JSONUtils.mapToJsonObject;
40  import static org.opencastproject.index.service.util.JSONUtils.safeString;
41  import static org.opencastproject.index.service.util.RestUtils.conflictJson;
42  import static org.opencastproject.index.service.util.RestUtils.notFound;
43  import static org.opencastproject.index.service.util.RestUtils.notFoundJson;
44  import static org.opencastproject.index.service.util.RestUtils.okJson;
45  import static org.opencastproject.index.service.util.RestUtils.okJsonList;
46  import static org.opencastproject.index.service.util.RestUtils.serverErrorJson;
47  import static org.opencastproject.util.DateTimeSupport.toUTC;
48  import static org.opencastproject.util.RestUtil.R.badRequest;
49  import static org.opencastproject.util.RestUtil.R.conflict;
50  import static org.opencastproject.util.RestUtil.R.forbidden;
51  import static org.opencastproject.util.RestUtil.R.noContent;
52  import static org.opencastproject.util.RestUtil.R.notFound;
53  import static org.opencastproject.util.RestUtil.R.ok;
54  import static org.opencastproject.util.RestUtil.R.serverError;
55  import static org.opencastproject.util.doc.rest.RestParameter.Type.BOOLEAN;
56  import static org.opencastproject.util.doc.rest.RestParameter.Type.STRING;
57  import static org.opencastproject.util.doc.rest.RestParameter.Type.TEXT;
58  
59  import org.opencastproject.adminui.exception.JobEndpointException;
60  import org.opencastproject.adminui.impl.AdminUIConfiguration;
61  import org.opencastproject.adminui.tobira.TobiraException;
62  import org.opencastproject.adminui.tobira.TobiraService;
63  import org.opencastproject.adminui.util.BulkUpdateUtil;
64  import org.opencastproject.assetmanager.api.AssetManager;
65  import org.opencastproject.authorization.xacml.manager.api.AclService;
66  import org.opencastproject.authorization.xacml.manager.api.ManagedAcl;
67  import org.opencastproject.authorization.xacml.manager.util.AccessInformationUtil;
68  import org.opencastproject.capture.CaptureParameters;
69  import org.opencastproject.capture.admin.api.Agent;
70  import org.opencastproject.capture.admin.api.CaptureAgentStateService;
71  import org.opencastproject.elasticsearch.api.SearchIndexException;
72  import org.opencastproject.elasticsearch.api.SearchResult;
73  import org.opencastproject.elasticsearch.api.SearchResultItem;
74  import org.opencastproject.elasticsearch.index.ElasticsearchIndex;
75  import org.opencastproject.elasticsearch.index.objects.event.Event;
76  import org.opencastproject.elasticsearch.index.objects.event.EventIndexSchema;
77  import org.opencastproject.elasticsearch.index.objects.event.EventSearchQuery;
78  import org.opencastproject.event.comment.EventComment;
79  import org.opencastproject.event.comment.EventCommentException;
80  import org.opencastproject.event.comment.EventCommentReply;
81  import org.opencastproject.event.comment.EventCommentService;
82  import org.opencastproject.index.service.api.IndexService;
83  import org.opencastproject.index.service.api.IndexService.Source;
84  import org.opencastproject.index.service.exception.IndexServiceException;
85  import org.opencastproject.index.service.exception.UnsupportedAssetException;
86  import org.opencastproject.index.service.impl.util.EventUtils;
87  import org.opencastproject.index.service.resources.list.provider.EventsListProvider.Comments;
88  import org.opencastproject.index.service.resources.list.provider.EventsListProvider.IsPublished;
89  import org.opencastproject.index.service.resources.list.query.EventListQuery;
90  import org.opencastproject.index.service.resources.list.query.SeriesListQuery;
91  import org.opencastproject.index.service.util.JSONUtils;
92  import org.opencastproject.index.service.util.RestUtils;
93  import org.opencastproject.list.api.ListProviderException;
94  import org.opencastproject.list.api.ListProvidersService;
95  import org.opencastproject.list.api.ResourceListQuery;
96  import org.opencastproject.list.impl.ResourceListQueryImpl;
97  import org.opencastproject.mediapackage.Attachment;
98  import org.opencastproject.mediapackage.AudioStream;
99  import org.opencastproject.mediapackage.Catalog;
100 import org.opencastproject.mediapackage.MediaPackage;
101 import org.opencastproject.mediapackage.MediaPackageElement;
102 import org.opencastproject.mediapackage.MediaPackageException;
103 import org.opencastproject.mediapackage.Publication;
104 import org.opencastproject.mediapackage.Track;
105 import org.opencastproject.mediapackage.VideoStream;
106 import org.opencastproject.mediapackage.track.AudioStreamImpl;
107 import org.opencastproject.mediapackage.track.SubtitleStreamImpl;
108 import org.opencastproject.mediapackage.track.VideoStreamImpl;
109 import org.opencastproject.metadata.dublincore.DublinCore;
110 import org.opencastproject.metadata.dublincore.DublinCoreMetadataCollection;
111 import org.opencastproject.metadata.dublincore.EventCatalogUIAdapter;
112 import org.opencastproject.metadata.dublincore.MetadataField;
113 import org.opencastproject.metadata.dublincore.MetadataJson;
114 import org.opencastproject.metadata.dublincore.MetadataList;
115 import org.opencastproject.metadata.dublincore.MetadataList.Locked;
116 import org.opencastproject.rest.BulkOperationResult;
117 import org.opencastproject.rest.RestConstants;
118 import org.opencastproject.scheduler.api.Recording;
119 import org.opencastproject.scheduler.api.SchedulerException;
120 import org.opencastproject.scheduler.api.SchedulerService;
121 import org.opencastproject.scheduler.api.TechnicalMetadata;
122 import org.opencastproject.scheduler.api.Util;
123 import org.opencastproject.security.api.AccessControlList;
124 import org.opencastproject.security.api.AccessControlParser;
125 import org.opencastproject.security.api.AclScope;
126 import org.opencastproject.security.api.AuthorizationService;
127 import org.opencastproject.security.api.Organization;
128 import org.opencastproject.security.api.Permissions;
129 import org.opencastproject.security.api.SecurityService;
130 import org.opencastproject.security.api.UnauthorizedException;
131 import org.opencastproject.security.api.User;
132 import org.opencastproject.security.api.UserDirectoryService;
133 import org.opencastproject.security.urlsigning.exception.UrlSigningException;
134 import org.opencastproject.security.urlsigning.service.UrlSigningService;
135 import org.opencastproject.security.util.SecurityUtil;
136 import org.opencastproject.systems.OpencastConstants;
137 import org.opencastproject.util.DateTimeSupport;
138 import org.opencastproject.util.Jsons.Val;
139 import org.opencastproject.util.NotFoundException;
140 import org.opencastproject.util.RestUtil;
141 import org.opencastproject.util.UrlSupport;
142 import org.opencastproject.util.data.Tuple;
143 import org.opencastproject.util.data.Tuple3;
144 import org.opencastproject.util.doc.rest.RestParameter;
145 import org.opencastproject.util.doc.rest.RestQuery;
146 import org.opencastproject.util.doc.rest.RestResponse;
147 import org.opencastproject.util.requests.SortCriterion;
148 import org.opencastproject.workflow.api.RetryStrategy;
149 import org.opencastproject.workflow.api.WorkflowDatabaseException;
150 import org.opencastproject.workflow.api.WorkflowDefinition;
151 import org.opencastproject.workflow.api.WorkflowInstance;
152 import org.opencastproject.workflow.api.WorkflowOperationInstance;
153 import org.opencastproject.workflow.api.WorkflowService;
154 import org.opencastproject.workflow.api.WorkflowStateException;
155 import org.opencastproject.workflow.api.WorkflowUtil;
156 
157 import com.google.gson.JsonArray;
158 import com.google.gson.JsonNull;
159 import com.google.gson.JsonObject;
160 import com.google.gson.JsonPrimitive;
161 
162 import net.fortuna.ical4j.model.property.RRule;
163 
164 import org.apache.commons.lang3.BooleanUtils;
165 import org.apache.commons.lang3.StringUtils;
166 import org.codehaus.jettison.json.JSONException;
167 import org.json.simple.JSONArray;
168 import org.json.simple.JSONObject;
169 import org.json.simple.parser.JSONParser;
170 import org.osgi.service.component.ComponentContext;
171 import org.osgi.service.component.annotations.Activate;
172 import org.slf4j.Logger;
173 import org.slf4j.LoggerFactory;
174 
175 import java.net.URI;
176 import java.text.ParseException;
177 import java.time.Instant;
178 import java.util.ArrayList;
179 import java.util.Collections;
180 import java.util.Date;
181 import java.util.HashMap;
182 import java.util.HashSet;
183 import java.util.List;
184 import java.util.Map;
185 import java.util.Map.Entry;
186 import java.util.Objects;
187 import java.util.Optional;
188 import java.util.Set;
189 import java.util.TimeZone;
190 import java.util.function.Function;
191 import java.util.stream.Collectors;
192 
193 import javax.servlet.http.HttpServletRequest;
194 import javax.servlet.http.HttpServletResponse;
195 import javax.ws.rs.Consumes;
196 import javax.ws.rs.DELETE;
197 import javax.ws.rs.FormParam;
198 import javax.ws.rs.GET;
199 import javax.ws.rs.POST;
200 import javax.ws.rs.PUT;
201 import javax.ws.rs.Path;
202 import javax.ws.rs.PathParam;
203 import javax.ws.rs.Produces;
204 import javax.ws.rs.QueryParam;
205 import javax.ws.rs.WebApplicationException;
206 import javax.ws.rs.core.Context;
207 import javax.ws.rs.core.MediaType;
208 import javax.ws.rs.core.Response;
209 import javax.ws.rs.core.Response.Status;
210 
211 /**
212  * The event endpoint acts as a facade for WorkflowService and Archive providing a unified query interface and result
213  * set.
214  * <p>
215  * This first implementation uses the {@link org.opencastproject.assetmanager.api.AssetManager}. In a later iteration
216  * the endpoint may abstract over the concrete archive.
217  */
218 @Path("/admin-ng/event")
219 public abstract class AbstractEventEndpoint {
220 
221   /**
222    * Scheduling JSON keys
223    */
224   public static final String SCHEDULING_AGENT_ID_KEY = "agentId";
225   public static final String SCHEDULING_START_KEY = "start";
226   public static final String SCHEDULING_END_KEY = "end";
227   private static final String SCHEDULING_AGENT_CONFIGURATION_KEY = "agentConfiguration";
228   public static final String SCHEDULING_PREVIOUS_AGENTID = "previousAgentId";
229   public static final String SCHEDULING_PREVIOUS_PREVIOUSENTRIES = "previousEntries";
230 
231   private static final String WORKFLOW_ACTION_STOP = "STOP";
232 
233   /** The logging facility */
234   static final Logger logger = LoggerFactory.getLogger(AbstractEventEndpoint.class);
235 
236   /** The configuration key that defines the default workflow definition */
237   //TODO Move to a constants file instead of declaring it at the top of multiple files?
238   protected static final String WORKFLOW_DEFINITION_DEFAULT = "org.opencastproject.workflow.default.definition";
239 
240   private static final String WORKFLOW_STATUS_TRANSLATION_PREFIX = "EVENTS.EVENTS.DETAILS.WORKFLOWS.OPERATION_STATUS.";
241 
242   /** The default time before a piece of signed content expires. 2 Hours. */
243   protected static final long DEFAULT_URL_SIGNING_EXPIRE_DURATION = 2 * 60 * 60;
244 
245   public abstract AssetManager getAssetManager();
246 
247   public abstract WorkflowService getWorkflowService();
248 
249   public abstract ElasticsearchIndex getIndex();
250 
251   public abstract JobEndpoint getJobService();
252 
253   public abstract SeriesEndpoint getSeriesEndpoint();
254 
255   public abstract AclService getAclService();
256 
257   public abstract EventCommentService getEventCommentService();
258 
259   public abstract SecurityService getSecurityService();
260 
261   public abstract IndexService getIndexService();
262 
263   public abstract AuthorizationService getAuthorizationService();
264 
265   public abstract SchedulerService getSchedulerService();
266 
267   public abstract CaptureAgentStateService getCaptureAgentStateService();
268 
269   public abstract AdminUIConfiguration getAdminUIConfiguration();
270 
271   public abstract long getUrlSigningExpireDuration();
272 
273   public abstract UrlSigningService getUrlSigningService();
274 
275   public abstract Boolean signWithClientIP();
276 
277   public abstract Boolean getOnlySeriesWithWriteAccessEventModal();
278 
279   public abstract Boolean getOnlyEventsWithWriteAccessEventsTab();
280 
281   public abstract UserDirectoryService getUserDirectoryService();
282 
283   public abstract ListProvidersService getListProvidersService();
284 
285   /** Default server URL */
286   protected String serverUrl = "http://localhost:8080";
287 
288   /** Service url */
289   protected String serviceUrl = null;
290 
291   /** The default workflow identifier, if one is configured */
292   protected String defaultWorkflowDefinionId = null;
293 
294   /** The system user name (default set here for unit tests) */
295   private String systemUserName = "opencast_system_account";
296 
297   /**
298    * Activates REST service.
299    *
300    * @param cc
301    *          ComponentContext
302    */
303   @Activate
304   public void activate(ComponentContext cc) {
305     if (cc != null) {
306       String ccServerUrl = cc.getBundleContext().getProperty(OpencastConstants.SERVER_URL_PROPERTY);
307       if (StringUtils.isNotBlank(ccServerUrl)) {
308         this.serverUrl = ccServerUrl;
309       }
310 
311       this.serviceUrl = (String) cc.getProperties().get(RestConstants.SERVICE_PATH_PROPERTY);
312 
313       String ccDefaultWorkflowDefinionId = StringUtils.trimToNull(cc.getBundleContext()
314           .getProperty(WORKFLOW_DEFINITION_DEFAULT));
315 
316       if (StringUtils.isNotBlank(ccDefaultWorkflowDefinionId)) {
317         this.defaultWorkflowDefinionId = ccDefaultWorkflowDefinionId;
318       }
319 
320       systemUserName = SecurityUtil.getSystemUserName(cc);
321     }
322   }
323 
324   /* As the list of event ids can grow large, we use a POST request to avoid problems with too large query strings */
325   @POST
326   @Path("workflowProperties")
327   @Produces(MediaType.APPLICATION_JSON)
328   @RestQuery(
329       name = "workflowProperties",
330       description = "Returns workflow properties for the specified events",
331       returnDescription = "The workflow properties for every event as JSON",
332       restParameters = {
333           @RestParameter(name = "eventIds", description = "A JSON array of ids of the events", isRequired = true,
334               type = RestParameter.Type.STRING)},
335       responses = {
336           @RestResponse(description = "Returns the workflow properties for the events as JSON",
337               responseCode = HttpServletResponse.SC_OK),
338           @RestResponse(description = "The list of ids could not be parsed into a json list.",
339               responseCode = HttpServletResponse.SC_BAD_REQUEST)
340       })
341   public Response getEventWorkflowProperties(@FormParam("eventIds") String eventIds) throws UnauthorizedException {
342     if (StringUtils.isBlank(eventIds)) {
343       return Response.status(Response.Status.BAD_REQUEST).build();
344     }
345 
346     JSONParser parser = new JSONParser();
347     List<String> ids;
348     try {
349       ids = (List<String>) parser.parse(eventIds);
350     } catch (org.json.simple.parser.ParseException e) {
351       logger.error("Unable to parse '{}'", eventIds, e);
352       return Response.status(Response.Status.BAD_REQUEST).build();
353     } catch (ClassCastException e) {
354       logger.error("Unable to cast '{}'", eventIds, e);
355       return Response.status(Response.Status.BAD_REQUEST).build();
356     }
357 
358     final Map<String, Map<String, String>> eventWithProperties = getIndexService().getEventWorkflowProperties(ids);
359     JsonObject jsonEvents = new JsonObject();
360 
361     for (Entry<String, Map<String, String>> event : eventWithProperties.entrySet()) {
362       JsonObject jsonProperties = new JsonObject();
363 
364       for (Entry<String, String> property : event.getValue().entrySet()) {
365         jsonProperties.add(property.getKey(), new JsonPrimitive(property.getValue()));
366       }
367 
368       jsonEvents.add(event.getKey(), jsonProperties);
369     }
370 
371     return okJson(jsonEvents);
372   }
373 
374 
375   @GET
376   @Path("catalogAdapters")
377   @Produces(MediaType.APPLICATION_JSON)
378   @RestQuery(
379       name = "getcataloguiadapters",
380       description = "Returns the available catalog UI adapters as JSON",
381       returnDescription = "The catalog UI adapters as JSON",
382       responses = {
383           @RestResponse(description = "Returns the available catalog UI adapters as JSON",
384               responseCode = HttpServletResponse.SC_OK)
385       })
386   public Response getCatalogAdapters() {
387     JsonArray jsonAdapters = new JsonArray();
388     for (EventCatalogUIAdapter adapter : getIndexService().getEventCatalogUIAdapters()) {
389       JsonObject obj = new JsonObject();
390       obj.addProperty("flavor", adapter.getFlavor().toString());
391       obj.addProperty("title", adapter.getUITitle());
392       jsonAdapters.add(obj);
393     }
394 
395     return okJson(jsonAdapters);
396   }
397 
398   @GET
399   @Path("{eventId}")
400   @Produces(MediaType.APPLICATION_JSON)
401   @RestQuery(
402       name = "getevent",
403       description = "Returns the event by the given id as JSON",
404       returnDescription = "The event as JSON",
405       pathParameters = {
406           @RestParameter(name = "eventId", description = "The event id", isRequired = true,
407               type = RestParameter.Type.STRING)
408       },
409       responses = {
410           @RestResponse(description = "Returns the event as JSON", responseCode = HttpServletResponse.SC_OK),
411           @RestResponse(description = "No event with this identifier was found.",
412               responseCode = HttpServletResponse.SC_NOT_FOUND)
413       })
414   public Response getEventResponse(@PathParam("eventId") String id) throws Exception {
415     Optional<Event> eventOpt = getIndexService().getEvent(id, getIndex());
416     if (eventOpt.isPresent()) {
417       Event event = eventOpt.get();
418       event.updatePreview(getAdminUIConfiguration().getPreviewSubtype());
419       JsonObject json = eventToJSON(event, Optional.empty(), Optional.empty());
420       return okJson(json);
421     }
422     return notFound("Cannot find an event with id '%s'.", id);
423   }
424 
425   @DELETE
426   @Path("{eventId}")
427   @Produces(MediaType.APPLICATION_JSON)
428   @RestQuery(
429       name = "deleteevent",
430       description = "Delete a single event.",
431       returnDescription = "Ok if the event has been deleted.",
432       pathParameters = {
433           @RestParameter(name = "eventId", isRequired = true, description = "The id of the event to delete.",
434               type = STRING),
435       },
436       responses = {
437           @RestResponse(responseCode = SC_OK, description = "The event has been deleted."),
438           @RestResponse(responseCode = SC_ACCEPTED, description = "The event will be retracted and deleted "
439               + "afterwards."),
440           @RestResponse(responseCode = HttpServletResponse.SC_UNAUTHORIZED, description = "If the current user is not "
441               + "authorized to perform this action")
442       })
443   public Response deleteEvent(@PathParam("eventId") String id) throws UnauthorizedException, SearchIndexException {
444     final Optional<Event> event = checkAgentAccessForEvent(id);
445     if (event.isEmpty()) {
446       return RestUtil.R.notFound(id);
447     }
448     final IndexService.EventRemovalResult result;
449     try {
450       result = getIndexService().removeEvent(event.get(), getAdminUIConfiguration().getRetractWorkflowId());
451     } catch (WorkflowDatabaseException e) {
452       logger.error("Workflow database is not reachable. This may be a temporary problem.");
453       return RestUtil.R.serverError();
454     } catch (NotFoundException e) {
455       logger.error("Configured retract workflow not found. Check your configuration.");
456       return RestUtil.R.serverError();
457     }
458     switch (result) {
459       case SUCCESS:
460         return Response.ok().build();
461       case RETRACTING:
462         return Response.accepted().build();
463       case GENERAL_FAILURE:
464         return Response.serverError().build();
465       case NOT_FOUND:
466         return RestUtil.R.notFound(id);
467       default:
468         throw new RuntimeException("Unknown EventRemovalResult type: " + result.name());
469     }
470   }
471 
472   @POST
473   @Path("deleteEvents")
474   @Produces(MediaType.APPLICATION_JSON)
475   @RestQuery(
476       name = "deleteevents",
477       description = "Deletes a json list of events by their given ids e.g. [\"1dbe7255-e17d-4279-811d-a5c7ced689bf\", "
478           + "\"04fae22b-0717-4f59-8b72-5f824f76d529\"]",
479       returnDescription = "Returns a JSON object containing a list of event ids that were deleted, not found or if "
480           + "there was a server error.",
481       responses = {
482           @RestResponse(description = "Events have been deleted", responseCode = HttpServletResponse.SC_OK),
483           @RestResponse(description = "The list of ids could not be parsed into a json list.",
484               responseCode = HttpServletResponse.SC_BAD_REQUEST),
485           @RestResponse(description = "If the current user is not authorized to perform this action",
486               responseCode = HttpServletResponse.SC_UNAUTHORIZED)
487       })
488   public Response deleteEvents(String eventIdsContent) throws UnauthorizedException, SearchIndexException {
489     if (StringUtils.isBlank(eventIdsContent)) {
490       return Response.status(Response.Status.BAD_REQUEST).build();
491     }
492 
493     JSONParser parser = new JSONParser();
494     JSONArray eventIdsJsonArray;
495     try {
496       eventIdsJsonArray = (JSONArray) parser.parse(eventIdsContent);
497     } catch (org.json.simple.parser.ParseException e) {
498       logger.error("Unable to parse '{}'", eventIdsContent, e);
499       return Response.status(Response.Status.BAD_REQUEST).build();
500     } catch (ClassCastException e) {
501       logger.error("Unable to cast '{}'", eventIdsContent, e);
502       return Response.status(Response.Status.BAD_REQUEST).build();
503     }
504 
505     BulkOperationResult result = new BulkOperationResult();
506 
507     for (Object eventIdObject : eventIdsJsonArray) {
508       final String eventId = eventIdObject.toString();
509       try {
510         final Optional<Event> event = checkAgentAccessForEvent(eventId);
511         if (event.isPresent()) {
512           final IndexService.EventRemovalResult currentResult = getIndexService().removeEvent(event.get(),
513                   getAdminUIConfiguration().getRetractWorkflowId());
514           switch (currentResult) {
515             case SUCCESS:
516               result.addOk(eventId);
517               break;
518             case RETRACTING:
519               result.addAccepted(eventId);
520               break;
521             case GENERAL_FAILURE:
522               result.addServerError(eventId);
523               break;
524             case NOT_FOUND:
525               result.addNotFound(eventId);
526               break;
527             default:
528               throw new RuntimeException("Unknown EventRemovalResult type: " + currentResult.name());
529           }
530         } else {
531           result.addNotFound(eventId);
532         }
533       } catch (UnauthorizedException e) {
534         result.addUnauthorized(eventId);
535       } catch (WorkflowDatabaseException e) {
536         logger.error("Workflow database is not reachable. This may be a temporary problem.");
537         return RestUtil.R.serverError();
538       } catch (NotFoundException e) {
539         logger.error("Configured retract workflow not found. Check your configuration.");
540         return RestUtil.R.serverError();
541       }
542     }
543     return Response.ok(result.toJson()).build();
544   }
545 
546   @GET
547   @Path("{eventId}/publications.json")
548   @Produces(MediaType.APPLICATION_JSON)
549   @RestQuery(
550       name = "geteventpublications",
551       description = "Returns all the data related to the publications tab in the event details modal as JSON",
552       returnDescription = "All the data related to the event publications tab as JSON",
553       pathParameters = {
554           @RestParameter(name = "eventId", description = "The event id (mediapackage id).", isRequired = true,
555               type = RestParameter.Type.STRING)
556       },
557       responses = {
558           @RestResponse(description = "Returns all the data related to the event publications tab as JSON",
559               responseCode = HttpServletResponse.SC_OK),
560           @RestResponse(description = "No event with this identifier was found.",
561               responseCode = HttpServletResponse.SC_NOT_FOUND)
562       })
563   public Response getEventPublicationsTab(@PathParam("eventId") String id) throws Exception {
564     Optional<Event> optEvent = getIndexService().getEvent(id, getIndex());
565     if (optEvent.isEmpty()) {
566       return notFound("Cannot find an event with id '%s'.", id);
567     }
568 
569     // Quick actions have been temporally removed from the publications tab
570     // ---------------------------------------------------------------
571     // List<JValue> actions = new ArrayList<JValue>();
572     // List<WorkflowDefinition> workflowsDefinitions = getWorkflowService().listAvailableWorkflowDefinitions();
573     // for (WorkflowDefinition wflDef : workflowsDefinitions) {
574     // if (wflDef.containsTag(WORKFLOWDEF_TAG)) {
575     //
576     // actions.add(obj(f("id", v(wflDef.getId())), f("title", v(Opt.nul(wflDef.getTitle()).or(""))),
577     // f("description", v(Opt.nul(wflDef.getDescription()).or(""))),
578     // f("configuration_panel", v(Opt.nul(wflDef.getConfigurationPanel()).or("")))));
579     // }
580     // }
581 
582     Event event = optEvent.get();
583 
584     // Convert event publications to JSON array
585     List<JsonObject> pubJSONList = eventPublicationsToJson(event);
586     JsonArray publicationsJsonArray = new JsonArray();
587     for (JsonObject pubJson : pubJSONList) {
588       publicationsJsonArray.add(pubJson);
589     }
590 
591     JsonObject result = new JsonObject();
592     result.add("publications", publicationsJsonArray);
593 
594     // Add start-date and end-date as strings, or blank if null
595     String startDate = event.getRecordingStartDate() != null
596         ? event.getRecordingStartDate().toString()
597         : "";
598     String endDate = event.getRecordingEndDate() != null
599         ? event.getRecordingEndDate().toString()
600         : "";
601     result.addProperty("start-date", startDate);
602     result.addProperty("end-date", endDate);
603 
604     return okJson(result);
605   }
606 
607   private List<JsonObject> eventPublicationsToJson(Event event) {
608     List<JsonObject> pubJSON = new ArrayList<>();
609 
610     for (Publication publication : event.getPublications()) {
611       if (internalChannelFilter.test(publication)) {
612         pubJSON.add(publicationToJson.apply(publication));
613       }
614     }
615 
616     return pubJSON;
617   }
618 
619   private List<JsonObject> eventCommentsToJson(List<EventComment> comments) {
620     List<JsonObject> commentArr = new ArrayList<>();
621 
622     for (EventComment c : comments) {
623       JsonObject author = new JsonObject();
624       author.addProperty("name", c.getAuthor().getName());
625       if (c.getAuthor().getEmail() != null) {
626         author.addProperty("email", c.getAuthor().getEmail());
627       } else {
628         author.add("email", null);
629       }
630       author.addProperty("username", c.getAuthor().getUsername());
631 
632       JsonArray replies = new JsonArray();
633       List<JsonObject> replyJsonList = eventCommentRepliesToJson(c.getReplies());
634       for (JsonObject replyJson : replyJsonList) {
635         replies.add(replyJson);
636       }
637 
638       JsonObject commentJson = new JsonObject();
639       commentJson.addProperty("reason", c.getReason());
640       commentJson.addProperty("resolvedStatus", c.isResolvedStatus());
641       commentJson.addProperty("modificationDate", c.getModificationDate().toInstant().toString());
642       commentJson.add("replies", replies);
643       commentJson.add("author", author);
644       commentJson.addProperty("id", c.getId().get());
645       commentJson.addProperty("text", c.getText());
646       commentJson.addProperty("creationDate", c.getCreationDate().toInstant().toString());
647 
648       commentArr.add(commentJson);
649     }
650 
651     return commentArr;
652   }
653 
654   private List<JsonObject> eventCommentRepliesToJson(List<EventCommentReply> replies) {
655     List<JsonObject> repliesArr = new ArrayList<>();
656 
657     for (EventCommentReply r : replies) {
658       JsonObject author = new JsonObject();
659       author.addProperty("name", r.getAuthor().getName());
660       if (r.getAuthor().getEmail() != null) {
661         author.addProperty("email", r.getAuthor().getEmail());
662       } else {
663         author.add("email", null);
664       }
665       author.addProperty("username", r.getAuthor().getUsername());
666 
667       JsonObject replyJson = new JsonObject();
668       replyJson.addProperty("id", r.getId().get());
669       replyJson.addProperty("text", r.getText());
670       replyJson.addProperty("creationDate", r.getCreationDate().toInstant().toString());
671       replyJson.addProperty("modificationDate", r.getModificationDate().toInstant().toString());
672       replyJson.add("author", author);
673 
674       repliesArr.add(replyJson);
675     }
676 
677     return repliesArr;
678   }
679 
680   @GET
681   @Path("{eventId}/scheduling.json")
682   @Produces(MediaType.APPLICATION_JSON)
683   @RestQuery(
684       name = "getEventSchedulingMetadata",
685       description = "Returns all of the scheduling metadata for an event",
686       returnDescription = "All the technical metadata related to scheduling as JSON",
687       pathParameters = {
688           @RestParameter(name = "eventId", description = "The event id (mediapackage id).", isRequired = true,
689               type = RestParameter.Type.STRING)
690       },
691       responses = {
692           @RestResponse(description = "Returns all the data related to the event scheduling tab as JSON",
693               responseCode = HttpServletResponse.SC_OK),
694           @RestResponse(description = "No event with this identifier was found.",
695               responseCode = HttpServletResponse.SC_NOT_FOUND)
696       })
697   public Response getEventScheduling(@PathParam("eventId") String eventId)
698           throws NotFoundException, UnauthorizedException, SearchIndexException {
699     Optional<Event> optEvent = getIndexService().getEvent(eventId, getIndex());
700     if (optEvent.isEmpty()) {
701       return notFound("Cannot find an event with id '%s'.", eventId);
702     }
703 
704     try {
705       TechnicalMetadata technicalMetadata = getSchedulerService().getTechnicalMetadata(eventId);
706       return okJson(technicalMetadataToJson(technicalMetadata));
707     } catch (SchedulerException e) {
708       logger.error("Unable to get technical metadata for event with id {}", eventId);
709       throw new WebApplicationException(e, SC_INTERNAL_SERVER_ERROR);
710     }
711   }
712 
713   @POST
714   @Path("scheduling.json")
715   @Produces(MediaType.APPLICATION_JSON)
716   @RestQuery(
717       name = "getEventsScheduling",
718       description = "Returns all of the scheduling metadata for a list of events",
719       returnDescription = "All the technical metadata related to scheduling as JSON",
720       restParameters = {
721           @RestParameter(name = "eventIds", description = "An array of event IDs (mediapackage id)", isRequired = true,
722               type = RestParameter.Type.STRING),
723           @RestParameter(name = "ignoreNonScheduled", description = "Whether events that are not really scheduled "
724               + "events should be ignored or produce an error", isRequired = true, type = RestParameter.Type.BOOLEAN)
725       },
726       responses = {
727           @RestResponse(description = "Returns all the data related to the event scheduling tab as JSON",
728               responseCode = HttpServletResponse.SC_OK),
729           @RestResponse(description = "No event with this identifier was found.",
730               responseCode = HttpServletResponse.SC_NOT_FOUND)
731       })
732   public Response getEventsScheduling(@FormParam("eventIds") final List<String> eventIds,
733       @FormParam("ignoreNonScheduled") final boolean ignoreNonScheduled) {
734     JsonArray fields = new JsonArray();
735 
736     for (final String eventId : eventIds) {
737       try {
738         fields.add(technicalMetadataToJson(getSchedulerService().getTechnicalMetadata(eventId)));
739       } catch (final NotFoundException e) {
740         if (!ignoreNonScheduled) {
741           logger.warn("Unable to find id {}", eventId, e);
742           return notFound("Cannot find an event with id '%s'.", eventId);
743         }
744       } catch (final UnauthorizedException e) {
745         logger.warn("Unauthorized access to event ID {}", eventId, e);
746         return Response.status(Status.BAD_REQUEST).build();
747       } catch (final SchedulerException e) {
748         logger.warn("Scheduler exception accessing event ID {}", eventId, e);
749         return Response.status(Status.BAD_REQUEST).build();
750       }
751     }
752     return okJson(fields);
753   }
754 
755   @PUT
756   @Path("{eventId}/scheduling")
757   @RestQuery(
758       name = "updateEventScheduling",
759       description = "Updates the scheduling information of an event",
760       returnDescription = "The method doesn't return any content",
761       pathParameters = {
762           @RestParameter(name = "eventId", isRequired = true, description = "The event identifier",
763               type = RestParameter.Type.STRING)
764       },
765       restParameters = {
766           @RestParameter(name = "scheduling", isRequired = true, description = "The updated scheduling (JSON object)",
767               type = RestParameter.Type.TEXT)
768       },
769       responses = {
770           @RestResponse(responseCode = SC_BAD_REQUEST, description = "The required params were missing in the "
771               + "request."),
772           @RestResponse(responseCode = SC_NOT_FOUND, description = "If the event has not been found."),
773           @RestResponse(responseCode = SC_NO_CONTENT, description = "The method doesn't return any content")
774       })
775   public Response updateEventScheduling(@PathParam("eventId") String eventId,
776           @FormParam("scheduling") String scheduling)
777           throws NotFoundException, UnauthorizedException, SearchIndexException, IndexServiceException {
778     if (StringUtils.isBlank(scheduling)) {
779       return RestUtil.R.badRequest("Missing parameters");
780     }
781 
782     try {
783       final Event event = getEventOrThrowNotFoundException(eventId);
784       updateEventScheduling(scheduling, event);
785       return Response.noContent().build();
786     } catch (JSONException e) {
787       return RestUtil.R.badRequest("The scheduling object is not valid");
788     } catch (ParseException e) {
789       return RestUtil.R.badRequest("The UTC dates in the scheduling object is not valid");
790     } catch (SchedulerException e) {
791       logger.error("Unable to update scheduling technical metadata of event {}", eventId, e);
792       throw new WebApplicationException(e, SC_INTERNAL_SERVER_ERROR);
793     } catch (IllegalStateException e) {
794       return RestUtil.R.badRequest(e.getMessage());
795     }
796   }
797 
798   private void updateEventScheduling(String scheduling, Event event) throws NotFoundException, UnauthorizedException,
799           SchedulerException, JSONException, ParseException, SearchIndexException, IndexServiceException {
800     final TechnicalMetadata technicalMetadata = getSchedulerService().getTechnicalMetadata(event.getIdentifier());
801     final org.codehaus.jettison.json.JSONObject schedulingJson = new org.codehaus.jettison.json.JSONObject(
802             scheduling);
803     Optional<String> agentId = Optional.empty();
804     if (schedulingJson.has(SCHEDULING_AGENT_ID_KEY)) {
805       agentId = Optional.of(schedulingJson.getString(SCHEDULING_AGENT_ID_KEY));
806       logger.trace("Updating agent id of event '{}' from '{}' to '{}'",
807               event.getIdentifier(), technicalMetadata.getAgentId(), agentId);
808     }
809 
810     Optional<String> previousAgentId = Optional.empty();
811     if (schedulingJson.has(SCHEDULING_PREVIOUS_AGENTID)) {
812       previousAgentId = Optional.of(schedulingJson.getString(SCHEDULING_PREVIOUS_AGENTID));
813     }
814 
815     Optional<String> previousAgentInputs = Optional.empty();
816     Optional<String> agentInputs = Optional.empty();
817     if (agentId.isPresent() && previousAgentId.isPresent()) {
818       Agent previousAgent = getCaptureAgentStateService().getAgent(previousAgentId.get());
819       Agent agent = getCaptureAgentStateService().getAgent(agentId.get());
820 
821       previousAgentInputs = Optional.ofNullable(previousAgent.getCapabilities().getProperty(
822           CaptureParameters.CAPTURE_DEVICE_NAMES));
823       agentInputs = Optional.ofNullable(agent.getCapabilities().getProperty(CaptureParameters.CAPTURE_DEVICE_NAMES));
824     }
825 
826     // Check if we are allowed to re-schedule on this agent
827     checkAgentAccessForAgent(technicalMetadata.getAgentId());
828     if (agentId.isPresent()) {
829       checkAgentAccessForAgent(agentId.get());
830     }
831 
832     Optional<Date> start = Optional.empty();
833     if (schedulingJson.has(SCHEDULING_START_KEY)) {
834       start = Optional.of(new Date(DateTimeSupport.fromUTC(schedulingJson.getString(SCHEDULING_START_KEY))));
835       logger.trace("Updating start time of event '{}' id from '{}' to '{}'",
836           event.getIdentifier(), DateTimeSupport.toUTC(technicalMetadata.getStartDate().getTime()),
837           DateTimeSupport.toUTC(start.get().getTime()));
838     }
839 
840     Optional<Date> end = Optional.empty();
841     if (schedulingJson.has(SCHEDULING_END_KEY)) {
842       end = Optional.of(new Date(DateTimeSupport.fromUTC(schedulingJson.getString(SCHEDULING_END_KEY))));
843       logger.trace("Updating end time of event '{}' id from '{}' to '{}'",
844           event.getIdentifier(), DateTimeSupport.toUTC(technicalMetadata.getEndDate().getTime()),
845           DateTimeSupport.toUTC(end.get().getTime()));
846     }
847 
848     Optional<Map<String, String>> agentConfiguration = Optional.empty();
849     if (schedulingJson.has(SCHEDULING_AGENT_CONFIGURATION_KEY)) {
850       agentConfiguration = Optional.of(JSONUtils.toMap(schedulingJson.getJSONObject(
851           SCHEDULING_AGENT_CONFIGURATION_KEY)));
852       logger.trace("Updating agent configuration of event '{}' id from '{}' to '{}'",
853           event.getIdentifier(), technicalMetadata.getCaptureAgentConfiguration(), agentConfiguration);
854     }
855 
856     Optional<Map<String, String>> previousAgentInputMethods = Optional.empty();
857     if (schedulingJson.has(SCHEDULING_PREVIOUS_PREVIOUSENTRIES)) {
858       previousAgentInputMethods = Optional.of(
859               JSONUtils.toMap(schedulingJson.getJSONObject(SCHEDULING_PREVIOUS_PREVIOUSENTRIES)));
860     }
861 
862     // If we had previously selected an agent, and both the old and new agent have the same set of input channels,
863     // copy which input channels are active to the new agent
864     if (previousAgentInputs.isPresent() && previousAgentInputs.isPresent() && agentInputs.isPresent()) {
865       Map<String, String> map = previousAgentInputMethods.get();
866       String mapAsString = map.keySet().stream()
867               .collect(Collectors.joining(","));
868       String previousInputs = mapAsString;
869 
870       if (previousAgentInputs.equals(agentInputs)) {
871         final Map<String, String> configMap = new HashMap<>(agentConfiguration.get());
872         configMap.put(CaptureParameters.CAPTURE_DEVICE_NAMES, previousInputs);
873         agentConfiguration = Optional.of(configMap);
874       }
875     }
876 
877     if ((start.isPresent() || end.isPresent())
878             && end.orElse(technicalMetadata.getEndDate()).before(start.orElse(technicalMetadata.getStartDate()))) {
879       throw new IllegalStateException("The end date is before the start date");
880     }
881 
882     if (!start.isEmpty() || !end.isEmpty() || !agentId.isEmpty() || !agentConfiguration.isEmpty()) {
883       getSchedulerService().updateEvent(event.getIdentifier(), start, end, agentId, Optional.empty(), Optional.empty(),
884           Optional.empty(), agentConfiguration);
885     }
886   }
887 
888   private Event getEventOrThrowNotFoundException(final String eventId) throws NotFoundException, SearchIndexException {
889     Optional<Event> optEvent = getIndexService().getEvent(eventId, getIndex());
890     if (optEvent.isPresent()) {
891       return optEvent.get();
892     } else {
893       throw new NotFoundException(format("Cannot find an event with id '%s'.", eventId));
894     }
895   }
896 
897   @GET
898   @Path("{eventId}/comments")
899   @Produces(MediaType.APPLICATION_JSON)
900   @RestQuery(
901       name = "geteventcomments",
902       description = "Returns all the data related to the comments tab in the event details modal as JSON",
903       returnDescription = "All the data related to the event comments tab as JSON",
904       pathParameters = {
905           @RestParameter(name = "eventId", description = "The event id", isRequired = true,
906               type = RestParameter.Type.STRING)
907       },
908       responses = {
909           @RestResponse(description = "Returns all the data related to the event comments tab as JSON",
910               responseCode = HttpServletResponse.SC_OK),
911           @RestResponse(description = "No event with this identifier was found.",
912               responseCode = HttpServletResponse.SC_NOT_FOUND)
913       })
914   public Response getEventComments(@PathParam("eventId") String eventId) throws Exception {
915     Optional<Event> optEvent = getIndexService().getEvent(eventId, getIndex());
916     if (optEvent.isEmpty()) {
917       return notFound("Cannot find an event with id '%s'.", eventId);
918     }
919 
920     try {
921       List<EventComment> comments = getEventCommentService().getComments(eventId);
922       List<Val> commentArr = new ArrayList<>();
923       for (EventComment c : comments) {
924         commentArr.add(c.toJson());
925       }
926       return Response.ok(org.opencastproject.util.Jsons.arr(commentArr).toJson(), MediaType.APPLICATION_JSON_TYPE)
927               .build();
928     } catch (EventCommentException e) {
929       logger.error("Unable to get comments from event {}", eventId, e);
930       throw new WebApplicationException(e);
931     }
932   }
933 
934   @GET
935   @Path("{eventId}/hasActiveTransaction")
936   @Produces(MediaType.TEXT_PLAIN)
937   @RestQuery(
938       name = "hasactivetransaction",
939       description = "Returns whether there is currently a transaction in progress for the given event",
940       returnDescription = "Whether there is currently a transaction in progress for the given event",
941       pathParameters = {
942           @RestParameter(name = "eventId", description = "The event id", isRequired = true,
943               type = RestParameter.Type.STRING)
944       },
945       responses = {
946           @RestResponse(description = "Returns whether there is currently a transaction in progress for the given "
947               + "event", responseCode = HttpServletResponse.SC_OK),
948           @RestResponse(description = "No event with this identifier was found.",
949               responseCode = HttpServletResponse.SC_NOT_FOUND)
950       })
951   public Response hasActiveTransaction(@PathParam("eventId") String eventId) throws Exception {
952     Optional<Event> optEvent = getIndexService().getEvent(eventId, getIndex());
953     if (optEvent.isEmpty()) {
954       return notFound("Cannot find an event with id '%s'.", eventId);
955     }
956 
957     JSONObject json = new JSONObject();
958 
959     if (WorkflowUtil.isActive(optEvent.get().getWorkflowState())) {
960       json.put("active", true);
961     } else {
962       json.put("active", false);
963     }
964 
965     return Response.ok(json.toJSONString()).build();
966   }
967 
968   @GET
969   @Produces(MediaType.APPLICATION_JSON)
970   @Path("{eventId}/comment/{commentId}")
971   @RestQuery(
972       name = "geteventcomment",
973       description = "Returns the comment with the given identifier",
974       returnDescription = "Returns the comment as JSON",
975       pathParameters = {
976           @RestParameter(name = "eventId", description = "The event id", isRequired = true,
977               type = RestParameter.Type.STRING),
978           @RestParameter(name = "commentId", isRequired = true, description = "The comment identifier", type = STRING)
979       },
980       responses = {
981           @RestResponse(responseCode = SC_OK, description = "The comment as JSON."),
982           @RestResponse(responseCode = SC_NOT_FOUND, description = "No event or comment with this identifier was "
983               + "found.")
984       })
985   public Response getEventComment(@PathParam("eventId") String eventId, @PathParam("commentId") long commentId)
986           throws NotFoundException, Exception {
987     Optional<Event> optEvent = getIndexService().getEvent(eventId, getIndex());
988     if (optEvent.isEmpty()) {
989       return notFound("Cannot find an event with id '%s'.", eventId);
990     }
991 
992     try {
993       EventComment comment = getEventCommentService().getComment(commentId);
994       return Response.ok(comment.toJson().toJson()).build();
995     } catch (NotFoundException e) {
996       throw e;
997     } catch (Exception e) {
998       logger.error("Could not retrieve comment {}", commentId, e);
999       throw new WebApplicationException(e);
1000     }
1001   }
1002 
1003   @PUT
1004   @Path("{eventId}/comment/{commentId}")
1005   @RestQuery(
1006       name = "updateeventcomment",
1007       description = "Updates an event comment",
1008       returnDescription = "The updated comment as JSON.",
1009       pathParameters = {
1010           @RestParameter(name = "eventId", description = "The event id", isRequired = true,
1011               type = RestParameter.Type.STRING),
1012           @RestParameter(name = "commentId", isRequired = true, description = "The comment identifier", type = STRING)
1013       },
1014       restParameters = {
1015           @RestParameter(name = "text", isRequired = false, description = "The comment text", type = TEXT),
1016           @RestParameter(name = "reason", isRequired = false, description = "The comment reason", type = STRING),
1017           @RestParameter(name = "resolved", isRequired = false, description = "The comment resolved status",
1018               type = RestParameter.Type.BOOLEAN)
1019       },
1020       responses = {
1021           @RestResponse(responseCode = SC_NOT_FOUND, description = "The event or comment to update has not been "
1022               + "found."),
1023           @RestResponse(responseCode = SC_OK, description = "The updated comment as JSON.")
1024       })
1025   public Response updateEventComment(@PathParam("eventId") String eventId, @PathParam("commentId") long commentId,
1026           @FormParam("text") String text, @FormParam("reason") String reason, @FormParam("resolved") Boolean resolved)
1027                   throws Exception {
1028     Optional<Event> optEvent = getIndexService().getEvent(eventId, getIndex());
1029     if (optEvent.isEmpty()) {
1030       return notFound("Cannot find an event with id '%s'.", eventId);
1031     }
1032 
1033     try {
1034       EventComment dto = getEventCommentService().getComment(commentId);
1035 
1036       if (StringUtils.isNotBlank(text)) {
1037         text = text.trim();
1038       } else {
1039         text = dto.getText();
1040       }
1041 
1042       if (StringUtils.isNotBlank(reason)) {
1043         reason = reason.trim();
1044       } else {
1045         reason = dto.getReason();
1046       }
1047 
1048       if (resolved == null) {
1049         resolved = dto.isResolvedStatus();
1050       }
1051 
1052       EventComment updatedComment = EventComment.create(dto.getId(), eventId,
1053               getSecurityService().getOrganization().getId(), text, dto.getAuthor(), reason, resolved,
1054               dto.getCreationDate(), new Date(), dto.getReplies());
1055 
1056       updatedComment = getEventCommentService().updateComment(updatedComment);
1057       List<EventComment> comments = getEventCommentService().getComments(eventId);
1058       getIndexService().updateCommentCatalog(optEvent.get(), comments);
1059       return Response.ok(updatedComment.toJson().toJson()).build();
1060     } catch (NotFoundException e) {
1061       throw e;
1062     } catch (Exception e) {
1063       logger.error("Unable to update the comments catalog on event {}", eventId, e);
1064       throw new WebApplicationException(e);
1065     }
1066   }
1067 
1068   @POST
1069   @Path("{eventId}/access")
1070   @RestQuery(
1071       name = "applyAclToEvent",
1072       description = "Immediate application of an ACL to an event",
1073       returnDescription = "Status code",
1074       pathParameters = {
1075           @RestParameter(name = "eventId", isRequired = true, description = "The event ID", type = STRING)
1076       },
1077       restParameters = {
1078           @RestParameter(name = "acl", isRequired = true, description = "The ACL to apply", type = STRING)
1079       },
1080       responses = {
1081           @RestResponse(responseCode = SC_OK, description = "The ACL has been successfully applied"),
1082           @RestResponse(responseCode = SC_BAD_REQUEST, description = "Unable to parse the given ACL"),
1083           @RestResponse(responseCode = SC_NOT_FOUND, description = "The the event has not been found"),
1084           @RestResponse(responseCode = SC_UNAUTHORIZED, description = "Not authorized to perform this action"),
1085           @RestResponse(responseCode = SC_INTERNAL_SERVER_ERROR, description = "Internal error")
1086       })
1087   public Response applyAclToEvent(@PathParam("eventId") String eventId, @FormParam("acl") String acl)
1088           throws NotFoundException, UnauthorizedException, SearchIndexException, IndexServiceException {
1089     final AccessControlList accessControlList;
1090     try {
1091       accessControlList = AccessControlParser.parseAcl(acl);
1092     } catch (Exception e) {
1093       logger.warn("Unable to parse ACL '{}'", acl);
1094       return badRequest();
1095     }
1096 
1097     try {
1098       final Optional<Event> optEvent = getIndexService().getEvent(eventId, getIndex());
1099       if (optEvent.isEmpty()) {
1100         logger.warn("Unable to find the event '{}'", eventId);
1101         return notFound();
1102       }
1103 
1104       Source eventSource = getIndexService().getEventSource(optEvent.get());
1105       if (eventSource == Source.ARCHIVE) {
1106         Optional<MediaPackage> mediaPackage = getAssetManager().getMediaPackage(eventId);
1107         Optional<AccessControlList> aclOpt = Optional.ofNullable(accessControlList);
1108         // the episode service is the source of authority for the retrieval of media packages
1109         if (mediaPackage.isPresent()) {
1110           MediaPackage episodeSvcMp = mediaPackage.get();
1111           aclOpt.ifPresentOrElse(
1112               aclPresent -> {
1113                 try {
1114                   MediaPackage mp = getAuthorizationService()
1115                       .setAcl(episodeSvcMp, AclScope.Episode, aclPresent)
1116                       .getA();
1117                   getAssetManager().takeSnapshot(mp);
1118                 } catch (MediaPackageException e) {
1119                   logger.error("Error getting ACL from media package", e);
1120                 }
1121               },
1122               () -> {
1123                 MediaPackage mp = getAuthorizationService().removeAcl(episodeSvcMp, AclScope.Episode);
1124                 getAssetManager().takeSnapshot(mp);
1125               }
1126           );
1127           return ok();
1128         }
1129         logger.warn("Unable to find the event '{}'", eventId);
1130         return notFound();
1131       } else if (eventSource == Source.WORKFLOW) {
1132         logger.warn("An ACL cannot be edited while an event is part of a current workflow because it might"
1133                 + " lead to inconsistent ACLs i.e. changed after distribution so that the old ACL is still "
1134                 + "being used by the distribution channel.");
1135         JSONObject json = new JSONObject();
1136         json.put("Error", "Unable to edit an ACL for a current workflow.");
1137         return conflict(json.toJSONString());
1138       } else {
1139         MediaPackage mediaPackage = getIndexService().getEventMediapackage(optEvent.get());
1140         mediaPackage = getAuthorizationService().setAcl(mediaPackage, AclScope.Episode, accessControlList).getA();
1141         // We could check agent access here if we want to forbid updating ACLs for users without access.
1142         getSchedulerService().updateEvent(eventId, Optional.empty(), Optional.empty(), Optional.empty(),
1143             Optional.empty(), Optional.of(mediaPackage), Optional.empty(), Optional.empty());
1144         return ok();
1145       }
1146     } catch (MediaPackageException e) {
1147       if (e.getCause() instanceof UnauthorizedException) {
1148         return forbidden();
1149       }
1150       logger.error("Error applying acl '{}' to event '{}'", accessControlList, eventId, e);
1151       return serverError();
1152     } catch (SchedulerException e) {
1153       logger.error("Error applying ACL to scheduled event {}", eventId, e);
1154       return serverError();
1155     }
1156   }
1157 
1158   @POST
1159   @Path("{eventId}/comment")
1160   @Produces(MediaType.APPLICATION_JSON)
1161   @RestQuery(
1162       name = "createeventcomment",
1163       description = "Creates a comment related to the event given by the identifier",
1164       returnDescription = "The comment related to the event as JSON",
1165       pathParameters = {
1166           @RestParameter(name = "eventId", description = "The event id", isRequired = true,
1167               type = RestParameter.Type.STRING)
1168       },
1169       restParameters = {
1170           @RestParameter(name = "text", isRequired = true, description = "The comment text", type = TEXT),
1171           @RestParameter(name = "resolved", isRequired = false, description = "The comment resolved status",
1172               type = RestParameter.Type.BOOLEAN),
1173           @RestParameter(name = "reason", isRequired = false, description = "The comment reason", type = STRING)
1174       },
1175       responses = {
1176           @RestResponse(description = "The comment has been created.", responseCode = HttpServletResponse.SC_CREATED),
1177           @RestResponse(description = "If no text ist set.", responseCode = HttpServletResponse.SC_BAD_REQUEST),
1178           @RestResponse(description = "No event with this identifier was found.",
1179               responseCode = HttpServletResponse.SC_NOT_FOUND)
1180       })
1181   public Response createEventComment(@PathParam("eventId") String eventId, @FormParam("text") String text,
1182           @FormParam("reason") String reason, @FormParam("resolved") Boolean resolved) throws Exception {
1183     Optional<Event> optEvent = getIndexService().getEvent(eventId, getIndex());
1184     if (optEvent.isEmpty()) {
1185       return notFound("Cannot find an event with id '%s'.", eventId);
1186     }
1187 
1188     if (StringUtils.isBlank(text)) {
1189       return Response.status(Status.BAD_REQUEST).build();
1190     }
1191 
1192     User author = getSecurityService().getUser();
1193     try {
1194       EventComment createdComment = EventComment.create(Optional.<Long> empty(), eventId,
1195               getSecurityService().getOrganization().getId(), text, author, reason, BooleanUtils.toBoolean(reason));
1196       createdComment = getEventCommentService().updateComment(createdComment);
1197       List<EventComment> comments = getEventCommentService().getComments(eventId);
1198       getIndexService().updateCommentCatalog(optEvent.get(), comments);
1199       return Response.created(getCommentUrl(eventId, createdComment.getId().get()))
1200               .entity(createdComment.toJson().toJson()).build();
1201     } catch (Exception e) {
1202       logger.error("Unable to create a comment on the event {}", eventId, e);
1203       throw new WebApplicationException(e);
1204     }
1205   }
1206 
1207   @POST
1208   @Path("{eventId}/comment/{commentId}")
1209   @RestQuery(
1210       name = "resolveeventcomment",
1211       description = "Resolves an event comment",
1212       returnDescription = "The resolved comment.",
1213       pathParameters = {
1214           @RestParameter(name = "eventId", description = "The event id", isRequired = true,
1215               type = RestParameter.Type.STRING),
1216           @RestParameter(name = "commentId", isRequired = true, description = "The comment identifier", type = STRING)
1217       },
1218       responses = {
1219           @RestResponse(responseCode = SC_NOT_FOUND, description = "The event or comment to resolve has not been "
1220               + "found."),
1221           @RestResponse(responseCode = SC_OK, description = "The resolved comment as JSON.")
1222       })
1223   public Response resolveEventComment(@PathParam("eventId") String eventId, @PathParam("commentId") long commentId)
1224           throws Exception {
1225     Optional<Event> optEvent = getIndexService().getEvent(eventId, getIndex());
1226     if (optEvent.isEmpty()) {
1227       return notFound("Cannot find an event with id '%s'.", eventId);
1228     }
1229 
1230     try {
1231       EventComment dto = getEventCommentService().getComment(commentId);
1232       EventComment updatedComment = EventComment.create(dto.getId(), dto.getEventId(), dto.getOrganization(),
1233               dto.getText(), dto.getAuthor(), dto.getReason(), true, dto.getCreationDate(), new Date(),
1234               dto.getReplies());
1235 
1236       updatedComment = getEventCommentService().updateComment(updatedComment);
1237       List<EventComment> comments = getEventCommentService().getComments(eventId);
1238       getIndexService().updateCommentCatalog(optEvent.get(), comments);
1239       return Response.ok(updatedComment.toJson().toJson()).build();
1240     } catch (NotFoundException e) {
1241       throw e;
1242     } catch (Exception e) {
1243       logger.error("Could not resolve comment {}", commentId, e);
1244       throw new WebApplicationException(e);
1245     }
1246   }
1247 
1248   @DELETE
1249   @Path("{eventId}/comment/{commentId}")
1250   @Produces(MediaType.APPLICATION_JSON)
1251   @RestQuery(
1252       name = "deleteeventcomment",
1253       description = "Deletes a event related comment by its identifier",
1254       returnDescription = "No content",
1255       pathParameters = {
1256           @RestParameter(name = "eventId", description = "The event id", isRequired = true,
1257               type = RestParameter.Type.STRING),
1258           @RestParameter(name = "commentId", description = "The comment id", isRequired = true,
1259               type = RestParameter.Type.STRING)
1260       },
1261       responses = {
1262           @RestResponse(description = "The event related comment has been deleted.",
1263               responseCode = HttpServletResponse.SC_NO_CONTENT),
1264           @RestResponse(description = "No event or comment with this identifier was found.",
1265               responseCode = HttpServletResponse.SC_NOT_FOUND)
1266       })
1267   public Response deleteEventComment(@PathParam("eventId") String eventId, @PathParam("commentId") long commentId)
1268           throws Exception {
1269     Optional<Event> optEvent = getIndexService().getEvent(eventId, getIndex());
1270     if (optEvent.isEmpty()) {
1271       return notFound("Cannot find an event with id '%s'.", eventId);
1272     }
1273 
1274     try {
1275       getEventCommentService().deleteComment(commentId);
1276       List<EventComment> comments = getEventCommentService().getComments(eventId);
1277       getIndexService().updateCommentCatalog(optEvent.get(), comments);
1278       return Response.noContent().build();
1279     } catch (NotFoundException e) {
1280       throw e;
1281     } catch (Exception e) {
1282       logger.error("Unable to delete comment {} on event {}", commentId, eventId, e);
1283       throw new WebApplicationException(e);
1284     }
1285   }
1286 
1287   @DELETE
1288   @Path("{eventId}/comment/{commentId}/{replyId}")
1289   @RestQuery(
1290       name = "deleteeventreply",
1291       description = "Delete an event comment reply",
1292       returnDescription = "The updated comment as JSON.",
1293       pathParameters = {
1294           @RestParameter(name = "eventId", description = "The event id", isRequired = true,
1295               type = RestParameter.Type.STRING),
1296           @RestParameter(name = "commentId", isRequired = true, description = "The comment identifier",
1297               type = STRING),
1298           @RestParameter(name = "replyId", isRequired = true, description = "The comment reply identifier",
1299               type = STRING)
1300       },
1301       responses = {
1302           @RestResponse(responseCode = SC_NOT_FOUND, description = "No event comment or reply with this identifier was "
1303               + "found."),
1304           @RestResponse(responseCode = SC_OK, description = "The updated comment as JSON.")
1305       })
1306   public Response deleteEventCommentReply(@PathParam("eventId") String eventId, @PathParam("commentId") long commentId,
1307           @PathParam("replyId") long replyId) throws Exception {
1308     Optional<Event> optEvent = getIndexService().getEvent(eventId, getIndex());
1309     if (optEvent.isEmpty()) {
1310       return notFound("Cannot find an event with id '%s'.", eventId);
1311     }
1312 
1313     EventComment comment = null;
1314     EventCommentReply reply = null;
1315     try {
1316       comment = getEventCommentService().getComment(commentId);
1317       for (EventCommentReply r : comment.getReplies()) {
1318         if (r.getId().isEmpty() || replyId != r.getId().get().longValue()) {
1319           continue;
1320         }
1321         reply = r;
1322         break;
1323       }
1324 
1325       if (reply == null) {
1326         throw new NotFoundException("Reply with id " + replyId + " not found!");
1327       }
1328 
1329       comment.removeReply(reply);
1330 
1331       EventComment updatedComment = getEventCommentService().updateComment(comment);
1332       List<EventComment> comments = getEventCommentService().getComments(eventId);
1333       getIndexService().updateCommentCatalog(optEvent.get(), comments);
1334       return Response.ok(updatedComment.toJson().toJson()).build();
1335     } catch (NotFoundException e) {
1336       throw e;
1337     } catch (Exception e) {
1338       logger.warn("Could not remove event comment reply {} from comment {}", replyId, commentId, e);
1339       throw new WebApplicationException(e);
1340     }
1341   }
1342 
1343   @PUT
1344   @Path("{eventId}/comment/{commentId}/{replyId}")
1345   @RestQuery(
1346       name = "updateeventcommentreply",
1347       description = "Updates an event comment reply",
1348       returnDescription = "The updated comment as JSON.",
1349       pathParameters = {
1350           @RestParameter(name = "eventId", description = "The event id", isRequired = true,
1351               type = RestParameter.Type.STRING),
1352           @RestParameter(name = "commentId", isRequired = true, description = "The comment identifier",
1353               type = STRING),
1354           @RestParameter(name = "replyId", isRequired = true, description = "The comment reply identifier",
1355               type = STRING)
1356       },
1357       restParameters = {
1358           @RestParameter(name = "text", isRequired = true, description = "The comment reply text", type = TEXT)
1359       },
1360       responses = {
1361           @RestResponse(responseCode = SC_NOT_FOUND, description = "The event or comment to extend with a reply or the "
1362               + "reply has not been found."),
1363           @RestResponse(responseCode = HttpServletResponse.SC_BAD_REQUEST, description = "If no text is set."),
1364           @RestResponse(responseCode = SC_OK, description = "The updated comment as JSON.")
1365       })
1366   public Response updateEventCommentReply(@PathParam("eventId") String eventId, @PathParam("commentId") long commentId,
1367           @PathParam("replyId") long replyId, @FormParam("text") String text) throws Exception {
1368     if (StringUtils.isBlank(text)) {
1369       return Response.status(Status.BAD_REQUEST).build();
1370     }
1371 
1372     Optional<Event> optEvent = getIndexService().getEvent(eventId, getIndex());
1373     if (optEvent.isEmpty()) {
1374       return notFound("Cannot find an event with id '%s'.", eventId);
1375     }
1376 
1377     EventComment comment = null;
1378     EventCommentReply reply = null;
1379     try {
1380       comment = getEventCommentService().getComment(commentId);
1381       for (EventCommentReply r : comment.getReplies()) {
1382         if (r.getId().isEmpty() || replyId != r.getId().get().longValue()) {
1383           continue;
1384         }
1385         reply = r;
1386         break;
1387       }
1388 
1389       if (reply == null) {
1390         throw new NotFoundException("Reply with id " + replyId + " not found!");
1391       }
1392 
1393       EventCommentReply updatedReply = EventCommentReply.create(reply.getId(), text.trim(), reply.getAuthor(),
1394               reply.getCreationDate(), new Date());
1395       comment.removeReply(reply);
1396       comment.addReply(updatedReply);
1397 
1398       EventComment updatedComment = getEventCommentService().updateComment(comment);
1399       List<EventComment> comments = getEventCommentService().getComments(eventId);
1400       getIndexService().updateCommentCatalog(optEvent.get(), comments);
1401       return Response.ok(updatedComment.toJson().toJson()).build();
1402     } catch (NotFoundException e) {
1403       throw e;
1404     } catch (Exception e) {
1405       logger.warn("Could not update event comment reply {} from comment {}", replyId, commentId, e);
1406       throw new WebApplicationException(e);
1407     }
1408   }
1409 
1410   @POST
1411   @Path("{eventId}/comment/{commentId}/reply")
1412   @RestQuery(
1413       name = "createeventcommentreply",
1414       description = "Creates an event comment reply",
1415       returnDescription = "The updated comment as JSON.",
1416       pathParameters = {
1417           @RestParameter(name = "eventId", description = "The event id", isRequired = true,
1418               type = RestParameter.Type.STRING),
1419           @RestParameter(name = "commentId", isRequired = true, description = "The comment identifier",
1420               type = STRING)
1421       },
1422       restParameters = {
1423           @RestParameter(name = "text", isRequired = true, description = "The comment reply text", type = TEXT),
1424           @RestParameter(name = "resolved", isRequired = false, description = "Flag defining if this reply solve or "
1425               + "not the comment.", type = BOOLEAN)
1426       },
1427       responses = {
1428           @RestResponse(responseCode = SC_NOT_FOUND, description = "The event or comment to extend with a reply has "
1429               + "not been found."),
1430           @RestResponse(responseCode = HttpServletResponse.SC_BAD_REQUEST, description = "If no text is set."),
1431           @RestResponse(responseCode = SC_OK, description = "The updated comment as JSON.")
1432       })
1433   public Response createEventCommentReply(@PathParam("eventId") String eventId, @PathParam("commentId") long commentId,
1434           @FormParam("text") String text, @FormParam("resolved") Boolean resolved) throws Exception {
1435     if (StringUtils.isBlank(text)) {
1436       return Response.status(Status.BAD_REQUEST).build();
1437     }
1438 
1439     Optional<Event> optEvent = getIndexService().getEvent(eventId, getIndex());
1440     if (optEvent.isEmpty()) {
1441       return notFound("Cannot find an event with id '%s'.", eventId);
1442     }
1443 
1444     EventComment comment = null;
1445     try {
1446       comment = getEventCommentService().getComment(commentId);
1447       EventComment updatedComment;
1448 
1449       if (resolved != null && resolved) {
1450         // If the resolve flag is set to true, change to comment to resolved
1451         updatedComment = EventComment.create(comment.getId(), comment.getEventId(), comment.getOrganization(),
1452                 comment.getText(), comment.getAuthor(), comment.getReason(), true, comment.getCreationDate(),
1453                 new Date(), comment.getReplies());
1454       } else {
1455         updatedComment = comment;
1456       }
1457 
1458       User author = getSecurityService().getUser();
1459       EventCommentReply reply = EventCommentReply.create(Optional.<Long> empty(), text, author);
1460       updatedComment.addReply(reply);
1461 
1462       updatedComment = getEventCommentService().updateComment(updatedComment);
1463       List<EventComment> comments = getEventCommentService().getComments(eventId);
1464       getIndexService().updateCommentCatalog(optEvent.get(), comments);
1465       return Response.ok(updatedComment.toJson().toJson()).build();
1466     } catch (Exception e) {
1467       logger.warn("Could not create event comment reply on comment {}", comment, e);
1468       throw new WebApplicationException(e);
1469     }
1470   }
1471 
1472   /**
1473    * Removes emtpy series titles from the collection of the isPartOf Field
1474    * @param ml the list to modify
1475    */
1476   private void removeSeriesWithNullTitlesFromFieldCollection(MetadataList ml) {
1477     // get Series MetadataField from MetadataList
1478     MetadataField seriesField = Optional.ofNullable(ml.getMetadataList().get("dublincore/episode"))
1479             .flatMap(titledMetadataCollection -> Optional.ofNullable(titledMetadataCollection.getCollection()))
1480             .flatMap(dcMetadataCollection -> Optional.ofNullable(dcMetadataCollection.getOutputFields()))
1481             .flatMap(metadataFields -> Optional.ofNullable(metadataFields.get("isPartOf")))
1482             .orElse(null);
1483     if (seriesField == null || seriesField.getCollection() == null) {
1484       return;
1485     }
1486 
1487     // Remove null keys
1488     Map<String, String> seriesCollection = seriesField.getCollection();
1489     seriesCollection.remove(null);
1490     seriesField.setCollection(seriesCollection);
1491 
1492     return;
1493   }
1494 
1495   @GET
1496   @Path("{eventId}/metadata.json")
1497   @Produces(MediaType.APPLICATION_JSON)
1498   @RestQuery(
1499       name = "geteventmetadata",
1500       description = "Returns all the data related to the metadata tab in the event details modal as JSON",
1501       returnDescription = "All the data related to the event metadata tab as JSON",
1502       pathParameters = {
1503           @RestParameter(name = "eventId", description = "The event id", isRequired = true,
1504               type = RestParameter.Type.STRING)
1505       },
1506       responses = {
1507           @RestResponse(description = "Returns all the data related to the event metadata tab as JSON",
1508               responseCode = HttpServletResponse.SC_OK),
1509           @RestResponse(description = "No event with this identifier was found.",
1510               responseCode = HttpServletResponse.SC_NOT_FOUND)
1511       })
1512   public Response getEventMetadata(@PathParam("eventId") String eventId) throws Exception {
1513     Optional<Event> optEvent = getIndexService().getEvent(eventId, getIndex());
1514     if (optEvent.isEmpty()) {
1515       return notFound("Cannot find an event with id '%s'.", eventId);
1516     }
1517     Event event = optEvent.get();
1518     MetadataList metadataList = new MetadataList();
1519 
1520     // Load extended metadata
1521     List<EventCatalogUIAdapter> extendedCatalogUIAdapters = getIndexService().getExtendedEventCatalogUIAdapters();
1522     if (!extendedCatalogUIAdapters.isEmpty()) {
1523       MediaPackage mediaPackage;
1524       try {
1525         mediaPackage = getIndexService().getEventMediapackage(event);
1526       } catch (IndexServiceException e) {
1527         if (e.getCause() instanceof NotFoundException) {
1528           return notFound("Cannot find data for event %s", eventId);
1529         } else if (e.getCause() instanceof UnauthorizedException) {
1530           return Response.status(Status.FORBIDDEN).entity("Not authorized to access " + eventId).build();
1531         }
1532         logger.error("Internal error when trying to access metadata for " + eventId, e);
1533         return serverError();
1534       }
1535 
1536       for (EventCatalogUIAdapter extendedCatalogUIAdapter : extendedCatalogUIAdapters) {
1537         metadataList.add(extendedCatalogUIAdapter, extendedCatalogUIAdapter.getFields(mediaPackage));
1538       }
1539     }
1540 
1541     // Load common metadata
1542     // We do this after extended metadata because we want to overwrite any extended metadata adapters with the same
1543     // flavor instead of the other way around.
1544     EventCatalogUIAdapter eventCatalogUiAdapter = getIndexService().getCommonEventCatalogUIAdapter();
1545     DublinCoreMetadataCollection metadataCollection = eventCatalogUiAdapter.getRawFields(getCollectionQueryDisable());
1546     EventUtils.setEventMetadataValues(event, metadataCollection);
1547     metadataList.add(eventCatalogUiAdapter, metadataCollection);
1548 
1549     // remove series with empty titles from the collection of the isPartOf field as these can't be converted to json
1550     removeSeriesWithNullTitlesFromFieldCollection(metadataList);
1551 
1552     // lock metadata?
1553     final String wfState = event.getWorkflowState();
1554     if (wfState != null && WorkflowUtil.isActive(WorkflowInstance.WorkflowState.valueOf(wfState))) {
1555       metadataList.setLocked(Locked.WORKFLOW_RUNNING);
1556     }
1557 
1558     return okJson(MetadataJson.listToJson(metadataList, true));
1559   }
1560 
1561   /**
1562    * Create a special query that disables filling the collection of a series, for performance reasons.
1563    * The collection can still be fetched via the listprovider endpoint.
1564    *
1565    * @return a map with resource list queries belonging to metadata fields
1566    */
1567   private Map getCollectionQueryDisable() {
1568     HashMap<String, ResourceListQuery> collectionQueryOverrides = new HashMap();
1569     SeriesListQuery seriesListQuery = new SeriesListQuery();
1570     seriesListQuery.setLimit(0);
1571     collectionQueryOverrides.put(DublinCore.PROPERTY_IS_PART_OF.getLocalName(), seriesListQuery);
1572     return collectionQueryOverrides;
1573   }
1574 
1575   @POST  // use POST instead of GET because of a possibly long list of ids
1576   @Path("events/metadata.json")
1577   @Produces(MediaType.APPLICATION_JSON)
1578   @RestQuery(
1579       name = "geteventsmetadata",
1580       description = "Returns all the data related to the edit events metadata modal as JSON",
1581       returnDescription = "All the data related to the edit events metadata modal as JSON",
1582       restParameters = {
1583           @RestParameter(name = "eventIds", description = "The event ids", isRequired = true,
1584               type = RestParameter.Type.STRING)
1585       }, responses = {
1586           @RestResponse(description = "Returns all the data related to the edit events metadata modal as JSON",
1587               responseCode = HttpServletResponse.SC_OK),
1588           @RestResponse(description = "No events to update, either not found or with running workflow, details in "
1589               + "response body.", responseCode = HttpServletResponse.SC_NOT_FOUND)
1590       })
1591   public Response getEventsMetadata(@FormParam("eventIds") String eventIds) throws Exception {
1592     if (StringUtils.isBlank(eventIds)) {
1593       return badRequest("Event ids can't be empty");
1594     }
1595 
1596     JSONParser parser = new JSONParser();
1597     List<String> ids;
1598     try {
1599       ids = (List<String>) parser.parse(eventIds);
1600     } catch (org.json.simple.parser.ParseException e) {
1601       logger.error("Unable to parse '{}'", eventIds, e);
1602       return badRequest("Unable to parse event ids");
1603     } catch (ClassCastException e) {
1604       logger.error("Unable to cast '{}'", eventIds, e);
1605       return badRequest("Unable to parse event ids");
1606     }
1607 
1608     Set<String> eventsNotFound = new HashSet();
1609     Set<String> eventsWithRunningWorkflow = new HashSet();
1610     Set<String> eventsMerged = new HashSet();
1611 
1612     // collect the metadata of all events
1613     List<DublinCoreMetadataCollection> collectedMetadata = new ArrayList();
1614     for (String eventId: ids) {
1615       Optional<Event> optEvent = getIndexService().getEvent(eventId, getIndex());
1616       // not found?
1617       if (optEvent.isEmpty()) {
1618         eventsNotFound.add(eventId);
1619         continue;
1620       }
1621 
1622       Event event = optEvent.get();
1623 
1624       // check if there's a running workflow
1625       final String wfState = event.getWorkflowState();
1626       if (wfState != null && WorkflowUtil.isActive(WorkflowInstance.WorkflowState.valueOf(wfState))) {
1627         eventsWithRunningWorkflow.add(eventId);
1628         continue;
1629       }
1630 
1631       // collect metadata
1632       EventCatalogUIAdapter eventCatalogUiAdapter = getIndexService().getCommonEventCatalogUIAdapter();
1633       DublinCoreMetadataCollection metadataCollection = eventCatalogUiAdapter.getRawFields(
1634             getCollectionQueryDisable());
1635       EventUtils.setEventMetadataValues(event, metadataCollection);
1636       collectedMetadata.add(metadataCollection);
1637 
1638       eventsMerged.add(eventId);
1639     }
1640 
1641     // no events found?
1642     if (collectedMetadata.isEmpty()) {
1643       JsonObject response = new JsonObject();
1644       response.add("notFound", collectionToJsonArray(eventsNotFound));
1645       response.add("runningWorkflow", collectionToJsonArray(eventsWithRunningWorkflow));
1646       return Response.status(Status.NOT_FOUND).type(MediaType.APPLICATION_JSON_TYPE).build();
1647     }
1648 
1649     // merge metadata of events
1650     DublinCoreMetadataCollection mergedMetadata;
1651     if (collectedMetadata.size() == 1) {
1652       mergedMetadata = collectedMetadata.get(0);
1653     }
1654     else {
1655       //use first metadata collection as base
1656       mergedMetadata = new DublinCoreMetadataCollection(collectedMetadata.get(0));
1657       collectedMetadata.remove(0);
1658 
1659       for (MetadataField field : mergedMetadata.getFields()) {
1660         for (DublinCoreMetadataCollection otherMetadataCollection : collectedMetadata) {
1661           MetadataField matchingField = otherMetadataCollection.getOutputFields().get(field.getOutputID());
1662 
1663           // check if fields have the same value
1664           if (!Objects.equals(field.getValue(), matchingField.getValue())) {
1665             field.setDifferentValues();
1666             break;
1667           }
1668         }
1669       }
1670     }
1671 
1672     JsonObject result = new JsonObject();
1673     result.add("metadata", MetadataJson.collectionToJson(mergedMetadata, true));
1674     result.add("notFound", collectionToJsonArray(eventsNotFound));
1675     result.add("runningWorkflow", collectionToJsonArray(eventsWithRunningWorkflow));
1676     result.add("merged", collectionToJsonArray(eventsMerged));
1677 
1678     return okJson(result);
1679   }
1680 
1681   @PUT
1682   @Path("bulk/update")
1683   @RestQuery(
1684       name = "bulkupdate",
1685       description = "Update all of the given events at once",
1686       restParameters = {
1687         @RestParameter(name = "update", isRequired = true, type = RestParameter.Type.TEXT,
1688             description = "The list of groups with events and fields to update.")
1689       }, responses = {
1690         @RestResponse(description = "All events have been updated successfully.",
1691             responseCode = HttpServletResponse.SC_OK),
1692         @RestResponse(description = "Could not parse update instructions.",
1693             responseCode = HttpServletResponse.SC_BAD_REQUEST),
1694         @RestResponse(description = "Field updating metadata or scheduling information. Some events may have been "
1695             + "updated. Details are available in the response body.",
1696             responseCode = HttpServletResponse.SC_INTERNAL_SERVER_ERROR),
1697         @RestResponse(description = "The events in the response body were not found. No events were updated.",
1698             responseCode = HttpServletResponse.SC_NOT_FOUND)
1699       },
1700       returnDescription = "In case of success, no content is returned. In case of errors while updating the metadata "
1701           + "or scheduling information, the errors are returned. In case events were not found, their ids are returned")
1702   public Response bulkUpdate(@FormParam("update") String updateJson) {
1703 
1704     final BulkUpdateUtil.BulkUpdateInstructions instructions;
1705     try {
1706       instructions = new BulkUpdateUtil.BulkUpdateInstructions(updateJson);
1707     } catch (IllegalArgumentException e) {
1708       return badRequest("Cannot parse bulk update instructions");
1709     }
1710 
1711     final Map<String, String> metadataUpdateFailures = new HashMap<>();
1712     final Map<String, String> schedulingUpdateFailures = new HashMap<>();
1713 
1714     for (final BulkUpdateUtil.BulkUpdateInstructionGroup groupInstructions : instructions.getGroups()) {
1715       // Get all the events to edit
1716       final Map<String, Optional<Event>> events = groupInstructions.getEventIds().stream()
1717           .collect(Collectors.toMap(id -> id, id -> BulkUpdateUtil.getEvent(getIndexService(), getIndex(), id)));
1718 
1719       // Check for invalid (non-existing) event ids
1720       final Set<String> notFoundIds = events.entrySet().stream()
1721           .filter(e -> !e.getValue().isPresent())
1722           .map(Entry::getKey)
1723           .collect(Collectors.toSet());
1724       if (!notFoundIds.isEmpty()) {
1725         return notFoundJson(collectionToJsonArray(notFoundIds));
1726       }
1727 
1728 
1729       events.values().forEach(e -> e.ifPresent(event -> {
1730 
1731         JSONObject metadata = null;
1732 
1733         // Update the scheduling information
1734         try {
1735           if (groupInstructions.getScheduling() != null) {
1736             // Since we only have the start/end time, we have to add the correct date(s) for this event.
1737             final JSONObject scheduling = BulkUpdateUtil.addSchedulingDates(event, groupInstructions.getScheduling());
1738             updateEventScheduling(scheduling.toJSONString(), event);
1739             // We have to update the non-technical metadata as well to keep them in sync with the technical ones.
1740             metadata = BulkUpdateUtil.toNonTechnicalMetadataJson(scheduling);
1741           }
1742         } catch (Exception exception) {
1743           schedulingUpdateFailures.put(event.getIdentifier(), exception.getMessage());
1744         }
1745 
1746         // Update the event metadata
1747         try {
1748           if (groupInstructions.getMetadata() != null || metadata != null) {
1749             metadata = BulkUpdateUtil.mergeMetadataFields(metadata, groupInstructions.getMetadata());
1750             getIndexService().updateAllEventMetadata(event.getIdentifier(),
1751                 JSONArray.toJSONString(Collections.singletonList(metadata)), getIndex());
1752           }
1753         } catch (Exception exception) {
1754           metadataUpdateFailures.put(event.getIdentifier(), exception.getMessage());
1755         }
1756       }));
1757     }
1758 
1759     // Check if there were any errors updating the metadata or scheduling information
1760     if (!metadataUpdateFailures.isEmpty() || !schedulingUpdateFailures.isEmpty()) {
1761       JsonObject json = new JsonObject();
1762       json.add("metadataFailures", mapToJsonObject(metadataUpdateFailures));
1763       json.add("schedulingFailures", mapToJsonObject(schedulingUpdateFailures));
1764       return serverErrorJson(json);
1765     }
1766     return ok();
1767   }
1768 
1769   @POST
1770   @Path("bulk/conflicts")
1771   @RestQuery(
1772       name = "getBulkConflicts",
1773       description = "Checks if the current bulk update scheduling settings are in a conflict with another event",
1774       returnDescription = "Returns NO CONTENT if no event are in conflict within specified period or list of "
1775           + "conflicting recordings in JSON",
1776       restParameters = {
1777           @RestParameter(name = "update", isRequired = true, type = RestParameter.Type.TEXT, description = "The list "
1778               + "of events and fields to update.")
1779       },
1780       responses = {
1781           @RestResponse(responseCode = HttpServletResponse.SC_NO_CONTENT, description = "No conflicting events found"),
1782           @RestResponse(responseCode = HttpServletResponse.SC_NOT_FOUND, description = "The events in the response "
1783               + "body were not found. No events were updated."),
1784           @RestResponse(responseCode = HttpServletResponse.SC_CONFLICT, description = "There is a conflict"),
1785           @RestResponse(responseCode = HttpServletResponse.SC_BAD_REQUEST, description = "Missing or invalid "
1786               + "parameters")
1787       })
1788   public Response getBulkConflicts(@FormParam("update") final String updateJson) throws NotFoundException {
1789     final BulkUpdateUtil.BulkUpdateInstructions instructions;
1790     try {
1791       instructions = new BulkUpdateUtil.BulkUpdateInstructions(updateJson);
1792     } catch (IllegalArgumentException e) {
1793       return badRequest("Cannot parse bulk update instructions");
1794     }
1795 
1796     final Map<String, List<JsonObject>> conflicts = new HashMap<>();
1797     final List<Tuple3<String, Optional<Event>, JSONObject>> eventsWithSchedulingOpt = instructions.getGroups().stream()
1798         .flatMap(group -> group.getEventIds().stream().map(eventId -> Tuple3
1799             .tuple3(eventId, BulkUpdateUtil.getEvent(getIndexService(), getIndex(), eventId), group.getScheduling())))
1800         .collect(Collectors.toList());
1801     // Check for invalid (non-existing) event ids
1802     final Set<String> notFoundIds = eventsWithSchedulingOpt.stream().filter(e -> !e.getB().isPresent())
1803         .map(Tuple3::getA).collect(Collectors.toSet());
1804     if (!notFoundIds.isEmpty()) {
1805       return notFoundJson(collectionToJsonArray(notFoundIds));
1806     }
1807     final List<Tuple<Event, JSONObject>> eventsWithScheduling = eventsWithSchedulingOpt.stream()
1808         .map(e -> Tuple.tuple(e.getB().get(), e.getC())).collect(Collectors.toList());
1809     final Set<String> changedIds = eventsWithScheduling.stream().map(e -> e.getA().getIdentifier())
1810         .collect(Collectors.toSet());
1811     for (final Tuple<Event, JSONObject> eventWithGroup : eventsWithScheduling) {
1812       final Event event = eventWithGroup.getA();
1813       final JSONObject groupScheduling = eventWithGroup.getB();
1814       try {
1815         if (groupScheduling != null) {
1816           // Since we only have the start/end time, we have to add the correct date(s) for this event.
1817           final JSONObject scheduling = BulkUpdateUtil.addSchedulingDates(event, groupScheduling);
1818           final Date start = Date.from(Instant.parse((String) scheduling.get(SCHEDULING_START_KEY)));
1819           final Date end = Date.from(Instant.parse((String) scheduling.get(SCHEDULING_END_KEY)));
1820           final String agentId = Optional.ofNullable((String) scheduling.get(SCHEDULING_AGENT_ID_KEY))
1821               .orElse(event.getAgentId());
1822 
1823           final List<JsonObject> currentConflicts = new ArrayList<>();
1824 
1825           // Check for conflicts between the events themselves
1826           eventsWithScheduling.stream()
1827               .filter(otherEvent -> !otherEvent.getA().getIdentifier().equals(event.getIdentifier()))
1828               .forEach(otherEvent -> {
1829                 final JSONObject otherScheduling = BulkUpdateUtil.addSchedulingDates(otherEvent.getA(),
1830                     otherEvent.getB());
1831                 final Date otherStart = Date.from(Instant.parse((String) otherScheduling.get(SCHEDULING_START_KEY)));
1832                 final Date otherEnd = Date.from(Instant.parse((String) otherScheduling.get(SCHEDULING_END_KEY)));
1833                 final String otherAgentId = Optional.ofNullable((String) otherScheduling.get(SCHEDULING_AGENT_ID_KEY))
1834                     .orElse(otherEvent.getA().getAgentId());
1835                 if (!otherAgentId.equals(agentId)) {
1836                   // different agent -> no conflict
1837                   return;
1838                 }
1839                 if (Util.schedulingIntervalsOverlap(start, end, otherStart, otherEnd)) {
1840                   // conflict
1841                   currentConflicts.add(convertEventToConflictingObject(
1842                       DateTimeSupport.toUTC(otherStart.getTime()),
1843                       DateTimeSupport.toUTC(otherEnd.getTime()),
1844                       otherEvent.getA().getTitle()));
1845                 }
1846               });
1847 
1848           // Check for conflicts with other events from the database
1849           final List<MediaPackage> conflicting = getSchedulerService().findConflictingEvents(agentId, start, end)
1850               .stream()
1851               .filter(mp -> !changedIds.contains(mp.getIdentifier().toString()))
1852               .collect(Collectors.toList());
1853           if (!conflicting.isEmpty()) {
1854             currentConflicts.addAll(convertToConflictObjects(event.getIdentifier(), conflicting));
1855           }
1856           conflicts.put(event.getIdentifier(), currentConflicts);
1857         }
1858       } catch (final SchedulerException | UnauthorizedException | SearchIndexException exception) {
1859         throw new RuntimeException(exception);
1860       }
1861     }
1862 
1863     if (!conflicts.isEmpty()) {
1864       JsonArray responseJson = new JsonArray();
1865 
1866       conflicts.forEach((eventId, conflictingEvents) -> {
1867         if (!conflictingEvents.isEmpty()) {
1868           JsonObject obj = new JsonObject();
1869           obj.addProperty("eventId", eventId);
1870 
1871           JsonArray conflictsArray = new JsonArray();
1872           for (JsonObject conflict : conflictingEvents) {
1873             conflictsArray.add(conflict);
1874           }
1875 
1876           obj.add("conflicts", conflictsArray);
1877           responseJson.add(obj);
1878         }
1879       });
1880 
1881       if (responseJson.size() > 0) {
1882         return conflictJson(responseJson);
1883       }
1884     }
1885 
1886     return noContent();
1887   }
1888 
1889   @PUT
1890   @Path("{eventId}/metadata")
1891   @RestQuery(
1892       name = "updateeventmetadata",
1893       description = "Update the passed metadata for the event with the given Id",
1894       pathParameters = {
1895           @RestParameter(name = "eventId", description = "The event id", isRequired = true,
1896               type = RestParameter.Type.STRING)
1897       },
1898       restParameters = {
1899           @RestParameter(name = "metadata", isRequired = true, type = RestParameter.Type.TEXT,
1900               description = "The list of metadata to update")
1901       },
1902       responses = {
1903           @RestResponse(description = "The metadata have been updated.", responseCode = HttpServletResponse.SC_OK),
1904           @RestResponse(description = "Could not parse metadata.", responseCode = HttpServletResponse.SC_BAD_REQUEST),
1905           @RestResponse(description = "No event with this identifier was found.",
1906               responseCode = HttpServletResponse.SC_NOT_FOUND)
1907       },
1908       returnDescription = "No content is returned.")
1909   public Response updateEventMetadata(@PathParam("eventId") String id, @FormParam("metadata") String metadataJSON)
1910           throws Exception {
1911     Optional<Event> optEvent = getIndexService().getEvent(id, getIndex());
1912     if (optEvent.isEmpty()) {
1913       return notFound("Cannot find an event with id '%s'.", id);
1914     }
1915 
1916     try {
1917       MetadataList metadataList = getIndexService().updateAllEventMetadata(id, metadataJSON, getIndex());
1918       return okJson(MetadataJson.listToJson(metadataList, true));
1919     } catch (IllegalArgumentException e) {
1920       return badRequest(String.format("Event %s metadata can't be updated.: %s", id, e.getMessage()));
1921     }
1922   }
1923 
1924   @PUT
1925   @Path("events/metadata")
1926   @RestQuery(
1927       name = "updateeventsmetadata",
1928       description = "Update the passed metadata for the events with the given ids",
1929       restParameters = {
1930           @RestParameter(name = "eventIds", isRequired = true, type = RestParameter.Type.STRING,
1931               description = "The ids of the events to update"),
1932           @RestParameter(name = "metadata", isRequired = true, type = RestParameter.Type.TEXT,
1933               description = "The metadata fields to update"),
1934       },
1935       responses = {
1936           @RestResponse(description = "All events have been updated successfully.",
1937               responseCode = HttpServletResponse.SC_NO_CONTENT),
1938           @RestResponse(description = "One or multiple errors occured while updating event metadata. "
1939               + "Some events may have been updated successfully. "
1940               + "Details are available in the response body.",
1941               responseCode = HttpServletResponse.SC_INTERNAL_SERVER_ERROR)
1942       },
1943       returnDescription = "In case of complete success, no content is returned. Otherwise, the response content "
1944           + "contains the ids of events that couldn't be found and the ids and errors of events where the update "
1945           + "failed as well as the ids of the events that were updated successfully.")
1946   public Response updateEventsMetadata(@FormParam("eventIds") String eventIds, @FormParam("metadata") String metadata)
1947           throws Exception {
1948 
1949     if (StringUtils.isBlank(eventIds)) {
1950       return badRequest("Event ids can't be empty");
1951     }
1952 
1953     JSONParser parser = new JSONParser();
1954     List<String> ids;
1955     try {
1956       ids = (List<String>) parser.parse(eventIds);
1957     } catch (org.json.simple.parser.ParseException e) {
1958       logger.error("Unable to parse '{}'", eventIds, e);
1959       return badRequest("Unable to parse event ids");
1960     } catch (ClassCastException e) {
1961       logger.error("Unable to cast '{}'", eventIds, e);
1962       return badRequest("Unable to parse event ids");
1963     }
1964 
1965     // try to update each event
1966     Set<String> eventsNotFound = new HashSet<>();
1967     Set<String> eventsUpdated = new HashSet<>();
1968     Set<String> eventsUpdateFailure = new HashSet();
1969 
1970     for (String eventId : ids) {
1971       Optional<Event> optEvent = getIndexService().getEvent(eventId, getIndex());
1972       // not found?
1973 
1974       if (optEvent.isEmpty()) {
1975         eventsNotFound.add(eventId);
1976         continue;
1977       }
1978 
1979       // update
1980       try {
1981         getIndexService().updateAllEventMetadata(eventId, metadata, getIndex());
1982         eventsUpdated.add(eventId);
1983       } catch (IllegalArgumentException e) {
1984         eventsUpdateFailure.add(eventId);
1985       }
1986     }
1987 
1988     // errors occurred?
1989     if (!eventsNotFound.isEmpty() || !eventsUpdateFailure.isEmpty()) {
1990       JsonObject errorJson = new JsonObject();
1991 
1992       errorJson.add("updateFailures", collectionToJsonArray(eventsUpdateFailure));
1993       errorJson.add("notFound", collectionToJsonArray(eventsNotFound));
1994       errorJson.add("updated", collectionToJsonArray(eventsUpdated));
1995 
1996       return serverErrorJson(errorJson);
1997     }
1998 
1999     return noContent();
2000   }
2001 
2002   @GET
2003   @Path("{eventId}/asset/assets.json")
2004   @Produces(MediaType.APPLICATION_JSON)
2005   @RestQuery(
2006       name = "getAssetList",
2007       description = "Returns the number of assets from each types as JSON",
2008       returnDescription = "The number of assets from each types as JSON",
2009       pathParameters = {
2010           @RestParameter(name = "eventId", description = "The event id", isRequired = true,
2011               type = RestParameter.Type.STRING)
2012       },
2013       responses = {
2014           @RestResponse(description = "Returns the number of assets from each types as JSON",
2015               responseCode = HttpServletResponse.SC_OK),
2016           @RestResponse(description = "No event with this identifier was found.",
2017               responseCode = HttpServletResponse.SC_NOT_FOUND)
2018       })
2019   public Response getAssetList(@PathParam("eventId") String id) throws Exception {
2020     Optional<Event> optEvent = getIndexService().getEvent(id, getIndex());
2021     if (optEvent.isEmpty()) {
2022       return notFound("Cannot find an event with id '%s'.", id);
2023     }
2024     MediaPackage mp;
2025     try {
2026       mp = getIndexService().getEventMediapackage(optEvent.get());
2027     } catch (IndexServiceException e) {
2028       if (e.getCause() instanceof NotFoundException) {
2029         return notFound("Cannot find data for event %s", id);
2030       } else if (e.getCause() instanceof UnauthorizedException) {
2031         return Response.status(Status.FORBIDDEN).entity("Not authorized to access " + id).build();
2032       }
2033       logger.error("Internal error when trying to access metadata for " + id, e);
2034       return serverError();
2035     }
2036     int attachments = mp.getAttachments().length;
2037     int catalogs = mp.getCatalogs().length;
2038     int media = mp.getTracks().length;
2039     int publications = mp.getPublications().length;
2040 
2041     JsonObject result = new JsonObject();
2042     result.addProperty("attachments", attachments);
2043     result.addProperty("catalogs", catalogs);
2044     result.addProperty("media", media);
2045     result.addProperty("publications", publications);
2046 
2047     return okJson(result);
2048   }
2049 
2050   @GET
2051   @Path("{eventId}/asset/attachment/attachments.json")
2052   @Produces(MediaType.APPLICATION_JSON)
2053   @RestQuery(
2054       name = "getAttachmentsList",
2055       description = "Returns a list of attachments from the given event as JSON",
2056       returnDescription = "The list of attachments from the given event as JSON",
2057       pathParameters = {
2058           @RestParameter(name = "eventId", description = "The event id", isRequired = true,
2059               type = RestParameter.Type.STRING)
2060       },
2061       responses = {
2062           @RestResponse(description = "Returns a list of attachments from the given event as JSON",
2063               responseCode = HttpServletResponse.SC_OK),
2064           @RestResponse(description = "No event with this identifier was found.",
2065               responseCode = HttpServletResponse.SC_NOT_FOUND)
2066       })
2067   public Response getAttachmentsList(@PathParam("eventId") String id) throws Exception {
2068     Optional<Event> optEvent = getIndexService().getEvent(id, getIndex());
2069     if (optEvent.isEmpty()) {
2070       return notFound("Cannot find an event with id '%s'.", id);
2071     }
2072     MediaPackage mp = getIndexService().getEventMediapackage(optEvent.get());
2073     return okJson(getEventMediaPackageElements(mp.getAttachments()));
2074   }
2075 
2076   @GET
2077   @Path("{eventId}/asset/attachment/{id}.json")
2078   @Produces(MediaType.APPLICATION_JSON)
2079   @RestQuery(
2080       name = "getAttachment",
2081       description = "Returns the details of an attachment from the given event and attachment id as JSON",
2082       returnDescription = "The details of an attachment from the given event and attachment id as JSON",
2083       pathParameters = {
2084           @RestParameter(name = "eventId", description = "The event id", isRequired = true,
2085               type = RestParameter.Type.STRING),
2086           @RestParameter(name = "id", description = "The attachment id", isRequired = true,
2087               type = RestParameter.Type.STRING)
2088       },
2089       responses = {
2090           @RestResponse(description = "Returns the details of an attachment from the given event and attachment id as "
2091               + "JSON", responseCode = HttpServletResponse.SC_OK),
2092           @RestResponse(description = "No event or attachment with this identifier was found.",
2093               responseCode = HttpServletResponse.SC_NOT_FOUND)
2094       })
2095   public Response getAttachment(@PathParam("eventId") String eventId, @PathParam("id") String id)
2096           throws NotFoundException, SearchIndexException, IndexServiceException {
2097     MediaPackage mp = getMediaPackageByEventId(eventId);
2098 
2099     Attachment attachment = mp.getAttachment(id);
2100     if (attachment == null) {
2101       return notFound("Cannot find an attachment with id '%s'.", id);
2102     }
2103     return okJson(attachmentToJSON(attachment));
2104   }
2105 
2106   @GET
2107   @Path("{eventId}/asset/catalog/catalogs.json")
2108   @Produces(MediaType.APPLICATION_JSON)
2109   @RestQuery(
2110       name = "getCatalogList",
2111       description = "Returns a list of catalogs from the given event as JSON",
2112       returnDescription = "The list of catalogs from the given event as JSON",
2113       pathParameters = {
2114           @RestParameter(name = "eventId", description = "The event id", isRequired = true,
2115               type = RestParameter.Type.STRING)
2116       },
2117       responses = {
2118           @RestResponse(description = "Returns a list of catalogs from the given event as JSON",
2119               responseCode = HttpServletResponse.SC_OK),
2120           @RestResponse(description = "No event with this identifier was found.",
2121               responseCode = HttpServletResponse.SC_NOT_FOUND)
2122       })
2123   public Response getCatalogList(@PathParam("eventId") String id) throws Exception {
2124     Optional<Event> optEvent = getIndexService().getEvent(id, getIndex());
2125     if (optEvent.isEmpty()) {
2126       return notFound("Cannot find an event with id '%s'.", id);
2127     }
2128     MediaPackage mp = getIndexService().getEventMediapackage(optEvent.get());
2129     return okJson(getEventMediaPackageElements(mp.getCatalogs()));
2130   }
2131 
2132   @GET
2133   @Path("{eventId}/asset/catalog/{id}.json")
2134   @Produces(MediaType.APPLICATION_JSON)
2135   @RestQuery(
2136       name = "getCatalog",
2137       description = "Returns the details of a catalog from the given event and catalog id as JSON",
2138       returnDescription = "The details of a catalog from the given event and catalog id as JSON",
2139       pathParameters = {
2140           @RestParameter(name = "eventId", description = "The event id", isRequired = true,
2141               type = RestParameter.Type.STRING),
2142           @RestParameter(name = "id", description = "The catalog id", isRequired = true,
2143               type = RestParameter.Type.STRING)
2144       },
2145       responses = {
2146           @RestResponse(description = "Returns the details of a catalog from the given event and catalog id as JSON",
2147               responseCode = HttpServletResponse.SC_OK),
2148           @RestResponse(description = "No event or catalog with this identifier was found.",
2149               responseCode = HttpServletResponse.SC_NOT_FOUND)
2150       })
2151   public Response getCatalog(@PathParam("eventId") String eventId, @PathParam("id") String id)
2152           throws NotFoundException, SearchIndexException, IndexServiceException {
2153     MediaPackage mp = getMediaPackageByEventId(eventId);
2154 
2155     Catalog catalog = mp.getCatalog(id);
2156     if (catalog == null) {
2157       return notFound("Cannot find a catalog with id '%s'.", id);
2158     }
2159     return okJson(catalogToJSON(catalog));
2160   }
2161 
2162   @GET
2163   @Path("{eventId}/asset/media/media.json")
2164   @Produces(MediaType.APPLICATION_JSON)
2165   @RestQuery(
2166       name = "getMediaList",
2167       description = "Returns a list of media from the given event as JSON",
2168       returnDescription = "The list of media from the given event as JSON",
2169       pathParameters = {
2170           @RestParameter(name = "eventId", description = "The event id", isRequired = true,
2171               type = RestParameter.Type.STRING)
2172       },
2173       responses = {
2174           @RestResponse(description = "Returns a list of media from the given event as JSON",
2175               responseCode = HttpServletResponse.SC_OK),
2176           @RestResponse(description = "No event with this identifier was found.",
2177               responseCode = HttpServletResponse.SC_NOT_FOUND)
2178       })
2179   public Response getMediaList(@PathParam("eventId") String id) throws Exception {
2180     Optional<Event> optEvent = getIndexService().getEvent(id, getIndex());
2181     if (optEvent.isEmpty()) {
2182       return notFound("Cannot find an event with id '%s'.", id);
2183     }
2184     MediaPackage mp = getIndexService().getEventMediapackage(optEvent.get());
2185     return okJson(getEventMediaPackageElements(mp.getTracks()));
2186   }
2187 
2188   @GET
2189   @Path("{eventId}/asset/media/{id}.json")
2190   @Produces(MediaType.APPLICATION_JSON)
2191   @RestQuery(
2192       name = "getMedia",
2193       description = "Returns the details of a media from the given event and media id as JSON",
2194       returnDescription = "The details of a media from the given event and media id as JSON",
2195       pathParameters = {
2196           @RestParameter(name = "eventId", description = "The event id", isRequired = true,
2197               type = RestParameter.Type.STRING),
2198           @RestParameter(name = "id", description = "The media id", isRequired = true, type = RestParameter.Type.STRING)
2199       },
2200 
2201       responses = {
2202           @RestResponse(description = "Returns the media of a catalog from the given event and media id as JSON",
2203               responseCode = HttpServletResponse.SC_OK),
2204           @RestResponse(description = "No event or media with this identifier was found.",
2205               responseCode = HttpServletResponse.SC_NOT_FOUND)
2206       })
2207   public Response getMedia(@PathParam("eventId") String eventId, @PathParam("id") String id)
2208           throws NotFoundException, SearchIndexException, IndexServiceException {
2209     MediaPackage mp = getMediaPackageByEventId(eventId);
2210 
2211     Track track = mp.getTrack(id);
2212     if (track == null) {
2213       return notFound("Cannot find media with id '%s'.", id);
2214     }
2215     return okJson(trackToJSON(track));
2216   }
2217 
2218   @GET
2219   @Path("{eventId}/asset/publication/publications.json")
2220   @Produces(MediaType.APPLICATION_JSON)
2221   @RestQuery(
2222       name = "getPublicationList",
2223       description = "Returns a list of publications from the given event as JSON",
2224       returnDescription = "The list of publications from the given event as JSON",
2225       pathParameters = {
2226           @RestParameter(name = "eventId", description = "The event id", isRequired = true,
2227               type = RestParameter.Type.STRING)
2228       },
2229       responses = {
2230           @RestResponse(description = "Returns a list of publications from the given event as JSON",
2231               responseCode = HttpServletResponse.SC_OK),
2232           @RestResponse(description = "No event with this identifier was found.",
2233               responseCode = HttpServletResponse.SC_NOT_FOUND)
2234       })
2235   public Response getPublicationList(@PathParam("eventId") String id) throws Exception {
2236     Optional<Event> optEvent = getIndexService().getEvent(id, getIndex());
2237     if (optEvent.isEmpty()) {
2238       return notFound("Cannot find an event with id '%s'.", id);
2239     }
2240     MediaPackage mp = getIndexService().getEventMediapackage(optEvent.get());
2241     return okJson(getEventPublications(mp.getPublications()));
2242   }
2243 
2244   @GET
2245   @Path("{eventId}/asset/publication/{id}.json")
2246   @Produces(MediaType.APPLICATION_JSON)
2247   @RestQuery(
2248       name = "getPublication",
2249       description = "Returns the details of a publication from the given event and publication id as JSON",
2250       returnDescription = "The details of a publication from the given event and publication id as JSON",
2251       pathParameters = {
2252           @RestParameter(name = "eventId", description = "The event id", isRequired = true,
2253               type = RestParameter.Type.STRING),
2254           @RestParameter(name = "id", description = "The publication id", isRequired = true,
2255               type = RestParameter.Type.STRING)
2256       },
2257       responses = {
2258           @RestResponse(description = "Returns the publication of a catalog from the given event and publication id as "
2259               + "JSON", responseCode = HttpServletResponse.SC_OK),
2260           @RestResponse(description = "No event or publication with this identifier was found.",
2261               responseCode = HttpServletResponse.SC_NOT_FOUND)
2262       })
2263   public Response getPublication(@PathParam("eventId") String eventId, @PathParam("id") String id)
2264           throws NotFoundException, SearchIndexException, IndexServiceException {
2265     MediaPackage mp = getMediaPackageByEventId(eventId);
2266 
2267     Publication publication = null;
2268     for (Publication p : mp.getPublications()) {
2269       if (id.equals(p.getIdentifier())) {
2270         publication = p;
2271         break;
2272       }
2273     }
2274 
2275     if (publication == null) {
2276       return notFound("Cannot find publication with id '%s'.", id);
2277     }
2278     return okJson(publicationToJSON(publication));
2279   }
2280 
2281   @GET
2282   @Path("{eventId}/tobira/pages")
2283   @RestQuery(
2284       name = "getEventHostPages",
2285       description = "Returns the pages of a connected Tobira instance that contain the given event",
2286       returnDescription = "The Tobira pages that contain the given event",
2287       pathParameters = {
2288           @RestParameter(
2289               name = "eventId",
2290               isRequired = true,
2291               description = "The event identifier",
2292               type = STRING
2293           ),
2294       },
2295       responses = {
2296           @RestResponse(
2297               responseCode = SC_OK,
2298               description = "The Tobira pages containing the given event"
2299           ),
2300           @RestResponse(
2301               responseCode = SC_NOT_FOUND,
2302               description = "Tobira doesn't know about the given event"
2303           ),
2304           @RestResponse(
2305               responseCode = SC_SERVICE_UNAVAILABLE,
2306               description = "Tobira is not configured (correctly)"
2307           ),
2308       }
2309   )
2310   public Response getEventHostPages(@PathParam("eventId") String eventId) {
2311     var tobira = TobiraService.getTobira(getSecurityService().getOrganization().getId());
2312     if (!tobira.ready()) {
2313       return Response.status(Status.SERVICE_UNAVAILABLE)
2314               .entity("Tobira is not configured (correctly)")
2315               .build();
2316     }
2317 
2318     try {
2319       var eventData = tobira.getEventHostPages(eventId);
2320       if (eventData == null) {
2321         throw new WebApplicationException(NOT_FOUND);
2322       }
2323       eventData.put("baseURL", tobira.getOrigin());
2324       return Response.ok(eventData.toJSONString()).build();
2325     } catch (TobiraException e) {
2326       throw new WebApplicationException(e, Status.INTERNAL_SERVER_ERROR);
2327     }
2328   }
2329 
2330   @GET
2331   @Path("{eventId}/workflows.json")
2332   @Produces(MediaType.APPLICATION_JSON)
2333   @RestQuery(
2334       name = "geteventworkflows",
2335       description = "Returns all the data related to the workflows tab in the event details modal as JSON",
2336       returnDescription = "All the data related to the event workflows tab as JSON",
2337       pathParameters = {
2338           @RestParameter(name = "eventId", description = "The event id", isRequired = true,
2339               type = RestParameter.Type.STRING)
2340       },
2341       responses = {
2342           @RestResponse(description = "Returns all the data related to the event workflows tab as JSON",
2343               responseCode = HttpServletResponse.SC_OK),
2344           @RestResponse(description = "No event with this identifier was found.",
2345               responseCode = HttpServletResponse.SC_NOT_FOUND)
2346       })
2347   public Response getEventWorkflows(@PathParam("eventId") String id)
2348           throws UnauthorizedException, SearchIndexException, JobEndpointException {
2349     Optional<Event> optEvent = getIndexService().getEvent(id, getIndex());
2350     if (optEvent.isEmpty()) {
2351       return notFound("Cannot find an event with id '%s'.", id);
2352     }
2353 
2354     try {
2355       if (optEvent.get().getEventStatus().equals("EVENTS.EVENTS.STATUS.SCHEDULED")) {
2356         Map<String, String> workflowConfig = getSchedulerService().getWorkflowConfig(id);
2357         JsonObject configJson = new JsonObject();
2358         for (Map.Entry<String, String> entry : workflowConfig.entrySet()) {
2359           configJson.addProperty(entry.getKey(), safeString(entry.getValue()));
2360         }
2361 
2362         Map<String, String> agentConfiguration = getSchedulerService().getCaptureAgentConfiguration(id);
2363         JsonObject responseJson = new JsonObject();
2364         responseJson.addProperty("workflowId", agentConfiguration.getOrDefault(
2365             CaptureParameters.INGEST_WORKFLOW_DEFINITION, ""));
2366         responseJson.add("configuration", configJson);
2367 
2368         return okJson(responseJson);
2369       } else {
2370         List<WorkflowInstance> workflowInstances = getWorkflowService().getWorkflowInstancesByMediaPackage(id);
2371         JsonArray jsonArray = new JsonArray();
2372 
2373         for (WorkflowInstance instance : workflowInstances) {
2374           JsonObject instanceJson = new JsonObject();
2375           instanceJson.addProperty("id", instance.getId());
2376           instanceJson.addProperty("title", safeString(instance.getTitle()));
2377           instanceJson.addProperty("status", WORKFLOW_STATUS_TRANSLATION_PREFIX + instance.getState().toString());
2378 
2379           Date created = instance.getDateCreated();
2380           instanceJson.addProperty("submitted", created != null ? DateTimeSupport.toUTC(created.getTime()) : "");
2381 
2382           String submitter = instance.getCreatorName();
2383           instanceJson.addProperty("submitter", safeString(submitter));
2384 
2385           User user = submitter == null ? null : getUserDirectoryService().loadUser(submitter);
2386           String submitterName = null;
2387           String submitterEmail = null;
2388           if (user != null) {
2389             submitterName = user.getName();
2390             submitterEmail = user.getEmail();
2391           }
2392           instanceJson.addProperty("submitterName", safeString(submitterName));
2393           instanceJson.addProperty("submitterEmail", safeString(submitterEmail));
2394 
2395           jsonArray.add(instanceJson);
2396         }
2397 
2398         JsonObject result = new JsonObject();
2399         result.add("results", jsonArray);
2400         result.addProperty("count", workflowInstances.size());
2401 
2402         return okJson(result);
2403       }
2404     } catch (NotFoundException e) {
2405       return notFound("Cannot find workflows for event %s", id);
2406     } catch (SchedulerException e) {
2407       logger.error("Unable to get workflow data for event with id {}", id);
2408       throw new WebApplicationException(e, SC_INTERNAL_SERVER_ERROR);
2409     } catch (WorkflowDatabaseException e) {
2410       throw new JobEndpointException(String.format("Not able to get the list of job from the database: %s", e),
2411               e.getCause());
2412     }
2413   }
2414 
2415   @PUT
2416   @Path("{eventId}/workflows")
2417   @RestQuery(
2418       name = "updateEventWorkflow",
2419       description = "Update the workflow configuration for the scheduled event with the given id",
2420       pathParameters = {
2421           @RestParameter(name = "eventId", description = "The event id", isRequired = true,
2422               type = RestParameter.Type.STRING)
2423       },
2424       restParameters = {
2425           @RestParameter(name = "configuration", isRequired = true, description = "The workflow configuration as JSON",
2426               type = RestParameter.Type.TEXT)
2427       },
2428       responses = {
2429           @RestResponse(description = "Request executed succesfully", responseCode = HttpServletResponse.SC_NO_CONTENT),
2430           @RestResponse(description = "No event with this identifier was found.",
2431               responseCode = HttpServletResponse.SC_NOT_FOUND)
2432       },
2433       returnDescription = "The method does not retrun any content.")
2434   public Response updateEventWorkflow(@PathParam("eventId") String id, @FormParam("configuration") String configuration)
2435           throws SearchIndexException, UnauthorizedException {
2436     Optional<Event> optEvent = getIndexService().getEvent(id, getIndex());
2437     if (optEvent.isEmpty()) {
2438       return notFound("Cannot find an event with id '%s'.", id);
2439     }
2440 
2441     if (optEvent.get().isScheduledEvent() && !optEvent.get().hasRecordingStarted()) {
2442       try {
2443 
2444         JSONObject configJSON;
2445         try {
2446           configJSON = (JSONObject) new JSONParser().parse(configuration);
2447         } catch (Exception e) {
2448           logger.warn("Unable to parse the workflow configuration {}", configuration);
2449           return badRequest();
2450         }
2451 
2452         Optional<Map<String, String>> caMetadataOpt = Optional.empty();
2453         Optional<Map<String, String>> workflowConfigOpt = Optional.empty();
2454 
2455         String workflowId = (String) configJSON.get("id");
2456         Map<String, String> caMetadata = new HashMap<>(getSchedulerService().getCaptureAgentConfiguration(id));
2457         if (!workflowId.equals(caMetadata.get(CaptureParameters.INGEST_WORKFLOW_DEFINITION))) {
2458           caMetadata.put(CaptureParameters.INGEST_WORKFLOW_DEFINITION, workflowId);
2459           caMetadataOpt = Optional.of(caMetadata);
2460         }
2461 
2462         Map<String, String> workflowConfig = new HashMap<>((JSONObject) configJSON.get("configuration"));
2463         Map<String, String> oldWorkflowConfig = new HashMap<>(getSchedulerService().getWorkflowConfig(id));
2464         if (!oldWorkflowConfig.equals(workflowConfig)) {
2465           workflowConfigOpt = Optional.of(workflowConfig);
2466         }
2467 
2468         if (caMetadataOpt.isEmpty() && workflowConfigOpt.isEmpty()) {
2469           return Response.noContent().build();
2470         }
2471 
2472         checkAgentAccessForAgent(optEvent.get().getAgentId());
2473 
2474         getSchedulerService().updateEvent(id, Optional.empty(), Optional.empty(), Optional.empty(),
2475             Optional.empty(), Optional.empty(), workflowConfigOpt, caMetadataOpt);
2476         return Response.noContent().build();
2477       } catch (NotFoundException e) {
2478         return notFound("Cannot find event %s in scheduler service", id);
2479       } catch (SchedulerException e) {
2480         logger.error("Unable to update scheduling workflow data for event with id {}", id);
2481         throw new WebApplicationException(e, SC_INTERNAL_SERVER_ERROR);
2482       }
2483     } else {
2484       return badRequest(String.format("Event %s workflow can not be updated as the recording already started.", id));
2485     }
2486   }
2487 
2488   @GET
2489   @Path("{eventId}/workflows/{workflowId}")
2490   @Produces(MediaType.APPLICATION_JSON)
2491   @RestQuery(
2492       name = "geteventworkflow",
2493       description = "Returns all the data related to the single workflow tab in the event details modal as JSON",
2494       returnDescription = "All the data related to the event singe workflow tab as JSON",
2495       pathParameters = {
2496           @RestParameter(name = "eventId", description = "The event id", isRequired = true,
2497               type = RestParameter.Type.STRING),
2498           @RestParameter(name = "workflowId", description = "The workflow id", isRequired = true,
2499               type = RestParameter.Type.STRING)
2500       },
2501       responses = {
2502           @RestResponse(description = "Returns all the data related to the event single workflow tab as JSON",
2503               responseCode = HttpServletResponse.SC_OK),
2504           @RestResponse(description = "Unable to parse workflowId", responseCode = HttpServletResponse.SC_BAD_REQUEST),
2505           @RestResponse(description = "No event with this identifier was found.",
2506               responseCode = HttpServletResponse.SC_NOT_FOUND)
2507       })
2508   public Response getEventWorkflow(@PathParam("eventId") String eventId, @PathParam("workflowId") String workflowId)
2509           throws SearchIndexException {
2510     Optional<Event> optEvent = getIndexService().getEvent(eventId, getIndex());
2511     if (optEvent.isEmpty()) {
2512       return notFound("Cannot find an event with id '%s'.", eventId);
2513     }
2514 
2515     long workflowInstanceId;
2516     try {
2517       workflowId = StringUtils.remove(workflowId, ".json");
2518       workflowInstanceId = Long.parseLong(workflowId);
2519     } catch (Exception e) {
2520       logger.warn("Unable to parse workflow id {}", workflowId);
2521       return RestUtil.R.badRequest();
2522     }
2523 
2524     try {
2525       WorkflowInstance instance = getWorkflowService().getWorkflowById(workflowInstanceId);
2526       // Retrieve submission date with the workflow instance main job
2527       Date created = instance.getDateCreated();
2528       Date completed = instance.getDateCompleted();
2529       if (completed == null) {
2530         completed = new Date();
2531       }
2532 
2533       long executionTime = completed.getTime() - created.getTime();
2534 
2535       JsonObject configurationObj = new JsonObject();
2536       for (Entry<String, String> entry : instance.getConfigurations().entrySet()) {
2537         configurationObj.addProperty(entry.getKey(), safeString(entry.getValue()));
2538       }
2539 
2540       JsonObject json = new JsonObject();
2541       json.addProperty("status", WORKFLOW_STATUS_TRANSLATION_PREFIX + instance.getState());
2542       json.addProperty("description", safeString(instance.getDescription()));
2543       json.addProperty("executionTime", executionTime);
2544       json.addProperty("wiid", instance.getId());
2545       json.addProperty("title", safeString(instance.getTitle()));
2546       json.addProperty("wdid", safeString(instance.getTemplate()));
2547       if (!configurationObj.isEmpty()) {
2548         json.add("configuration", configurationObj);
2549       }
2550       json.addProperty("submittedAt", DateTimeSupport.toUTC(created.getTime()));
2551       json.addProperty("creator", safeString(instance.getCreatorName()));
2552 
2553       return okJson(json);
2554 
2555     } catch (NotFoundException e) {
2556       return notFound("Cannot find workflow  %s", workflowId);
2557     } catch (WorkflowDatabaseException e) {
2558       logger.error("Unable to get workflow {} of event {}", workflowId, eventId, e);
2559       return serverError();
2560     } catch (UnauthorizedException e) {
2561       return forbidden();
2562     }
2563   }
2564 
2565   @GET
2566   @Path("{eventId}/workflows/{workflowId}/operations.json")
2567   @Produces(MediaType.APPLICATION_JSON)
2568   @RestQuery(
2569       name = "geteventoperations",
2570       description = "Returns all the data related to the workflow/operations tab in the event details modal as JSON",
2571       returnDescription = "All the data related to the event workflow/opertations tab as JSON",
2572       pathParameters = {
2573           @RestParameter(name = "eventId", description = "The event id", isRequired = true,
2574               type = RestParameter.Type.STRING),
2575           @RestParameter(name = "workflowId", description = "The workflow id", isRequired = true,
2576               type = RestParameter.Type.STRING)
2577       },
2578       responses = {
2579           @RestResponse(description = "Returns all the data related to the event workflow/operations tab as JSON",
2580               responseCode = HttpServletResponse.SC_OK),
2581           @RestResponse(description = "Unable to parse workflowId", responseCode = HttpServletResponse.SC_BAD_REQUEST),
2582           @RestResponse(description = "No event with this identifier was found.",
2583               responseCode = HttpServletResponse.SC_NOT_FOUND)
2584       })
2585   public Response getEventOperations(@PathParam("eventId") String eventId, @PathParam("workflowId") String workflowId)
2586           throws SearchIndexException {
2587     Optional<Event> optEvent = getIndexService().getEvent(eventId, getIndex());
2588     if (optEvent.isEmpty()) {
2589       return notFound("Cannot find an event with id '%s'.", eventId);
2590     }
2591 
2592     long workflowInstanceId;
2593     try {
2594       workflowInstanceId = Long.parseLong(workflowId);
2595     } catch (Exception e) {
2596       logger.warn("Unable to parse workflow id {}", workflowId);
2597       return RestUtil.R.badRequest();
2598     }
2599 
2600     try {
2601       WorkflowInstance instance = getWorkflowService().getWorkflowById(workflowInstanceId);
2602       List<WorkflowOperationInstance> operations = instance.getOperations();
2603       JsonArray operationsJsonArray = new JsonArray();
2604 
2605       for (WorkflowOperationInstance wflOp : operations) {
2606         JsonObject operationJson = new JsonObject();
2607         operationJson.addProperty("status", WORKFLOW_STATUS_TRANSLATION_PREFIX + wflOp.getState());
2608         operationJson.addProperty("title", safeString(wflOp.getTemplate()));
2609         operationJson.addProperty("description", safeString(wflOp.getDescription()));
2610         operationJson.addProperty("id", wflOp.getId());
2611         if (!wflOp.getConfigurationKeys().isEmpty()) {
2612           operationJson.add("configuration", collectionToJsonArray(wflOp.getConfigurationKeys()));
2613         }
2614         operationsJsonArray.add(operationJson);
2615       }
2616 
2617       return okJson(operationsJsonArray);
2618     } catch (NotFoundException e) {
2619       return notFound("Cannot find workflow %s", workflowId);
2620     } catch (WorkflowDatabaseException e) {
2621       logger.error("Unable to get workflow operations of event {} and workflow {}", eventId, workflowId, e);
2622       return serverError();
2623     } catch (UnauthorizedException e) {
2624       return forbidden();
2625     }
2626   }
2627 
2628   @GET
2629   @Path("{eventId}/workflows/{workflowId}/operations/{operationPosition}")
2630   @Produces(MediaType.APPLICATION_JSON)
2631   @RestQuery(
2632       name = "geteventoperation",
2633       description = "Returns all the data related to the workflow/operation tab in the event details modal as JSON",
2634       returnDescription = "All the data related to the event workflow/opertation tab as JSON",
2635       pathParameters = {
2636           @RestParameter(name = "eventId", description = "The event id", isRequired = true,
2637               type = RestParameter.Type.STRING),
2638           @RestParameter(name = "workflowId", description = "The workflow id", isRequired = true,
2639               type = RestParameter.Type.STRING),
2640           @RestParameter(name = "operationPosition", description = "The operation position", isRequired = true,
2641               type = RestParameter.Type.INTEGER)
2642       },
2643       responses = {
2644           @RestResponse(description = "Returns all the data related to the event workflow/operation tab as JSON",
2645               responseCode = HttpServletResponse.SC_OK),
2646           @RestResponse(description = "Unable to parse workflowId or operationPosition",
2647               responseCode = HttpServletResponse.SC_BAD_REQUEST),
2648           @RestResponse(description = "No operation with these identifiers was found.",
2649               responseCode = HttpServletResponse.SC_NOT_FOUND)
2650       })
2651   public Response getEventOperation(@PathParam("eventId") String eventId, @PathParam("workflowId") String workflowId,
2652       @PathParam("operationPosition") Integer operationPosition) throws SearchIndexException {
2653     Optional<Event> optEvent = getIndexService().getEvent(eventId, getIndex());
2654     if (optEvent.isEmpty()) {
2655       return notFound("Cannot find an event with id '%s'.", eventId);
2656     }
2657 
2658     long workflowInstanceId;
2659     try {
2660       workflowInstanceId = Long.parseLong(workflowId);
2661     } catch (Exception e) {
2662       logger.warn("Unable to parse workflow id {}", workflowId);
2663       return RestUtil.R.badRequest();
2664     }
2665 
2666     WorkflowInstance instance;
2667     try {
2668       instance = getWorkflowService().getWorkflowById(workflowInstanceId);
2669     } catch (NotFoundException e) {
2670       return notFound("Cannot find workflow %s", workflowId);
2671     } catch (WorkflowDatabaseException e) {
2672       logger.error("Unable to get workflow operation of event {} and workflow {} at position {}", eventId, workflowId,
2673           operationPosition, e);
2674       return serverError();
2675     } catch (UnauthorizedException e) {
2676       return forbidden();
2677     }
2678 
2679     List<WorkflowOperationInstance> operations = instance.getOperations();
2680 
2681     if (operationPosition < operations.size()) {
2682       WorkflowOperationInstance wflOp = operations.get(operationPosition);
2683       JsonObject json = new JsonObject();
2684 
2685       json.addProperty("retry_strategy", wflOp.getRetryStrategy() != null ? wflOp.getRetryStrategy().toString() : "");
2686       json.addProperty("execution_host", safeString(wflOp.getExecutionHost()));
2687       json.addProperty("failed_attempts", wflOp.getFailedAttempts());
2688       json.addProperty("max_attempts", wflOp.getMaxAttempts());
2689       json.addProperty("exception_handler_workflow", safeString(wflOp.getExceptionHandlingWorkflow()));
2690       json.addProperty("fail_on_error", wflOp.isFailOnError());
2691       json.addProperty("description", safeString(wflOp.getDescription()));
2692       json.addProperty("state", WORKFLOW_STATUS_TRANSLATION_PREFIX + wflOp.getState());
2693       json.addProperty("job", wflOp.getId());
2694       json.addProperty("name", safeString(wflOp.getTemplate()));
2695       json.addProperty("time_in_queue", wflOp.getTimeInQueue() != null ? wflOp.getTimeInQueue() : 0);
2696       json.addProperty("started", wflOp.getDateStarted() != null ? toUTC(wflOp.getDateStarted().getTime()) : "");
2697       json.addProperty("completed", wflOp.getDateCompleted() != null ? toUTC(wflOp.getDateCompleted().getTime()) : "");
2698 
2699       return okJson(json);
2700     }
2701 
2702     return notFound("Cannot find workflow operation of workflow %s at position %s", workflowId, operationPosition);
2703   }
2704 
2705   @GET
2706   @Path("{eventId}/workflows/{workflowId}/errors.json")
2707   @Produces(MediaType.APPLICATION_JSON)
2708   @RestQuery(
2709       name = "geteventerrors",
2710       description = "Returns all the data related to the workflow/errors tab in the event details modal as JSON",
2711       returnDescription = "All the data related to the event workflow/errors tab as JSON",
2712       pathParameters = {
2713           @RestParameter(name = "eventId", description = "The event id", isRequired = true,
2714               type = RestParameter.Type.STRING),
2715           @RestParameter(name = "workflowId", description = "The workflow id", isRequired = true,
2716               type = RestParameter.Type.STRING)
2717       },
2718       responses = {
2719           @RestResponse(description = "Returns all the data related to the event workflow/errors tab as JSON",
2720               responseCode = HttpServletResponse.SC_OK),
2721           @RestResponse(description = "Unable to parse workflowId", responseCode = HttpServletResponse.SC_BAD_REQUEST),
2722           @RestResponse(description = "No event with this identifier was found.",
2723               responseCode = HttpServletResponse.SC_NOT_FOUND)
2724       })
2725   public Response getEventErrors(@PathParam("eventId") String eventId, @PathParam("workflowId") String workflowId,
2726           @Context HttpServletRequest req) throws JobEndpointException, SearchIndexException {
2727     // the call to #getEvent should make sure that the calling user has access rights to the workflow
2728     // FIXME since there is no dependency between the event and the workflow (the fetched event is
2729     // simply ignored) an attacker can get access by using an event he owns and a workflow ID of
2730     // someone else.
2731     Optional<Event> eventOpt = getIndexService().getEvent(eventId, getIndex());
2732     if (eventOpt.isPresent()) {
2733       final long workflowIdLong;
2734       try {
2735         workflowIdLong = Long.parseLong(workflowId);
2736       } catch (Exception e) {
2737         logger.warn("Unable to parse workflow id {}", workflowId);
2738         return RestUtil.R.badRequest();
2739       }
2740       try {
2741         return okJson(getJobService().getIncidentsAsJSON(workflowIdLong, req.getLocale(), true));
2742       } catch (NotFoundException e) {
2743         return notFound("Cannot find the incident for the workflow %s", workflowId);
2744       }
2745     }
2746     return notFound("Cannot find an event with id '%s'.", eventId);
2747   }
2748 
2749   @GET
2750   @Path("{eventId}/workflows/{workflowId}/errors/{errorId}.json")
2751   @Produces(MediaType.APPLICATION_JSON)
2752   @RestQuery(
2753       name = "geteventerror",
2754       description = "Returns all the data related to the workflow/error tab in the event details modal as JSON",
2755       returnDescription = "All the data related to the event workflow/error tab as JSON",
2756       pathParameters = {
2757           @RestParameter(name = "eventId", description = "The event id", isRequired = true,
2758               type = RestParameter.Type.STRING),
2759           @RestParameter(name = "workflowId", description = "The workflow id", isRequired = true,
2760               type = RestParameter.Type.STRING),
2761           @RestParameter(name = "errorId", description = "The error id", isRequired = true,
2762               type = RestParameter.Type.STRING)
2763       },
2764       responses = {
2765           @RestResponse(description = "Returns all the data related to the event workflow/error tab as JSON",
2766               responseCode = HttpServletResponse.SC_OK),
2767           @RestResponse(description = "Unable to parse workflowId", responseCode = HttpServletResponse.SC_BAD_REQUEST),
2768           @RestResponse(description = "No event with this identifier was found.",
2769               responseCode = HttpServletResponse.SC_NOT_FOUND)
2770       })
2771 
2772   public Response getEventError(@PathParam("eventId") String eventId, @PathParam("workflowId") String workflowId,
2773           @PathParam("errorId") String errorId, @Context HttpServletRequest req)
2774                   throws JobEndpointException, SearchIndexException {
2775     // the call to #getEvent should make sure that the calling user has access rights to the workflow
2776     // FIXME since there is no dependency between the event and the workflow (the fetched event is
2777     // simply ignored) an attacker can get access by using an event he owns and a workflow ID of
2778     // someone else.
2779     Optional<Event> eventOpt = getIndexService().getEvent(eventId, getIndex());
2780     if (eventOpt.isPresent()) {
2781       final long errorIdLong;
2782       try {
2783         errorIdLong = Long.parseLong(errorId);
2784       } catch (Exception e) {
2785         logger.warn("Unable to parse error id {}", errorId);
2786         return RestUtil.R.badRequest();
2787       }
2788       try {
2789         return okJson(getJobService().getIncidentAsJSON(errorIdLong, req.getLocale()));
2790       } catch (NotFoundException e) {
2791         return notFound("Cannot find the incident %s", errorId);
2792       }
2793     }
2794     return notFound("Cannot find an event with id '%s'.", eventId);
2795   }
2796 
2797   @GET
2798   @Path("{eventId}/access.json")
2799   @SuppressWarnings("unchecked")
2800   @Produces(MediaType.APPLICATION_JSON)
2801   @RestQuery(
2802       name = "getEventAccessInformation",
2803       description = "Get the access information of an event",
2804       returnDescription = "The access information",
2805       pathParameters = {
2806           @RestParameter(name = "eventId", isRequired = true, description = "The event identifier",
2807               type = RestParameter.Type.STRING)
2808       },
2809       responses = {
2810           @RestResponse(responseCode = SC_BAD_REQUEST, description = "The required form params were missing in the "
2811               + "request."),
2812           @RestResponse(responseCode = SC_NOT_FOUND, description = "If the event has not been found."),
2813           @RestResponse(responseCode = SC_OK, description = "The access information ")
2814       })
2815   public Response getEventAccessInformation(@PathParam("eventId") String eventId) throws Exception {
2816     Optional<Event> optEvent = getIndexService().getEvent(eventId, getIndex());
2817     if (optEvent.isEmpty()) {
2818       return notFound("Cannot find an event with id '%s'.", eventId);
2819     }
2820 
2821     // Add all available ACLs to the response
2822     JSONArray systemAclsJson = new JSONArray();
2823     List<ManagedAcl> acls = getAclService().getAcls();
2824     for (ManagedAcl acl : acls) {
2825       systemAclsJson.add(AccessInformationUtil.serializeManagedAcl(acl));
2826     }
2827 
2828     AccessControlList activeAcl = new AccessControlList();
2829     try {
2830       if (optEvent.get().getAccessPolicy() != null) {
2831         activeAcl = AccessControlParser.parseAcl(optEvent.get().getAccessPolicy());
2832       }
2833     } catch (Exception e) {
2834       logger.error("Unable to parse access policy", e);
2835     }
2836     Optional<ManagedAcl> currentAcl = AccessInformationUtil.matchAclsLenient(acls, activeAcl,
2837             getAdminUIConfiguration().getMatchManagedAclRolePrefixes());
2838 
2839     JSONObject episodeAccessJson = new JSONObject();
2840     episodeAccessJson.put("current_acl", currentAcl.isPresent() ? currentAcl.get().getId() : 0L);
2841     episodeAccessJson.put("acl", transformAccessControList(activeAcl, getUserDirectoryService()));
2842     episodeAccessJson.put("privileges", AccessInformationUtil.serializePrivilegesByRole(activeAcl));
2843     if (StringUtils.isNotBlank(optEvent.get().getWorkflowState())
2844             && WorkflowUtil.isActive(WorkflowInstance.WorkflowState.valueOf(optEvent.get().getWorkflowState()))) {
2845       episodeAccessJson.put("locked", true);
2846     }
2847 
2848     JSONObject jsonReturnObj = new JSONObject();
2849     jsonReturnObj.put("episode_access", episodeAccessJson);
2850     jsonReturnObj.put("system_acls", systemAclsJson);
2851 
2852     return Response.ok(jsonReturnObj.toString()).build();
2853   }
2854 
2855   // MH-12085 Add manually uploaded assets, multipart file upload has to be a POST
2856   @POST
2857   @Path("{eventId}/assets")
2858   @Consumes(MediaType.MULTIPART_FORM_DATA)
2859   @RestQuery(
2860       name = "updateAssets",
2861       description = "Update or create an asset for the eventId by the given metadata as JSON and files in the body",
2862       pathParameters = {
2863           @RestParameter(name = "eventId", description = "The event id", isRequired = true,
2864               type = RestParameter.Type.STRING)
2865       },
2866       restParameters = {
2867           @RestParameter(name = "metadata", isRequired = true, type = RestParameter.Type.TEXT,
2868               description = "The list of asset metadata")
2869       },
2870       responses = {
2871           @RestResponse(description = "The asset has been added.", responseCode = HttpServletResponse.SC_OK),
2872           @RestResponse(description = "Could not add asset, problem with the metadata or files.",
2873               responseCode = HttpServletResponse.SC_BAD_REQUEST),
2874           @RestResponse(description = "No event with this identifier was found.",
2875               responseCode = HttpServletResponse.SC_NOT_FOUND)
2876       },
2877       returnDescription = "The workflow identifier")
2878   public Response updateAssets(@PathParam("eventId") final String eventId,
2879           @Context HttpServletRequest request)  throws Exception {
2880     try {
2881       MediaPackage mp = getMediaPackageByEventId(eventId);
2882       String result = getIndexService().updateEventAssets(mp, request);
2883       return Response.status(Status.CREATED).entity(result).build();
2884     }  catch (NotFoundException e) {
2885       return notFound("Cannot find an event with id '%s'.", eventId);
2886     } catch (IllegalArgumentException | UnsupportedAssetException e) {
2887       return RestUtil.R.badRequest(e.getMessage());
2888     } catch (Exception e) {
2889       return RestUtil.R.serverError();
2890     }
2891   }
2892 
2893   @GET
2894   @Path("new/metadata")
2895   @RestQuery(
2896       name = "getNewMetadata",
2897       description = "Returns all the data related to the metadata tab in the new event modal as JSON",
2898       returnDescription = "All the data related to the event metadata tab as JSON",
2899       responses = {
2900           @RestResponse(responseCode = SC_OK, description = "Returns all the data related to the event metadata tab as "
2901               + "JSON")
2902       })
2903   public Response getNewMetadata() {
2904     MetadataList metadataList = new MetadataList();
2905 
2906     // Extended metadata
2907     List<EventCatalogUIAdapter> extendedCatalogUIAdapters = getIndexService().getExtendedEventCatalogUIAdapters();
2908     for (EventCatalogUIAdapter extendedCatalogUIAdapter : extendedCatalogUIAdapters) {
2909       metadataList.add(extendedCatalogUIAdapter, extendedCatalogUIAdapter.getRawFields());
2910     }
2911 
2912     // Common metadata
2913     // We do this after extended metadata because we want to overwrite any extended metadata adapters with the same
2914     // flavor instead of the other way around.
2915     EventCatalogUIAdapter commonCatalogUiAdapter = getIndexService().getCommonEventCatalogUIAdapter();
2916     DublinCoreMetadataCollection commonMetadata = commonCatalogUiAdapter.getRawFields(getCollectionQueryDisable());
2917 
2918     if (commonMetadata.getOutputFields().containsKey(DublinCore.PROPERTY_CREATED.getLocalName())) {
2919       commonMetadata.removeField(commonMetadata.getOutputFields().get(DublinCore.PROPERTY_CREATED.getLocalName()));
2920     }
2921     if (commonMetadata.getOutputFields().containsKey("duration")) {
2922       commonMetadata.removeField(commonMetadata.getOutputFields().get("duration"));
2923     }
2924     if (commonMetadata.getOutputFields().containsKey(DublinCore.PROPERTY_IDENTIFIER.getLocalName())) {
2925       commonMetadata.removeField(commonMetadata.getOutputFields().get(DublinCore.PROPERTY_IDENTIFIER.getLocalName()));
2926     }
2927     if (commonMetadata.getOutputFields().containsKey(DublinCore.PROPERTY_SOURCE.getLocalName())) {
2928       commonMetadata.removeField(commonMetadata.getOutputFields().get(DublinCore.PROPERTY_SOURCE.getLocalName()));
2929     }
2930     if (commonMetadata.getOutputFields().containsKey("startDate")) {
2931       commonMetadata.removeField(commonMetadata.getOutputFields().get("startDate"));
2932     }
2933     if (commonMetadata.getOutputFields().containsKey("startTime")) {
2934       commonMetadata.removeField(commonMetadata.getOutputFields().get("startTime"));
2935     }
2936     if (commonMetadata.getOutputFields().containsKey("location")) {
2937       commonMetadata.removeField(commonMetadata.getOutputFields().get("location"));
2938     }
2939 
2940     // Set publisher to user
2941     if (commonMetadata.getOutputFields().containsKey(DublinCore.PROPERTY_PUBLISHER.getLocalName())) {
2942       MetadataField publisher = commonMetadata.getOutputFields().get(DublinCore.PROPERTY_PUBLISHER.getLocalName());
2943       Map<String, String> users = new HashMap<>();
2944       if (publisher.getCollection() != null) {
2945         users = publisher.getCollection();
2946       }
2947       String loggedInUser = getSecurityService().getUser().getName();
2948       if (!users.containsKey(loggedInUser)) {
2949         users.put(loggedInUser, loggedInUser);
2950       }
2951       publisher.setValue(loggedInUser);
2952     }
2953 
2954     metadataList.add(commonCatalogUiAdapter, commonMetadata);
2955 
2956     // remove series with empty titles from the collection of the isPartOf field as these can't be converted to json
2957     removeSeriesWithNullTitlesFromFieldCollection(metadataList);
2958 
2959     return okJson(MetadataJson.listToJson(metadataList, true));
2960   }
2961 
2962   @GET
2963   @Path("new/processing")
2964   @RestQuery(
2965       name = "getNewProcessing",
2966       description = "Returns all the data related to the processing tab in the new event modal as JSON",
2967       returnDescription = "All the data related to the event processing tab as JSON",
2968       restParameters = {
2969           @RestParameter(name = "tags", isRequired = false, description = "A comma separated list of tags to filter "
2970               + "the workflow definitions", type = RestParameter.Type.STRING)
2971       },
2972       responses = {
2973           @RestResponse(responseCode = SC_OK, description = "Returns all the data related to the event processing tab "
2974               + "as JSON")
2975       })
2976   public Response getNewProcessing(@QueryParam("tags") String tagsString) {
2977     List<String> tags = RestUtil.splitCommaSeparatedParam(Optional.ofNullable(tagsString));
2978 
2979     JsonArray workflowsArray = new JsonArray();
2980     try {
2981       List<WorkflowDefinition> workflowsDefinitions = getWorkflowService().listAvailableWorkflowDefinitions();
2982       for (WorkflowDefinition wflDef : workflowsDefinitions) {
2983         if (wflDef.containsTag(tags)) {
2984           JsonObject wfJson = new JsonObject();
2985           wfJson.addProperty("id", wflDef.getId());
2986           wfJson.add("tags", arrayToJsonArray(wflDef.getTags()));
2987           wfJson.addProperty("title", safeString(wflDef.getTitle()));
2988           wfJson.addProperty("description", safeString(wflDef.getDescription()));
2989           wfJson.addProperty("displayOrder", wflDef.getDisplayOrder());
2990           wfJson.addProperty("configuration_panel", safeString(wflDef.getConfigurationPanel()));
2991           wfJson.addProperty("configuration_panel_json", safeString(wflDef.getConfigurationPanelJson()));
2992 
2993           workflowsArray.add(wfJson);
2994         }
2995       }
2996     } catch (WorkflowDatabaseException e) {
2997       logger.error("Unable to get available workflow definitions", e);
2998       return RestUtil.R.serverError();
2999     }
3000 
3001     JsonObject data = new JsonObject();
3002     data.add("workflows", workflowsArray);
3003     data.addProperty("default_workflow_id", defaultWorkflowDefinionId);
3004 
3005     return okJson(data);
3006   }
3007 
3008   @POST
3009   @Path("new/conflicts")
3010   @RestQuery(
3011       name = "checkNewConflicts",
3012       description = "Checks if the current scheduler parameters are in a conflict with another event",
3013       returnDescription = "Returns NO CONTENT if no event are in conflict within specified period or list of "
3014           + "conflicting recordings in JSON",
3015       restParameters = {
3016           @RestParameter(name = "metadata", isRequired = true, description = "The metadata as JSON",
3017               type = RestParameter.Type.TEXT)
3018       },
3019       responses = {
3020           @RestResponse(responseCode = HttpServletResponse.SC_NO_CONTENT, description = "No conflicting events found"),
3021           @RestResponse(responseCode = HttpServletResponse.SC_CONFLICT, description = "There is a conflict"),
3022           @RestResponse(responseCode = HttpServletResponse.SC_BAD_REQUEST, description = "Missing or invalid "
3023               + "parameters")
3024       })
3025   public Response getNewConflicts(@FormParam("metadata") String metadata) throws NotFoundException {
3026     if (StringUtils.isBlank(metadata)) {
3027       logger.warn("Metadata is not specified");
3028       return Response.status(Status.BAD_REQUEST).build();
3029     }
3030 
3031     JSONParser parser = new JSONParser();
3032     JSONObject metadataJson;
3033     try {
3034       metadataJson = (JSONObject) parser.parse(metadata);
3035     } catch (Exception e) {
3036       logger.warn("Unable to parse metadata {}", metadata);
3037       return RestUtil.R.badRequest("Unable to parse metadata");
3038     }
3039 
3040     String device;
3041     String startDate;
3042     String endDate;
3043     try {
3044       device = (String) metadataJson.get("device");
3045       startDate = (String) metadataJson.get("start");
3046       endDate = (String) metadataJson.get("end");
3047     } catch (Exception e) {
3048       logger.warn("Unable to parse metadata {}", metadata);
3049       return RestUtil.R.badRequest("Unable to parse metadata");
3050     }
3051 
3052     if (StringUtils.isBlank(device) || StringUtils.isBlank(startDate) || StringUtils.isBlank(endDate)) {
3053       logger.warn("Either device, start date or end date were not specified");
3054       return Response.status(Status.BAD_REQUEST).build();
3055     }
3056 
3057     Date start;
3058     try {
3059       start = new Date(DateTimeSupport.fromUTC(startDate));
3060     } catch (Exception e) {
3061       logger.warn("Unable to parse start date {}", startDate);
3062       return RestUtil.R.badRequest("Unable to parse start date");
3063     }
3064 
3065     Date end;
3066     try {
3067       end = new Date(DateTimeSupport.fromUTC(endDate));
3068     } catch (Exception e) {
3069       logger.warn("Unable to parse end date {}", endDate);
3070       return RestUtil.R.badRequest("Unable to parse end date");
3071     }
3072 
3073     String rruleString = (String) metadataJson.get("rrule");
3074 
3075     RRule rrule = null;
3076     TimeZone timeZone = TimeZone.getDefault();
3077     String durationString = null;
3078     if (StringUtils.isNotEmpty(rruleString)) {
3079       try {
3080         rrule = new RRule(rruleString);
3081         rrule.validate();
3082       } catch (Exception e) {
3083         logger.warn("Unable to parse rrule {}: {}", rruleString, e.getMessage());
3084         return Response.status(Status.BAD_REQUEST).build();
3085       }
3086 
3087       durationString = (String) metadataJson.get("duration");
3088       if (StringUtils.isBlank(durationString)) {
3089         logger.warn("If checking recurrence, must include duration.");
3090         return Response.status(Status.BAD_REQUEST).build();
3091       }
3092 
3093       Agent agent = getCaptureAgentStateService().getAgent(device);
3094       String timezone = agent.getConfiguration().getProperty("capture.device.timezone");
3095       if (StringUtils.isBlank(timezone)) {
3096         timezone = TimeZone.getDefault().getID();
3097         logger.warn("No 'capture.device.timezone' set on agent {}. The default server timezone {} will be used.",
3098                 device, timezone);
3099       }
3100       timeZone = TimeZone.getTimeZone(timezone);
3101     }
3102 
3103     String eventId = (String) metadataJson.get("id");
3104 
3105     try {
3106       List<MediaPackage> events = null;
3107       if (StringUtils.isNotEmpty(rruleString)) {
3108         events = getSchedulerService().findConflictingEvents(device, rrule, start, end, Long.parseLong(durationString),
3109                 timeZone);
3110       } else {
3111         events = getSchedulerService().findConflictingEvents(device, start, end);
3112       }
3113       if (!events.isEmpty()) {
3114         final List<JsonObject> eventsJSON = convertToConflictObjects(eventId, events);
3115         if (!eventsJSON.isEmpty()) {
3116           JsonArray jsonArray = new JsonArray();
3117           for (JsonObject jsonObj : eventsJSON) {
3118             jsonArray.add(jsonObj);
3119           }
3120           return conflictJson(jsonArray);
3121         }
3122       }
3123       return Response.noContent().build();
3124     } catch (Exception e) {
3125       logger.error("Unable to find conflicting events for {}, {}, {}",
3126               device, startDate, endDate, e);
3127       return RestUtil.R.serverError();
3128     }
3129   }
3130 
3131   private List<JsonObject> convertToConflictObjects(final String eventId, final List<MediaPackage> events)
3132           throws SearchIndexException {
3133     final List<JsonObject> eventsJSON = new ArrayList<>();
3134     final Organization organization = getSecurityService().getOrganization();
3135     final User user = SecurityUtil.createSystemUser(systemUserName, organization);
3136 
3137     SecurityUtil.runAs(getSecurityService(), organization, user, () -> {
3138       try {
3139         for (final MediaPackage event : events) {
3140           final Optional<Event> eventOpt = getIndexService().getEvent(event.getIdentifier().toString(), getIndex());
3141           if (eventOpt.isPresent()) {
3142             final Event e = eventOpt.get();
3143             if (StringUtils.isNotEmpty(eventId) && eventId.equals(e.getIdentifier())) {
3144               continue;
3145             }
3146             eventsJSON.add(convertEventToConflictingObject(e.getTechnicalStartTime(), e.getTechnicalEndTime(),
3147                 e.getTitle()));
3148           } else {
3149             logger.warn("Index out of sync! Conflicting event catalog {} not found on event index!",
3150                 event.getIdentifier().toString());
3151           }
3152         }
3153       } catch (Exception e) {
3154         logger.error("Failed to get conflicting events", e);
3155       }
3156     });
3157 
3158     return eventsJSON;
3159   }
3160 
3161   private JsonObject convertEventToConflictingObject(final String start, final String end, final String title) {
3162     JsonObject json = new JsonObject();
3163     json.addProperty("start", start);
3164     json.addProperty("end", end);
3165     json.addProperty("title", title);
3166     return json;
3167   }
3168 
3169   @POST
3170   @Path("/new")
3171   @Consumes(MediaType.MULTIPART_FORM_DATA)
3172   @RestQuery(
3173       name = "createNewEvent",
3174       description = "Creates a new event by the given metadata as JSON and the files in the body",
3175       returnDescription = "The workflow identifier",
3176       restParameters = {
3177           @RestParameter(name = "metadata", isRequired = true, description = "The metadata as JSON",
3178               type = RestParameter.Type.TEXT)
3179       },
3180       responses = {
3181           @RestResponse(responseCode = HttpServletResponse.SC_CREATED, description = "Event sucessfully added"),
3182           @RestResponse(responseCode = SC_BAD_REQUEST, description = "If the metadata is not set or couldn't be parsed")
3183       })
3184   public Response createNewEvent(@Context HttpServletRequest request) {
3185     try {
3186       String result = getIndexService().createEvent(request);
3187       if (StringUtils.isEmpty(result)) {
3188         return RestUtil.R.badRequest("The date range provided did not include any events");
3189       }
3190       return Response.status(Status.CREATED).entity(result).build();
3191     } catch (IllegalArgumentException | UnsupportedAssetException e) {
3192       return RestUtil.R.badRequest(e.getMessage());
3193     } catch (Exception e) {
3194       return RestUtil.R.serverError();
3195     }
3196   }
3197 
3198   @GET
3199   @Path("events.json")
3200   @Produces(MediaType.APPLICATION_JSON)
3201   @RestQuery(
3202       name = "getevents",
3203       description = "Returns all the events as JSON",
3204       returnDescription = "All the events as JSON",
3205       restParameters = {
3206           @RestParameter(name = "filter", isRequired = false, description = "The filter used for the query. They "
3207               + "should be formated like that: 'filter1:value1,filter2:value2'", type = STRING),
3208           @RestParameter(name = "sort", description = "The order instructions used to sort the query result. Must be "
3209               + "in the form '<field name>:(ASC|DESC)'", isRequired = false, type = STRING),
3210           @RestParameter(name = "limit", description = "The maximum number of items to return per page.",
3211               isRequired = false, type = RestParameter.Type.INTEGER),
3212           @RestParameter(name = "offset", description = "The page number.", isRequired = false,
3213               type = RestParameter.Type.INTEGER),
3214           @RestParameter(name = "getComments", description = "If comments should be fetched", isRequired = false,
3215               type = RestParameter.Type.BOOLEAN)
3216       },
3217       responses = {
3218           @RestResponse(description = "Returns all events as JSON", responseCode = HttpServletResponse.SC_OK)
3219       })
3220   public Response getEvents(@QueryParam("id") String id, @QueryParam("commentReason") String reasonFilter,
3221           @QueryParam("commentResolution") String resolutionFilter, @QueryParam("filter") String filter,
3222           @QueryParam("sort") String sort, @QueryParam("offset") Integer offset, @QueryParam("limit") Integer limit,
3223           @QueryParam("getComments") Boolean getComments) {
3224 
3225     Optional<Integer> optLimit = Optional.ofNullable(limit);
3226     Optional<Integer> optOffset = Optional.ofNullable(offset);
3227     Optional<String> optSort = Optional.ofNullable(trimToNull(sort));
3228     Optional<Boolean> optGetComments = Optional.ofNullable(getComments);
3229     List<JsonObject> eventsList = new ArrayList<>();
3230     final Organization organization = getSecurityService().getOrganization();
3231     final User user = getSecurityService().getUser();
3232     if (organization == null || user == null) {
3233       return Response.status(SC_SERVICE_UNAVAILABLE).build();
3234     }
3235     EventSearchQuery query = new EventSearchQuery(organization.getId(), user);
3236 
3237     // If the limit is set to 0, this is not taken into account
3238     if (optLimit.isPresent() && limit == 0) {
3239       optLimit = Optional.empty();
3240     }
3241 
3242     Map<String, String> filters = RestUtils.parseFilter(filter);
3243     for (String name : filters.keySet()) {
3244       if (EventListQuery.FILTER_PRESENTERS_BIBLIOGRAPHIC_NAME.equals(name)) {
3245         query.withPresenter(filters.get(name));
3246       }
3247       if (EventListQuery.FILTER_PRESENTERS_TECHNICAL_NAME.equals(name)) {
3248         query.withTechnicalPresenters(filters.get(name));
3249       }
3250       if (EventListQuery.FILTER_CONTRIBUTORS_NAME.equals(name)) {
3251         query.withContributor(filters.get(name));
3252       }
3253       if (EventListQuery.FILTER_LOCATION_NAME.equals(name)) {
3254         query.withLocation(filters.get(name));
3255       }
3256       if (EventListQuery.FILTER_LANGUAGE_NAME.equals(name)) {
3257         query.withLanguage(filters.get(name));
3258       }
3259       if (EventListQuery.FILTER_AGENT_NAME.equals(name)) {
3260         query.withAgentId(filters.get(name));
3261       }
3262       if (EventListQuery.FILTER_TEXT_NAME.equals(name)) {
3263         query.withText(filters.get(name));
3264       }
3265       if (EventListQuery.FILTER_SERIES_NAME.equals(name)) {
3266         query.withSeriesId(filters.get(name));
3267       }
3268       if (EventListQuery.FILTER_STATUS_NAME.equals(name)) {
3269         query.withEventStatus(filters.get(name));
3270       }
3271       if (EventListQuery.FILTER_PUBLISHER_NAME.equals(name)) {
3272         query.withPublisher(filters.get(name));
3273       }
3274       if (EventListQuery.FILTER_COMMENTS_NAME.equals(name)) {
3275         switch (Comments.valueOf(filters.get(name))) {
3276           case NONE:
3277             query.withComments(false);
3278             break;
3279           case OPEN:
3280             query.withOpenComments(true);
3281             break;
3282           case RESOLVED:
3283             query.withComments(true);
3284             query.withOpenComments(false);
3285             break;
3286           default:
3287             logger.info("Unknown comment {}", filters.get(name));
3288             return Response.status(SC_BAD_REQUEST).build();
3289         }
3290       }
3291       if (EventListQuery.FILTER_IS_PUBLISHED_NAME.equals(name)) {
3292         if (filters.containsKey(name)) {
3293           switch (IsPublished.valueOf(filters.get(name))) {
3294             case YES:
3295               query.withIsPublished(true);
3296               break;
3297             case NO:
3298               query.withIsPublished(false);
3299               break;
3300             default:
3301               break;
3302           }
3303         } else {
3304           logger.info("Query for invalid published status: {}", filters.get(name));
3305           return Response.status(SC_BAD_REQUEST).build();
3306         }
3307       }
3308       if (EventListQuery.FILTER_STARTDATE_NAME.equals(name)) {
3309         try {
3310           Tuple<Date, Date> fromAndToCreationRange = RestUtils.getFromAndToDateRange(filters.get(name));
3311           query.withStartFrom(fromAndToCreationRange.getA());
3312           query.withStartTo(fromAndToCreationRange.getB());
3313         } catch (IllegalArgumentException e) {
3314           return RestUtil.R.badRequest(e.getMessage());
3315         }
3316       }
3317     }
3318 
3319     if (optSort.isPresent()) {
3320       ArrayList<SortCriterion> sortCriteria = RestUtils.parseSortQueryParameter(optSort.get());
3321       for (SortCriterion criterion : sortCriteria) {
3322         switch (criterion.getFieldName()) {
3323           case EventIndexSchema.UID:
3324             query.sortByUID(criterion.getOrder());
3325             break;
3326           case EventIndexSchema.TITLE:
3327             query.sortByTitle(criterion.getOrder());
3328             break;
3329           case EventIndexSchema.PRESENTER:
3330             query.sortByPresenter(criterion.getOrder());
3331             break;
3332           case EventIndexSchema.TECHNICAL_START:
3333           case "technical_date":
3334             query.sortByTechnicalStartDate(criterion.getOrder());
3335             break;
3336           case EventIndexSchema.TECHNICAL_END:
3337             query.sortByTechnicalEndDate(criterion.getOrder());
3338             break;
3339           case EventIndexSchema.PUBLICATION:
3340             query.sortByPublicationIgnoringInternal(criterion.getOrder());
3341             break;
3342           case EventIndexSchema.START_DATE:
3343           case "date":
3344             query.sortByStartDate(criterion.getOrder());
3345             break;
3346           case EventIndexSchema.END_DATE:
3347             query.sortByEndDate(criterion.getOrder());
3348             break;
3349           case EventIndexSchema.SERIES_NAME:
3350             query.sortBySeriesName(criterion.getOrder());
3351             break;
3352           case EventIndexSchema.LOCATION:
3353             query.sortByLocation(criterion.getOrder());
3354             break;
3355           case EventIndexSchema.EVENT_STATUS:
3356             query.sortByEventStatus(criterion.getOrder());
3357             break;
3358           default:
3359             final String msg = String.format("Unknown sort criteria field %s", criterion.getFieldName());
3360             logger.debug(msg);
3361             return RestUtil.R.badRequest(msg);
3362         }
3363       }
3364     }
3365 
3366     // We search for write actions
3367     if (getOnlyEventsWithWriteAccessEventsTab()) {
3368       query.withoutActions();
3369       query.withAction(Permissions.Action.WRITE);
3370       query.withAction(Permissions.Action.READ);
3371     }
3372 
3373     if (optLimit.isPresent()) {
3374       query.withLimit(optLimit.get());
3375     }
3376     if (optOffset.isPresent()) {
3377       query.withOffset(offset);
3378     }
3379     // TODO: Add other filters to the query
3380 
3381     SearchResult<Event> results = null;
3382     try {
3383       results = getIndex().getByQuery(query);
3384     } catch (SearchIndexException e) {
3385       logger.error("The admin UI Search Index was not able to get the events list:", e);
3386       return RestUtil.R.serverError();
3387     }
3388 
3389     // If the results list if empty, we return already a response.
3390     if (results.getPageSize() == 0) {
3391       logger.debug("No events match the given filters.");
3392       return okJsonList(eventsList, Optional.ofNullable(offset).orElse(0), Optional.ofNullable(limit).orElse(0), 0);
3393     }
3394 
3395     Map<String, String> languages;
3396     try {
3397       languages = getListProvidersService().getList("LANGUAGES", new ResourceListQueryImpl(), false);
3398     } catch (ListProviderException e) {
3399       logger.info("Could not get languages from listprovider");
3400       throw new WebApplicationException(e);
3401     }
3402 
3403     for (SearchResultItem<Event> item : results.getItems()) {
3404       Event source = item.getSource();
3405       source.updatePreview(getAdminUIConfiguration().getPreviewSubtype());
3406       List<EventComment> comments = null;
3407       if (optGetComments.isPresent() && optGetComments.get()) {
3408         try {
3409           comments = getEventCommentService().getComments(source.getIdentifier());
3410         } catch (EventCommentException e) {
3411           logger.error("Unable to get comments from event {}", source.getIdentifier(), e);
3412           throw new WebApplicationException(e);
3413         }
3414       }
3415       eventsList.add(eventToJSON(source, Optional.ofNullable(comments), Optional.ofNullable(languages)));
3416     }
3417 
3418     return okJsonList(eventsList, Optional.ofNullable(offset).orElse(0), Optional.ofNullable(limit).orElse(0),
3419         results.getHitCount());
3420   }
3421 
3422   // --
3423 
3424   private MediaPackage getMediaPackageByEventId(String eventId)
3425           throws SearchIndexException, NotFoundException, IndexServiceException {
3426     Optional<Event> optEvent = getIndexService().getEvent(eventId, getIndex());
3427     if (optEvent.isEmpty()) {
3428       throw new NotFoundException(format("Cannot find an event with id '%s'.", eventId));
3429     }
3430     return getIndexService().getEventMediapackage(optEvent.get());
3431   }
3432 
3433   private URI getCommentUrl(String eventId, long commentId) {
3434     return UrlSupport.uri(serverUrl, eventId, "comment", Long.toString(commentId));
3435   }
3436 
3437 
3438   private JsonObject eventToJSON(Event event, Optional<List<EventComment>> comments,
3439       Optional<Map<String, String>> languages) {
3440     JsonObject json = new JsonObject();
3441 
3442     json.addProperty("id", event.getIdentifier());
3443     json.addProperty("title", event.getTitle() != null ? event.getTitle() : "");
3444     json.addProperty("source", event.getSource() != null ? event.getSource() : "");
3445     json.add("presenters", collectionToJsonArray(event.getPresenters()));
3446 
3447     if (StringUtils.isNotBlank(event.getSeriesId())) {
3448       JsonObject seriesObj = new JsonObject();
3449       seriesObj.addProperty("id", event.getSeriesId() != null ? event.getSeriesId() : "");
3450       seriesObj.addProperty("title", event.getSeriesName() != null ? event.getSeriesName() : "");
3451       json.add("series", seriesObj);
3452     }
3453 
3454     json.addProperty("location", safeString(event.getLocation()));
3455     json.addProperty("start_date", safeString(event.getRecordingStartDate()));
3456     json.addProperty("end_date", safeString(event.getRecordingEndDate()));
3457     json.addProperty("managedAcl", safeString(event.getManagedAcl()));
3458     json.addProperty("workflow_state", safeString(event.getWorkflowState()));
3459     json.addProperty("event_status", event.getEventStatus());
3460     json.addProperty("displayable_status", event.getDisplayableStatus(getWorkflowService().getWorkflowStateMappings()));
3461     json.addProperty("source", getIndexService().getEventSource(event).toString());
3462     json.addProperty("has_comments", event.hasComments());
3463     json.addProperty("has_open_comments", event.hasOpenComments());
3464     json.addProperty("needs_cutting", event.needsCutting());
3465     json.addProperty("has_preview", event.hasPreview());
3466     json.addProperty("agent_id", safeString(event.getAgentId()));
3467     json.addProperty("technical_start", safeString(event.getTechnicalStartTime()));
3468     json.addProperty("technical_end", safeString(event.getTechnicalEndTime()));
3469     json.addProperty("language", safeString(event.getLanguage()));
3470     json.addProperty("language_translation_key", languages.isPresent()
3471         ? safeString(languages.get().get(event.getLanguage())) : "");
3472     json.add("technical_presenters", collectionToJsonArray(event.getTechnicalPresenters()));
3473     json.add("publications", collectionToJsonArray(eventPublicationsToJson(event)));
3474     if (comments.isPresent()) {
3475       json.add("comments", collectionToJsonArray(eventCommentsToJson(comments.get())));
3476     }
3477 
3478     return json;
3479   }
3480 
3481 
3482 
3483   private void mergeJsonObjects(JsonObject target, JsonObject source) {
3484     for (String key : source.keySet()) {
3485       target.add(key, source.get(key));
3486     }
3487   }
3488 
3489   private JsonObject attachmentToJSON(Attachment attachment) {
3490     JsonObject json = new JsonObject();
3491     mergeJsonObjects(json, getEventMediaPackageElementFields(attachment));
3492     mergeJsonObjects(json, getCommonElementFields(attachment));
3493     return json;
3494   }
3495 
3496   private JsonObject catalogToJSON(Catalog catalog) {
3497     JsonObject json = new JsonObject();
3498     mergeJsonObjects(json, getEventMediaPackageElementFields(catalog));
3499     mergeJsonObjects(json, getCommonElementFields(catalog));
3500     return json;
3501   }
3502 
3503   private JsonObject trackToJSON(Track track) {
3504     JsonObject json = new JsonObject();
3505     mergeJsonObjects(json, getEventMediaPackageElementFields(track));
3506     mergeJsonObjects(json, getCommonElementFields(track));
3507     json.addProperty("duration", track.getDuration());
3508     json.addProperty("has_audio", track.hasAudio());
3509     json.addProperty("has_video", track.hasVideo());
3510     json.addProperty("has_subtitle", track.hasSubtitle());
3511     json.add("streams", streamsToJSON(track.getStreams()));
3512     return json;
3513   }
3514 
3515   private JsonObject streamsToJSON(org.opencastproject.mediapackage.Stream[] streams) {
3516     JsonArray audioArray = new JsonArray();
3517     JsonArray videoArray = new JsonArray();
3518     JsonArray subtitleArray = new JsonArray();
3519 
3520     for (org.opencastproject.mediapackage.Stream stream : streams) {
3521       if (stream instanceof AudioStreamImpl) {
3522         AudioStream audioStream = (AudioStream) stream;
3523         JsonObject audioJson = new JsonObject();
3524         audioJson.addProperty("id", safeString(audioStream.getIdentifier()));
3525         audioJson.addProperty("type", safeString(audioStream.getFormat()));
3526         audioJson.addProperty("channels", safeString(audioStream.getChannels()));
3527         audioJson.addProperty("bitrate", audioStream.getBitRate());
3528         audioJson.addProperty("bitdepth", safeString(audioStream.getBitDepth()));
3529         audioJson.addProperty("samplingrate", safeString(audioStream.getSamplingRate()));
3530         audioJson.addProperty("framecount", safeString(audioStream.getFrameCount()));
3531         audioJson.addProperty("peakleveldb", safeString(audioStream.getPkLevDb()));
3532         audioJson.addProperty("rmsleveldb", safeString(audioStream.getRmsLevDb()));
3533         audioJson.addProperty("rmspeakdb", safeString(audioStream.getRmsPkDb()));
3534         audioArray.add(audioJson);
3535 
3536       } else if (stream instanceof VideoStreamImpl) {
3537         VideoStream videoStream = (VideoStream) stream;
3538         JsonObject videoJson = new JsonObject();
3539         videoJson.addProperty("id", safeString(videoStream.getIdentifier()));
3540         videoJson.addProperty("type", safeString(videoStream.getFormat()));
3541         videoJson.addProperty("bitrate", videoStream.getBitRate());
3542         videoJson.addProperty("framerate", safeString(videoStream.getFrameRate()));
3543         videoJson.addProperty("resolution", safeString(videoStream.getFrameWidth() + "x"
3544             + videoStream.getFrameHeight()));
3545         videoJson.addProperty("framecount", safeString(videoStream.getFrameCount()));
3546         videoJson.addProperty("scantype", safeString(videoStream.getScanType()));
3547         videoJson.addProperty("scanorder", safeString(videoStream.getScanOrder()));
3548         videoArray.add(videoJson);
3549 
3550       } else if (stream instanceof SubtitleStreamImpl) {
3551         SubtitleStreamImpl subtitleStream = (SubtitleStreamImpl) stream;
3552         JsonObject subtitleJson = new JsonObject();
3553         subtitleJson.addProperty("id", safeString(subtitleStream.getIdentifier()));
3554         subtitleJson.addProperty("type", safeString(subtitleStream.getFormat()));
3555         subtitleArray.add(subtitleJson);
3556 
3557       } else {
3558         throw new IllegalArgumentException("Stream must be either audio, video, or subtitle");
3559       }
3560     }
3561 
3562     JsonObject result = new JsonObject();
3563     result.add("audio", audioArray);
3564     result.add("video", videoArray);
3565     result.add("subtitle", subtitleArray);
3566     return result;
3567   }
3568 
3569   private JsonObject publicationToJSON(Publication publication) {
3570     JsonObject json = new JsonObject();
3571 
3572     json.addProperty("id", safeString(publication.getIdentifier()));
3573     json.addProperty("channel", safeString(publication.getChannel()));
3574     json.addProperty("mimetype", safeString(publication.getMimeType()));
3575     json.add("tags", arrayToJsonArray(publication.getTags()));
3576     URI uri = signUrl(publication.getURI());
3577     json.addProperty("url", safeString(uri));
3578 
3579     JsonObject commonFields = getCommonElementFields(publication);
3580     for (String key : commonFields.keySet()) {
3581       json.add(key, commonFields.get(key));
3582     }
3583 
3584     return json;
3585   }
3586 
3587   private JsonObject getCommonElementFields(MediaPackageElement element) {
3588     JsonObject fields = new JsonObject();
3589 
3590     fields.addProperty("size", element.getSize());
3591     fields.addProperty("checksum", element.getChecksum() != null ? element.getChecksum().getValue() : "");
3592     fields.addProperty("reference", element.getReference() != null ? element.getReference().getIdentifier() : "");
3593 
3594     return fields;
3595   }
3596 
3597   /**
3598    * Render an array of {@link Publication}s into a list of JSON values.
3599    *
3600    * @param publications
3601    *          The elements to pull the data from to create the {@link JsonArray}
3602    * @return {@link JsonArray} that represent the {@link Publication}
3603    */
3604   private JsonArray getEventPublications(Publication[] publications) {
3605     JsonArray publicationJsonArray = new JsonArray();
3606 
3607     for (Publication publication : publications) {
3608       JsonObject pubJson = new JsonObject();
3609 
3610       pubJson.addProperty("id", safeString(publication.getIdentifier()));
3611       pubJson.addProperty("channel", safeString(publication.getChannel()));
3612       pubJson.addProperty("mimetype", safeString(publication.getMimeType()));
3613       pubJson.add("tags", arrayToJsonArray(publication.getTags()));
3614       pubJson.addProperty("url",  safeString(signUrl(publication.getURI())));
3615 
3616       publicationJsonArray.add(pubJson);
3617     }
3618 
3619     return publicationJsonArray;
3620   }
3621 
3622   private URI signUrl(URI url) {
3623     if (url == null) {
3624       return null;
3625     }
3626     if (getUrlSigningService().accepts(url.toString())) {
3627       try {
3628         String clientIP = null;
3629         if (signWithClientIP()) {
3630           clientIP = getSecurityService().getUserIP();
3631         }
3632         return URI.create(getUrlSigningService().sign(url.toString(), getUrlSigningExpireDuration(), null, clientIP));
3633       } catch (UrlSigningException e) {
3634         logger.warn("Unable to sign url '{}'", url, e);
3635       }
3636     }
3637     return url;
3638   }
3639 
3640   /**
3641    * Render an array of {@link MediaPackageElement}s into a list of JSON values.
3642    *
3643    * @param elements
3644    *          The elements to pull the data from to create the {@link JsonArray}
3645    * @return {@link JsonArray} that represent the {@link MediaPackageElement}
3646    */
3647   private JsonArray getEventMediaPackageElements(MediaPackageElement[] elements) {
3648     JsonArray elementJsonArray = new JsonArray();
3649     for (MediaPackageElement element : elements) {
3650       JsonObject elementJson = getEventMediaPackageElementFields(element);
3651       elementJsonArray.add(elementJson);
3652     }
3653     return elementJsonArray;
3654   }
3655 
3656   private JsonObject getEventMediaPackageElementFields(MediaPackageElement element) {
3657     JsonObject json = new JsonObject();
3658 
3659     json.addProperty("id", safeString(element.getIdentifier()));
3660     json.addProperty("type", safeString(element.getFlavor()));
3661     json.addProperty("mimetype", safeString(element.getMimeType()));
3662     json.add("tags", arrayToJsonArray(element.getTags()));
3663     json.addProperty("url", safeString(signUrl(element.getURI())));
3664 
3665     return json;
3666   }
3667 
3668   private final Function<Publication, JsonObject> publicationToJson = publication -> {
3669     String channelName = EventUtils.PUBLICATION_CHANNELS.get(publication.getChannel());
3670     if (channelName == null) {
3671       channelName = "EVENTS.EVENTS.DETAILS.PUBLICATIONS.CUSTOM";
3672     }
3673     String url = publication.getURI() == null ? "" : signUrl(publication.getURI()).toString();
3674 
3675     JsonObject json = new JsonObject();
3676     json.addProperty("id", publication.getChannel());
3677     json.addProperty("name", channelName);
3678     json.addProperty("url", url);
3679 
3680     return json;
3681   };
3682 
3683   private JsonObject technicalMetadataToJson(TechnicalMetadata technicalMetadata) {
3684     JsonObject json = new JsonObject();
3685 
3686     json.addProperty("agentId", technicalMetadata.getAgentId() != null ? technicalMetadata.getAgentId() : "");
3687     if (technicalMetadata.getCaptureAgentConfiguration() != null) {
3688       json.add("agentConfiguration", mapToJsonObject(technicalMetadata.getCaptureAgentConfiguration()));
3689     } else {
3690       json.add("agentConfiguration", JsonNull.INSTANCE);
3691     }
3692     if (technicalMetadata.getStartDate() != null) {
3693       String startUtc = DateTimeSupport.toUTC(technicalMetadata.getStartDate().getTime());
3694       json.addProperty("start", startUtc);
3695     } else {
3696       json.addProperty("start", "");
3697     }
3698     if (technicalMetadata.getEndDate() != null) {
3699       String endUtc = DateTimeSupport.toUTC(technicalMetadata.getEndDate().getTime());
3700       json.addProperty("end", endUtc);
3701     } else {
3702       json.addProperty("end", "");
3703     }
3704     String eventId = technicalMetadata.getEventId();
3705     json.addProperty("eventId", safeString(eventId));
3706     json.add("presenters", collectionToJsonArray(technicalMetadata.getPresenters()));
3707     Optional<Recording> optRecording = technicalMetadata.getRecording();
3708     if (optRecording.isPresent()) {
3709       json.add("recording", recordingToJson(optRecording.get()));
3710     }
3711 
3712     return json;
3713   }
3714 
3715   public static JsonObject recordingToJson(Recording recording) {
3716     JsonObject json = new JsonObject();
3717 
3718     json.addProperty("id", safeString(recording.getID()));
3719     json.addProperty("lastCheckInTime", recording.getLastCheckinTime() != null ? recording.getLastCheckinTime() : 0L);
3720     json.addProperty("lastCheckInTimeUTC", recording.getLastCheckinTime() != null
3721         ? toUTC(recording.getLastCheckinTime()) : "");
3722     json.addProperty("state", safeString(recording.getState()));
3723 
3724     return json;
3725   }
3726 
3727   @PUT
3728   @Path("{eventId}/workflows/{workflowId}/action/{action}")
3729   @RestQuery(
3730       name = "workflowAction",
3731       description = "Performs the given action for the given workflow.",
3732       returnDescription = "",
3733       pathParameters = {
3734           @RestParameter(name = "eventId", description = "The id of the media package", isRequired = true,
3735               type = RestParameter.Type.STRING),
3736           @RestParameter(name = "workflowId", description = "The id of the workflow", isRequired = true,
3737               type = RestParameter.Type.STRING),
3738           @RestParameter(name = "action", description = "The action to take: STOP, RETRY or NONE (abort processing)",
3739               isRequired = true, type = RestParameter.Type.STRING)
3740       },
3741       responses = {
3742           @RestResponse(responseCode = SC_OK, description = "Workflow resumed."),
3743           @RestResponse(responseCode = SC_NOT_FOUND, description = "Event or workflow instance not found."),
3744           @RestResponse(responseCode = SC_BAD_REQUEST, description = "Invalid action entered."),
3745           @RestResponse(responseCode = SC_UNAUTHORIZED, description = "You do not have permission to perform the "
3746               + "action. Maybe you need to authenticate."),
3747           @RestResponse(responseCode = SC_INTERNAL_SERVER_ERROR, description = "An exception occurred.")
3748       })
3749   public Response workflowAction(@PathParam("eventId") String id, @PathParam("workflowId") long wfId,
3750           @PathParam("action") String action) {
3751     if (StringUtils.isEmpty(id) || StringUtils.isEmpty(action)) {
3752       return badRequest();
3753     }
3754 
3755     try {
3756       final Optional<Event> optEvent = getIndexService().getEvent(id, getIndex());
3757       if (optEvent.isEmpty()) {
3758         return notFound("Cannot find an event with id '%s'.", id);
3759       }
3760 
3761       final WorkflowInstance wfInstance = getWorkflowService().getWorkflowById(wfId);
3762       if (!wfInstance.getMediaPackage().getIdentifier().toString().equals(id)) {
3763         return badRequest(String.format("Workflow %s is not associated to event %s", wfId, id));
3764       }
3765 
3766       if (RetryStrategy.NONE.toString().equalsIgnoreCase(action)
3767           || RetryStrategy.RETRY.toString().equalsIgnoreCase(action)) {
3768         getWorkflowService().resume(wfId, Collections.singletonMap("retryStrategy", action));
3769         return ok();
3770       }
3771 
3772       if (WORKFLOW_ACTION_STOP.equalsIgnoreCase(action)) {
3773         getWorkflowService().stop(wfId);
3774         return ok();
3775       }
3776 
3777       return badRequest("Action not supported: " + action);
3778     } catch (NotFoundException e) {
3779       return notFound("Workflow not found: '%d'.", wfId);
3780     } catch (IllegalStateException e) {
3781       return badRequest(String.format("Action %s not allowed for current workflow state. EventId: %s", action, id));
3782     } catch (UnauthorizedException e) {
3783       return forbidden();
3784     } catch (Exception e) {
3785       return serverError();
3786     }
3787   }
3788 
3789   @DELETE
3790   @Path("{eventId}/workflows/{workflowId}")
3791   @RestQuery(
3792       name = "deleteWorkflow",
3793       description = "Deletes a workflow",
3794       returnDescription = "The method doesn't return any content",
3795       pathParameters = {
3796           @RestParameter(name = "eventId", isRequired = true, description = "The event identifier",
3797               type = RestParameter.Type.STRING),
3798           @RestParameter(name = "workflowId", isRequired = true, description = "The workflow identifier",
3799               type = RestParameter.Type.INTEGER)
3800       },
3801       responses = {
3802           @RestResponse(responseCode = SC_BAD_REQUEST, description = "When trying to delete the latest workflow of the "
3803               + "event."),
3804           @RestResponse(responseCode = SC_NOT_FOUND, description = "If the event or the workflow has not been found."),
3805           @RestResponse(responseCode = SC_NO_CONTENT, description = "The method does not return any content")
3806       })
3807   public Response deleteWorkflow(@PathParam("eventId") String id, @PathParam("workflowId") long wfId)
3808           throws SearchIndexException {
3809     final Optional<Event> optEvent = getIndexService().getEvent(id, getIndex());
3810     try {
3811       if (optEvent.isEmpty()) {
3812         return notFound("Cannot find an event with id '%s'.", id);
3813       }
3814 
3815       final WorkflowInstance wfInstance = getWorkflowService().getWorkflowById(wfId);
3816       if (!wfInstance.getMediaPackage().getIdentifier().toString().equals(id)) {
3817         return badRequest(String.format("Workflow %s is not associated to event %s", wfId, id));
3818       }
3819 
3820       if (wfId == optEvent.get().getWorkflowId()) {
3821         return badRequest(String.format("Cannot delete current workflow %s from event %s."
3822           + " Only older workflows can be deleted.", wfId, id));
3823       }
3824 
3825       getWorkflowService().remove(wfId);
3826 
3827       return Response.noContent().build();
3828     } catch (WorkflowStateException e) {
3829       return badRequest("Deleting is not allowed for current workflow state. EventId: " + id);
3830     } catch (NotFoundException e) {
3831       return notFound("Workflow not found: '%d'.", wfId);
3832     } catch (UnauthorizedException e) {
3833       return forbidden();
3834     } catch (Exception e) {
3835       return serverError();
3836     }
3837   }
3838 
3839   private Optional<Event> checkAgentAccessForEvent(final String eventId)
3840           throws UnauthorizedException, SearchIndexException {
3841     final Optional<Event> event = getIndexService().getEvent(eventId, getIndex());
3842     if (event.isEmpty() || !event.get().getEventStatus().contains("SCHEDULE")) {
3843       return event;
3844     }
3845     SecurityUtil.checkAgentAccess(getSecurityService(), event.get().getAgentId());
3846     return event;
3847   }
3848 
3849   private void checkAgentAccessForAgent(final String agentId) throws UnauthorizedException {
3850     SecurityUtil.checkAgentAccess(getSecurityService(), agentId);
3851   }
3852 
3853 }