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.adminui.endpoint.EndpointUtil.transformAccessControList;
44  import static org.opencastproject.index.service.util.RestUtils.conflictJson;
45  import static org.opencastproject.index.service.util.RestUtils.notFound;
46  import static org.opencastproject.index.service.util.RestUtils.notFoundJson;
47  import static org.opencastproject.index.service.util.RestUtils.okJson;
48  import static org.opencastproject.index.service.util.RestUtils.okJsonList;
49  import static org.opencastproject.index.service.util.RestUtils.serverErrorJson;
50  import static org.opencastproject.util.DateTimeSupport.toUTC;
51  import static org.opencastproject.util.RestUtil.R.badRequest;
52  import static org.opencastproject.util.RestUtil.R.conflict;
53  import static org.opencastproject.util.RestUtil.R.forbidden;
54  import static org.opencastproject.util.RestUtil.R.noContent;
55  import static org.opencastproject.util.RestUtil.R.notFound;
56  import static org.opencastproject.util.RestUtil.R.ok;
57  import static org.opencastproject.util.RestUtil.R.serverError;
58  import static org.opencastproject.util.doc.rest.RestParameter.Type.BOOLEAN;
59  import static org.opencastproject.util.doc.rest.RestParameter.Type.STRING;
60  import static org.opencastproject.util.doc.rest.RestParameter.Type.TEXT;
61  
62  import org.opencastproject.adminui.exception.JobEndpointException;
63  import org.opencastproject.adminui.impl.AdminUIConfiguration;
64  import org.opencastproject.adminui.tobira.TobiraException;
65  import org.opencastproject.adminui.tobira.TobiraService;
66  import org.opencastproject.adminui.util.BulkUpdateUtil;
67  import org.opencastproject.assetmanager.api.AssetManager;
68  import org.opencastproject.authorization.xacml.manager.api.AclService;
69  import org.opencastproject.authorization.xacml.manager.api.ManagedAcl;
70  import org.opencastproject.authorization.xacml.manager.util.AccessInformationUtil;
71  import org.opencastproject.capture.CaptureParameters;
72  import org.opencastproject.capture.admin.api.Agent;
73  import org.opencastproject.capture.admin.api.CaptureAgentStateService;
74  import org.opencastproject.elasticsearch.api.SearchIndexException;
75  import org.opencastproject.elasticsearch.api.SearchResult;
76  import org.opencastproject.elasticsearch.api.SearchResultItem;
77  import org.opencastproject.elasticsearch.index.ElasticsearchIndex;
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     Optional<String> agentId = Optional.empty();
672     if (schedulingJson.has(SCHEDULING_AGENT_ID_KEY)) {
673       agentId = Optional.of(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.isPresent() && 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.isPresent()) {
696       checkAgentAccessForAgent(agentId.get());
697     }
698 
699     Optional<Date> start = Optional.empty();
700     if (schedulingJson.has(SCHEDULING_START_KEY)) {
701       start = Optional.of(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     Optional<Date> end = Optional.empty();
708     if (schedulingJson.has(SCHEDULING_END_KEY)) {
709       end = Optional.of(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     Optional<Map<String, String>> agentConfiguration = Optional.empty();
716     if (schedulingJson.has(SCHEDULING_AGENT_CONFIGURATION_KEY)) {
717       agentConfiguration = Optional.of(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 = Optional.of(configMap);
740       }
741     }
742 
743     if ((start.isPresent() || end.isPresent())
744             && end.orElse(technicalMetadata.getEndDate()).before(start.orElse(technicalMetadata.getStartDate()))) {
745       throw new IllegalStateException("The end date is before the start date");
746     }
747 
748     if (!start.isEmpty() || !end.isEmpty() || !agentId.isEmpty() || !agentConfiguration.isEmpty()) {
749       getSchedulerService()
750         .updateEvent(event.getIdentifier(), start, end, agentId, Optional.empty(), Optional.empty(), Optional.empty(), 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, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(),
960                 Optional.of(mediaPackage), Optional.empty(), Optional.empty());
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(getCollectionQueryDisable());
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    * Create a special query that disables filling the collection of a series, for performance reasons.
1286    * The collection can still be fetched via the listprovider endpoint.
1287    *
1288    * @return a map with resource list queries belonging to metadata fields
1289    */
1290   private Map getCollectionQueryDisable() {
1291     HashMap<String, ResourceListQuery> collectionQueryOverrides = new HashMap();
1292     SeriesListQuery seriesListQuery = new SeriesListQuery();
1293     seriesListQuery.setLimit(0);
1294     collectionQueryOverrides.put(DublinCore.PROPERTY_IS_PART_OF.getLocalName(), seriesListQuery);
1295     return collectionQueryOverrides;
1296   }
1297 
1298   @POST  // use POST instead of GET because of a possibly long list of ids
1299   @Path("events/metadata.json")
1300   @Produces(MediaType.APPLICATION_JSON)
1301   @RestQuery(name = "geteventsmetadata",
1302              description = "Returns all the data related to the edit events metadata modal as JSON",
1303              returnDescription = "All the data related to the edit events metadata modal as JSON",
1304              restParameters = {
1305                @RestParameter(name = "eventIds", description = "The event ids", isRequired = true,
1306                               type = RestParameter.Type.STRING)
1307              }, responses = {
1308                @RestResponse(description = "Returns all the data related to the edit events metadata modal as JSON",
1309                              responseCode = HttpServletResponse.SC_OK),
1310                @RestResponse(description = "No events to update, either not found or with running workflow, "
1311                                          + "details in response body.",
1312                              responseCode = HttpServletResponse.SC_NOT_FOUND)
1313              })
1314   public Response getEventsMetadata(@FormParam("eventIds") String eventIds) throws Exception {
1315     if (StringUtils.isBlank(eventIds)) {
1316       return badRequest("Event ids can't be empty");
1317     }
1318 
1319     JSONParser parser = new JSONParser();
1320     List<String> ids;
1321     try {
1322       ids = (List<String>) parser.parse(eventIds);
1323     } catch (org.json.simple.parser.ParseException e) {
1324       logger.error("Unable to parse '{}'", eventIds, e);
1325       return badRequest("Unable to parse event ids");
1326     } catch (ClassCastException e) {
1327       logger.error("Unable to cast '{}'", eventIds, e);
1328       return badRequest("Unable to parse event ids");
1329     }
1330 
1331     Set<String> eventsNotFound = new HashSet();
1332     Set<String> eventsWithRunningWorkflow = new HashSet();
1333     Set<String> eventsMerged = new HashSet();
1334 
1335     // collect the metadata of all events
1336     List<DublinCoreMetadataCollection> collectedMetadata = new ArrayList();
1337     for (String eventId: ids) {
1338       Opt<Event> optEvent = getIndexService().getEvent(eventId, getIndex());
1339       // not found?
1340       if (optEvent.isNone()) {
1341         eventsNotFound.add(eventId);
1342         continue;
1343       }
1344 
1345       Event event = optEvent.get();
1346 
1347       // check if there's a running workflow
1348       final String wfState = event.getWorkflowState();
1349       if (wfState != null && WorkflowUtil.isActive(WorkflowInstance.WorkflowState.valueOf(wfState))) {
1350         eventsWithRunningWorkflow.add(eventId);
1351         continue;
1352       }
1353 
1354       // collect metadata
1355       EventCatalogUIAdapter eventCatalogUiAdapter = getIndexService().getCommonEventCatalogUIAdapter();
1356       DublinCoreMetadataCollection metadataCollection = eventCatalogUiAdapter.getRawFields(
1357             getCollectionQueryDisable());
1358       EventUtils.setEventMetadataValues(event, metadataCollection);
1359       collectedMetadata.add(metadataCollection);
1360 
1361       eventsMerged.add(eventId);
1362     }
1363 
1364     // no events found?
1365     if (collectedMetadata.isEmpty()) {
1366       return notFoundJson(obj(
1367         f("notFound", JSONUtils.setToJSON(eventsNotFound)),
1368         f("runningWorkflow", JSONUtils.setToJSON(eventsWithRunningWorkflow))));
1369     }
1370 
1371     // merge metadata of events
1372     DublinCoreMetadataCollection mergedMetadata;
1373     if (collectedMetadata.size() == 1) {
1374       mergedMetadata = collectedMetadata.get(0);
1375     }
1376     else {
1377       //use first metadata collection as base
1378       mergedMetadata = new DublinCoreMetadataCollection(collectedMetadata.get(0));
1379       collectedMetadata.remove(0);
1380 
1381       for (MetadataField field : mergedMetadata.getFields()) {
1382         for (DublinCoreMetadataCollection otherMetadataCollection : collectedMetadata) {
1383           MetadataField matchingField = otherMetadataCollection.getOutputFields().get(field.getOutputID());
1384 
1385           // check if fields have the same value
1386           if (!Objects.equals(field.getValue(), matchingField.getValue())) {
1387             field.setDifferentValues();
1388             break;
1389           }
1390         }
1391       }
1392     }
1393 
1394     return okJson(obj(
1395       f("metadata", MetadataJson.collectionToJson(mergedMetadata, true)),
1396       f("notFound", JSONUtils.setToJSON(eventsNotFound)),
1397       f("runningWorkflow", JSONUtils.setToJSON(eventsWithRunningWorkflow)),
1398       f("merged", JSONUtils.setToJSON(eventsMerged))
1399     ));
1400   }
1401 
1402   @PUT
1403   @Path("bulk/update")
1404   @RestQuery(name = "bulkupdate", description = "Update all of the given events at once", restParameters = {
1405     @RestParameter(name = "update", isRequired = true, type = RestParameter.Type.TEXT, description = "The list of groups with events and fields to update.")}, responses = {
1406     @RestResponse(description = "All events have been updated successfully.", responseCode = HttpServletResponse.SC_OK),
1407     @RestResponse(description = "Could not parse update instructions.", responseCode = HttpServletResponse.SC_BAD_REQUEST),
1408     @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),
1409     @RestResponse(description = "The events in the response body were not found. No events were updated.", responseCode = HttpServletResponse.SC_NOT_FOUND)},
1410     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")
1411   public Response bulkUpdate(@FormParam("update") String updateJson) {
1412 
1413     final BulkUpdateUtil.BulkUpdateInstructions instructions;
1414     try {
1415       instructions = new BulkUpdateUtil.BulkUpdateInstructions(updateJson);
1416     } catch (IllegalArgumentException e) {
1417       return badRequest("Cannot parse bulk update instructions");
1418     }
1419 
1420     final Map<String, String> metadataUpdateFailures = new HashMap<>();
1421     final Map<String, String> schedulingUpdateFailures = new HashMap<>();
1422 
1423     for (final BulkUpdateUtil.BulkUpdateInstructionGroup groupInstructions : instructions.getGroups()) {
1424       // Get all the events to edit
1425       final Map<String, Optional<Event>> events = groupInstructions.getEventIds().stream()
1426         .collect(Collectors.toMap(id -> id, id -> BulkUpdateUtil.getEvent(getIndexService(), getIndex(), id)));
1427 
1428       // Check for invalid (non-existing) event ids
1429       final Set<String> notFoundIds = events.entrySet().stream().filter(e -> !e.getValue().isPresent()).map(Entry::getKey).collect(Collectors.toSet());
1430       if (!notFoundIds.isEmpty()) {
1431         return notFoundJson(JSONUtils.setToJSON(notFoundIds));
1432       }
1433 
1434 
1435       events.values().forEach(e -> e.ifPresent(event -> {
1436 
1437         JSONObject metadata = null;
1438 
1439         // Update the scheduling information
1440         try {
1441           if (groupInstructions.getScheduling() != null) {
1442             // Since we only have the start/end time, we have to add the correct date(s) for this event.
1443             final JSONObject scheduling = BulkUpdateUtil.addSchedulingDates(event, groupInstructions.getScheduling());
1444             updateEventScheduling(scheduling.toJSONString(), event);
1445             // We have to update the non-technical metadata as well to keep them in sync with the technical ones.
1446             metadata = BulkUpdateUtil.toNonTechnicalMetadataJson(scheduling);
1447           }
1448         } catch (Exception exception) {
1449           schedulingUpdateFailures.put(event.getIdentifier(), exception.getMessage());
1450         }
1451 
1452         // Update the event metadata
1453         try {
1454           if (groupInstructions.getMetadata() != null || metadata != null) {
1455             metadata = BulkUpdateUtil.mergeMetadataFields(metadata, groupInstructions.getMetadata());
1456             getIndexService().updateAllEventMetadata(event.getIdentifier(), JSONArray.toJSONString(Collections.singletonList(metadata)), getIndex());
1457           }
1458         } catch (Exception exception) {
1459           metadataUpdateFailures.put(event.getIdentifier(), exception.getMessage());
1460         }
1461       }));
1462     }
1463 
1464     // Check if there were any errors updating the metadata or scheduling information
1465     if (!metadataUpdateFailures.isEmpty() || !schedulingUpdateFailures.isEmpty()) {
1466       return serverErrorJson(obj(
1467         f("metadataFailures", JSONUtils.mapToJSON(metadataUpdateFailures)),
1468         f("schedulingFailures", JSONUtils.mapToJSON(schedulingUpdateFailures))
1469       ));
1470     }
1471     return ok();
1472   }
1473 
1474   @POST
1475   @Path("bulk/conflicts")
1476   @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 = {
1477     @RestParameter(name = "update", isRequired = true, type = RestParameter.Type.TEXT, description = "The list of events and fields to update.")}, responses = {
1478     @RestResponse(responseCode = HttpServletResponse.SC_NO_CONTENT, description = "No conflicting events found"),
1479     @RestResponse(responseCode = HttpServletResponse.SC_NOT_FOUND, description = "The events in the response body were not found. No events were updated."),
1480     @RestResponse(responseCode = HttpServletResponse.SC_CONFLICT, description = "There is a conflict"),
1481     @RestResponse(responseCode = HttpServletResponse.SC_BAD_REQUEST, description = "Missing or invalid parameters")})
1482   public Response getBulkConflicts(@FormParam("update") final String updateJson) throws NotFoundException {
1483     final BulkUpdateUtil.BulkUpdateInstructions instructions;
1484     try {
1485       instructions = new BulkUpdateUtil.BulkUpdateInstructions(updateJson);
1486     } catch (IllegalArgumentException e) {
1487       return badRequest("Cannot parse bulk update instructions");
1488     }
1489 
1490     final Map<String, List<JValue>> conflicts = new HashMap<>();
1491     final List<Tuple3<String, Optional<Event>, JSONObject>> eventsWithSchedulingOpt = instructions.getGroups().stream()
1492       .flatMap(group -> group.getEventIds().stream().map(eventId -> Tuple3
1493         .tuple3(eventId, BulkUpdateUtil.getEvent(getIndexService(), getIndex(), eventId), group.getScheduling())))
1494       .collect(Collectors.toList());
1495     // Check for invalid (non-existing) event ids
1496     final Set<String> notFoundIds = eventsWithSchedulingOpt.stream().filter(e -> !e.getB().isPresent())
1497       .map(Tuple3::getA).collect(Collectors.toSet());
1498     if (!notFoundIds.isEmpty()) {
1499       return notFoundJson(JSONUtils.setToJSON(notFoundIds));
1500     }
1501     final List<Tuple<Event, JSONObject>> eventsWithScheduling = eventsWithSchedulingOpt.stream()
1502       .map(e -> Tuple.tuple(e.getB().get(), e.getC())).collect(Collectors.toList());
1503     final Set<String> changedIds = eventsWithScheduling.stream().map(e -> e.getA().getIdentifier())
1504       .collect(Collectors.toSet());
1505     for (final Tuple<Event, JSONObject> eventWithGroup : eventsWithScheduling) {
1506       final Event event = eventWithGroup.getA();
1507       final JSONObject groupScheduling = eventWithGroup.getB();
1508       try {
1509         if (groupScheduling != null) {
1510           // Since we only have the start/end time, we have to add the correct date(s) for this event.
1511           final JSONObject scheduling = BulkUpdateUtil.addSchedulingDates(event, groupScheduling);
1512           final Date start = Date.from(Instant.parse((String) scheduling.get(SCHEDULING_START_KEY)));
1513           final Date end = Date.from(Instant.parse((String) scheduling.get(SCHEDULING_END_KEY)));
1514           final String agentId = Optional.ofNullable((String) scheduling.get(SCHEDULING_AGENT_ID_KEY))
1515             .orElse(event.getAgentId());
1516 
1517           final List<JValue> currentConflicts = new ArrayList<>();
1518 
1519           // Check for conflicts between the events themselves
1520           eventsWithScheduling.stream()
1521             .filter(otherEvent -> !otherEvent.getA().getIdentifier().equals(event.getIdentifier()))
1522             .forEach(otherEvent -> {
1523             final JSONObject otherScheduling = BulkUpdateUtil.addSchedulingDates(otherEvent.getA(), otherEvent.getB());
1524             final Date otherStart = Date.from(Instant.parse((String) otherScheduling.get(SCHEDULING_START_KEY)));
1525             final Date otherEnd = Date.from(Instant.parse((String) otherScheduling.get(SCHEDULING_END_KEY)));
1526             final String otherAgentId = Optional.ofNullable((String) otherScheduling.get(SCHEDULING_AGENT_ID_KEY))
1527               .orElse(otherEvent.getA().getAgentId());
1528             if (!otherAgentId.equals(agentId)) {
1529               // different agent -> no conflict
1530               return;
1531             }
1532             if (Util.schedulingIntervalsOverlap(start, end, otherStart, otherEnd)) {
1533               // conflict
1534               currentConflicts.add(convertEventToConflictingObject(DateTimeSupport.toUTC(otherStart.getTime()),
1535                 DateTimeSupport.toUTC(otherEnd.getTime()), otherEvent.getA().getTitle()));
1536             }
1537           });
1538 
1539           // Check for conflicts with other events from the database
1540           final List<MediaPackage> conflicting = getSchedulerService().findConflictingEvents(agentId, start, end)
1541             .stream()
1542             .filter(mp -> !changedIds.contains(mp.getIdentifier().toString()))
1543             .collect(Collectors.toList());
1544           if (!conflicting.isEmpty()) {
1545             currentConflicts.addAll(convertToConflictObjects(event.getIdentifier(), conflicting));
1546           }
1547           conflicts.put(event.getIdentifier(), currentConflicts);
1548         }
1549       } catch (final SchedulerException | UnauthorizedException | SearchIndexException exception) {
1550         throw new RuntimeException(exception);
1551       }
1552     }
1553 
1554     if (!conflicts.isEmpty()) {
1555       final List<JValue> responseJson = new ArrayList<>();
1556       conflicts.forEach((eventId, conflictingEvents) -> {
1557         if (!conflictingEvents.isEmpty()) {
1558           responseJson.add(obj(f("eventId", eventId), f("conflicts", arr(conflictingEvents))));
1559         }
1560       });
1561       if (!responseJson.isEmpty()) {
1562         return conflictJson(arr(responseJson));
1563       }
1564     }
1565 
1566     return noContent();
1567   }
1568 
1569   @PUT
1570   @Path("{eventId}/metadata")
1571   @RestQuery(name = "updateeventmetadata", description = "Update the passed metadata for the event with the given Id", pathParameters = {
1572           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = RestParameter.Type.STRING) }, restParameters = {
1573                   @RestParameter(name = "metadata", isRequired = true, type = RestParameter.Type.TEXT, description = "The list of metadata to update") }, responses = {
1574                           @RestResponse(description = "The metadata have been updated.", responseCode = HttpServletResponse.SC_OK),
1575                           @RestResponse(description = "Could not parse metadata.", responseCode = HttpServletResponse.SC_BAD_REQUEST),
1576                           @RestResponse(description = "No event with this identifier was found.", responseCode = HttpServletResponse.SC_NOT_FOUND) }, returnDescription = "No content is returned.")
1577   public Response updateEventMetadata(@PathParam("eventId") String id, @FormParam("metadata") String metadataJSON)
1578           throws Exception {
1579     Opt<Event> optEvent = getIndexService().getEvent(id, getIndex());
1580     if (optEvent.isNone())
1581       return notFound("Cannot find an event with id '%s'.", id);
1582 
1583     try {
1584       MetadataList metadataList = getIndexService().updateAllEventMetadata(id, metadataJSON, getIndex());
1585       return okJson(MetadataJson.listToJson(metadataList, true));
1586     } catch (IllegalArgumentException e) {
1587       return badRequest(String.format("Event %s metadata can't be updated.: %s", id, e.getMessage()));
1588     }
1589   }
1590 
1591   @PUT
1592   @Path("events/metadata")
1593   @RestQuery(name = "updateeventsmetadata",
1594     description = "Update the passed metadata for the events with the given ids",
1595     restParameters = {
1596       @RestParameter(name = "eventIds", isRequired = true, type = RestParameter.Type.STRING,
1597         description = "The ids of the events to update"),
1598       @RestParameter(name = "metadata", isRequired = true, type = RestParameter.Type.TEXT,
1599         description = "The metadata fields to update"),
1600     }, responses = {
1601     @RestResponse(description = "All events have been updated successfully.",
1602       responseCode = HttpServletResponse.SC_NO_CONTENT),
1603     @RestResponse(description = "One or multiple errors occured while updating event metadata. "
1604       + "Some events may have been updated successfully. "
1605       + "Details are available in the response body.",
1606       responseCode = HttpServletResponse.SC_INTERNAL_SERVER_ERROR)},
1607     returnDescription = "In case of complete success, no content is returned. Otherwise, the response content "
1608       + "contains the ids of events that couldn't be found and the ids and errors of events where the update failed "
1609       + "as well as the ids of the events that were updated successfully.")
1610   public Response updateEventsMetadata(@FormParam("eventIds") String eventIds, @FormParam("metadata") String metadata)
1611     throws Exception {
1612 
1613     if (StringUtils.isBlank(eventIds)) {
1614       return badRequest("Event ids can't be empty");
1615     }
1616 
1617     JSONParser parser = new JSONParser();
1618     List<String> ids;
1619     try {
1620       ids = (List<String>) parser.parse(eventIds);
1621     } catch (org.json.simple.parser.ParseException e) {
1622       logger.error("Unable to parse '{}'", eventIds, e);
1623       return badRequest("Unable to parse event ids");
1624     } catch (ClassCastException e) {
1625       logger.error("Unable to cast '{}'", eventIds, e);
1626       return badRequest("Unable to parse event ids");
1627     }
1628 
1629     // try to update each event
1630     Set<String> eventsNotFound = new HashSet<>();
1631     Set<String> eventsUpdated = new HashSet<>();
1632     Set<String> eventsUpdateFailure = new HashSet();
1633 
1634     for (String eventId : ids) {
1635       Opt<Event> optEvent = getIndexService().getEvent(eventId, getIndex());
1636       // not found?
1637 
1638       if (optEvent.isNone()) {
1639         eventsNotFound.add(eventId);
1640         continue;
1641       }
1642 
1643       // update
1644       try {
1645         getIndexService().updateAllEventMetadata(eventId, metadata, getIndex());
1646         eventsUpdated.add(eventId);
1647       } catch (IllegalArgumentException e) {
1648         eventsUpdateFailure.add(eventId);
1649       }
1650     }
1651 
1652     // errors occurred?
1653     if (!eventsNotFound.isEmpty() || !eventsUpdateFailure.isEmpty()) {
1654       return serverErrorJson(obj(
1655         f("updateFailures", JSONUtils.setToJSON(eventsUpdateFailure)),
1656         f("notFound", JSONUtils.setToJSON(eventsNotFound)),
1657         f("updated", JSONUtils.setToJSON(eventsUpdated))
1658       ));
1659     }
1660 
1661     return noContent();
1662   }
1663 
1664   @GET
1665   @Path("{eventId}/asset/assets.json")
1666   @Produces(MediaType.APPLICATION_JSON)
1667   @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 = {
1668           @RestResponse(description = "Returns the number of assets from each types as JSON", responseCode = HttpServletResponse.SC_OK),
1669           @RestResponse(description = "No event with this identifier was found.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
1670   public Response getAssetList(@PathParam("eventId") String id) throws Exception {
1671     Opt<Event> optEvent = getIndexService().getEvent(id, getIndex());
1672     if (optEvent.isNone())
1673       return notFound("Cannot find an event with id '%s'.", id);
1674     MediaPackage mp;
1675     try {
1676       mp = getIndexService().getEventMediapackage(optEvent.get());
1677     } catch (IndexServiceException e) {
1678       if (e.getCause() instanceof NotFoundException) {
1679         return notFound("Cannot find data for event %s", id);
1680       } else if (e.getCause() instanceof UnauthorizedException) {
1681         return Response.status(Status.FORBIDDEN).entity("Not authorized to access " + id).build();
1682       }
1683       logger.error("Internal error when trying to access metadata for " + id, e);
1684       return serverError();
1685     }
1686     int attachments = mp.getAttachments().length;
1687     int catalogs = mp.getCatalogs().length;
1688     int media = mp.getTracks().length;
1689     int publications = mp.getPublications().length;
1690     return okJson(obj(f("attachments", v(attachments)), f("catalogs", v(catalogs)), f("media", v(media)),
1691             f("publications", v(publications))));
1692   }
1693 
1694   @GET
1695   @Path("{eventId}/asset/attachment/attachments.json")
1696   @Produces(MediaType.APPLICATION_JSON)
1697   @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 = {
1698           @RestResponse(description = "Returns a list of attachments from the given event as JSON", responseCode = HttpServletResponse.SC_OK),
1699           @RestResponse(description = "No event with this identifier was found.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
1700   public Response getAttachmentsList(@PathParam("eventId") String id) throws Exception {
1701     Opt<Event> optEvent = getIndexService().getEvent(id, getIndex());
1702     if (optEvent.isNone())
1703       return notFound("Cannot find an event with id '%s'.", id);
1704     MediaPackage mp = getIndexService().getEventMediapackage(optEvent.get());
1705     return okJson(arr(getEventMediaPackageElements(mp.getAttachments())));
1706   }
1707 
1708   @GET
1709   @Path("{eventId}/asset/attachment/{id}.json")
1710   @Produces(MediaType.APPLICATION_JSON)
1711   @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 = {
1712           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = RestParameter.Type.STRING),
1713           @RestParameter(name = "id", description = "The attachment id", isRequired = true, type = RestParameter.Type.STRING) }, responses = {
1714           @RestResponse(description = "Returns the details of an attachment from the given event and attachment id as JSON", responseCode = HttpServletResponse.SC_OK),
1715           @RestResponse(description = "No event or attachment with this identifier was found.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
1716   public Response getAttachment(@PathParam("eventId") String eventId, @PathParam("id") String id)
1717           throws NotFoundException, SearchIndexException, IndexServiceException {
1718     MediaPackage mp = getMediaPackageByEventId(eventId);
1719 
1720     Attachment attachment = mp.getAttachment(id);
1721     if (attachment == null)
1722       return notFound("Cannot find an attachment with id '%s'.", id);
1723     return okJson(attachmentToJSON(attachment));
1724   }
1725 
1726   @GET
1727   @Path("{eventId}/asset/catalog/catalogs.json")
1728   @Produces(MediaType.APPLICATION_JSON)
1729   @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 = {
1730           @RestResponse(description = "Returns a list of catalogs from the given event as JSON", responseCode = HttpServletResponse.SC_OK),
1731           @RestResponse(description = "No event with this identifier was found.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
1732   public Response getCatalogList(@PathParam("eventId") String id) throws Exception {
1733     Opt<Event> optEvent = getIndexService().getEvent(id, getIndex());
1734     if (optEvent.isNone())
1735       return notFound("Cannot find an event with id '%s'.", id);
1736     MediaPackage mp = getIndexService().getEventMediapackage(optEvent.get());
1737     return okJson(arr(getEventMediaPackageElements(mp.getCatalogs())));
1738   }
1739 
1740   @GET
1741   @Path("{eventId}/asset/catalog/{id}.json")
1742   @Produces(MediaType.APPLICATION_JSON)
1743   @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 = {
1744           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = RestParameter.Type.STRING),
1745           @RestParameter(name = "id", description = "The catalog id", isRequired = true, type = RestParameter.Type.STRING) }, responses = {
1746           @RestResponse(description = "Returns the details of a catalog from the given event and catalog id as JSON", responseCode = HttpServletResponse.SC_OK),
1747           @RestResponse(description = "No event or catalog with this identifier was found.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
1748   public Response getCatalog(@PathParam("eventId") String eventId, @PathParam("id") String id)
1749           throws NotFoundException, SearchIndexException, IndexServiceException {
1750     MediaPackage mp = getMediaPackageByEventId(eventId);
1751 
1752     Catalog catalog = mp.getCatalog(id);
1753     if (catalog == null)
1754       return notFound("Cannot find a catalog with id '%s'.", id);
1755     return okJson(catalogToJSON(catalog));
1756   }
1757 
1758   @GET
1759   @Path("{eventId}/asset/media/media.json")
1760   @Produces(MediaType.APPLICATION_JSON)
1761   @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 = {
1762           @RestResponse(description = "Returns a list of media from the given event as JSON", responseCode = HttpServletResponse.SC_OK),
1763           @RestResponse(description = "No event with this identifier was found.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
1764   public Response getMediaList(@PathParam("eventId") String id) throws Exception {
1765     Opt<Event> optEvent = getIndexService().getEvent(id, getIndex());
1766     if (optEvent.isNone())
1767       return notFound("Cannot find an event with id '%s'.", id);
1768     MediaPackage mp = getIndexService().getEventMediapackage(optEvent.get());
1769     return okJson(arr(getEventMediaPackageElements(mp.getTracks())));
1770   }
1771 
1772   @GET
1773   @Path("{eventId}/asset/media/{id}.json")
1774   @Produces(MediaType.APPLICATION_JSON)
1775   @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 = {
1776           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = RestParameter.Type.STRING),
1777           @RestParameter(name = "id", description = "The media id", isRequired = true, type = RestParameter.Type.STRING) }, responses = {
1778                   @RestResponse(description = "Returns the media of a catalog from the given event and media id as JSON", responseCode = HttpServletResponse.SC_OK),
1779                   @RestResponse(description = "No event or media with this identifier was found.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
1780   public Response getMedia(@PathParam("eventId") String eventId, @PathParam("id") String id)
1781           throws NotFoundException, SearchIndexException, IndexServiceException {
1782     MediaPackage mp = getMediaPackageByEventId(eventId);
1783 
1784     Track track = mp.getTrack(id);
1785     if (track == null)
1786       return notFound("Cannot find media with id '%s'.", id);
1787     return okJson(trackToJSON(track));
1788   }
1789 
1790   @GET
1791   @Path("{eventId}/asset/publication/publications.json")
1792   @Produces(MediaType.APPLICATION_JSON)
1793   @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 = {
1794           @RestResponse(description = "Returns a list of publications from the given event as JSON", responseCode = HttpServletResponse.SC_OK),
1795           @RestResponse(description = "No event with this identifier was found.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
1796   public Response getPublicationList(@PathParam("eventId") String id) throws Exception {
1797     Opt<Event> optEvent = getIndexService().getEvent(id, getIndex());
1798     if (optEvent.isNone())
1799       return notFound("Cannot find an event with id '%s'.", id);
1800     MediaPackage mp = getIndexService().getEventMediapackage(optEvent.get());
1801     return okJson(arr(getEventPublications(mp.getPublications())));
1802   }
1803 
1804   @GET
1805   @Path("{eventId}/asset/publication/{id}.json")
1806   @Produces(MediaType.APPLICATION_JSON)
1807   @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 = {
1808           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = RestParameter.Type.STRING),
1809           @RestParameter(name = "id", description = "The publication id", isRequired = true, type = RestParameter.Type.STRING) }, responses = {
1810           @RestResponse(description = "Returns the publication of a catalog from the given event and publication id as JSON", responseCode = HttpServletResponse.SC_OK),
1811           @RestResponse(description = "No event or publication with this identifier was found.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
1812   public Response getPublication(@PathParam("eventId") String eventId, @PathParam("id") String id)
1813           throws NotFoundException, SearchIndexException, IndexServiceException {
1814     MediaPackage mp = getMediaPackageByEventId(eventId);
1815 
1816     Publication publication = null;
1817     for (Publication p : mp.getPublications()) {
1818       if (id.equals(p.getIdentifier())) {
1819         publication = p;
1820         break;
1821       }
1822     }
1823 
1824     if (publication == null)
1825       return notFound("Cannot find publication with id '%s'.", id);
1826     return okJson(publicationToJSON(publication));
1827   }
1828 
1829   @GET
1830   @Path("{eventId}/tobira/pages")
1831   @RestQuery(
1832           name = "getEventHostPages",
1833           description = "Returns the pages of a connected Tobira instance that contain the given event",
1834           returnDescription = "The Tobira pages that contain the given event",
1835           pathParameters = {
1836                   @RestParameter(
1837                           name = "eventId",
1838                           isRequired = true,
1839                           description = "The event identifier",
1840                           type = STRING
1841                   ),
1842           },
1843           responses = {
1844                   @RestResponse(
1845                           responseCode = SC_OK,
1846                           description = "The Tobira pages containing the given event"
1847                   ),
1848                   @RestResponse(
1849                           responseCode = SC_NOT_FOUND,
1850                           description = "Tobira doesn't know about the given event"
1851                   ),
1852                   @RestResponse(
1853                           responseCode = SC_SERVICE_UNAVAILABLE,
1854                           description = "Tobira is not configured (correctly)"
1855                   ),
1856           }
1857   )
1858   public Response getEventHostPages(@PathParam("eventId") String eventId) {
1859     var tobira = TobiraService.getTobira(getSecurityService().getOrganization().getId());
1860     if (!tobira.ready()) {
1861       return Response.status(Status.SERVICE_UNAVAILABLE)
1862               .entity("Tobira is not configured (correctly)")
1863               .build();
1864     }
1865 
1866     try {
1867       var eventData = tobira.getEventHostPages(eventId);
1868       if (eventData == null) {
1869         throw new WebApplicationException(NOT_FOUND);
1870       }
1871       eventData.put("baseURL", tobira.getOrigin());
1872       return Response.ok(eventData.toJSONString()).build();
1873     } catch (TobiraException e) {
1874       throw new WebApplicationException(e, Status.INTERNAL_SERVER_ERROR);
1875     }
1876   }
1877 
1878   @GET
1879   @Path("{eventId}/workflows.json")
1880   @Produces(MediaType.APPLICATION_JSON)
1881   @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 = {
1882           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = RestParameter.Type.STRING) }, responses = {
1883                   @RestResponse(description = "Returns all the data related to the event workflows tab as JSON", responseCode = HttpServletResponse.SC_OK),
1884                   @RestResponse(description = "No event with this identifier was found.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
1885   public Response getEventWorkflows(@PathParam("eventId") String id)
1886           throws UnauthorizedException, SearchIndexException, JobEndpointException {
1887     Opt<Event> optEvent = getIndexService().getEvent(id, getIndex());
1888     if (optEvent.isNone())
1889       return notFound("Cannot find an event with id '%s'.", id);
1890 
1891     try {
1892       if (optEvent.get().getEventStatus().equals("EVENTS.EVENTS.STATUS.SCHEDULED")) {
1893         List<Field> fields = new ArrayList<Field>();
1894         Map<String, String> workflowConfig = getSchedulerService().getWorkflowConfig(id);
1895         for (Entry<String, String> entry : workflowConfig.entrySet()) {
1896           fields.add(f(entry.getKey(), v(entry.getValue(), Jsons.BLANK)));
1897         }
1898 
1899         Map<String, String> agentConfiguration = getSchedulerService().getCaptureAgentConfiguration(id);
1900         return okJson(obj(f("workflowId", v(agentConfiguration.get(CaptureParameters.INGEST_WORKFLOW_DEFINITION), Jsons.BLANK)),
1901                 f("configuration", obj(fields))));
1902       } else {
1903         List<WorkflowInstance> workflowInstances = getWorkflowService().getWorkflowInstancesByMediaPackage(id);
1904         List<JValue> jsonList = new ArrayList<>();
1905 
1906         for (WorkflowInstance instance : workflowInstances) {
1907           long instanceId = instance.getId();
1908           Date created = instance.getDateCreated();
1909           String submitter = instance.getCreatorName();
1910 
1911           User user = submitter == null ? null : getUserDirectoryService().loadUser(submitter);
1912           String submitterName = null;
1913           String submitterEmail = null;
1914           if (user != null) {
1915             submitterName = user.getName();
1916             submitterEmail = user.getEmail();
1917           }
1918 
1919           jsonList.add(obj(f("id", v(instanceId)), f("title", v(instance.getTitle(), Jsons.BLANK)),
1920                   f("status", v(WORKFLOW_STATUS_TRANSLATION_PREFIX + instance.getState().toString())),
1921                   f("submitted", v(created != null ? DateTimeSupport.toUTC(created.getTime()) : "", Jsons.BLANK)),
1922                   f("submitter", v(submitter, Jsons.BLANK)),
1923                   f("submitterName", v(submitterName, Jsons.BLANK)),
1924                   f("submitterEmail", v(submitterEmail, Jsons.BLANK))));
1925         }
1926         JObject json = obj(f("results", arr(jsonList)), f("count", v(workflowInstances.size())));
1927         return okJson(json);
1928       }
1929     } catch (NotFoundException e) {
1930       return notFound("Cannot find workflows for event %s", id);
1931     } catch (SchedulerException e) {
1932       logger.error("Unable to get workflow data for event with id {}", id);
1933       throw new WebApplicationException(e, SC_INTERNAL_SERVER_ERROR);
1934     } catch (WorkflowDatabaseException e) {
1935       throw new JobEndpointException(String.format("Not able to get the list of job from the database: %s", e),
1936               e.getCause());
1937     }
1938   }
1939 
1940   @PUT
1941   @Path("{eventId}/workflows")
1942   @RestQuery(name = "updateEventWorkflow", description = "Update the workflow configuration for the scheduled event with the given id", pathParameters = {
1943           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = RestParameter.Type.STRING) }, restParameters = {
1944                   @RestParameter(name = "configuration", isRequired = true, description = "The workflow configuration as JSON", type = RestParameter.Type.TEXT) }, responses = {
1945                           @RestResponse(description = "Request executed succesfully", responseCode = HttpServletResponse.SC_NO_CONTENT),
1946                           @RestResponse(description = "No event with this identifier was found.", responseCode = HttpServletResponse.SC_NOT_FOUND) }, returnDescription = "The method does not retrun any content.")
1947   public Response updateEventWorkflow(@PathParam("eventId") String id, @FormParam("configuration") String configuration)
1948           throws SearchIndexException, UnauthorizedException {
1949     Opt<Event> optEvent = getIndexService().getEvent(id, getIndex());
1950     if (optEvent.isNone())
1951       return notFound("Cannot find an event with id '%s'.", id);
1952 
1953     if (optEvent.get().isScheduledEvent() && !optEvent.get().hasRecordingStarted()) {
1954       try {
1955 
1956         JSONObject configJSON;
1957         try {
1958           configJSON = (JSONObject) new JSONParser().parse(configuration);
1959         } catch (Exception e) {
1960           logger.warn("Unable to parse the workflow configuration {}", configuration);
1961           return badRequest();
1962         }
1963 
1964         Optional<Map<String, String>> caMetadataOpt = Optional.empty();
1965         Optional<Map<String, String>> workflowConfigOpt = Optional.empty();
1966 
1967         String workflowId = (String) configJSON.get("id");
1968         Map<String, String> caMetadata = new HashMap<>(getSchedulerService().getCaptureAgentConfiguration(id));
1969         if (!workflowId.equals(caMetadata.get(CaptureParameters.INGEST_WORKFLOW_DEFINITION))) {
1970           caMetadata.put(CaptureParameters.INGEST_WORKFLOW_DEFINITION, workflowId);
1971           caMetadataOpt = Optional.of(caMetadata);
1972         }
1973 
1974         Map<String, String> workflowConfig = new HashMap<>((JSONObject) configJSON.get("configuration"));
1975         Map<String, String> oldWorkflowConfig = new HashMap<>(getSchedulerService().getWorkflowConfig(id));
1976         if (!oldWorkflowConfig.equals(workflowConfig))
1977           workflowConfigOpt = Optional.of(workflowConfig);
1978 
1979         if (caMetadataOpt.isEmpty() && workflowConfigOpt.isEmpty())
1980           return Response.noContent().build();
1981 
1982         checkAgentAccessForAgent(optEvent.get().getAgentId());
1983 
1984         getSchedulerService().updateEvent(id, Optional.empty(), Optional.empty(), Optional.empty(),
1985             Optional.empty(), Optional.empty(), workflowConfigOpt, caMetadataOpt);
1986         return Response.noContent().build();
1987       } catch (NotFoundException e) {
1988         return notFound("Cannot find event %s in scheduler service", id);
1989       } catch (SchedulerException e) {
1990         logger.error("Unable to update scheduling workflow data for event with id {}", id);
1991         throw new WebApplicationException(e, SC_INTERNAL_SERVER_ERROR);
1992       }
1993     } else {
1994       return badRequest(String.format("Event %s workflow can not be updated as the recording already started.", id));
1995     }
1996   }
1997 
1998   @GET
1999   @Path("{eventId}/workflows/{workflowId}")
2000   @Produces(MediaType.APPLICATION_JSON)
2001   @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 = {
2002           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = RestParameter.Type.STRING),
2003           @RestParameter(name = "workflowId", description = "The workflow id", isRequired = true, type = RestParameter.Type.STRING) }, responses = {
2004                   @RestResponse(description = "Returns all the data related to the event single workflow tab as JSON", responseCode = HttpServletResponse.SC_OK),
2005                   @RestResponse(description = "Unable to parse workflowId", responseCode = HttpServletResponse.SC_BAD_REQUEST),
2006                   @RestResponse(description = "No event with this identifier was found.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
2007   public Response getEventWorkflow(@PathParam("eventId") String eventId, @PathParam("workflowId") String workflowId)
2008           throws SearchIndexException {
2009     Opt<Event> optEvent = getIndexService().getEvent(eventId, getIndex());
2010     if (optEvent.isNone())
2011       return notFound("Cannot find an event with id '%s'.", eventId);
2012 
2013     long workflowInstanceId;
2014     try {
2015       workflowId = StringUtils.remove(workflowId, ".json");
2016       workflowInstanceId = Long.parseLong(workflowId);
2017     } catch (Exception e) {
2018       logger.warn("Unable to parse workflow id {}", workflowId);
2019       return RestUtil.R.badRequest();
2020     }
2021 
2022     try {
2023       WorkflowInstance instance = getWorkflowService().getWorkflowById(workflowInstanceId);
2024 
2025       // Retrieve submission date with the workflow instance main job
2026       Date created = instance.getDateCreated();
2027 
2028       Date completed = instance.getDateCompleted();
2029       if (completed == null)
2030         completed = new Date();
2031 
2032       long executionTime = completed.getTime() - created.getTime();
2033 
2034       var fields = instance.getConfigurations()
2035           .entrySet()
2036           .stream()
2037           .map(e -> f(e.getKey(), v(e.getValue(), Jsons.BLANK)))
2038           .collect(Collectors.toList());
2039 
2040       return okJson(obj(
2041               f("status", v(WORKFLOW_STATUS_TRANSLATION_PREFIX + instance.getState(), Jsons.BLANK)),
2042               f("description", v(instance.getDescription(), Jsons.BLANK)),
2043               f("executionTime", v(executionTime, Jsons.BLANK)),
2044               f("wiid", v(instance.getId(), Jsons.BLANK)), f("title", v(instance.getTitle(), Jsons.BLANK)),
2045               f("wdid", v(instance.getTemplate(), Jsons.BLANK)),
2046               f("configuration", obj(fields)),
2047               f("submittedAt", v(toUTC(created.getTime()), Jsons.BLANK)),
2048               f("creator", v(instance.getCreatorName(), Jsons.BLANK))));
2049     } catch (NotFoundException e) {
2050       return notFound("Cannot find workflow  %s", workflowId);
2051     } catch (WorkflowDatabaseException e) {
2052       logger.error("Unable to get workflow {} of event {}", workflowId, eventId, e);
2053       return serverError();
2054     } catch (UnauthorizedException e) {
2055       return forbidden();
2056     }
2057   }
2058 
2059   @GET
2060   @Path("{eventId}/workflows/{workflowId}/operations.json")
2061   @Produces(MediaType.APPLICATION_JSON)
2062   @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 = {
2063           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = RestParameter.Type.STRING),
2064           @RestParameter(name = "workflowId", description = "The workflow id", isRequired = true, type = RestParameter.Type.STRING) }, responses = {
2065                   @RestResponse(description = "Returns all the data related to the event workflow/operations tab as JSON", responseCode = HttpServletResponse.SC_OK),
2066                   @RestResponse(description = "Unable to parse workflowId", responseCode = HttpServletResponse.SC_BAD_REQUEST),
2067                   @RestResponse(description = "No event with this identifier was found.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
2068   public Response getEventOperations(@PathParam("eventId") String eventId, @PathParam("workflowId") String workflowId)
2069           throws SearchIndexException {
2070     Opt<Event> optEvent = getIndexService().getEvent(eventId, getIndex());
2071     if (optEvent.isNone())
2072       return notFound("Cannot find an event with id '%s'.", eventId);
2073 
2074     long workflowInstanceId;
2075     try {
2076       workflowInstanceId = Long.parseLong(workflowId);
2077     } catch (Exception e) {
2078       logger.warn("Unable to parse workflow id {}", workflowId);
2079       return RestUtil.R.badRequest();
2080     }
2081 
2082     try {
2083       WorkflowInstance instance = getWorkflowService().getWorkflowById(workflowInstanceId);
2084 
2085       List<WorkflowOperationInstance> operations = instance.getOperations();
2086       List<JValue> operationsJSON = new ArrayList<>();
2087 
2088       for (WorkflowOperationInstance wflOp : operations) {
2089         List<Field> fields = new ArrayList<>();
2090         for (String key : wflOp.getConfigurationKeys()) {
2091           fields.add(f(key, v(wflOp.getConfiguration(key), Jsons.BLANK)));
2092         }
2093         operationsJSON.add(obj(
2094                 f("status",
2095                 v(WORKFLOW_STATUS_TRANSLATION_PREFIX + wflOp.getState(), Jsons.BLANK)),
2096                 f("title", v(wflOp.getTemplate(), Jsons.BLANK)),
2097                 f("description", v(wflOp.getDescription(), Jsons.BLANK)),
2098                 f("id", v(wflOp.getId(), Jsons.BLANK)),
2099                 f("configuration", obj(fields))
2100         ));
2101       }
2102 
2103       return okJson(arr(operationsJSON));
2104     } catch (NotFoundException e) {
2105       return notFound("Cannot find workflow %s", workflowId);
2106     } catch (WorkflowDatabaseException e) {
2107       logger.error("Unable to get workflow operations of event {} and workflow {}", eventId, workflowId, e);
2108       return serverError();
2109     } catch (UnauthorizedException e) {
2110       return forbidden();
2111     }
2112   }
2113 
2114   @GET
2115   @Path("{eventId}/workflows/{workflowId}/operations/{operationPosition}")
2116   @Produces(MediaType.APPLICATION_JSON)
2117   @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 = {
2118           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = RestParameter.Type.STRING),
2119           @RestParameter(name = "workflowId", description = "The workflow id", isRequired = true, type = RestParameter.Type.STRING),
2120           @RestParameter(name = "operationPosition", description = "The operation position", isRequired = true, type = RestParameter.Type.INTEGER) }, responses = {
2121                   @RestResponse(description = "Returns all the data related to the event workflow/operation tab as JSON", responseCode = HttpServletResponse.SC_OK),
2122                   @RestResponse(description = "Unable to parse workflowId or operationPosition", responseCode = HttpServletResponse.SC_BAD_REQUEST),
2123                   @RestResponse(description = "No operation with these identifiers was found.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
2124   public Response getEventOperation(@PathParam("eventId") String eventId, @PathParam("workflowId") String workflowId,
2125           @PathParam("operationPosition") Integer operationPosition) throws SearchIndexException {
2126     Opt<Event> optEvent = getIndexService().getEvent(eventId, getIndex());
2127     if (optEvent.isNone())
2128       return notFound("Cannot find an event with id '%s'.", eventId);
2129 
2130     long workflowInstanceId;
2131     try {
2132       workflowInstanceId = Long.parseLong(workflowId);
2133     } catch (Exception e) {
2134       logger.warn("Unable to parse workflow id {}", workflowId);
2135       return RestUtil.R.badRequest();
2136     }
2137 
2138     WorkflowInstance instance;
2139     try {
2140       instance = getWorkflowService().getWorkflowById(workflowInstanceId);
2141     } catch (NotFoundException e) {
2142       return notFound("Cannot find workflow %s", workflowId);
2143     } catch (WorkflowDatabaseException e) {
2144       logger.error("Unable to get workflow operation of event {} and workflow {} at position {}", eventId, workflowId,
2145               operationPosition, e);
2146       return serverError();
2147     } catch (UnauthorizedException e) {
2148       return forbidden();
2149     }
2150 
2151     List<WorkflowOperationInstance> operations = instance.getOperations();
2152 
2153     if (operations.size() > operationPosition) {
2154       WorkflowOperationInstance wflOp = operations.get(operationPosition);
2155       return okJson(obj(f("retry_strategy", v(wflOp.getRetryStrategy(), Jsons.BLANK)),
2156               f("execution_host", v(wflOp.getExecutionHost(), Jsons.BLANK)),
2157               f("failed_attempts", v(wflOp.getFailedAttempts())),
2158               f("max_attempts", v(wflOp.getMaxAttempts())),
2159               f("exception_handler_workflow", v(wflOp.getExceptionHandlingWorkflow(), Jsons.BLANK)),
2160               f("fail_on_error", v(wflOp.isFailOnError())),
2161               f("description", v(wflOp.getDescription(), Jsons.BLANK)),
2162               f("state", v(WORKFLOW_STATUS_TRANSLATION_PREFIX + wflOp.getState(), Jsons.BLANK)),
2163               f("job", v(wflOp.getId(), Jsons.BLANK)),
2164               f("name", v(wflOp.getTemplate(), Jsons.BLANK)),
2165               f("time_in_queue", v(wflOp.getTimeInQueue(), v(0))),
2166               f("started", wflOp.getDateStarted() != null ? v(toUTC(wflOp.getDateStarted().getTime())) : Jsons.BLANK),
2167               f("completed", wflOp.getDateCompleted() != null ? v(toUTC(wflOp.getDateCompleted().getTime())) : Jsons.BLANK))
2168       );
2169     }
2170     return notFound("Cannot find workflow operation of workflow %s at position %s", workflowId, operationPosition);
2171   }
2172 
2173   @GET
2174   @Path("{eventId}/workflows/{workflowId}/errors.json")
2175   @Produces(MediaType.APPLICATION_JSON)
2176   @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 = {
2177           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = RestParameter.Type.STRING),
2178           @RestParameter(name = "workflowId", description = "The workflow id", isRequired = true, type = RestParameter.Type.STRING) }, responses = {
2179                   @RestResponse(description = "Returns all the data related to the event workflow/errors tab as JSON", responseCode = HttpServletResponse.SC_OK),
2180                   @RestResponse(description = "Unable to parse workflowId", responseCode = HttpServletResponse.SC_BAD_REQUEST),
2181                   @RestResponse(description = "No event with this identifier was found.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
2182   public Response getEventErrors(@PathParam("eventId") String eventId, @PathParam("workflowId") String workflowId,
2183           @Context HttpServletRequest req) throws JobEndpointException, SearchIndexException {
2184     // the call to #getEvent should make sure that the calling user has access rights to the workflow
2185     // FIXME since there is no dependency between the event and the workflow (the fetched event is
2186     // simply ignored) an attacker can get access by using an event he owns and a workflow ID of
2187     // someone else.
2188     for (final Event ignore : getIndexService().getEvent(eventId, getIndex())) {
2189       final long workflowIdLong;
2190       try {
2191         workflowIdLong = Long.parseLong(workflowId);
2192       } catch (Exception e) {
2193         logger.warn("Unable to parse workflow id {}", workflowId);
2194         return RestUtil.R.badRequest();
2195       }
2196       try {
2197         return okJson(getJobService().getIncidentsAsJSON(workflowIdLong, req.getLocale(), true));
2198       } catch (NotFoundException e) {
2199         return notFound("Cannot find the incident for the workflow %s", workflowId);
2200       }
2201     }
2202     return notFound("Cannot find an event with id '%s'.", eventId);
2203   }
2204 
2205   @GET
2206   @Path("{eventId}/workflows/{workflowId}/errors/{errorId}.json")
2207   @Produces(MediaType.APPLICATION_JSON)
2208   @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 = {
2209           @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = RestParameter.Type.STRING),
2210           @RestParameter(name = "workflowId", description = "The workflow id", isRequired = true, type = RestParameter.Type.STRING),
2211           @RestParameter(name = "errorId", description = "The error id", isRequired = true, type = RestParameter.Type.STRING) }, responses = {
2212                   @RestResponse(description = "Returns all the data related to the event workflow/error tab as JSON", responseCode = HttpServletResponse.SC_OK),
2213                   @RestResponse(description = "Unable to parse workflowId", responseCode = HttpServletResponse.SC_BAD_REQUEST),
2214                   @RestResponse(description = "No event with this identifier was found.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
2215   public Response getEventError(@PathParam("eventId") String eventId, @PathParam("workflowId") String workflowId,
2216           @PathParam("errorId") String errorId, @Context HttpServletRequest req)
2217                   throws JobEndpointException, SearchIndexException {
2218     // the call to #getEvent should make sure that the calling user has access rights to the workflow
2219     // FIXME since there is no dependency between the event and the workflow (the fetched event is
2220     // simply ignored) an attacker can get access by using an event he owns and a workflow ID of
2221     // someone else.
2222     for (Event ignore : getIndexService().getEvent(eventId, getIndex())) {
2223       final long errorIdLong;
2224       try {
2225         errorIdLong = Long.parseLong(errorId);
2226       } catch (Exception e) {
2227         logger.warn("Unable to parse error id {}", errorId);
2228         return RestUtil.R.badRequest();
2229       }
2230       try {
2231         return okJson(getJobService().getIncidentAsJSON(errorIdLong, req.getLocale()));
2232       } catch (NotFoundException e) {
2233         return notFound("Cannot find the incident %s", errorId);
2234       }
2235     }
2236     return notFound("Cannot find an event with id '%s'.", eventId);
2237   }
2238 
2239   @GET
2240   @Path("{eventId}/access.json")
2241   @SuppressWarnings("unchecked")
2242   @Produces(MediaType.APPLICATION_JSON)
2243   @RestQuery(name = "getEventAccessInformation", description = "Get the access information of an event", returnDescription = "The access information", pathParameters = {
2244           @RestParameter(name = "eventId", isRequired = true, description = "The event identifier", type = RestParameter.Type.STRING) }, responses = {
2245                   @RestResponse(responseCode = SC_BAD_REQUEST, description = "The required form params were missing in the request."),
2246                   @RestResponse(responseCode = SC_NOT_FOUND, description = "If the event has not been found."),
2247                   @RestResponse(responseCode = SC_OK, description = "The access information ") })
2248   public Response getEventAccessInformation(@PathParam("eventId") String eventId) throws Exception {
2249     Opt<Event> optEvent = getIndexService().getEvent(eventId, getIndex());
2250     if (optEvent.isNone())
2251       return notFound("Cannot find an event with id '%s'.", eventId);
2252 
2253     // Add all available ACLs to the response
2254     JSONArray systemAclsJson = new JSONArray();
2255     List<ManagedAcl> acls = getAclService().getAcls();
2256     for (ManagedAcl acl : acls) {
2257       systemAclsJson.add(AccessInformationUtil.serializeManagedAcl(acl));
2258     }
2259 
2260     AccessControlList activeAcl = new AccessControlList();
2261     try {
2262       if (optEvent.get().getAccessPolicy() != null)
2263         activeAcl = AccessControlParser.parseAcl(optEvent.get().getAccessPolicy());
2264     } catch (Exception e) {
2265       logger.error("Unable to parse access policy", e);
2266     }
2267     Option<ManagedAcl> currentAcl = AccessInformationUtil.matchAclsLenient(acls, activeAcl,
2268             getAdminUIConfiguration().getMatchManagedAclRolePrefixes());
2269 
2270     JSONObject episodeAccessJson = new JSONObject();
2271     episodeAccessJson.put("current_acl", currentAcl.isSome() ? currentAcl.get().getId() : 0L);
2272     episodeAccessJson.put("acl", transformAccessControList(activeAcl, getUserDirectoryService()));
2273     episodeAccessJson.put("privileges", AccessInformationUtil.serializePrivilegesByRole(activeAcl));
2274     if (StringUtils.isNotBlank(optEvent.get().getWorkflowState())
2275             && WorkflowUtil.isActive(WorkflowInstance.WorkflowState.valueOf(optEvent.get().getWorkflowState())))
2276       episodeAccessJson.put("locked", true);
2277 
2278     JSONObject jsonReturnObj = new JSONObject();
2279     jsonReturnObj.put("episode_access", episodeAccessJson);
2280     jsonReturnObj.put("system_acls", systemAclsJson);
2281 
2282     return Response.ok(jsonReturnObj.toString()).build();
2283   }
2284 
2285   // MH-12085 Add manually uploaded assets, multipart file upload has to be a POST
2286   @POST
2287   @Path("{eventId}/assets")
2288   @Consumes(MediaType.MULTIPART_FORM_DATA)
2289   @RestQuery(name = "updateAssets", description = "Update or create an asset for the eventId by the given metadata as JSON and files in the body",
2290   pathParameters = {
2291   @RestParameter(name = "eventId", description = "The event id", isRequired = true, type = RestParameter.Type.STRING) },
2292   restParameters = {
2293   @RestParameter(name = "metadata", isRequired = true, type = RestParameter.Type.TEXT, description = "The list of asset metadata") },
2294   responses = {
2295   @RestResponse(description = "The asset has been added.", responseCode = HttpServletResponse.SC_OK),
2296   @RestResponse(description = "Could not add asset, problem with the metadata or files.", responseCode = HttpServletResponse.SC_BAD_REQUEST),
2297   @RestResponse(description = "No event with this identifier was found.", responseCode = HttpServletResponse.SC_NOT_FOUND) },
2298   returnDescription = "The workflow identifier")
2299   public Response updateAssets(@PathParam("eventId") final String eventId,
2300           @Context HttpServletRequest request)  throws Exception {
2301     try {
2302       MediaPackage mp = getMediaPackageByEventId(eventId);
2303       String result = getIndexService().updateEventAssets(mp, request);
2304       return Response.status(Status.CREATED).entity(result).build();
2305     }  catch (NotFoundException e) {
2306       return notFound("Cannot find an event with id '%s'.", eventId);
2307     } catch (IllegalArgumentException | UnsupportedAssetException e) {
2308       return RestUtil.R.badRequest(e.getMessage());
2309     } catch (Exception e) {
2310       return RestUtil.R.serverError();
2311     }
2312   }
2313 
2314   @GET
2315   @Path("new/metadata")
2316   @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 = {
2317           @RestResponse(responseCode = SC_OK, description = "Returns all the data related to the event metadata tab as JSON") })
2318   public Response getNewMetadata() {
2319     MetadataList metadataList = new MetadataList();
2320 
2321     // Extended metadata
2322     List<EventCatalogUIAdapter> extendedCatalogUIAdapters = getIndexService().getExtendedEventCatalogUIAdapters();
2323     for (EventCatalogUIAdapter extendedCatalogUIAdapter : extendedCatalogUIAdapters) {
2324       metadataList.add(extendedCatalogUIAdapter, extendedCatalogUIAdapter.getRawFields());
2325     }
2326 
2327     // Common metadata
2328     // We do this after extended metadata because we want to overwrite any extended metadata adapters with the same
2329     // flavor instead of the other way around.
2330     EventCatalogUIAdapter commonCatalogUiAdapter = getIndexService().getCommonEventCatalogUIAdapter();
2331     DublinCoreMetadataCollection commonMetadata = commonCatalogUiAdapter.getRawFields(getCollectionQueryDisable());
2332 
2333     if (commonMetadata.getOutputFields().containsKey(DublinCore.PROPERTY_CREATED.getLocalName()))
2334       commonMetadata.removeField(commonMetadata.getOutputFields().get(DublinCore.PROPERTY_CREATED.getLocalName()));
2335     if (commonMetadata.getOutputFields().containsKey("duration"))
2336       commonMetadata.removeField(commonMetadata.getOutputFields().get("duration"));
2337     if (commonMetadata.getOutputFields().containsKey(DublinCore.PROPERTY_IDENTIFIER.getLocalName()))
2338       commonMetadata.removeField(commonMetadata.getOutputFields().get(DublinCore.PROPERTY_IDENTIFIER.getLocalName()));
2339     if (commonMetadata.getOutputFields().containsKey(DublinCore.PROPERTY_SOURCE.getLocalName()))
2340       commonMetadata.removeField(commonMetadata.getOutputFields().get(DublinCore.PROPERTY_SOURCE.getLocalName()));
2341     if (commonMetadata.getOutputFields().containsKey("startDate"))
2342       commonMetadata.removeField(commonMetadata.getOutputFields().get("startDate"));
2343     if (commonMetadata.getOutputFields().containsKey("startTime"))
2344       commonMetadata.removeField(commonMetadata.getOutputFields().get("startTime"));
2345     if (commonMetadata.getOutputFields().containsKey("location"))
2346       commonMetadata.removeField(commonMetadata.getOutputFields().get("location"));
2347 
2348     // Set publisher to user
2349     if (commonMetadata.getOutputFields().containsKey(DublinCore.PROPERTY_PUBLISHER.getLocalName())) {
2350       MetadataField publisher = commonMetadata.getOutputFields().get(DublinCore.PROPERTY_PUBLISHER.getLocalName());
2351       Map<String, String> users = new HashMap<>();
2352       if (publisher.getCollection() != null) {
2353         users = publisher.getCollection();
2354       }
2355       String loggedInUser = getSecurityService().getUser().getName();
2356       if (!users.containsKey(loggedInUser)) {
2357         users.put(loggedInUser, loggedInUser);
2358       }
2359       publisher.setValue(loggedInUser);
2360     }
2361 
2362     metadataList.add(commonCatalogUiAdapter, commonMetadata);
2363 
2364     // remove series with empty titles from the collection of the isPartOf field as these can't be converted to json
2365     removeSeriesWithNullTitlesFromFieldCollection(metadataList);
2366 
2367     return okJson(MetadataJson.listToJson(metadataList, true));
2368   }
2369 
2370   @GET
2371   @Path("new/processing")
2372   @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 = {
2373           @RestParameter(name = "tags", isRequired = false, description = "A comma separated list of tags to filter the workflow definitions", type = RestParameter.Type.STRING) }, responses = {
2374                   @RestResponse(responseCode = SC_OK, description = "Returns all the data related to the event processing tab as JSON") })
2375   public Response getNewProcessing(@QueryParam("tags") String tagsString) {
2376     List<String> tags = RestUtil.splitCommaSeparatedParam(Option.option(tagsString)).value();
2377 
2378     List<JValue> workflows = new ArrayList<>();
2379     try {
2380       List<WorkflowDefinition> workflowsDefinitions = getWorkflowService().listAvailableWorkflowDefinitions();
2381       for (WorkflowDefinition wflDef : workflowsDefinitions) {
2382         if (wflDef.containsTag(tags)) {
2383 
2384           workflows.add(obj(f("id", v(wflDef.getId())), f("tags", arr(wflDef.getTags())),
2385                   f("title", v(nul(wflDef.getTitle()).getOr(""))),
2386                   f("description", v(nul(wflDef.getDescription()).getOr(""))),
2387                   f("displayOrder", v(wflDef.getDisplayOrder())),
2388                   f("configuration_panel", v(nul(wflDef.getConfigurationPanel()).getOr(""))),
2389                   f("configuration_panel_json", v(nul(wflDef.getConfigurationPanelJson()).getOr("")))));
2390         }
2391       }
2392     } catch (WorkflowDatabaseException e) {
2393       logger.error("Unable to get available workflow definitions", e);
2394       return RestUtil.R.serverError();
2395     }
2396 
2397     JValue data = obj(f("workflows",arr(workflows)), f("default_workflow_id",v(defaultWorkflowDefinionId,Jsons.NULL)));
2398 
2399     return okJson(data);
2400   }
2401 
2402   @POST
2403   @Path("new/conflicts")
2404   @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 = {
2405           @RestParameter(name = "metadata", isRequired = true, description = "The metadata as JSON", type = RestParameter.Type.TEXT) }, responses = {
2406                   @RestResponse(responseCode = HttpServletResponse.SC_NO_CONTENT, description = "No conflicting events found"),
2407                   @RestResponse(responseCode = HttpServletResponse.SC_CONFLICT, description = "There is a conflict"),
2408                   @RestResponse(responseCode = HttpServletResponse.SC_BAD_REQUEST, description = "Missing or invalid parameters") })
2409   public Response getNewConflicts(@FormParam("metadata") String metadata) throws NotFoundException {
2410     if (StringUtils.isBlank(metadata)) {
2411       logger.warn("Metadata is not specified");
2412       return Response.status(Status.BAD_REQUEST).build();
2413     }
2414 
2415     JSONParser parser = new JSONParser();
2416     JSONObject metadataJson;
2417     try {
2418       metadataJson = (JSONObject) parser.parse(metadata);
2419     } catch (Exception e) {
2420       logger.warn("Unable to parse metadata {}", metadata);
2421       return RestUtil.R.badRequest("Unable to parse metadata");
2422     }
2423 
2424     String device;
2425     String startDate;
2426     String endDate;
2427     try {
2428       device = (String) metadataJson.get("device");
2429       startDate = (String) metadataJson.get("start");
2430       endDate = (String) metadataJson.get("end");
2431     } catch (Exception e) {
2432       logger.warn("Unable to parse metadata {}", metadata);
2433       return RestUtil.R.badRequest("Unable to parse metadata");
2434     }
2435 
2436     if (StringUtils.isBlank(device) || StringUtils.isBlank(startDate) || StringUtils.isBlank(endDate)) {
2437       logger.warn("Either device, start date or end date were not specified");
2438       return Response.status(Status.BAD_REQUEST).build();
2439     }
2440 
2441     Date start;
2442     try {
2443       start = new Date(DateTimeSupport.fromUTC(startDate));
2444     } catch (Exception e) {
2445       logger.warn("Unable to parse start date {}", startDate);
2446       return RestUtil.R.badRequest("Unable to parse start date");
2447     }
2448 
2449     Date end;
2450     try {
2451       end = new Date(DateTimeSupport.fromUTC(endDate));
2452     } catch (Exception e) {
2453       logger.warn("Unable to parse end date {}", endDate);
2454       return RestUtil.R.badRequest("Unable to parse end date");
2455     }
2456 
2457     String rruleString = (String) metadataJson.get("rrule");
2458 
2459     RRule rrule = null;
2460     TimeZone timeZone = TimeZone.getDefault();
2461     String durationString = null;
2462     if (StringUtils.isNotEmpty(rruleString)) {
2463       try {
2464         rrule = new RRule(rruleString);
2465         rrule.validate();
2466       } catch (Exception e) {
2467         logger.warn("Unable to parse rrule {}: {}", rruleString, e.getMessage());
2468         return Response.status(Status.BAD_REQUEST).build();
2469       }
2470 
2471       durationString = (String) metadataJson.get("duration");
2472       if (StringUtils.isBlank(durationString)) {
2473         logger.warn("If checking recurrence, must include duration.");
2474         return Response.status(Status.BAD_REQUEST).build();
2475       }
2476 
2477       Agent agent = getCaptureAgentStateService().getAgent(device);
2478       String timezone = agent.getConfiguration().getProperty("capture.device.timezone");
2479       if (StringUtils.isBlank(timezone)) {
2480         timezone = TimeZone.getDefault().getID();
2481         logger.warn("No 'capture.device.timezone' set on agent {}. The default server timezone {} will be used.",
2482                 device, timezone);
2483       }
2484       timeZone = TimeZone.getTimeZone(timezone);
2485     }
2486 
2487     String eventId = (String) metadataJson.get("id");
2488 
2489     try {
2490       List<MediaPackage> events = null;
2491       if (StringUtils.isNotEmpty(rruleString)) {
2492         events = getSchedulerService().findConflictingEvents(device, rrule, start, end, Long.parseLong(durationString),
2493                 timeZone);
2494       } else {
2495         events = getSchedulerService().findConflictingEvents(device, start, end);
2496       }
2497       if (!events.isEmpty()) {
2498         final List<JValue> eventsJSON = convertToConflictObjects(eventId, events);
2499         if (!eventsJSON.isEmpty())
2500           return conflictJson(arr(eventsJSON));
2501       }
2502       return Response.noContent().build();
2503     } catch (Exception e) {
2504       logger.error("Unable to find conflicting events for {}, {}, {}",
2505               device, startDate, endDate, e);
2506       return RestUtil.R.serverError();
2507     }
2508   }
2509 
2510   private List<JValue> convertToConflictObjects(final String eventId, final List<MediaPackage> events) throws SearchIndexException {
2511     final List<JValue> eventsJSON = new ArrayList<>();
2512     final Organization organization = getSecurityService().getOrganization();
2513     final User user = SecurityUtil.createSystemUser(systemUserName, organization);
2514 
2515     SecurityUtil.runAs(getSecurityService(), organization, user, () -> {
2516       try {
2517         for (final MediaPackage event : events) {
2518           final Opt<Event> eventOpt = getIndexService().getEvent(event.getIdentifier().toString(), getIndex());
2519           if (eventOpt.isSome()) {
2520             final Event e = eventOpt.get();
2521             if (StringUtils.isNotEmpty(eventId) && eventId.equals(e.getIdentifier())) {
2522               continue;
2523             }
2524             eventsJSON.add(convertEventToConflictingObject(e.getTechnicalStartTime(), e.getTechnicalEndTime(), e.getTitle()));
2525           } else {
2526             logger.warn("Index out of sync! Conflicting event catalog {} not found on event index!",
2527               event.getIdentifier().toString());
2528           }
2529         }
2530       } catch (Exception e) {
2531          logger.error("Failed to get conflicting events", e);
2532       }
2533     });
2534 
2535     return eventsJSON;
2536   }
2537 
2538   private JValue convertEventToConflictingObject(final String start, final String end, final String title) {
2539     return obj(
2540       f("start", v(start)),
2541       f("end", v(end)),
2542       f("title", v(title))
2543     );
2544   }
2545 
2546   @POST
2547   @Path("/new")
2548   @Consumes(MediaType.MULTIPART_FORM_DATA)
2549   @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 = {
2550           @RestParameter(name = "metadata", isRequired = true, description = "The metadata as JSON", type = RestParameter.Type.TEXT) }, responses = {
2551                   @RestResponse(responseCode = HttpServletResponse.SC_CREATED, description = "Event sucessfully added"),
2552                   @RestResponse(responseCode = SC_BAD_REQUEST, description = "If the metadata is not set or couldn't be parsed") })
2553   public Response createNewEvent(@Context HttpServletRequest request) {
2554     try {
2555       String result = getIndexService().createEvent(request);
2556       if (StringUtils.isEmpty(result)) {
2557         return RestUtil.R.badRequest("The date range provided did not include any events");
2558       }
2559       return Response.status(Status.CREATED).entity(result).build();
2560     } catch (IllegalArgumentException | UnsupportedAssetException e) {
2561       return RestUtil.R.badRequest(e.getMessage());
2562     } catch (Exception e) {
2563       return RestUtil.R.serverError();
2564     }
2565   }
2566 
2567   @GET
2568   @Path("events.json")
2569   @Produces(MediaType.APPLICATION_JSON)
2570   @RestQuery(name = "getevents", description = "Returns all the events as JSON", returnDescription = "All the events as JSON", restParameters = {
2571           @RestParameter(name = "filter", isRequired = false, description = "The filter used for the query. They should be formated like that: 'filter1:value1,filter2:value2'", type = STRING),
2572           @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),
2573           @RestParameter(name = "limit", description = "The maximum number of items to return per page.", isRequired = false, type = RestParameter.Type.INTEGER),
2574           @RestParameter(name = "offset", description = "The page number.", isRequired = false, type = RestParameter.Type.INTEGER),
2575           @RestParameter(name = "getComments", description = "If comments should be fetched", isRequired = false, type = RestParameter.Type.BOOLEAN) }, responses = {
2576                   @RestResponse(description = "Returns all events as JSON", responseCode = HttpServletResponse.SC_OK) })
2577   public Response getEvents(@QueryParam("id") String id, @QueryParam("commentReason") String reasonFilter,
2578           @QueryParam("commentResolution") String resolutionFilter, @QueryParam("filter") String filter,
2579           @QueryParam("sort") String sort, @QueryParam("offset") Integer offset, @QueryParam("limit") Integer limit,
2580           @QueryParam("getComments") Boolean getComments) {
2581 
2582     Option<Integer> optLimit = Option.option(limit);
2583     Option<Integer> optOffset = Option.option(offset);
2584     Option<String> optSort = Option.option(trimToNull(sort));
2585     Option<Boolean> optGetComments = Option.option(getComments);
2586     ArrayList<JValue> eventsList = new ArrayList<>();
2587     final Organization organization = getSecurityService().getOrganization();
2588     final User user = getSecurityService().getUser();
2589     if (organization == null || user == null) {
2590       return Response.status(SC_SERVICE_UNAVAILABLE).build();
2591     }
2592     EventSearchQuery query = new EventSearchQuery(organization.getId(), user);
2593 
2594     // If the limit is set to 0, this is not taken into account
2595     if (optLimit.isSome() && limit == 0) {
2596       optLimit = Option.none();
2597     }
2598 
2599     Map<String, String> filters = RestUtils.parseFilter(filter);
2600     for (String name : filters.keySet()) {
2601       if (EventListQuery.FILTER_PRESENTERS_BIBLIOGRAPHIC_NAME.equals(name))
2602         query.withPresenter(filters.get(name));
2603       if (EventListQuery.FILTER_PRESENTERS_TECHNICAL_NAME.equals(name))
2604         query.withTechnicalPresenters(filters.get(name));
2605       if (EventListQuery.FILTER_CONTRIBUTORS_NAME.equals(name))
2606         query.withContributor(filters.get(name));
2607       if (EventListQuery.FILTER_LOCATION_NAME.equals(name))
2608         query.withLocation(filters.get(name));
2609       if (EventListQuery.FILTER_AGENT_NAME.equals(name))
2610         query.withAgentId(filters.get(name));
2611       if (EventListQuery.FILTER_TEXT_NAME.equals(name))
2612         query.withText(filters.get(name));
2613       if (EventListQuery.FILTER_SERIES_NAME.equals(name))
2614         query.withSeriesId(filters.get(name));
2615       if (EventListQuery.FILTER_STATUS_NAME.equals(name))
2616         query.withEventStatus(filters.get(name));
2617       if (EventListQuery.FILTER_PUBLISHER_NAME.equals(name))
2618         query.withPublisher(filters.get(name));
2619       if (EventListQuery.FILTER_COMMENTS_NAME.equals(name)) {
2620         switch (Comments.valueOf(filters.get(name))) {
2621           case NONE:
2622             query.withComments(false);
2623             break;
2624           case OPEN:
2625             query.withOpenComments(true);
2626             break;
2627           case RESOLVED:
2628             query.withComments(true);
2629             query.withOpenComments(false);
2630             break;
2631           default:
2632             logger.info("Unknown comment {}", filters.get(name));
2633             return Response.status(SC_BAD_REQUEST).build();
2634         }
2635       }
2636       if (EventListQuery.FILTER_IS_PUBLISHED_NAME.equals(name)) {
2637         if (filters.containsKey(name)) {
2638           switch (IsPublished.valueOf(filters.get(name))) {
2639             case YES:
2640               query.withIsPublished(true);
2641               break;
2642             case NO:
2643               query.withIsPublished(false);
2644               break;
2645             default:
2646               break;
2647           }
2648         } else {
2649           logger.info("Query for invalid published status: {}", filters.get(name));
2650           return Response.status(SC_BAD_REQUEST).build();
2651         }
2652       }
2653       if (EventListQuery.FILTER_STARTDATE_NAME.equals(name)) {
2654         try {
2655           Tuple<Date, Date> fromAndToCreationRange = RestUtils.getFromAndToDateRange(filters.get(name));
2656           query.withStartFrom(fromAndToCreationRange.getA());
2657           query.withStartTo(fromAndToCreationRange.getB());
2658         } catch (IllegalArgumentException e) {
2659           return RestUtil.R.badRequest(e.getMessage());
2660         }
2661       }
2662     }
2663 
2664     if (optSort.isSome()) {
2665       ArrayList<SortCriterion> sortCriteria = RestUtils.parseSortQueryParameter(optSort.get());
2666       for (SortCriterion criterion : sortCriteria) {
2667         switch (criterion.getFieldName()) {
2668           case EventIndexSchema.UID:
2669             query.sortByUID(criterion.getOrder());
2670             break;
2671           case EventIndexSchema.TITLE:
2672             query.sortByTitle(criterion.getOrder());
2673             break;
2674           case EventIndexSchema.PRESENTER:
2675             query.sortByPresenter(criterion.getOrder());
2676             break;
2677           case EventIndexSchema.TECHNICAL_START:
2678           case "technical_date":
2679             query.sortByTechnicalStartDate(criterion.getOrder());
2680             break;
2681           case EventIndexSchema.TECHNICAL_END:
2682             query.sortByTechnicalEndDate(criterion.getOrder());
2683             break;
2684           case EventIndexSchema.PUBLICATION:
2685             query.sortByPublicationIgnoringInternal(criterion.getOrder());
2686             break;
2687           case EventIndexSchema.START_DATE:
2688           case "date":
2689             query.sortByStartDate(criterion.getOrder());
2690             break;
2691           case EventIndexSchema.END_DATE:
2692             query.sortByEndDate(criterion.getOrder());
2693             break;
2694           case EventIndexSchema.SERIES_NAME:
2695             query.sortBySeriesName(criterion.getOrder());
2696             break;
2697           case EventIndexSchema.LOCATION:
2698             query.sortByLocation(criterion.getOrder());
2699             break;
2700           case EventIndexSchema.EVENT_STATUS:
2701             query.sortByEventStatus(criterion.getOrder());
2702             break;
2703           default:
2704             final String msg = String.format("Unknown sort criteria field %s", criterion.getFieldName());
2705             logger.debug(msg);
2706             return RestUtil.R.badRequest(msg);
2707         }
2708       }
2709     }
2710 
2711     // We search for write actions
2712     if (getOnlyEventsWithWriteAccessEventsTab()) {
2713       query.withoutActions();
2714       query.withAction(Permissions.Action.WRITE);
2715       query.withAction(Permissions.Action.READ);
2716     }
2717 
2718     if (optLimit.isSome())
2719       query.withLimit(optLimit.get());
2720     if (optOffset.isSome())
2721       query.withOffset(offset);
2722     // TODO: Add other filters to the query
2723 
2724     SearchResult<Event> results = null;
2725     try {
2726       results = getIndex().getByQuery(query);
2727     } catch (SearchIndexException e) {
2728       logger.error("The admin UI Search Index was not able to get the events list:", e);
2729       return RestUtil.R.serverError();
2730     }
2731 
2732     // If the results list if empty, we return already a response.
2733     if (results.getPageSize() == 0) {
2734       logger.debug("No events match the given filters.");
2735       return okJsonList(eventsList, nul(offset).getOr(0), nul(limit).getOr(0), 0);
2736     }
2737 
2738     for (SearchResultItem<Event> item : results.getItems()) {
2739       Event source = item.getSource();
2740       source.updatePreview(getAdminUIConfiguration().getPreviewSubtype());
2741       List<EventComment> comments = null;
2742       if (optGetComments.isSome() && optGetComments.get()) {
2743         try {
2744           comments = getEventCommentService().getComments(source.getIdentifier());
2745         } catch (EventCommentException e) {
2746           logger.error("Unable to get comments from event {}", source.getIdentifier(), e);
2747           throw new WebApplicationException(e);
2748         }
2749       }
2750       eventsList.add(eventToJSON(source, Optional.ofNullable(comments)));
2751     }
2752 
2753     return okJsonList(eventsList, nul(offset).getOr(0), nul(limit).getOr(0), results.getHitCount());
2754   }
2755 
2756   // --
2757 
2758   private MediaPackage getMediaPackageByEventId(String eventId)
2759           throws SearchIndexException, NotFoundException, IndexServiceException {
2760     Opt<Event> optEvent = getIndexService().getEvent(eventId, getIndex());
2761     if (optEvent.isNone())
2762       throw new NotFoundException(format("Cannot find an event with id '%s'.", eventId));
2763     return getIndexService().getEventMediapackage(optEvent.get());
2764   }
2765 
2766   private URI getCommentUrl(String eventId, long commentId) {
2767     return UrlSupport.uri(serverUrl, eventId, "comment", Long.toString(commentId));
2768   }
2769 
2770   private JValue eventToJSON(Event event, Optional<List<EventComment>> comments) {
2771     List<Field> fields = new ArrayList<>();
2772 
2773     fields.add(f("id", v(event.getIdentifier())));
2774     fields.add(f("title", v(event.getTitle(), BLANK)));
2775     fields.add(f("source", v(event.getSource(), BLANK)));
2776     fields.add(f("presenters", arr($(event.getPresenters()).map(Functions.stringToJValue))));
2777     if (StringUtils.isNotBlank(event.getSeriesId())) {
2778       String seriesTitle = event.getSeriesName();
2779       String seriesID = event.getSeriesId();
2780 
2781       fields.add(f("series", obj(f("id", v(seriesID, BLANK)), f("title", v(seriesTitle, BLANK)))));
2782     }
2783     fields.add(f("location", v(event.getLocation(), BLANK)));
2784     fields.add(f("start_date", v(event.getRecordingStartDate(), BLANK)));
2785     fields.add(f("end_date", v(event.getRecordingEndDate(), BLANK)));
2786     fields.add(f("managedAcl", v(event.getManagedAcl(), BLANK)));
2787     fields.add(f("workflow_state", v(event.getWorkflowState(), BLANK)));
2788     fields.add(f("event_status", v(event.getEventStatus())));
2789     fields.add(f("displayable_status", v(event.getDisplayableStatus(getWorkflowService().getWorkflowStateMappings()))));
2790     fields.add(f("source", v(getIndexService().getEventSource(event).toString())));
2791     fields.add(f("has_comments", v(event.hasComments())));
2792     fields.add(f("has_open_comments", v(event.hasOpenComments())));
2793     fields.add(f("needs_cutting", v(event.needsCutting())));
2794     fields.add(f("has_preview", v(event.hasPreview())));
2795     fields.add(f("agent_id", v(event.getAgentId(), BLANK)));
2796     fields.add(f("technical_start", v(event.getTechnicalStartTime(), BLANK)));
2797     fields.add(f("technical_end", v(event.getTechnicalEndTime(), BLANK)));
2798     fields.add(f("technical_presenters", arr($(event.getTechnicalPresenters()).map(Functions.stringToJValue))));
2799     fields.add(f("publications", arr(eventPublicationsToJson(event))));
2800     if (comments.isPresent()) {
2801       fields.add(f("comments", arr(eventCommentsToJson(comments.get()))));
2802     }
2803     return obj(fields);
2804   }
2805 
2806   private JValue attachmentToJSON(Attachment attachment) {
2807     List<Field> fields = new ArrayList<>();
2808     fields.addAll(getEventMediaPackageElementFields(attachment));
2809     fields.addAll(getCommonElementFields(attachment));
2810     return obj(fields);
2811   }
2812 
2813   private JValue catalogToJSON(Catalog catalog) {
2814     List<Field> fields = new ArrayList<>();
2815     fields.addAll(getEventMediaPackageElementFields(catalog));
2816     fields.addAll(getCommonElementFields(catalog));
2817     return obj(fields);
2818   }
2819 
2820   private JValue trackToJSON(Track track) {
2821     List<Field> fields = new ArrayList<>();
2822     fields.addAll(getEventMediaPackageElementFields(track));
2823     fields.addAll(getCommonElementFields(track));
2824     fields.add(f("duration", v(track.getDuration(), BLANK)));
2825     fields.add(f("has_audio", v(track.hasAudio())));
2826     fields.add(f("has_video", v(track.hasVideo())));
2827     fields.add(f("has_subtitle", v(track.hasSubtitle())));
2828     fields.add(f("streams", obj(streamsToJSON(track.getStreams()))));
2829     return obj(fields);
2830   }
2831 
2832   private List<Field> streamsToJSON(org.opencastproject.mediapackage.Stream[] streams) {
2833     List<Field> fields = new ArrayList<>();
2834     List<JValue> audioList = new ArrayList<>();
2835     List<JValue> videoList = new ArrayList<>();
2836     List<JValue> subtitleList = new ArrayList<>();
2837     for (org.opencastproject.mediapackage.Stream stream : streams) {
2838       // TODO There is a bug with the stream ids, see MH-10325
2839       if (stream instanceof AudioStreamImpl) {
2840         List<Field> audio = new ArrayList<>();
2841         AudioStream audioStream = (AudioStream) stream;
2842         audio.add(f("id", v(audioStream.getIdentifier(), BLANK)));
2843         audio.add(f("type", v(audioStream.getFormat(), BLANK)));
2844         audio.add(f("channels", v(audioStream.getChannels(), BLANK)));
2845         audio.add(f("bitrate", v(audioStream.getBitRate(), BLANK)));
2846         audio.add(f("bitdepth", v(audioStream.getBitDepth(), BLANK)));
2847         audio.add(f("samplingrate", v(audioStream.getSamplingRate(), BLANK)));
2848         audio.add(f("framecount", v(audioStream.getFrameCount(), BLANK)));
2849         audio.add(f("peakleveldb", v(audioStream.getPkLevDb(), BLANK)));
2850         audio.add(f("rmsleveldb", v(audioStream.getRmsLevDb(), BLANK)));
2851         audio.add(f("rmspeakdb", v(audioStream.getRmsPkDb(), BLANK)));
2852         audioList.add(obj(audio));
2853       } else if (stream instanceof VideoStreamImpl) {
2854         List<Field> video = new ArrayList<>();
2855         VideoStream videoStream = (VideoStream) stream;
2856         video.add(f("id", v(videoStream.getIdentifier(), BLANK)));
2857         video.add(f("type", v(videoStream.getFormat(), BLANK)));
2858         video.add(f("bitrate", v(videoStream.getBitRate(), BLANK)));
2859         video.add(f("framerate", v(videoStream.getFrameRate(), BLANK)));
2860         video.add(f("resolution", v(videoStream.getFrameWidth() + "x" + videoStream.getFrameHeight(), BLANK)));
2861         video.add(f("framecount", v(videoStream.getFrameCount(), BLANK)));
2862         video.add(f("scantype", v(videoStream.getScanType(), BLANK)));
2863         video.add(f("scanorder", v(videoStream.getScanOrder(), BLANK)));
2864         videoList.add(obj(video));
2865       } else if (stream instanceof SubtitleStreamImpl) {
2866         List<Field> subtitle = new ArrayList<>();
2867         SubtitleStreamImpl subtitleStream = (SubtitleStreamImpl) stream;
2868         subtitle.add(f("id", v(subtitleStream.getIdentifier(), BLANK)));
2869         subtitle.add(f("type", v(subtitleStream.getFormat(), BLANK)));
2870         subtitleList.add(obj(subtitle));
2871       } else {
2872         throw new IllegalArgumentException("Stream must be either audio, video or subtitle");
2873       }
2874     }
2875     fields.add(f("audio", arr(audioList)));
2876     fields.add(f("video", arr(videoList)));
2877     fields.add(f("subtitle", arr(subtitleList)));
2878     return fields;
2879   }
2880 
2881   private JValue publicationToJSON(Publication publication) {
2882     List<Field> fields = new ArrayList<>();
2883     fields.add(f("id", v(publication.getIdentifier(), BLANK)));
2884     fields.add(f("channel", v(publication.getChannel(), BLANK)));
2885     fields.add(f("mimetype", v(publication.getMimeType(), BLANK)));
2886     fields.add(f("tags", arr($(publication.getTags()).map(toStringJValue))));
2887     fields.add(f("url", v(signUrl(publication.getURI()), BLANK)));
2888     fields.addAll(getCommonElementFields(publication));
2889     return obj(fields);
2890   }
2891 
2892   private List<Field> getCommonElementFields(MediaPackageElement element) {
2893     List<Field> fields = new ArrayList<>();
2894     fields.add(f("size", v(element.getSize(), BLANK)));
2895     fields.add(f("checksum", v(element.getChecksum() != null ? element.getChecksum().getValue() : null, BLANK)));
2896     fields.add(f("reference", v(element.getReference() != null ? element.getReference().getIdentifier() : null, BLANK)));
2897     return fields;
2898   }
2899 
2900   /**
2901    * Render an array of {@link Publication}s into a list of JSON values.
2902    *
2903    * @param publications
2904    *          The elements to pull the data from to create the list of {@link JValue}s
2905    * @return {@link List} of {@link JValue}s that represent the {@link Publication}
2906    */
2907   private List<JValue> getEventPublications(Publication[] publications) {
2908     List<JValue> publicationJSON = new ArrayList<>();
2909     for (Publication publication : publications) {
2910       publicationJSON.add(obj(f("id", v(publication.getIdentifier(), BLANK)),
2911               f("channel", v(publication.getChannel(), BLANK)), f("mimetype", v(publication.getMimeType(), BLANK)),
2912               f("tags", arr($(publication.getTags()).map(toStringJValue))),
2913               f("url", v(signUrl(publication.getURI()), BLANK))));
2914     }
2915     return publicationJSON;
2916   }
2917 
2918   private URI signUrl(URI url) {
2919     if (url == null) {
2920       return null;
2921     }
2922     if (getUrlSigningService().accepts(url.toString())) {
2923       try {
2924         String clientIP = null;
2925         if (signWithClientIP()) {
2926           clientIP = getSecurityService().getUserIP();
2927         }
2928         return URI.create(getUrlSigningService().sign(url.toString(), getUrlSigningExpireDuration(), null, clientIP));
2929       } catch (UrlSigningException e) {
2930         logger.warn("Unable to sign url '{}'", url, e);
2931       }
2932     }
2933     return url;
2934   }
2935 
2936   /**
2937    * Render an array of {@link MediaPackageElement}s into a list of JSON values.
2938    *
2939    * @param elements
2940    *          The elements to pull the data from to create the list of {@link JValue}s
2941    * @return {@link List} of {@link JValue}s that represent the {@link MediaPackageElement}
2942    */
2943   private List<JValue> getEventMediaPackageElements(MediaPackageElement[] elements) {
2944     List<JValue> elementJSON = new ArrayList<>();
2945     for (MediaPackageElement element : elements) {
2946       elementJSON.add(obj(getEventMediaPackageElementFields(element)));
2947     }
2948     return elementJSON;
2949   }
2950 
2951   private List<Field> getEventMediaPackageElementFields(MediaPackageElement element) {
2952     List<Field> fields = new ArrayList<>();
2953     fields.add(f("id", v(element.getIdentifier(), BLANK)));
2954     fields.add(f("type", v(element.getFlavor(), BLANK)));
2955     fields.add(f("mimetype", v(element.getMimeType(), BLANK)));
2956     List<JValue> tags = Stream.$(element.getTags()).map(toStringJValue).toList();
2957     fields.add(f("tags", arr(tags)));
2958     fields.add(f("url", v(signUrl(element.getURI()), BLANK)));
2959     return fields;
2960   }
2961 
2962   private static final Fn<String, JValue> toStringJValue = new Fn<String, JValue>() {
2963     @Override
2964     public JValue apply(String stringValue) {
2965       return v(stringValue, BLANK);
2966     }
2967   };
2968 
2969   private final Fn<Publication, JObject> publicationToJson = new Fn<Publication, JObject>() {
2970     @Override
2971     public JObject apply(Publication publication) {
2972       final Opt<String> channel = Opt.nul(EventUtils.PUBLICATION_CHANNELS.get(publication.getChannel()));
2973       String url = publication.getURI() == null ? "" : signUrl(publication.getURI()).toString();
2974       return obj(f("id", v(publication.getChannel())),
2975               f("name", v(channel.getOr("EVENTS.EVENTS.DETAILS.PUBLICATIONS.CUSTOM"))), f("url", v(url, NULL)));
2976     }
2977   };
2978 
2979   protected static final Fn<TechnicalMetadata, JObject> technicalMetadataToJson = new Fn<TechnicalMetadata, JObject>() {
2980     @Override
2981     public JObject apply(TechnicalMetadata technicalMetadata) {
2982       JValue agentConfig = technicalMetadata.getCaptureAgentConfiguration() == null ? v("")
2983               : JSONUtils.mapToJSON(technicalMetadata.getCaptureAgentConfiguration());
2984       JValue start = technicalMetadata.getStartDate() == null ? v("")
2985               : v(DateTimeSupport.toUTC(technicalMetadata.getStartDate().getTime()));
2986       JValue end = technicalMetadata.getEndDate() == null ? v("")
2987               : v(DateTimeSupport.toUTC(technicalMetadata.getEndDate().getTime()));
2988       return obj(f("agentId", v(technicalMetadata.getAgentId(), BLANK)), f("agentConfiguration", agentConfig),
2989               f("start", start), f("end", end), f("eventId", v(technicalMetadata.getEventId(), BLANK)),
2990               f("presenters", JSONUtils.setToJSON(technicalMetadata.getPresenters())),
2991               f("recording", recordingToJson.apply(technicalMetadata.getRecording())));
2992     }
2993   };
2994 
2995   protected static final Fn<Optional<Recording>, JObject> recordingToJson = new Fn<Optional<Recording>, JObject>() {
2996     @Override
2997     public JObject apply(Optional<Recording> recording) {
2998       if (recording.isEmpty()) {
2999         return obj();
3000       }
3001       return obj(f("id", v(recording.get().getID(), BLANK)),
3002               f("lastCheckInTime", v(recording.get().getLastCheckinTime(), BLANK)),
3003               f("lastCheckInTimeUTC", v(toUTC(recording.get().getLastCheckinTime()), BLANK)),
3004               f("state", v(recording.get().getState(), BLANK)));
3005     }
3006   };
3007 
3008   @PUT
3009   @Path("{eventId}/workflows/{workflowId}/action/{action}")
3010   @RestQuery(name = "workflowAction", description = "Performs the given action for the given workflow.", returnDescription = "", pathParameters = {
3011           @RestParameter(name = "eventId", description = "The id of the media package", isRequired = true, type = RestParameter.Type.STRING),
3012           @RestParameter(name = "workflowId", description = "The id of the workflow", isRequired = true, type = RestParameter.Type.STRING),
3013           @RestParameter(name = "action", description = "The action to take: STOP, RETRY or NONE (abort processing)", isRequired = true, type = RestParameter.Type.STRING) }, responses = {
3014                   @RestResponse(responseCode = SC_OK, description = "Workflow resumed."),
3015                   @RestResponse(responseCode = SC_NOT_FOUND, description = "Event or workflow instance not found."),
3016                   @RestResponse(responseCode = SC_BAD_REQUEST, description = "Invalid action entered."),
3017                   @RestResponse(responseCode = SC_UNAUTHORIZED, description = "You do not have permission to perform the action. Maybe you need to authenticate."),
3018                   @RestResponse(responseCode = SC_INTERNAL_SERVER_ERROR, description = "An exception occurred.") })
3019   public Response workflowAction(@PathParam("eventId") String id, @PathParam("workflowId") long wfId,
3020           @PathParam("action") String action) {
3021     if (StringUtils.isEmpty(id) || StringUtils.isEmpty(action)) {
3022       return badRequest();
3023     }
3024 
3025     try {
3026       final Opt<Event> optEvent = getIndexService().getEvent(id, getIndex());
3027       if (optEvent.isNone()) {
3028         return notFound("Cannot find an event with id '%s'.", id);
3029       }
3030 
3031       final WorkflowInstance wfInstance = getWorkflowService().getWorkflowById(wfId);
3032       if (!wfInstance.getMediaPackage().getIdentifier().toString().equals(id)) {
3033         return badRequest(String.format("Workflow %s is not associated to event %s", wfId, id));
3034       }
3035 
3036       if (RetryStrategy.NONE.toString().equalsIgnoreCase(action)
3037         || RetryStrategy.RETRY.toString().equalsIgnoreCase(action)) {
3038         getWorkflowService().resume(wfId, Collections.singletonMap("retryStrategy", action));
3039         return ok();
3040       }
3041 
3042       if (WORKFLOW_ACTION_STOP.equalsIgnoreCase(action)) {
3043         getWorkflowService().stop(wfId);
3044         return ok();
3045       }
3046 
3047       return badRequest("Action not supported: " + action);
3048     } catch (NotFoundException e) {
3049       return notFound("Workflow not found: '%d'.", wfId);
3050     } catch (IllegalStateException e) {
3051       return badRequest(String.format("Action %s not allowed for current workflow state. EventId: %s", action, id));
3052     } catch (UnauthorizedException e) {
3053       return forbidden();
3054     } catch (Exception e) {
3055       return serverError();
3056     }
3057   }
3058 
3059   @DELETE
3060   @Path("{eventId}/workflows/{workflowId}")
3061   @RestQuery(name = "deleteWorkflow", description = "Deletes a workflow", returnDescription = "The method doesn't return any content", pathParameters = {
3062     @RestParameter(name = "eventId", isRequired = true, description = "The event identifier", type = RestParameter.Type.STRING),
3063     @RestParameter(name = "workflowId", isRequired = true, description = "The workflow identifier", type = RestParameter.Type.INTEGER) }, responses = {
3064     @RestResponse(responseCode = SC_BAD_REQUEST, description = "When trying to delete the latest workflow of the event."),
3065     @RestResponse(responseCode = SC_NOT_FOUND, description = "If the event or the workflow has not been found."),
3066     @RestResponse(responseCode = SC_NO_CONTENT, description = "The method does not return any content") })
3067   public Response deleteWorkflow(@PathParam("eventId") String id, @PathParam("workflowId") long wfId)
3068     throws SearchIndexException {
3069     final Opt<Event> optEvent = getIndexService().getEvent(id, getIndex());
3070     try {
3071       if (optEvent.isNone()) {
3072         return notFound("Cannot find an event with id '%s'.", id);
3073       }
3074 
3075       final WorkflowInstance wfInstance = getWorkflowService().getWorkflowById(wfId);
3076       if (!wfInstance.getMediaPackage().getIdentifier().toString().equals(id)) {
3077         return badRequest(String.format("Workflow %s is not associated to event %s", wfId, id));
3078       }
3079 
3080       if (wfId == optEvent.get().getWorkflowId()) {
3081         return badRequest(String.format("Cannot delete current workflow %s from event %s."
3082           + " Only older workflows can be deleted.", wfId, id));
3083       }
3084 
3085       getWorkflowService().remove(wfId);
3086 
3087       return Response.noContent().build();
3088     } catch (WorkflowStateException e) {
3089       return badRequest("Deleting is not allowed for current workflow state. EventId: " + id);
3090     } catch (NotFoundException e) {
3091       return notFound("Workflow not found: '%d'.", wfId);
3092     } catch (UnauthorizedException e) {
3093       return forbidden();
3094     } catch (Exception e) {
3095       return serverError();
3096     }
3097   }
3098 
3099   private Opt<Event> checkAgentAccessForEvent(final String eventId) throws UnauthorizedException, SearchIndexException {
3100     final Opt<Event> event = getIndexService().getEvent(eventId, getIndex());
3101     if (event.isNone() || !event.get().getEventStatus().contains("SCHEDULE")) {
3102       return event;
3103     }
3104     SecurityUtil.checkAgentAccess(getSecurityService(), event.get().getAgentId());
3105     return event;
3106   }
3107 
3108   private void checkAgentAccessForAgent(final String agentId) throws UnauthorizedException {
3109     SecurityUtil.checkAgentAccess(getSecurityService(), agentId);
3110   }
3111 
3112 }