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.json.Jsons.arr;
26  import static com.entwinemedia.fn.data.json.Jsons.f;
27  import static com.entwinemedia.fn.data.json.Jsons.obj;
28  import static com.entwinemedia.fn.data.json.Jsons.v;
29  import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
30  import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
31  import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
32  import static javax.servlet.http.HttpServletResponse.SC_NO_CONTENT;
33  import static javax.servlet.http.HttpServletResponse.SC_OK;
34  import static javax.servlet.http.HttpServletResponse.SC_SERVICE_UNAVAILABLE;
35  import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
36  import static javax.ws.rs.core.Response.Status.BAD_REQUEST;
37  import static javax.ws.rs.core.Response.Status.NOT_FOUND;
38  import static javax.ws.rs.core.Response.Status.NO_CONTENT;
39  import static org.apache.commons.lang3.StringUtils.trimToNull;
40  import static org.opencastproject.adminui.endpoint.EndpointUtil.transformAccessControList;
41  import static org.opencastproject.index.service.util.RestUtils.notFound;
42  import static org.opencastproject.index.service.util.RestUtils.okJson;
43  import static org.opencastproject.index.service.util.RestUtils.okJsonList;
44  import static org.opencastproject.util.DateTimeSupport.toUTC;
45  import static org.opencastproject.util.RestUtil.R.badRequest;
46  import static org.opencastproject.util.RestUtil.R.conflict;
47  import static org.opencastproject.util.RestUtil.R.forbidden;
48  import static org.opencastproject.util.RestUtil.R.notFound;
49  import static org.opencastproject.util.RestUtil.R.ok;
50  import static org.opencastproject.util.RestUtil.R.serverError;
51  import static org.opencastproject.util.doc.rest.RestParameter.Type.BOOLEAN;
52  import static org.opencastproject.util.doc.rest.RestParameter.Type.INTEGER;
53  import static org.opencastproject.util.doc.rest.RestParameter.Type.STRING;
54  import static org.opencastproject.util.doc.rest.RestParameter.Type.TEXT;
55  
56  import org.opencastproject.adminui.impl.AdminUIConfiguration;
57  import org.opencastproject.adminui.tobira.TobiraException;
58  import org.opencastproject.adminui.tobira.TobiraService;
59  import org.opencastproject.authorization.xacml.manager.api.AclService;
60  import org.opencastproject.authorization.xacml.manager.api.AclServiceFactory;
61  import org.opencastproject.authorization.xacml.manager.api.ManagedAcl;
62  import org.opencastproject.authorization.xacml.manager.util.AccessInformationUtil;
63  import org.opencastproject.elasticsearch.api.SearchIndexException;
64  import org.opencastproject.elasticsearch.api.SearchResult;
65  import org.opencastproject.elasticsearch.api.SearchResultItem;
66  import org.opencastproject.elasticsearch.index.ElasticsearchIndex;
67  import org.opencastproject.elasticsearch.index.objects.event.Event;
68  import org.opencastproject.elasticsearch.index.objects.event.EventSearchQuery;
69  import org.opencastproject.elasticsearch.index.objects.series.Series;
70  import org.opencastproject.elasticsearch.index.objects.series.SeriesIndexSchema;
71  import org.opencastproject.elasticsearch.index.objects.series.SeriesSearchQuery;
72  import org.opencastproject.elasticsearch.index.objects.theme.IndexTheme;
73  import org.opencastproject.elasticsearch.index.objects.theme.ThemeSearchQuery;
74  import org.opencastproject.index.service.api.IndexService;
75  import org.opencastproject.index.service.exception.IndexServiceException;
76  import org.opencastproject.index.service.resources.list.provider.SeriesListProvider;
77  import org.opencastproject.index.service.resources.list.query.SeriesListQuery;
78  import org.opencastproject.index.service.util.RestUtils;
79  import org.opencastproject.list.api.ListProviderException;
80  import org.opencastproject.list.api.ListProvidersService;
81  import org.opencastproject.metadata.dublincore.DublinCore;
82  import org.opencastproject.metadata.dublincore.DublinCoreMetadataCollection;
83  import org.opencastproject.metadata.dublincore.MetadataField;
84  import org.opencastproject.metadata.dublincore.MetadataJson;
85  import org.opencastproject.metadata.dublincore.MetadataList;
86  import org.opencastproject.metadata.dublincore.SeriesCatalogUIAdapter;
87  import org.opencastproject.rest.BulkOperationResult;
88  import org.opencastproject.security.api.AccessControlList;
89  import org.opencastproject.security.api.AccessControlParser;
90  import org.opencastproject.security.api.Permissions;
91  import org.opencastproject.security.api.SecurityService;
92  import org.opencastproject.security.api.UnauthorizedException;
93  import org.opencastproject.security.api.UserDirectoryService;
94  import org.opencastproject.series.api.SeriesException;
95  import org.opencastproject.series.api.SeriesService;
96  import org.opencastproject.systems.OpencastConstants;
97  import org.opencastproject.util.NotFoundException;
98  import org.opencastproject.util.RestUtil;
99  import org.opencastproject.util.UrlSupport;
100 import org.opencastproject.util.data.Option;
101 import org.opencastproject.util.data.Tuple;
102 import org.opencastproject.util.doc.rest.RestParameter;
103 import org.opencastproject.util.doc.rest.RestParameter.Type;
104 import org.opencastproject.util.doc.rest.RestQuery;
105 import org.opencastproject.util.doc.rest.RestResponse;
106 import org.opencastproject.util.doc.rest.RestService;
107 import org.opencastproject.util.requests.SortCriterion;
108 import org.opencastproject.util.requests.SortCriterion.Order;
109 import org.opencastproject.workflow.api.WorkflowInstance;
110 
111 import com.entwinemedia.fn.data.Opt;
112 import com.entwinemedia.fn.data.json.Field;
113 import com.entwinemedia.fn.data.json.JValue;
114 import com.entwinemedia.fn.data.json.Jsons;
115 import com.entwinemedia.fn.data.json.Jsons.Functions;
116 
117 import org.apache.commons.lang3.BooleanUtils;
118 import org.apache.commons.lang3.StringUtils;
119 import org.json.simple.JSONArray;
120 import org.json.simple.JSONObject;
121 import org.json.simple.parser.JSONParser;
122 import org.osgi.service.component.ComponentContext;
123 import org.osgi.service.component.annotations.Activate;
124 import org.osgi.service.component.annotations.Component;
125 import org.osgi.service.component.annotations.Modified;
126 import org.osgi.service.component.annotations.Reference;
127 import org.osgi.service.jaxrs.whiteboard.propertytypes.JaxrsResource;
128 import org.slf4j.Logger;
129 import org.slf4j.LoggerFactory;
130 
131 import java.io.IOException;
132 import java.net.URI;
133 import java.util.ArrayList;
134 import java.util.Date;
135 import java.util.List;
136 import java.util.Map;
137 import java.util.Objects;
138 import java.util.Optional;
139 import java.util.regex.Pattern;
140 
141 import javax.servlet.http.HttpServletResponse;
142 import javax.ws.rs.DELETE;
143 import javax.ws.rs.DefaultValue;
144 import javax.ws.rs.FormParam;
145 import javax.ws.rs.GET;
146 import javax.ws.rs.POST;
147 import javax.ws.rs.PUT;
148 import javax.ws.rs.Path;
149 import javax.ws.rs.PathParam;
150 import javax.ws.rs.Produces;
151 import javax.ws.rs.QueryParam;
152 import javax.ws.rs.WebApplicationException;
153 import javax.ws.rs.core.MediaType;
154 import javax.ws.rs.core.Response;
155 import javax.ws.rs.core.Response.Status;
156 
157 @Path("/admin-ng/series")
158 @RestService(name = "SeriesProxyService", title = "UI Series",
159   abstractText = "This service provides the series data for the UI.",
160   notes = { "This service offers the series CRUD Operations for the admin UI.",
161             "<strong>Important:</strong> "
162               + "<em>This service is for exclusive use by the module admin-ui. Its API might change "
163               + "anytime without prior notice. Any dependencies other than the admin UI will be strictly ignored. "
164               + "DO NOT use this for integration of third-party applications.<em>"})
165 @Component(
166         immediate = true,
167         service = SeriesEndpoint.class,
168         property = {
169                 "service.description=Admin UI - SeriesEndpoint Endpoint",
170                 "opencast.service.type=org.opencastproject.adminui.SeriesEndpoint",
171                 "opencast.service.path=/admin-ng/series",
172         }
173 )
174 @JaxrsResource
175 public class SeriesEndpoint {
176 
177   private static final Logger logger = LoggerFactory.getLogger(SeriesEndpoint.class);
178 
179   private static final int CREATED_BY_UI_ORDER = 9;
180 
181   /** Default number of items on page */
182   private static final int DEFAULT_LIMIT = 100;
183 
184   public static final String THEME_KEY = "theme";
185 
186   private Boolean deleteSeriesWithEventsAllowed = true;
187   private Boolean onlySeriesWithWriteAccessSeriesTab = false;
188   private Boolean onlySeriesWithWriteAccessEventsFilter = false;
189 
190   public static final String SERIES_HASEVENTS_DELETE_ALLOW_KEY = "series.hasEvents.delete.allow";
191   public static final String SERIESTAB_ONLYSERIESWITHWRITEACCESS_KEY = "seriesTab.onlySeriesWithWriteAccess";
192   public static final String EVENTSFILTER_ONLYSERIESWITHWRITEACCESS_KEY = "eventsFilter.onlySeriesWithWriteAccess";
193   public static final Pattern TOBIRA_CONFIG = Pattern.compile("^tobira\\.(?<organization>.*)\\.(?<key>origin|trustedKey)$");
194 
195   private SeriesService seriesService;
196   private SecurityService securityService;
197   private AclServiceFactory aclServiceFactory;
198   private IndexService indexService;
199   private ListProvidersService listProvidersService;
200   private ElasticsearchIndex searchIndex;
201   private AdminUIConfiguration adminUIConfiguration;
202   private UserDirectoryService userDirectoryService;
203 
204   /** Default server URL */
205   private String serverUrl = "http://localhost:8080";
206 
207   /** OSGi callback for the series service. */
208   @Reference
209   public void setSeriesService(SeriesService seriesService) {
210     this.seriesService = seriesService;
211   }
212 
213   /** OSGi callback for the search index. */
214   @Reference
215   public void setIndex(ElasticsearchIndex index) {
216     this.searchIndex = index;
217   }
218 
219   /** OSGi DI. */
220   @Reference
221   public void setIndexService(IndexService indexService) {
222     this.indexService = indexService;
223   }
224 
225   /** OSGi callback for the list provider service */
226   @Reference
227   public void setListProvidersService(ListProvidersService listProvidersService) {
228     this.listProvidersService = listProvidersService;
229   }
230 
231   /** OSGi callback for the security service */
232   @Reference
233   public void setSecurityService(SecurityService securityService) {
234     this.securityService = securityService;
235   }
236 
237   /** OSGi callback for the acl service factory */
238   @Reference
239   public void setAclServiceFactory(AclServiceFactory aclServiceFactory) {
240     this.aclServiceFactory = aclServiceFactory;
241   }
242 
243   private AclService getAclService() {
244     return aclServiceFactory.serviceFor(securityService.getOrganization());
245   }
246 
247 
248   /** OSGi DI. */
249   @Reference
250   public void setAdminUIConfiguration(AdminUIConfiguration adminUIConfiguration) {
251     this.adminUIConfiguration = adminUIConfiguration;
252   }
253 
254   /** Sets the user directory service */
255   @Reference
256   public void setUserDirectoryService(UserDirectoryService userDirectoryService) {
257     this.userDirectoryService = userDirectoryService;
258   }
259 
260   @Activate
261   protected void activate(ComponentContext cc, Map<String, Object> properties) {
262     if (cc != null) {
263       String ccServerUrl = cc.getBundleContext().getProperty(OpencastConstants.SERVER_URL_PROPERTY);
264       logger.debug("Configured server url is {}", ccServerUrl);
265       if (ccServerUrl != null)
266         this.serverUrl = ccServerUrl;
267 
268       modified(properties);
269     }
270     logger.info("Activate series endpoint");
271   }
272 
273   /** OSGi callback if properties file is present */
274   @Modified
275   public void modified(Map<String, Object> properties) {
276     if (properties == null) {
277       logger.info("No configuration available, using defaults");
278       return;
279     }
280 
281     Object mapValue = properties.get(SERIES_HASEVENTS_DELETE_ALLOW_KEY);
282     if (mapValue != null) {
283       deleteSeriesWithEventsAllowed = BooleanUtils.toBoolean(mapValue.toString());
284     }
285 
286     mapValue = properties.get(SERIESTAB_ONLYSERIESWITHWRITEACCESS_KEY);
287     onlySeriesWithWriteAccessSeriesTab = BooleanUtils.toBoolean(Objects.toString(mapValue, "true"));
288 
289     mapValue = properties.get(EVENTSFILTER_ONLYSERIESWITHWRITEACCESS_KEY);
290     onlySeriesWithWriteAccessEventsFilter = BooleanUtils.toBoolean(Objects.toString(mapValue, "true"));
291 
292     properties.forEach((key, value) -> {
293       var matches = TOBIRA_CONFIG.matcher(key);
294       if (!matches.matches()) {
295         return;
296       }
297       var tobira = TobiraService.getTobira(matches.group("organization"));
298       switch (matches.group("key")) {
299         case "origin":
300           tobira.setOrigin((String) value);
301           break;
302         case "trustedKey":
303           tobira.setTrustedKey((String) value);
304           break;
305         default:
306           throw new RuntimeException("unhandled Tobira config key");
307       }
308     });
309 
310     logger.info("Configuration updated");
311   }
312 
313   @GET
314   @Path("{seriesId}/access.json")
315   @SuppressWarnings("unchecked")
316   @Produces(MediaType.APPLICATION_JSON)
317   @RestQuery(name = "getseriesaccessinformation", description = "Get the access information of a series", returnDescription = "The access information", pathParameters = { @RestParameter(name = "seriesId", isRequired = true, description = "The series identifier", type = Type.STRING) }, responses = {
318           @RestResponse(responseCode = SC_BAD_REQUEST, description = "The required form params were missing in the request."),
319           @RestResponse(responseCode = SC_NOT_FOUND, description = "If the series has not been found."),
320           @RestResponse(responseCode = SC_OK, description = "The access information ") })
321   public Response getSeriesAccessInformation(@PathParam("seriesId") String seriesId) throws NotFoundException {
322     if (StringUtils.isBlank(seriesId))
323       return RestUtil.R.badRequest("Path parameter series ID is missing");
324 
325     boolean hasProcessingEvents = hasProcessingEvents(seriesId);
326 
327     // Add all available ACLs to the response
328     JSONArray systemAclsJson = new JSONArray();
329     List<ManagedAcl> acls = getAclService().getAcls();
330     for (ManagedAcl acl : acls) {
331       systemAclsJson.add(AccessInformationUtil.serializeManagedAcl(acl));
332     }
333 
334     JSONObject seriesAccessJson = new JSONObject();
335     try {
336       AccessControlList seriesAccessControl = seriesService.getSeriesAccessControl(seriesId);
337       Option<ManagedAcl> currentAcl = AccessInformationUtil.matchAclsLenient(acls, seriesAccessControl,
338               adminUIConfiguration.getMatchManagedAclRolePrefixes());
339       seriesAccessJson.put("current_acl", currentAcl.isSome() ? currentAcl.get().getId() : 0);
340       seriesAccessJson.put("privileges", AccessInformationUtil.serializePrivilegesByRole(seriesAccessControl));
341       seriesAccessJson.put("acl", transformAccessControList(seriesAccessControl, userDirectoryService));
342       seriesAccessJson.put("locked", hasProcessingEvents);
343     } catch (SeriesException e) {
344       logger.error("Unable to get ACL from series {}", seriesId, e);
345       return RestUtil.R.serverError();
346     }
347 
348     JSONObject jsonReturnObj = new JSONObject();
349     jsonReturnObj.put("system_acls", systemAclsJson);
350     jsonReturnObj.put("series_access", seriesAccessJson);
351 
352     return Response.ok(jsonReturnObj.toString()).build();
353   }
354 
355   @GET
356   @Produces(MediaType.APPLICATION_JSON)
357   @Path("{seriesId}/metadata.json")
358   @RestQuery(name = "getseriesmetadata", description = "Returns the series metadata as JSON", returnDescription = "Returns the series metadata as JSON", pathParameters = { @RestParameter(name = "seriesId", isRequired = true, description = "The series identifier", type = STRING) }, responses = {
359           @RestResponse(responseCode = SC_OK, description = "The series metadata as JSON."),
360           @RestResponse(responseCode = SC_NOT_FOUND, description = "The series has not been found"),
361           @RestResponse(responseCode = SC_UNAUTHORIZED, description = "If the current user is not authorized to perform this action") })
362   public Response getSeriesMetadata(@PathParam("seriesId") String series) throws UnauthorizedException,
363           NotFoundException, SearchIndexException {
364     Optional<Series> optSeries = searchIndex.getSeries(series, securityService.getOrganization().getId(), securityService.getUser());
365     if (optSeries.isEmpty())
366       return notFound("Cannot find a series with id '%s'.", series);
367 
368     MetadataList metadataList = new MetadataList();
369     List<SeriesCatalogUIAdapter> catalogUIAdapters = indexService.getSeriesCatalogUIAdapters();
370     catalogUIAdapters.remove(indexService.getCommonSeriesCatalogUIAdapter());
371     for (SeriesCatalogUIAdapter adapter : catalogUIAdapters) {
372       final Opt<DublinCoreMetadataCollection> optSeriesMetadata = adapter.getFields(series);
373       if (optSeriesMetadata.isSome()) {
374         metadataList.add(adapter.getFlavor().toString(), adapter.getUITitle(), optSeriesMetadata.get());
375       }
376     }
377     metadataList.add(indexService.getCommonSeriesCatalogUIAdapter(), getSeriesMetadata(optSeries.get()));
378     return okJson(MetadataJson.listToJson(metadataList, true));
379   }
380 
381   /**
382    * Loads the metadata for the given series
383    *
384    * @param series
385    *          the source {@link Series}
386    * @return a {@link DublinCoreMetadataCollection} instance with all the series metadata
387    */
388   private DublinCoreMetadataCollection getSeriesMetadata(Series series) {
389     DublinCoreMetadataCollection metadata = indexService.getCommonSeriesCatalogUIAdapter().getRawFields();
390 
391     MetadataField title = metadata.getOutputFields().get(DublinCore.PROPERTY_TITLE.getLocalName());
392     metadata.removeField(title);
393     MetadataField newTitle = new MetadataField(title);
394     newTitle.setValue(series.getTitle());
395     metadata.addField(newTitle);
396 
397     MetadataField subject = metadata.getOutputFields().get(DublinCore.PROPERTY_SUBJECT.getLocalName());
398     metadata.removeField(subject);
399     MetadataField newSubject = new MetadataField(subject);
400     newSubject.setValue(series.getSubject());
401     metadata.addField(newSubject);
402 
403     MetadataField description = metadata.getOutputFields().get(DublinCore.PROPERTY_DESCRIPTION.getLocalName());
404     metadata.removeField(description);
405     MetadataField newDescription = new MetadataField(description);
406     newDescription.setValue(series.getDescription());
407     metadata.addField(newDescription);
408 
409     MetadataField language = metadata.getOutputFields().get(DublinCore.PROPERTY_LANGUAGE.getLocalName());
410     metadata.removeField(language);
411     MetadataField newLanguage = new MetadataField(language);
412     newLanguage.setValue(series.getLanguage());
413     metadata.addField(newLanguage);
414 
415     MetadataField rightsHolder = metadata.getOutputFields().get(DublinCore.PROPERTY_RIGHTS_HOLDER.getLocalName());
416     metadata.removeField(rightsHolder);
417     MetadataField newRightsHolder = new MetadataField(rightsHolder);
418     newRightsHolder.setValue(series.getRightsHolder());
419     metadata.addField(newRightsHolder);
420 
421     MetadataField license = metadata.getOutputFields().get(DublinCore.PROPERTY_LICENSE.getLocalName());
422     metadata.removeField(license);
423     MetadataField newLicense = new MetadataField(license);
424     newLicense.setValue(series.getLicense());
425     metadata.addField(newLicense);
426 
427     MetadataField organizers = metadata.getOutputFields().get(DublinCore.PROPERTY_CREATOR.getLocalName());
428     metadata.removeField(organizers);
429     MetadataField newOrganizers = new MetadataField(organizers);
430     newOrganizers.setValue(series.getOrganizers());
431     metadata.addField(newOrganizers);
432 
433     MetadataField contributors = metadata.getOutputFields().get(DublinCore.PROPERTY_CONTRIBUTOR.getLocalName());
434     metadata.removeField(contributors);
435     MetadataField newContributors = new MetadataField(contributors);
436     newContributors.setValue(series.getContributors());
437     metadata.addField(newContributors);
438 
439     MetadataField publishers = metadata.getOutputFields().get(DublinCore.PROPERTY_PUBLISHER.getLocalName());
440     metadata.removeField(publishers);
441     MetadataField newPublishers = new MetadataField(publishers);
442     newPublishers.setValue(series.getPublishers());
443     metadata.addField(newPublishers);
444 
445     // Admin UI only field
446     MetadataField createdBy = new MetadataField(
447       "createdBy",
448       null,
449       "EVENTS.SERIES.DETAILS.METADATA.CREATED_BY",
450       true,
451       false,
452       null,
453       null,
454       MetadataField.Type.TEXT,
455       null,
456       null,
457       CREATED_BY_UI_ORDER,
458       null,
459       null,
460       null,
461       null);
462     createdBy.setValue(series.getCreator());
463     metadata.addField(createdBy);
464 
465     MetadataField uid = metadata.getOutputFields().get(DublinCore.PROPERTY_IDENTIFIER.getLocalName());
466     metadata.removeField(uid);
467     MetadataField newUID = new MetadataField(uid);
468     newUID.setValue(series.getIdentifier());
469     metadata.addField(newUID);
470 
471     return metadata;
472   }
473 
474   @PUT
475   @Path("{seriesId}/metadata")
476   @RestQuery(name = "updateseriesmetadata", description = "Update the series metadata with the one given JSON", returnDescription = "Returns OK if the metadata have been saved.", pathParameters = { @RestParameter(name = "seriesId", isRequired = true, description = "The series identifier", type = STRING) }, restParameters = { @RestParameter(name = "metadata", isRequired = true, type = RestParameter.Type.TEXT, description = "The list of metadata to update") }, responses = {
477           @RestResponse(responseCode = SC_OK, description = "The series metadata as JSON."),
478           @RestResponse(responseCode = SC_NOT_FOUND, description = "The series has not been found"),
479           @RestResponse(responseCode = SC_UNAUTHORIZED, description = "If the current user is not authorized to perform this action") })
480   public Response updateSeriesMetadata(@PathParam("seriesId") String seriesID,
481           @FormParam("metadata") String metadataJSON) throws UnauthorizedException, NotFoundException,
482           SearchIndexException {
483     try {
484       MetadataList metadataList = indexService.updateAllSeriesMetadata(seriesID, metadataJSON, searchIndex);
485       return okJson(MetadataJson.listToJson(metadataList, true));
486     } catch (IllegalArgumentException e) {
487       return RestUtil.R.badRequest(e.getMessage());
488     } catch (IndexServiceException e) {
489       return RestUtil.R.serverError();
490     }
491   }
492 
493   @GET
494   @Path("new/metadata")
495   @RestQuery(name = "getNewMetadata", description = "Returns all the data related to the metadata tab in the new series modal as JSON", returnDescription = "All the data related to the series metadata tab as JSON", responses = { @RestResponse(responseCode = SC_OK, description = "Returns all the data related to the series metadata tab as JSON") })
496   public Response getNewMetadata() {
497     MetadataList metadataList = indexService.getMetadataListWithAllSeriesCatalogUIAdapters();
498     final DublinCoreMetadataCollection metadataByAdapter = metadataList
499             .getMetadataByAdapter(indexService.getCommonSeriesCatalogUIAdapter());
500     if (metadataByAdapter != null) {
501       DublinCoreMetadataCollection collection = metadataByAdapter;
502       safelyRemoveField(collection, "identifier");
503       metadataList.add(indexService.getCommonSeriesCatalogUIAdapter(), collection);
504     }
505     return okJson(MetadataJson.listToJson(metadataList, true));
506   }
507 
508   private void safelyRemoveField(DublinCoreMetadataCollection collection, String fieldName) {
509     MetadataField metadataField = collection.getOutputFields().get(fieldName);
510     if (metadataField != null) {
511       collection.removeField(metadataField);
512     }
513   }
514 
515   @GET
516   @Path("new/themes")
517   @SuppressWarnings("unchecked")
518   @RestQuery(name = "getNewThemes", description = "Returns all the data related to the themes tab in the new series modal as JSON", returnDescription = "All the data related to the series themes tab as JSON", responses = { @RestResponse(responseCode = SC_OK, description = "Returns all the data related to the series themes tab as JSON") })
519   public Response getNewThemes() {
520     ThemeSearchQuery query = new ThemeSearchQuery(securityService.getOrganization().getId(), securityService.getUser());
521     // need to set limit because elasticsearch limit results by 10 per default
522     query.withLimit(Integer.MAX_VALUE);
523     query.withOffset(0);
524     query.sortByName(Order.Ascending);
525     SearchResult<IndexTheme> results = null;
526     try {
527       results = searchIndex.getByQuery(query);
528     } catch (SearchIndexException e) {
529       logger.error("The admin UI Search Index was not able to get the themes", e);
530       return RestUtil.R.serverError();
531     }
532 
533     JSONObject themesJson = new JSONObject();
534     for (SearchResultItem<IndexTheme> item : results.getItems()) {
535       JSONObject themeInfoJson = new JSONObject();
536       IndexTheme theme = item.getSource();
537       themeInfoJson.put("name", theme.getName());
538       themeInfoJson.put("description", theme.getDescription());
539       themesJson.put(theme.getIdentifier(), themeInfoJson);
540     }
541     return Response.ok(themesJson.toJSONString()).build();
542   }
543 
544   private TobiraService getTobira() {
545     return TobiraService.getTobira(securityService.getOrganization().getId());
546   }
547 
548   @GET
549   @Path("new/tobira/page")
550   @Produces(MediaType.APPLICATION_JSON)
551   @RestQuery(
552           name = "getTobiraPage",
553           description = "Returns data about the page tree of a connected Tobira instance for use in the series creation wizard",
554           returnDescription = "Information about a given page in Tobira, and its direct children",
555           restParameters = { @RestParameter(
556                           name = "path",
557                           isRequired = true,
558                           type = STRING,
559                           description = "The path of the page you want information about"
560                   ) },
561           responses = {
562                   @RestResponse(
563                           responseCode = SC_OK,
564                           description = "Data about the given page in Tobira. Note that this does not mean the page exists!"),
565                   @RestResponse(
566                           responseCode = SC_NOT_FOUND,
567                           description = "Nonexistent `path`"),
568                   @RestResponse(
569                           responseCode = SC_BAD_REQUEST,
570                           description = "missing `path`"),
571                   @RestResponse(
572                           responseCode = SC_SERVICE_UNAVAILABLE,
573                           description = "Tobira is not configured (correctly)") })
574   public Response getTobiraPage(@QueryParam("path") String path) throws IOException, InterruptedException {
575     if (path == null) {
576       throw new WebApplicationException("`path` missing", BAD_REQUEST);
577     }
578 
579     var tobira = getTobira();
580     if (!tobira.ready()) {
581       return Response.status(Status.SERVICE_UNAVAILABLE)
582               .entity("Tobira is not configured (correctly)")
583               .build();
584     }
585 
586     try {
587       var page = (JSONObject) tobira.getPage(path).get("page");
588       if (page == null) {
589         throw new WebApplicationException(NOT_FOUND);
590       }
591       return Response.ok(page.toJSONString()).build();
592     } catch (TobiraException e) {
593       throw new WebApplicationException(e, Status.INTERNAL_SERVER_ERROR);
594     }
595   }
596 
597   @POST
598   @Path("new")
599   @RestQuery(name = "createNewSeries", description = "Creates a new series by the given metadata as JSON", returnDescription = "The created series id", restParameters = { @RestParameter(name = "metadata", isRequired = true, description = "The metadata as JSON", type = RestParameter.Type.TEXT) }, responses = {
600           @RestResponse(responseCode = HttpServletResponse.SC_CREATED, description = "Returns the created series id"),
601           @RestResponse(responseCode = HttpServletResponse.SC_BAD_REQUEST, description = "he request could not be fulfilled due to the incorrect syntax of the request"),
602           @RestResponse(responseCode = SC_UNAUTHORIZED, description = "If user doesn't have rights to create the series") })
603   public Response createNewSeries(@FormParam("metadata") String metadata) throws UnauthorizedException {
604     try {
605       JSONObject metadataJson;
606       try {
607         metadataJson = (JSONObject) new JSONParser().parse(metadata);
608       } catch (Exception e) {
609         throw new IllegalArgumentException("Unable to parse metadata " + metadata, e);
610       }
611 
612       if (metadataJson == null) {
613         throw new IllegalArgumentException("No metadata set to create series");
614       }
615 
616       String seriesId = indexService.createSeries(metadataJson);
617 
618       var mounted = mountSeriesInTobira(seriesId, metadataJson);
619 
620       var responseObject = new JSONObject();
621       responseObject.put("id", seriesId);
622       responseObject.put("mounted", mounted);
623 
624       return Response.created(URI.create(UrlSupport.concat(serverUrl, "admin-ng/series/", seriesId, "metadata.json")))
625               .entity(responseObject.toString()).build();
626     } catch (IllegalArgumentException e) {
627       return RestUtil.R.badRequest(e.getMessage());
628     } catch (IndexServiceException e) {
629       return RestUtil.R.serverError();
630     }
631   }
632 
633   private boolean mountSeriesInTobira(String seriesId, JSONObject params) {
634     var tobira = getTobira();
635     if (!tobira.ready()) {
636       return false;
637     }
638 
639     var tobiraParams = params.get("tobira");
640     if (tobiraParams == null) {
641       return false;
642     }
643     if (!(tobiraParams instanceof JSONObject)) {
644       return false;
645     }
646     var tobiraParamsObject = (JSONObject) tobiraParams;
647 
648     var metadataCatalogs = (JSONArray) params.get("metadata");
649     var firstCatalog = (JSONObject) metadataCatalogs.get(0);
650     var metadataFields = (List<JSONObject>) firstCatalog.get("fields");
651     var title = metadataFields.stream()
652             .filter(field -> field.get("id").equals("title"))
653             .findAny()
654             .map(field -> field.get("value"))
655             .map(String.class::cast)
656             .get();
657 
658     var series = new JSONObject(Map.of(
659             "opencastId", seriesId,
660             "title", title));
661     tobiraParamsObject.put("series", series);
662 
663     try {
664       tobira.mount(tobiraParamsObject);
665     } catch (TobiraException e) {
666       return false;
667     }
668 
669     return true;
670   }
671 
672   @DELETE
673   @Path("{seriesId}")
674   @Produces(MediaType.APPLICATION_JSON)
675   @RestQuery(name = "deleteseries", description = "Delete a series.", returnDescription = "Ok if the series has been deleted.", pathParameters = { @RestParameter(name = "seriesId", isRequired = true, description = "The id of the series to delete.", type = STRING), }, responses = {
676           @RestResponse(responseCode = SC_OK, description = "The series has been deleted."),
677           @RestResponse(responseCode = HttpServletResponse.SC_NOT_FOUND, description = "The series could not be found.") })
678   public Response deleteSeries(@PathParam("seriesId") String id) throws NotFoundException {
679     try {
680       indexService.removeSeries(id);
681       return Response.ok().build();
682     } catch (NotFoundException e) {
683       throw e;
684     } catch (Exception e) {
685       logger.error("Unable to delete the series '{}' due to", id, e);
686       return Response.serverError().build();
687     }
688   }
689 
690   @POST
691   @Path("deleteSeries")
692   @Produces(MediaType.APPLICATION_JSON)
693   @RestQuery(name = "deletemultipleseries", description = "Deletes a json list of series by their given ids e.g. [\"Series-1\", \"Series-2\"]", returnDescription = "A JSON object with arrays that show whether a series was deleted, was not found or there was an error deleting it.", responses = {
694           @RestResponse(description = "Series have been deleted", responseCode = HttpServletResponse.SC_OK),
695           @RestResponse(description = "The list of ids could not be parsed into a json list.", responseCode = HttpServletResponse.SC_BAD_REQUEST) })
696   public Response deleteMultipleSeries(String seriesIdsContent) throws NotFoundException {
697     if (StringUtils.isBlank(seriesIdsContent)) {
698       return Response.status(Status.BAD_REQUEST).build();
699     }
700 
701     JSONParser parser = new JSONParser();
702     JSONArray seriesIdsArray;
703     try {
704       seriesIdsArray = (JSONArray) parser.parse(seriesIdsContent);
705     } catch (org.json.simple.parser.ParseException e) {
706       logger.error("Unable to parse '{}'", seriesIdsContent, e);
707       return Response.status(Status.BAD_REQUEST).build();
708     } catch (ClassCastException e) {
709       logger.error("Unable to cast '{}' to a JSON array", seriesIdsContent, e);
710       return Response.status(Status.BAD_REQUEST).build();
711     }
712 
713     BulkOperationResult result = new BulkOperationResult();
714     for (Object seriesId : seriesIdsArray) {
715       try {
716         indexService.removeSeries(seriesId.toString());
717         result.addOk(seriesId.toString());
718       } catch (NotFoundException e) {
719         result.addNotFound(seriesId.toString());
720       } catch (Exception e) {
721         logger.error("Unable to remove the series '{}'", seriesId.toString(), e);
722         result.addServerError(seriesId.toString());
723       }
724     }
725     return Response.ok(result.toJson()).build();
726   }
727 
728   @GET
729   @Produces(MediaType.APPLICATION_JSON)
730   @Path("series.json")
731   @RestQuery(name = "listSeriesAsJson", description = "Returns the series matching the query parameters", returnDescription = "Returns the series search results as JSON", restParameters = {
732           @RestParameter(name = "sortorganizer", isRequired = false, description = "The sort type to apply to the series organizer or organizers either Ascending or Descending.", type = STRING),
733           @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),
734           @RestParameter(name = "filter", isRequired = false, description = "The filter used for the query. They should be formated like that: 'filter1:value1,filter2,value2'", type = STRING),
735           @RestParameter(name = "offset", isRequired = false, description = "The page offset", type = INTEGER, defaultValue = "0"),
736           @RestParameter(name = "limit", isRequired = false, description = "The limit to define the number of returned results (-1 for all)", type = INTEGER, defaultValue = "100") }, responses = {
737           @RestResponse(responseCode = SC_OK, description = "The access control list."),
738           @RestResponse(responseCode = SC_UNAUTHORIZED, description = "If the current user is not authorized to perform this action") })
739   public Response getSeries(@QueryParam("filter") String filter, @QueryParam("sort") String sort,
740           @QueryParam("offset") int offset, @QueryParam("limit") int limit)
741           throws UnauthorizedException {
742     try {
743       logger.debug("Requested series list");
744       SeriesSearchQuery query = new SeriesSearchQuery(securityService.getOrganization().getId(),
745               securityService.getUser());
746       Option<String> optSort = Option.option(trimToNull(sort));
747 
748       if (offset != 0) {
749         query.withOffset(offset);
750       }
751 
752       // If limit is 0, we set the default limit
753       query.withLimit(limit == 0 ? DEFAULT_LIMIT : limit);
754 
755       Map<String, String> filters = RestUtils.parseFilter(filter);
756       for (String name : filters.keySet()) {
757         if (SeriesListQuery.FILTER_ACL_NAME.equals(name)) {
758           query.withManagedAcl(filters.get(name));
759         } else if (SeriesListQuery.FILTER_CONTRIBUTORS_NAME.equals(name)) {
760           query.withContributor(filters.get(name));
761         } else if (SeriesListQuery.FILTER_CREATIONDATE_NAME.equals(name)) {
762           try {
763             Tuple<Date, Date> fromAndToCreationRange = RestUtils.getFromAndToDateRange(filters.get(name));
764             query.withCreatedFrom(fromAndToCreationRange.getA());
765             query.withCreatedTo(fromAndToCreationRange.getB());
766           } catch (IllegalArgumentException e) {
767             return RestUtil.R.badRequest(e.getMessage());
768           }
769         } else if (SeriesListQuery.FILTER_CREATOR_NAME.equals(name)) {
770           query.withCreator(filters.get(name));
771         } else if (SeriesListQuery.FILTER_TEXT_NAME.equals(name)) {
772           query.withText(filters.get(name));
773         } else if (SeriesListQuery.FILTER_LANGUAGE_NAME.equals(name)) {
774           query.withLanguage(filters.get(name));
775         } else if (SeriesListQuery.FILTER_LICENSE_NAME.equals(name)) {
776           query.withLicense(filters.get(name));
777         } else if (SeriesListQuery.FILTER_ORGANIZERS_NAME.equals(name)) {
778           query.withOrganizer(filters.get(name));
779         } else if (SeriesListQuery.FILTER_SUBJECT_NAME.equals(name)) {
780           query.withSubject(filters.get(name));
781         } else if (SeriesListQuery.FILTER_TITLE_NAME.equals(name)) {
782           query.withTitle(filters.get(name));
783         }
784       }
785 
786       if (optSort.isSome()) {
787         ArrayList<SortCriterion> sortCriteria = RestUtils.parseSortQueryParameter(optSort.get());
788         for (SortCriterion criterion : sortCriteria) {
789 
790           switch (criterion.getFieldName()) {
791             case SeriesIndexSchema.TITLE:
792               query.sortByTitle(criterion.getOrder());
793               break;
794             case SeriesIndexSchema.CONTRIBUTORS:
795               query.sortByContributors(criterion.getOrder());
796               break;
797             case SeriesIndexSchema.ORGANIZERS:
798               query.sortByOrganizers(criterion.getOrder());
799               break;
800             case SeriesIndexSchema.CREATED_DATE_TIME:
801               query.sortByCreatedDateTime(criterion.getOrder());
802               break;
803             case SeriesIndexSchema.MANAGED_ACL:
804               query.sortByManagedAcl(criterion.getOrder());
805               break;
806             default:
807               logger.info("Unknown filter criteria {}", criterion.getFieldName());
808               return Response.status(SC_BAD_REQUEST).build();
809           }
810         }
811       }
812 
813       // We search for write actions
814       if (onlySeriesWithWriteAccessSeriesTab) {
815         query.withoutActions();
816         query.withAction(Permissions.Action.WRITE);
817         query.withAction(Permissions.Action.READ);
818       }
819 
820       logger.trace("Using Query: " + query.toString());
821 
822       SearchResult<Series> result = searchIndex.getByQuery(query);
823       if (logger.isDebugEnabled()) {
824         logger.debug("Found {} results in {} ms", result.getDocumentCount(), result.getSearchTime());
825       }
826 
827       List<JValue> series = new ArrayList<>();
828       for (SearchResultItem<Series> item : result.getItems()) {
829         List<Field> fields = new ArrayList<>();
830         Series s = item.getSource();
831         String sId = s.getIdentifier();
832         fields.add(f("id", v(sId)));
833         fields.add(f("title", v(s.getTitle(), Jsons.BLANK)));
834         fields.add(f("organizers", arr($(s.getOrganizers()).map(Functions.stringToJValue))));
835         fields.add(f("contributors", arr($(s.getContributors()).map(Functions.stringToJValue))));
836         if (s.getCreator() != null) {
837           fields.add(f("createdBy", v(s.getCreator())));
838         }
839         if (s.getCreatedDateTime() != null) {
840           fields.add(f("creation_date", v(toUTC(s.getCreatedDateTime().getTime()), Jsons.BLANK)));
841         }
842         if (s.getLanguage() != null) {
843           fields.add(f("language", v(s.getLanguage())));
844         }
845         if (s.getLicense() != null) {
846           fields.add(f("license", v(s.getLicense())));
847         }
848         if (s.getRightsHolder() != null) {
849           fields.add(f("rightsHolder", v(s.getRightsHolder())));
850         }
851         if (StringUtils.isNotBlank(s.getManagedAcl())) {
852           fields.add(f("managedAcl", v(s.getManagedAcl())));
853         }
854         series.add(obj(fields));
855       }
856       logger.debug("Request done");
857 
858       return okJsonList(series, offset, limit, result.getHitCount());
859     } catch (Exception e) {
860       logger.warn("Could not perform search query", e);
861       throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
862     }
863   }
864 
865   /**
866    * Search all user series with write or read-only permissions.
867    *
868    * @param writeAccess
869    *         true: write access
870    *         false: read-only access
871    * @return user series with write or read-only access,
872    *         depending on the parameter
873    */
874   public Map<String, String> getUserSeriesByAccess(boolean writeAccess) {
875     SeriesListQuery query = new SeriesListQuery();
876     if (writeAccess) {
877       query.withoutPermissions();
878       query.withReadPermission(true);
879       query.withWritePermission(true);
880     }
881     try {
882       return listProvidersService.getList(SeriesListProvider.PROVIDER_PREFIX, query, true);
883     } catch (ListProviderException e) {
884       logger.warn("Could not perform search query.", e);
885       throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
886     }
887   }
888 
889   @SuppressWarnings("unchecked")
890   @GET
891   @Produces(MediaType.APPLICATION_JSON)
892   @Path("{id}/properties")
893   @RestQuery(name = "getSeriesProperties", description = "Returns the series properties", returnDescription = "Returns the series properties as JSON", pathParameters = { @RestParameter(name = "id", description = "ID of series", isRequired = true, type = Type.STRING) }, responses = {
894           @RestResponse(responseCode = SC_OK, description = "The access control list."),
895           @RestResponse(responseCode = SC_UNAUTHORIZED, description = "If the current user is not authorized to perform this action") })
896   public Response getSeriesPropertiesAsJson(@PathParam("id") String seriesId) throws UnauthorizedException,
897           NotFoundException {
898     if (StringUtils.isBlank(seriesId)) {
899       logger.warn("Series id parameter is blank '{}'.", seriesId);
900       return Response.status(BAD_REQUEST).build();
901     }
902     try {
903       Map<String, String> properties = seriesService.getSeriesProperties(seriesId);
904       JSONArray jsonProperties = new JSONArray();
905       for (String name : properties.keySet()) {
906         JSONObject property = new JSONObject();
907         property.put(name, properties.get(name));
908         jsonProperties.add(property);
909       }
910       return Response.ok(jsonProperties.toString()).build();
911     } catch (UnauthorizedException e) {
912       throw e;
913     } catch (NotFoundException e) {
914       throw e;
915     } catch (Exception e) {
916       logger.warn("Could not perform search query: {}", e.getMessage());
917     }
918     throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
919   }
920 
921   @GET
922   @Produces(MediaType.APPLICATION_JSON)
923   @Path("{seriesId}/property/{propertyName}.json")
924   @RestQuery(name = "getSeriesProperty", description = "Returns a series property value", returnDescription = "Returns the series property value", pathParameters = {
925           @RestParameter(name = "seriesId", description = "ID of series", isRequired = true, type = Type.STRING),
926           @RestParameter(name = "propertyName", description = "Name of series property", isRequired = true, type = Type.STRING) }, responses = {
927           @RestResponse(responseCode = SC_OK, description = "The access control list."),
928           @RestResponse(responseCode = SC_UNAUTHORIZED, description = "If the current user is not authorized to perform this action") })
929   public Response getSeriesProperty(@PathParam("seriesId") String seriesId,
930           @PathParam("propertyName") String propertyName) throws UnauthorizedException, NotFoundException {
931     if (StringUtils.isBlank(seriesId)) {
932       logger.warn("Series id parameter is blank '{}'.", seriesId);
933       return Response.status(BAD_REQUEST).build();
934     }
935     if (StringUtils.isBlank(propertyName)) {
936       logger.warn("Series property name parameter is blank '{}'.", propertyName);
937       return Response.status(BAD_REQUEST).build();
938     }
939     try {
940       String propertyValue = seriesService.getSeriesProperty(seriesId, propertyName);
941       return Response.ok(propertyValue).build();
942     } catch (UnauthorizedException e) {
943       throw e;
944     } catch (NotFoundException e) {
945       throw e;
946     } catch (Exception e) {
947       logger.warn("Could not perform search query", e);
948     }
949     throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
950   }
951 
952   @POST
953   @Path("/{seriesId}/property")
954   @RestQuery(name = "updateSeriesProperty", description = "Updates a series property", returnDescription = "No content.", restParameters = {
955           @RestParameter(name = "name", isRequired = true, description = "The property's name", type = TEXT),
956           @RestParameter(name = "value", isRequired = true, description = "The property's value", type = TEXT) }, pathParameters = { @RestParameter(name = "seriesId", isRequired = true, description = "The series identifier", type = STRING) }, responses = {
957           @RestResponse(responseCode = SC_NOT_FOUND, description = "No series with this identifier was found."),
958           @RestResponse(responseCode = SC_NO_CONTENT, description = "The access control list has been updated."),
959           @RestResponse(responseCode = SC_UNAUTHORIZED, description = "If the current user is not authorized to perform this action"),
960           @RestResponse(responseCode = SC_BAD_REQUEST, description = "The required path or form params were missing in the request.") })
961   public Response updateSeriesProperty(@PathParam("seriesId") String seriesId, @FormParam("name") String name,
962           @FormParam("value") String value) throws UnauthorizedException {
963     if (StringUtils.isBlank(seriesId)) {
964       logger.warn("Series id parameter is blank '{}'.", seriesId);
965       return Response.status(BAD_REQUEST).build();
966     }
967     if (StringUtils.isBlank(name)) {
968       logger.warn("Name parameter is blank '{}'.", name);
969       return Response.status(BAD_REQUEST).build();
970     }
971     if (StringUtils.isBlank(value)) {
972       logger.warn("Series id parameter is blank '{}'.", value);
973       return Response.status(BAD_REQUEST).build();
974     }
975     try {
976       seriesService.updateSeriesProperty(seriesId, name, value);
977       return Response.status(NO_CONTENT).build();
978     } catch (NotFoundException e) {
979       return Response.status(NOT_FOUND).build();
980     } catch (SeriesException e) {
981       logger.warn("Could not update series property for series {} property {}:{}", seriesId, name, value, e);
982     }
983     throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
984   }
985 
986   @DELETE
987   @Path("{seriesId}/property/{propertyName}")
988   @RestQuery(name = "deleteSeriesProperty", description = "Deletes a series property", returnDescription = "No Content", pathParameters = {
989           @RestParameter(name = "seriesId", description = "ID of series", isRequired = true, type = Type.STRING),
990           @RestParameter(name = "propertyName", description = "Name of series property", isRequired = true, type = Type.STRING) }, responses = {
991           @RestResponse(responseCode = SC_NO_CONTENT, description = "The series property has been deleted."),
992           @RestResponse(responseCode = SC_NOT_FOUND, description = "The series or property has not been found."),
993           @RestResponse(responseCode = SC_UNAUTHORIZED, description = "If the current user is not authorized to perform this action") })
994   public Response deleteSeriesProperty(@PathParam("seriesId") String seriesId,
995           @PathParam("propertyName") String propertyName) throws UnauthorizedException, NotFoundException {
996     if (StringUtils.isBlank(seriesId)) {
997       logger.warn("Series id parameter is blank '{}'.", seriesId);
998       return Response.status(BAD_REQUEST).build();
999     }
1000     if (StringUtils.isBlank(propertyName)) {
1001       logger.warn("Series property name parameter is blank '{}'.", propertyName);
1002       return Response.status(BAD_REQUEST).build();
1003     }
1004     try {
1005       seriesService.deleteSeriesProperty(seriesId, propertyName);
1006       return Response.status(NO_CONTENT).build();
1007     } catch (UnauthorizedException | NotFoundException e) {
1008       throw e;
1009     } catch (Exception e) {
1010       logger.warn("Could not delete series '{}' property '{}' query", seriesId, propertyName, e);
1011     }
1012     throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
1013   }
1014 
1015   /**
1016    * Creates an ok response with the entity being the theme id and name.
1017    *
1018    * @param theme
1019    *          The theme to get the id and name from.
1020    * @return A {@link Response} with the theme id and name as json contents
1021    */
1022   private Response getSimpleThemeJsonResponse(IndexTheme theme) {
1023     return okJson(obj(f(Long.toString(theme.getIdentifier()), v(theme.getName()))));
1024   }
1025 
1026   @GET
1027   @Produces(MediaType.APPLICATION_JSON)
1028   @Path("{seriesId}/theme.json")
1029   @RestQuery(name = "getSeriesTheme", description = "Returns the series theme id and name as JSON", returnDescription = "Returns the series theme name and id as JSON", pathParameters = { @RestParameter(name = "seriesId", isRequired = true, description = "The series identifier", type = STRING) }, responses = {
1030           @RestResponse(responseCode = SC_OK, description = "The series theme id and name as JSON."),
1031           @RestResponse(responseCode = SC_NOT_FOUND, description = "The series or theme has not been found") })
1032   public Response getSeriesTheme(@PathParam("seriesId") String seriesId) {
1033     Long themeId;
1034     try {
1035       Optional<Series> series = searchIndex.getSeries(seriesId, securityService.getOrganization().getId(), securityService.getUser());
1036       if (series.isEmpty())
1037         return notFound("Cannot find a series with id {}", seriesId);
1038 
1039       themeId = series.get().getTheme();
1040     } catch (SearchIndexException e) {
1041       logger.error("Unable to get series {}", seriesId, e);
1042       throw new WebApplicationException(e);
1043     }
1044 
1045     // If no theme is set return empty JSON
1046     if (themeId == null)
1047       return okJson(obj());
1048 
1049     try {
1050       Opt<IndexTheme> themeOpt = getTheme(themeId);
1051       if (themeOpt.isNone())
1052         return notFound("Cannot find a theme with id {}", themeId);
1053 
1054       return getSimpleThemeJsonResponse(themeOpt.get());
1055     } catch (SearchIndexException e) {
1056       logger.error("Unable to get theme {}", themeId, e);
1057       throw new WebApplicationException(e);
1058     }
1059   }
1060 
1061   @PUT
1062   @Path("{seriesId}/theme")
1063   @RestQuery(name = "updateSeriesTheme", description = "Update the series theme id", returnDescription = "Returns the id and name of the theme.", pathParameters = { @RestParameter(name = "seriesId", isRequired = true, description = "The series identifier", type = STRING) }, restParameters = { @RestParameter(name = "themeId", isRequired = true, type = RestParameter.Type.INTEGER, description = "The id of the theme for this series") }, responses = {
1064           @RestResponse(responseCode = SC_OK, description = "The series theme has been updated and the theme id and name are returned as JSON."),
1065           @RestResponse(responseCode = SC_NOT_FOUND, description = "The series or theme has not been found"),
1066           @RestResponse(responseCode = SC_UNAUTHORIZED, description = "If the current user is not authorized to perform this action") })
1067   public Response updateSeriesTheme(@PathParam("seriesId") String seriesID, @FormParam("themeId") long themeId)
1068           throws UnauthorizedException, NotFoundException {
1069     try {
1070       Opt<IndexTheme> themeOpt = getTheme(themeId);
1071       if (themeOpt.isNone())
1072         return notFound("Cannot find a theme with id {}", themeId);
1073 
1074       seriesService.updateSeriesProperty(seriesID, THEME_KEY, Long.toString(themeId));
1075       return getSimpleThemeJsonResponse(themeOpt.get());
1076     } catch (SeriesException e) {
1077       logger.error("Unable to update series theme {}", themeId, e);
1078       throw new WebApplicationException(e);
1079     } catch (SearchIndexException e) {
1080       logger.error("Unable to get theme {}", themeId, e);
1081       throw new WebApplicationException(e);
1082     }
1083   }
1084 
1085   @DELETE
1086   @Path("{seriesId}/theme")
1087   @RestQuery(name = "deleteSeriesTheme", description = "Removes the theme from the series", returnDescription = "Returns no content", pathParameters = { @RestParameter(name = "seriesId", isRequired = true, description = "The series identifier", type = STRING) }, responses = {
1088           @RestResponse(responseCode = SC_NO_CONTENT, description = "The series theme has been removed"),
1089           @RestResponse(responseCode = SC_NOT_FOUND, description = "The series has not been found"),
1090           @RestResponse(responseCode = SC_UNAUTHORIZED, description = "If the current user is not authorized to perform this action") })
1091   public Response deleteSeriesTheme(@PathParam("seriesId") String seriesID) throws UnauthorizedException,
1092           NotFoundException {
1093     try {
1094       seriesService.deleteSeriesProperty(seriesID, THEME_KEY);
1095       return Response.noContent().build();
1096     } catch (SeriesException e) {
1097       logger.error("Unable to remove theme from series {}", seriesID, e);
1098       throw new WebApplicationException(e);
1099     }
1100   }
1101 
1102   @GET
1103   @Path("{seriesId}/tobira/pages")
1104   @RestQuery(
1105           name = "getSeriesHostPages",
1106           description = "Returns the pages of a connected Tobira instance that contain the given series",
1107           returnDescription = "The Tobira pages that contain the given series",
1108           pathParameters = { @RestParameter(
1109                   name = "seriesId",
1110                   isRequired = true,
1111                   description = "The series identifier",
1112                   type = STRING) },
1113           responses = {
1114                   @RestResponse(
1115                           responseCode = SC_OK,
1116                           description = "The Tobira pages containing the given series"),
1117                   @RestResponse(
1118                           responseCode = SC_NOT_FOUND,
1119                           description = "Tobira doesn't know about the given series"),
1120                   @RestResponse(
1121                           responseCode = SC_SERVICE_UNAVAILABLE,
1122                           description = "Tobira is not configured (correctly)") })
1123 public Response getSeriesHostPages(@PathParam("seriesId") String seriesId) {
1124     var tobira = getTobira();
1125     if (!tobira.ready()) {
1126       return Response.status(Status.SERVICE_UNAVAILABLE)
1127               .entity("Tobira is not configured (correctly)")
1128               .build();
1129     }
1130 
1131     try {
1132       var seriesData = tobira.getHostPages(seriesId);
1133       if (seriesData == null) {
1134         throw new WebApplicationException(NOT_FOUND);
1135       }
1136       seriesData.put("baseURL", tobira.getOrigin());
1137       return Response.ok(seriesData.toJSONString()).build();
1138     } catch (TobiraException e) {
1139       throw new WebApplicationException(e, Status.INTERNAL_SERVER_ERROR);
1140     }
1141   }
1142 
1143   @POST
1144   @Path("{seriesId}/tobira/path")
1145   @RestQuery(
1146           name = "updateSeriesTobiraPath",
1147           description = "Updates the path of the given series in a connected Tobira instance",
1148           returnDescription = "Status code",
1149           pathParameters = { @RestParameter(
1150                   name = "seriesId",
1151                   isRequired = true,
1152                   description = "The series id",
1153                   type = STRING) },
1154           restParameters = {
1155                   @RestParameter(
1156                           name = "pathComponents",
1157                           isRequired = true,
1158                           description = "List of realms with name and path segment on path to series.",
1159                           type = TEXT),
1160                   @RestParameter(
1161                           name = "currentPath",
1162                           isRequired = false,
1163                           description = "Path where the series is currently mounted.",
1164                           type = STRING),
1165                   @RestParameter(
1166                           name = "targetPath",
1167                           isRequired = true,
1168                           description = "Path where the series will be mounted.",
1169                           type = STRING) },
1170           responses = {
1171                   @RestResponse(
1172                           responseCode = SC_OK,
1173                           description = "The path of the series has successfully been updated in Tobira."),
1174                   @RestResponse(
1175                           responseCode = SC_NOT_FOUND,
1176                           description = "Tobira doesn't know about the given series"),
1177                   @RestResponse(
1178                           responseCode = SC_SERVICE_UNAVAILABLE,
1179                           description = "Tobira is not configured (correctly)") })
1180   public Response updateSeriesTobiraPath(
1181     @PathParam("seriesId") String seriesId,
1182     @FormParam("pathComponents") String pathComponents,
1183     @FormParam("currentPath") String currentPath,
1184     @FormParam("targetPath") String targetPath
1185   ) throws IOException, InterruptedException {
1186     if (targetPath == null) {
1187       throw new WebApplicationException("target path is missing", BAD_REQUEST);
1188     }
1189 
1190     var tobira = getTobira();
1191     if (!tobira.ready()) {
1192       return Response.status(Status.SERVICE_UNAVAILABLE)
1193               .entity("Tobira is not configured (correctly)")
1194               .build();
1195     }
1196 
1197     try {
1198       var paths = (List<JSONObject>) new JSONParser().parse(pathComponents);
1199 
1200       var mountParams = new JSONObject();
1201       mountParams.put("seriesId", seriesId);
1202       mountParams.put("targetPath", targetPath);
1203 
1204       tobira.createRealmLineage(paths);
1205       tobira.addSeriesMountPoint(mountParams);
1206 
1207       if (currentPath != null && !currentPath.trim().isEmpty()) {
1208         var unmountParams = new JSONObject();
1209         unmountParams.put("seriesId", seriesId);
1210         unmountParams.put("currentPath", currentPath);
1211         tobira.removeSeriesMountPoint(unmountParams);
1212       }
1213 
1214       return ok();
1215     } catch (Exception e) {
1216       return Response.status(Status.INTERNAL_SERVER_ERROR)
1217               .entity("Internal server error: " + e.getMessage())
1218               .build();
1219     }
1220   }
1221 
1222   @DELETE
1223   @Path("{seriesId}/tobira/{currentPath}")
1224   @RestQuery(
1225           name = "removeSeriesTobiraPath",
1226           description = "Removes the path of the given series in a connected Tobira instance",
1227           returnDescription = "Status code",
1228           pathParameters = {
1229                   @RestParameter(
1230                           name = "seriesId",
1231                           isRequired = true,
1232                           description = "The series id",
1233                           type = STRING),
1234                   @RestParameter(
1235                           name = "currentPath",
1236                           isRequired = true,
1237                           description = "URL encoded path where the series is currently mounted.",
1238                           type = STRING) },
1239           responses = {
1240                   @RestResponse(
1241                           responseCode = SC_OK,
1242                           description = "The path of the series has successfully been removed in Tobira."),
1243                   @RestResponse(
1244                           responseCode = SC_NOT_FOUND,
1245                           description = "Tobira doesn't know about the given series"),
1246                   @RestResponse(
1247                           responseCode = SC_SERVICE_UNAVAILABLE,
1248                           description = "Tobira is not configured (correctly)") })
1249   public Response removeSeriesTobiraPath(
1250     @PathParam("seriesId") String seriesId,
1251     @PathParam("currentPath") String currentPath
1252   ) throws IOException, InterruptedException {
1253     var tobira = getTobira();
1254     if (!tobira.ready()) {
1255       return Response.status(Status.SERVICE_UNAVAILABLE)
1256               .entity("Tobira is not configured (correctly)")
1257               .build();
1258     }
1259 
1260     try {
1261       if (currentPath != null && !currentPath.trim().isEmpty()) {
1262         var unmountParams = new JSONObject();
1263         unmountParams.put("seriesId", seriesId);
1264         unmountParams.put("currentPath", currentPath);
1265         tobira.removeSeriesMountPoint(unmountParams);
1266       }
1267 
1268       return ok();
1269     } catch (Exception e) {
1270       return Response.status(Status.INTERNAL_SERVER_ERROR)
1271               .entity("Internal server error: " + e.getMessage())
1272               .build();
1273     }
1274   }
1275 
1276   @POST
1277   @Path("/{seriesId}/access")
1278   @RestQuery(name = "applyAclToSeries", description = "Immediate application of an ACL to a series", returnDescription = "Status code", pathParameters = { @RestParameter(name = "seriesId", isRequired = true, description = "The series ID", type = STRING) }, restParameters = {
1279           @RestParameter(name = "acl", isRequired = true, description = "The ACL to apply", type = STRING),
1280           @RestParameter(name = "override", isRequired = false, defaultValue = "false", description = "If true the series ACL will take precedence over any existing episode ACL", type = BOOLEAN) }, responses = {
1281           @RestResponse(responseCode = SC_OK, description = "The ACL has been successfully applied"),
1282           @RestResponse(responseCode = SC_BAD_REQUEST, description = "Unable to parse the given ACL"),
1283           @RestResponse(responseCode = SC_NOT_FOUND, description = "The series has not been found"),
1284           @RestResponse(responseCode = SC_INTERNAL_SERVER_ERROR, description = "Internal error"),
1285           @RestResponse(responseCode = SC_UNAUTHORIZED, description = "If the current user is not authorized to perform this action") })
1286   public Response applyAclToSeries(@PathParam("seriesId") String seriesId, @FormParam("acl") String acl,
1287           @DefaultValue("false") @FormParam("override") boolean override) throws SearchIndexException {
1288 
1289     AccessControlList accessControlList;
1290     try {
1291       accessControlList = AccessControlParser.parseAcl(acl);
1292     } catch (Exception e) {
1293       logger.warn("Unable to parse ACL '{}'", acl);
1294       return badRequest();
1295     }
1296 
1297     if (!accessControlList.isValid()) {
1298       logger.debug("POST api/series/{}/access: Invalid series ACL detected", seriesId);
1299       return badRequest();
1300     }
1301 
1302     Optional<Series> series = searchIndex.getSeries(seriesId, securityService.getOrganization().getId(), securityService.getUser());
1303     if (series.isEmpty())
1304       return notFound("Cannot find a series with id {}", seriesId);
1305 
1306     if (hasProcessingEvents(seriesId)) {
1307       logger.warn("Can not update the ACL from series {}. Events being part of the series are currently processed.",
1308               seriesId);
1309       return conflict();
1310     }
1311 
1312     try {
1313       seriesService.updateAccessControl(seriesId, accessControlList, override);
1314       return ok();
1315     } catch (NotFoundException e) {
1316       logger.warn("Unable to find series '{}' to apply the ACL.", seriesId);
1317       return notFound();
1318     } catch (UnauthorizedException e) {
1319       return forbidden();
1320     } catch (SeriesException e) {
1321       logger.error("Error applying acl to series {}", seriesId);
1322       return serverError();
1323     }
1324   }
1325 
1326   /**
1327    * Check if the series with the given Id has events being currently processed
1328    *
1329    * @param seriesId
1330    *          the series Id
1331    * @return true if events being part of the series are currently processed
1332    */
1333   private boolean hasProcessingEvents(String seriesId) {
1334     EventSearchQuery query = new EventSearchQuery(securityService.getOrganization().getId(), securityService.getUser());
1335     long elementsCount = 0;
1336     query.withSeriesId(seriesId);
1337 
1338     try {
1339       query.withWorkflowState(WorkflowInstance.WorkflowState.RUNNING.toString());
1340       SearchResult<Event> events = searchIndex.getByQuery(query);
1341       elementsCount = events.getHitCount();
1342       query.withWorkflowState(WorkflowInstance.WorkflowState.INSTANTIATED.toString());
1343       events = searchIndex.getByQuery(query);
1344       elementsCount += events.getHitCount();
1345     } catch (SearchIndexException e) {
1346       logger.warn("Could not perform search query", e);
1347       throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
1348     }
1349 
1350     return elementsCount > 0;
1351   }
1352 
1353   @GET
1354   @Path("{seriesId}/hasEvents.json")
1355   @Produces(MediaType.APPLICATION_JSON)
1356   @RestQuery(name = "hasEvents", description = "Check if given series has events", returnDescription = "true if series has events, otherwise false", pathParameters = {
1357     @RestParameter(name = "seriesId", isRequired = true, description = "The series identifier", type = Type.STRING) }, responses = {
1358     @RestResponse(responseCode = SC_BAD_REQUEST, description = "The required form params were missing in the request."),
1359     @RestResponse(responseCode = SC_NOT_FOUND, description = "If the series has not been found."),
1360     @RestResponse(responseCode = SC_OK, description = "The access information ") })
1361   public Response getSeriesEvents(@PathParam("seriesId") String seriesId) throws Exception {
1362     if (StringUtils.isBlank(seriesId))
1363       return RestUtil.R.badRequest("Path parameter series ID is missing");
1364 
1365     long elementsCount = 0;
1366 
1367     try {
1368       EventSearchQuery query = new EventSearchQuery(securityService.getOrganization().getId(), securityService.getUser());
1369       query.withSeriesId(seriesId);
1370       SearchResult<Event> result = searchIndex.getByQuery(query);
1371       elementsCount = result.getHitCount();
1372     } catch (SearchIndexException e) {
1373       logger.warn("Could not perform search query", e);
1374       throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
1375     }
1376 
1377     JSONObject jsonReturnObj = new JSONObject();
1378     jsonReturnObj.put("hasEvents", elementsCount > 0);
1379     return Response.ok(jsonReturnObj.toString()).build();
1380   }
1381 
1382   /**
1383    * Get a single theme
1384    *
1385    * @param id
1386    *          the theme id
1387    * @return a theme or none if not found, wrapped in an option
1388    * @throws SearchIndexException
1389    */
1390   private Opt<IndexTheme> getTheme(long id) throws SearchIndexException {
1391     SearchResult<IndexTheme> result = searchIndex.getByQuery(new ThemeSearchQuery(securityService.getOrganization().getId(),
1392             securityService.getUser()).withIdentifier(id));
1393     if (result.getPageSize() == 0) {
1394       logger.debug("Didn't find theme with id {}", id);
1395       return Opt.<IndexTheme> none();
1396     }
1397     return Opt.some(result.getItems()[0].getSource());
1398   }
1399 
1400   @GET
1401   @Path("configuration.json")
1402   @Produces(MediaType.APPLICATION_JSON)
1403   @RestQuery(name = "getseriesconfiguration", description = "Get the series configuration", returnDescription = "List of configuration keys", responses = {
1404     @RestResponse(responseCode = SC_BAD_REQUEST, description = "The required form params were missing in the request."),
1405     @RestResponse(responseCode = SC_NOT_FOUND, description = "If the series has not been found."),
1406     @RestResponse(responseCode = SC_OK, description = "The access information ") })
1407   public Response getSeriesOptions() {
1408     JSONObject jsonReturnObj = new JSONObject();
1409     jsonReturnObj.put("deleteSeriesWithEventsAllowed", deleteSeriesWithEventsAllowed);
1410     return Response.ok(jsonReturnObj.toString()).build();
1411   }
1412 
1413   public Boolean getOnlySeriesWithWriteAccessSeriesTab() {
1414     return onlySeriesWithWriteAccessSeriesTab;
1415   }
1416 
1417   public Boolean getOnlySeriesWithWriteAccessEventsFilter() {
1418     return onlySeriesWithWriteAccessEventsFilter;
1419   }
1420 }