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 com.entwinemedia.fn.data.json.Jsons.arr;
24  import static com.entwinemedia.fn.data.json.Jsons.f;
25  import static com.entwinemedia.fn.data.json.Jsons.obj;
26  import static com.entwinemedia.fn.data.json.Jsons.v;
27  import static java.time.ZoneOffset.UTC;
28  import static org.apache.commons.lang3.StringUtils.isBlank;
29  import static org.apache.commons.lang3.StringUtils.isNotBlank;
30  
31  import org.opencastproject.capture.CaptureParameters;
32  import org.opencastproject.capture.admin.api.Agent;
33  import org.opencastproject.capture.admin.api.CaptureAgentStateService;
34  import org.opencastproject.elasticsearch.api.SearchIndexException;
35  import org.opencastproject.elasticsearch.index.ElasticsearchIndex;
36  import org.opencastproject.elasticsearch.index.objects.event.Event;
37  import org.opencastproject.index.service.api.IndexService;
38  import org.opencastproject.mediapackage.MediaPackage;
39  import org.opencastproject.scheduler.api.SchedulerException;
40  import org.opencastproject.scheduler.api.SchedulerService;
41  import org.opencastproject.scheduler.api.TechnicalMetadata;
42  import org.opencastproject.security.api.UnauthorizedException;
43  import org.opencastproject.util.NotFoundException;
44  
45  import com.entwinemedia.fn.data.Opt;
46  import com.entwinemedia.fn.data.json.Field;
47  import com.entwinemedia.fn.data.json.JObject;
48  import com.entwinemedia.fn.data.json.JValue;
49  
50  import net.fortuna.ical4j.model.property.RRule;
51  
52  import org.apache.commons.lang3.StringUtils;
53  import org.json.simple.JSONArray;
54  import org.json.simple.JSONObject;
55  import org.slf4j.Logger;
56  import org.slf4j.LoggerFactory;
57  
58  import java.time.Instant;
59  import java.time.format.DateTimeFormatter;
60  import java.util.ArrayList;
61  import java.util.Date;
62  import java.util.List;
63  import java.util.Objects;
64  import java.util.Optional;
65  import java.util.TimeZone;
66  
67  public final class SchedulingUtils {
68  
69    /** The logging facility */
70    private static final Logger logger = LoggerFactory.getLogger(SchedulingUtils.class);
71  
72    private static final String JSON_KEY_AGENT_ID = "agent_id";
73    private static final String JSON_KEY_START_DATE = "start";
74    private static final String JSON_KEY_END_DATE = "end";
75    private static final String JSON_KEY_DURATION = "duration";
76    private static final String JSON_KEY_INPUTS = "inputs";
77    private static final String JSON_KEY_RRULE = "rrule";
78  
79  
80    private SchedulingUtils() {
81    }
82  
83    public static class SchedulingInfo {
84      private Opt<Date> startDate = Opt.none();
85      private Opt<Date> endDate = Opt.none();
86      private Opt<Long> duration = Opt.none();
87      private Opt<String> agentId = Opt.none();
88      private Opt<String> inputs = Opt.none();
89      private Opt<RRule> rrule = Opt.none();
90  
91      public SchedulingInfo() {
92      }
93  
94      /**
95       * Copy the given SchedulingInfo object.
96       *
97       * @param other
98       *          The SchedulingInfo object to copy.
99       */
100     public SchedulingInfo(SchedulingInfo other) {
101       this.startDate = other.startDate;
102       this.endDate = other.endDate;
103       this.duration = other.duration;
104       this.agentId = other.agentId;
105       this.inputs = other.inputs;
106       this.rrule = other.rrule;
107     }
108 
109     public Opt<Date> getStartDate() {
110       return startDate;
111     }
112 
113     public void setStartDate(Opt<Date> startDate) {
114       this.startDate = startDate;
115     }
116 
117     public Opt<Date> getEndDate() {
118       if (endDate.isSome()) {
119         return endDate;
120       } else if (startDate.isSome() && duration.isSome()) {
121         return Opt.some(Date.from(startDate.get().toInstant().plusMillis(duration.get())));
122       } else {
123         return Opt.none();
124       }
125     }
126 
127     public void setEndDate(Opt<Date> endDate) {
128       this.endDate = endDate;
129     }
130 
131     public Opt<Long> getDuration() {
132       if (duration.isSome()) {
133         return duration;
134       } else if (startDate.isSome() && endDate.isSome()) {
135         return Opt.some(endDate.get().getTime() - startDate.get().getTime());
136       } else {
137         return Opt.none();
138       }
139     }
140 
141     public void setDuration(Opt<Long> duration) {
142       this.duration = duration;
143     }
144 
145     public Opt<String> getAgentId() {
146       return agentId;
147     }
148 
149     public void setAgentId(Opt<String> agentId) {
150       this.agentId = agentId;
151     }
152 
153     public Opt<String> getInputs() {
154       return inputs;
155     }
156 
157     public void setInputs(Opt<String> inputs) {
158       this.inputs = inputs;
159     }
160 
161     public Opt<RRule> getRrule() {
162       return rrule;
163     }
164 
165     public void setRrule(Opt<RRule> rrule) {
166       this.rrule = rrule;
167     }
168 
169     /**
170      * @return A JSON representation of this ScheudlingInfo object.
171      */
172     public JObject toJson() {
173       final List<Field> fields = new ArrayList<>();
174       final DateTimeFormatter dateFormatter = DateTimeFormatter.ISO_DATE_TIME;
175       if (startDate.isSome()) {
176         fields.add(f(JSON_KEY_START_DATE, dateFormatter.format(startDate.get().toInstant().atZone(UTC))));
177       }
178       if (endDate.isSome()) {
179         fields.add(f(JSON_KEY_END_DATE, dateFormatter.format(endDate.get().toInstant().atZone(UTC))));
180       }
181       if (agentId.isSome()) {
182         fields.add(f(JSON_KEY_AGENT_ID, agentId.get()));
183       }
184       if (inputs.isSome()) {
185         fields.add(f(JSON_KEY_INPUTS, arr(inputs.get().split(","))));
186       }
187       return obj(fields);
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.isSome()) {
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.isSome()) {
204         sourceMetadata.put("start", dateFormatter.format(startDate.get().toInstant().atZone(UTC)));
205       }
206       if (endDate.isSome()) {
207         sourceMetadata.put("end", dateFormatter.format(endDate.get().toInstant().atZone(UTC)));
208       }
209       if (agentId.isSome()) {
210         sourceMetadata.put("device", agentId.get());
211       }
212       if (getDuration().isSome()) {
213         sourceMetadata.put("duration", String.valueOf(getDuration().get()));
214       }
215       if (rrule.isSome()) {
216         sourceMetadata.put("rrule", rrule.get().getValue());
217       }
218       sourceMetadata.put("inputs", inputs.getOr(""));
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.isNone()) {
236         result.startDate = Opt.some(metadata.getStartDate());
237       }
238       if (result.endDate.isNone()) {
239         result.endDate = Opt.some(metadata.getEndDate());
240       }
241       if (result.agentId.isNone()) {
242         result.agentId = Opt.some(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 = Opt.some(Date.from(Instant.from(dateFormatter.parse(startDate))));
269       }
270       if (isNotBlank(endDate)) {
271         schedulingInfo.endDate = Opt.some(Date.from(Instant.from(dateFormatter.parse(endDate))));
272       }
273       if (isNotBlank(agentId)) {
274         schedulingInfo.agentId = Opt.some(agentId);
275       }
276       if (isNotBlank(durationString)) {
277         try {
278           schedulingInfo.duration = Opt.some(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 = Opt.some(String.join(",", inputs));
290       }
291       if (isNotBlank(rrule)) {
292         try {
293           RRule parsedRrule = new RRule(rrule);
294           parsedRrule.validate();
295           schedulingInfo.rrule = Opt.some(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 = Opt.some(technicalMetadata.getStartDate());
327         result.endDate = Opt.some(technicalMetadata.getEndDate());
328         result.agentId = Opt.some(technicalMetadata.getAgentId());
329         String inputs = technicalMetadata.getCaptureAgentConfiguration().get(CaptureParameters.CAPTURE_DEVICE_NAMES);
330         if (isNotBlank(inputs)) {
331           result.inputs = Opt.some(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<JValue> convertConflictingEvents(
358       Optional<String> checkedEventId,
359       List<MediaPackage> mediaPackages,
360       IndexService indexService,
361       ElasticsearchIndex elasticsearchIndex
362   ) throws SearchIndexException {
363     final List<JValue> result = new ArrayList<>();
364     for (MediaPackage mediaPackage : mediaPackages) {
365       final Opt<Event> eventOpt = indexService.getEvent(mediaPackage.getIdentifier().toString(), elasticsearchIndex);
366       if (eventOpt.isSome()) {
367         final Event event = eventOpt.get();
368         if (checkedEventId.isPresent() && checkedEventId.equals(event.getIdentifier())) {
369           continue;
370         }
371         result.add(obj(f("start", v(event.getTechnicalStartTime())), f("end", v(event.getTechnicalEndTime())),
372             f("title", v(event.getTitle()))));
373       } else {
374         logger.warn("Index out of sync! Conflicting event catalog {} not found on event index!",
375             mediaPackage.getIdentifier().toString());
376       }
377     }
378     return result;
379   }
380 
381   /**
382    * Get the conflicting events for the given SchedulingInfo.
383    *
384    * @param schedulingInfo
385    *          The SchedulingInfo to check for conflicts.
386    * @param agentStateService
387    *          The {@link CaptureAgentStateService} to use for retrieving capture agents.
388    * @param schedulerService
389    *          The {@link SchedulerService} to use for conflict checking.
390    * @return
391    *          A list of {@link MediaPackage} elements which cause conflicts with the given SchedulingInfo.
392    *
393    * @throws NotFoundException
394    *          If the capture agent cannot be found.
395    * @throws UnauthorizedException
396    *          If the {@link SchedulerService} cannot be queried due to missing authorization.
397    * @throws SchedulerException
398    *          In case internal errors occur within the {@link SchedulerService}.
399    */
400   public static List<MediaPackage> getConflictingEvents(
401       SchedulingInfo schedulingInfo,
402       CaptureAgentStateService agentStateService,
403       SchedulerService schedulerService
404   ) throws NotFoundException, UnauthorizedException, SchedulerException {
405 
406     if (schedulingInfo.getRrule().isSome()) {
407       final Agent agent = agentStateService.getAgent(schedulingInfo.getAgentId().get());
408       String timezone = agent.getConfiguration().getProperty("capture.device.timezone");
409       if (StringUtils.isBlank(timezone)) {
410         timezone = TimeZone.getDefault().getID();
411         logger.warn("No 'capture.device.timezone' set on agent {}. The default server timezone {} will be used.",
412             schedulingInfo.getAgentId().get(), timezone);
413       }
414       return schedulerService.findConflictingEvents(
415           schedulingInfo.getAgentId().get(),
416           schedulingInfo.getRrule().get(),
417           schedulingInfo.getStartDate().get(),
418           schedulingInfo.getEndDate().get(),
419           schedulingInfo.getDuration().get(),
420           TimeZone.getTimeZone(timezone)
421       );
422     }
423 
424     return schedulerService.findConflictingEvents(
425         schedulingInfo.getAgentId().get(),
426         schedulingInfo.getStartDate().get(),
427         schedulingInfo.getEndDate().get()
428     );
429   }
430 
431 }