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  package org.opencastproject.external.util;
22  
23  import static java.time.ZoneOffset.UTC;
24  import static org.apache.commons.lang3.StringUtils.isBlank;
25  import static org.apache.commons.lang3.StringUtils.isNotBlank;
26  
27  import org.opencastproject.capture.CaptureParameters;
28  import org.opencastproject.capture.admin.api.Agent;
29  import org.opencastproject.capture.admin.api.CaptureAgentStateService;
30  import org.opencastproject.elasticsearch.api.SearchIndexException;
31  import org.opencastproject.elasticsearch.index.ElasticsearchIndex;
32  import org.opencastproject.elasticsearch.index.objects.event.Event;
33  import org.opencastproject.index.service.api.IndexService;
34  import org.opencastproject.mediapackage.MediaPackage;
35  import org.opencastproject.scheduler.api.SchedulerException;
36  import org.opencastproject.scheduler.api.SchedulerService;
37  import org.opencastproject.scheduler.api.TechnicalMetadata;
38  import org.opencastproject.security.api.UnauthorizedException;
39  import org.opencastproject.util.NotFoundException;
40  
41  import com.google.gson.JsonArray;
42  import com.google.gson.JsonObject;
43  import com.google.gson.JsonPrimitive;
44  
45  import net.fortuna.ical4j.model.property.RRule;
46  
47  import org.apache.commons.lang3.StringUtils;
48  import org.json.simple.JSONArray;
49  import org.json.simple.JSONObject;
50  import org.slf4j.Logger;
51  import org.slf4j.LoggerFactory;
52  
53  import java.time.Instant;
54  import java.time.ZoneOffset;
55  import java.time.format.DateTimeFormatter;
56  import java.util.ArrayList;
57  import java.util.Date;
58  import java.util.List;
59  import java.util.Objects;
60  import java.util.Optional;
61  import java.util.TimeZone;
62  
63  public final class SchedulingUtils {
64  
65    /** The logging facility */
66    private static final Logger logger = LoggerFactory.getLogger(SchedulingUtils.class);
67  
68    private static final String JSON_KEY_AGENT_ID = "agent_id";
69    private static final String JSON_KEY_START_DATE = "start";
70    private static final String JSON_KEY_END_DATE = "end";
71    private static final String JSON_KEY_DURATION = "duration";
72    private static final String JSON_KEY_INPUTS = "inputs";
73    private static final String JSON_KEY_RRULE = "rrule";
74  
75  
76    private SchedulingUtils() {
77    }
78  
79    public static class SchedulingInfo {
80      private Optional<Date> startDate = Optional.empty();
81      private Optional<Date> endDate = Optional.empty();
82      private Optional<Long> duration = Optional.empty();
83      private Optional<String> agentId = Optional.empty();
84      private Optional<String> inputs = Optional.empty();
85      private Optional<RRule> rrule = Optional.empty();
86  
87      public SchedulingInfo() {
88      }
89  
90      /**
91       * Copy the given SchedulingInfo object.
92       *
93       * @param other
94       *          The SchedulingInfo object to copy.
95       */
96      public SchedulingInfo(SchedulingInfo other) {
97        this.startDate = other.startDate;
98        this.endDate = other.endDate;
99        this.duration = other.duration;
100       this.agentId = other.agentId;
101       this.inputs = other.inputs;
102       this.rrule = other.rrule;
103     }
104 
105     public Optional<Date> getStartDate() {
106       return startDate;
107     }
108 
109     public void setStartDate(Optional<Date> startDate) {
110       this.startDate = startDate;
111     }
112 
113     public Optional<Date> getEndDate() {
114       if (endDate.isPresent()) {
115         return endDate;
116       } else if (startDate.isPresent() && duration.isPresent()) {
117         return Optional.of(Date.from(startDate.get().toInstant().plusMillis(duration.get())));
118       } else {
119         return Optional.empty();
120       }
121     }
122 
123     public void setEndDate(Optional<Date> endDate) {
124       this.endDate = endDate;
125     }
126 
127     public Optional<Long> getDuration() {
128       if (duration.isPresent()) {
129         return duration;
130       } else if (startDate.isPresent() && endDate.isPresent()) {
131         return Optional.of(endDate.get().getTime() - startDate.get().getTime());
132       } else {
133         return Optional.empty();
134       }
135     }
136 
137     public void setDuration(Optional<Long> duration) {
138       this.duration = duration;
139     }
140 
141     public Optional<String> getAgentId() {
142       return agentId;
143     }
144 
145     public void setAgentId(Optional<String> agentId) {
146       this.agentId = agentId;
147     }
148 
149     public Optional<String> getInputs() {
150       return inputs;
151     }
152 
153     public void setInputs(Optional<String> inputs) {
154       this.inputs = inputs;
155     }
156 
157     public Optional<RRule> getRrule() {
158       return rrule;
159     }
160 
161     public void setRrule(Optional<RRule> rrule) {
162       this.rrule = rrule;
163     }
164 
165     /**
166      * @return A JSON representation of this SchedulingInfo object.
167      */
168     public JsonObject toJson() {
169       JsonObject json = new JsonObject();
170       DateTimeFormatter dateFormatter = DateTimeFormatter.ISO_DATE_TIME;
171       if (startDate.isPresent()) {
172         json.addProperty(JSON_KEY_START_DATE, dateFormatter.format(startDate.get().toInstant().atZone(ZoneOffset.UTC)));
173       }
174       if (endDate.isPresent()) {
175         json.addProperty(JSON_KEY_END_DATE, dateFormatter.format(endDate.get().toInstant().atZone(ZoneOffset.UTC)));
176       }
177       if (agentId.isPresent()) {
178         json.addProperty(JSON_KEY_AGENT_ID, agentId.get());
179       }
180       if (inputs.isPresent()) {
181         JsonArray inputsArray = new JsonArray();
182         for (String input : inputs.get().split(",")) {
183           inputsArray.add(new JsonPrimitive(input.trim()));
184         }
185         json.add(JSON_KEY_INPUTS, inputsArray);
186       }
187       return json;
188     }
189 
190     /**
191      * @return A JSON source representation of this SchedulingInfo as needed by the IndexService to create an event.
192      */
193     @SuppressWarnings("unchecked")
194     public JSONObject toSource() {
195       final JSONObject source = new JSONObject();
196       if (rrule.isPresent()) {
197         source.put("type", "SCHEDULE_MULTIPLE");
198       } else {
199         source.put("type", "SCHEDULE_SINGLE");
200       }
201       final JSONObject sourceMetadata = new JSONObject();
202       final DateTimeFormatter dateFormatter = DateTimeFormatter.ISO_DATE_TIME;
203       if (startDate.isPresent()) {
204         sourceMetadata.put("start", dateFormatter.format(startDate.get().toInstant().atZone(UTC)));
205       }
206       if (endDate.isPresent()) {
207         sourceMetadata.put("end", dateFormatter.format(endDate.get().toInstant().atZone(UTC)));
208       }
209       if (agentId.isPresent()) {
210         sourceMetadata.put("device", agentId.get());
211       }
212       if (getDuration().isPresent()) {
213         sourceMetadata.put("duration", String.valueOf(getDuration().get()));
214       }
215       if (rrule.isPresent()) {
216         sourceMetadata.put("rrule", rrule.get().getValue());
217       }
218       sourceMetadata.put("inputs", inputs.orElse(""));
219 
220       source.put("metadata", sourceMetadata);
221       return source;
222     }
223 
224     /**
225      * Creates a new SchedulingInfo of this instance which uses start date, end date, and agent id form the given
226      * {@link TechnicalMetadata} if they are not present in this instance.
227      *
228      * @param metadata
229      *          The {@link TechnicalMetadata} of which to use start date, end date, and agent id in case they are missing.
230      *
231      * @return The new SchedulingInfo with start date, end date, and agent id set.
232      */
233     public SchedulingInfo merge(TechnicalMetadata metadata) {
234       SchedulingInfo result = new SchedulingInfo(this);
235       if (result.startDate.isEmpty()) {
236         result.startDate = Optional.of(metadata.getStartDate());
237       }
238       if (result.endDate.isEmpty()) {
239         result.endDate = Optional.of(metadata.getEndDate());
240       }
241       if (result.agentId.isEmpty()) {
242         result.agentId = Optional.of(metadata.getAgentId());
243       }
244       return result;
245     }
246 
247     /**
248      * Parse the given json and create a new SchedulingInfo.
249      *
250      * @param json
251      *          The JSONObject to parse.
252      *
253      * @return The SchedulingInfo instance represented by the given JSON.
254      */
255     public static SchedulingInfo of(JSONObject json) {
256       final DateTimeFormatter dateFormatter = DateTimeFormatter.ISO_DATE_TIME;
257       final SchedulingInfo schedulingInfo = new SchedulingInfo();
258       final String startDate = (String) json.get(JSON_KEY_START_DATE);
259       final String endDate = (String) json.get(JSON_KEY_END_DATE);
260       final String agentId = (String) json.get(JSON_KEY_AGENT_ID);
261       final JSONArray inputs = (JSONArray) json.get(JSON_KEY_INPUTS);
262       final String rrule = (String) json.get(JSON_KEY_RRULE);
263 
264       // Special handling because the original implementation required String but now we require long
265       final String durationString = Objects.toString(json.get(JSON_KEY_DURATION), null);
266 
267       if (isNotBlank(startDate)) {
268         schedulingInfo.startDate = Optional.of(Date.from(Instant.from(dateFormatter.parse(startDate))));
269       }
270       if (isNotBlank(endDate)) {
271         schedulingInfo.endDate = Optional.of(Date.from(Instant.from(dateFormatter.parse(endDate))));
272       }
273       if (isNotBlank(agentId)) {
274         schedulingInfo.agentId = Optional.of(agentId);
275       }
276       if (isNotBlank(durationString)) {
277         try {
278           schedulingInfo.duration = Optional.of(Long.parseLong(durationString));
279         } catch (Exception e) {
280           throw new IllegalArgumentException("Invalid format of field 'duration'");
281         }
282       }
283 
284       if (isBlank(endDate) && isBlank(durationString)) {
285         throw new IllegalArgumentException("Either 'end' or 'duration' must be specified");
286       }
287 
288       if (inputs != null) {
289         schedulingInfo.inputs = Optional.of(String.join(",", inputs));
290       }
291       if (isNotBlank(rrule)) {
292         try {
293           RRule parsedRrule = new RRule(rrule);
294           parsedRrule.validate();
295           schedulingInfo.rrule = Optional.of(parsedRrule);
296         } catch (Exception e) {
297           throw new IllegalArgumentException("Invalid RRule: " + rrule);
298         }
299         if (isBlank(durationString) || isBlank(startDate) || isBlank(endDate)) {
300           throw new IllegalArgumentException("'start', 'end' and 'duration' must be specified when 'rrule' is specified");
301         }
302       }
303       return schedulingInfo;
304     }
305 
306     /**
307      * Get the SchedulingInfo for the given event id.
308      *
309      * @param eventId
310      *          The id of the event to get the SchedulingInfo for.
311      * @param schedulerService
312      *          The {@link SchedulerService} to query for the event id.
313      *
314      * @return The SchedulingInfo for the given event id.
315      *
316      * @throws UnauthorizedException
317      *          If the {@link SchedulerService} cannot be queried due to missing authorization.
318      * @throws SchedulerException
319      *          In case internal errors occur within the {@link SchedulerService}.
320      */
321     public static SchedulingInfo of(String eventId, SchedulerService schedulerService)
322         throws UnauthorizedException, SchedulerException {
323       final SchedulingInfo result = new SchedulingInfo();
324       try {
325         final TechnicalMetadata technicalMetadata = schedulerService.getTechnicalMetadata(eventId);
326         result.startDate = Optional.of(technicalMetadata.getStartDate());
327         result.endDate = Optional.of(technicalMetadata.getEndDate());
328         result.agentId = Optional.of(technicalMetadata.getAgentId());
329         String inputs = technicalMetadata.getCaptureAgentConfiguration().get(CaptureParameters.CAPTURE_DEVICE_NAMES);
330         if (isNotBlank(inputs)) {
331           result.inputs = Optional.of(inputs);
332         }
333         return result;
334       } catch (NotFoundException e) {
335         return result;
336       }
337     }
338   }
339 
340   /**
341    * Convert the given list of {@link MediaPackage} elements to a JSON used to tell which events are causing conflicts.
342    *
343    * @param checkedEventId
344    *          The id of the event which was checked for conflicts. May be empty if an rrule was checked.
345    * @param mediaPackages
346    *          The conflicting {@link MediaPackage}s.
347    * @param indexService
348    *          The {@link IndexService} for getting the corresponding events for the conflicting {@link MediaPackage}s.
349    * @param elasticsearchIndex
350    *          The index to use for getting the corresponding events for the conflicting MediaPackages.
351    *
352    * @return A List of conflicting events, represented as JSON objects.
353    *
354    * @throws SearchIndexException
355    *          If an event cannot be found.
356    */
357   public static List<JsonObject> convertConflictingEvents(
358       Optional<String> checkedEventId,
359       List<MediaPackage> mediaPackages,
360       IndexService indexService,
361       ElasticsearchIndex elasticsearchIndex
362   ) throws SearchIndexException {
363     List<JsonObject> result = new ArrayList<>();
364     for (MediaPackage mediaPackage : mediaPackages) {
365       Optional<Event> eventOpt = indexService.getEvent(mediaPackage.getIdentifier().toString(), elasticsearchIndex);
366       if (eventOpt.isPresent()) {
367         final Event event = eventOpt.get();
368         if (checkedEventId.isPresent() && checkedEventId.get().equals(event.getIdentifier())) {
369           continue;
370         }
371 
372         JsonObject eventJson = new JsonObject();
373         if (event.getTechnicalStartTime() != null)
374           eventJson.addProperty("start", event.getTechnicalStartTime().toString());
375         if (event.getTechnicalEndTime() != null)
376           eventJson.addProperty("end", event.getTechnicalEndTime().toString());
377         eventJson.addProperty("title", event.getTitle());
378 
379         result.add(eventJson);
380       } else {
381         logger.warn("Index out of sync! Conflicting event catalog {} not found on event index!",
382             mediaPackage.getIdentifier().toString());
383       }
384     }
385     return result;
386   }
387 
388   /**
389    * Get the conflicting events for the given SchedulingInfo.
390    *
391    * @param schedulingInfo
392    *          The SchedulingInfo to check for conflicts.
393    * @param agentStateService
394    *          The {@link CaptureAgentStateService} to use for retrieving capture agents.
395    * @param schedulerService
396    *          The {@link SchedulerService} to use for conflict checking.
397    * @return
398    *          A list of {@link MediaPackage} elements which cause conflicts with the given SchedulingInfo.
399    *
400    * @throws NotFoundException
401    *          If the capture agent cannot be found.
402    * @throws UnauthorizedException
403    *          If the {@link SchedulerService} cannot be queried due to missing authorization.
404    * @throws SchedulerException
405    *          In case internal errors occur within the {@link SchedulerService}.
406    */
407   public static List<MediaPackage> getConflictingEvents(
408       SchedulingInfo schedulingInfo,
409       CaptureAgentStateService agentStateService,
410       SchedulerService schedulerService
411   ) throws NotFoundException, UnauthorizedException, SchedulerException {
412 
413     if (schedulingInfo.getRrule().isPresent()) {
414       final Agent agent = agentStateService.getAgent(schedulingInfo.getAgentId().get());
415       String timezone = agent.getConfiguration().getProperty("capture.device.timezone");
416       if (StringUtils.isBlank(timezone)) {
417         timezone = TimeZone.getDefault().getID();
418         logger.warn("No 'capture.device.timezone' set on agent {}. The default server timezone {} will be used.",
419             schedulingInfo.getAgentId().get(), timezone);
420       }
421       return schedulerService.findConflictingEvents(
422           schedulingInfo.getAgentId().get(),
423           schedulingInfo.getRrule().get(),
424           schedulingInfo.getStartDate().get(),
425           schedulingInfo.getEndDate().get(),
426           schedulingInfo.getDuration().get(),
427           TimeZone.getTimeZone(timezone)
428       );
429     }
430 
431     return schedulerService.findConflictingEvents(
432         schedulingInfo.getAgentId().get(),
433         schedulingInfo.getStartDate().get(),
434         schedulingInfo.getEndDate().get()
435     );
436   }
437 
438 }