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