calendar.rb 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  1. # Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
  2. class Calendar < ApplicationModel
  3. include ChecksClientNotification
  4. include CanUniqName
  5. include HasEscalationCalculationImpact
  6. store :business_hours
  7. store :public_holidays
  8. validate :validate_hours
  9. before_save :ensure_public_holidays_details, :fetch_ical
  10. after_destroy :min_one_check
  11. after_save :min_one_check
  12. after_save :sync_default
  13. =begin
  14. set initial default calendar
  15. calendar = Calendar.init_setup
  16. returns calendar object
  17. =end
  18. def self.init_setup(ip = nil)
  19. # ignore client ip if not public ip
  20. if ip && ip =~ %r{^(::1|127\.|10\.|172\.1[6-9]\.|172\.2[0-9]\.|172\.3[0-1]\.|192\.168\.)}
  21. ip = nil
  22. end
  23. # prevent multiple setups for same ip
  24. cache = Rails.cache.read('Calendar.init_setup.done')
  25. return if cache && cache[:ip] == ip
  26. Rails.cache.write('Calendar.init_setup.done', { ip: ip }, { expires_in: 1.hour })
  27. # call for calendar suggestion
  28. calendar_details = Service::GeoCalendar.location(ip)
  29. return if calendar_details.blank?
  30. return if calendar_details['name'].blank?
  31. return if calendar_details['business_hours'].blank?
  32. calendar_details['name'] = Calendar.generate_uniq_name(calendar_details['name'])
  33. calendar_details['default'] = true
  34. calendar_details['created_by_id'] = 1
  35. calendar_details['updated_by_id'] = 1
  36. # find if auto generated calendar exists
  37. calendar = Calendar.find_by(default: true, updated_by_id: 1, created_by_id: 1)
  38. if calendar
  39. calendar.update!(calendar_details)
  40. return calendar
  41. end
  42. create(calendar_details)
  43. end
  44. =begin
  45. get default calendar
  46. calendar = Calendar.default
  47. returns calendar object
  48. =end
  49. def self.default
  50. find_by(default: true)
  51. end
  52. =begin
  53. returns preset of ical feeds
  54. feeds = Calendar.ical_feeds
  55. returns
  56. {
  57. 'http://www.google.com/calendar/ical/en.usa%23holiday%40group.v.calendar.google.com/public/basic.ics' => 'US',
  58. ...
  59. }
  60. =end
  61. def self.ical_feeds
  62. data = YAML.load_file(Rails.root.join('config/holiday_calendars.yml'))
  63. url = data['url']
  64. data['countries'].to_h do |country, domain|
  65. [format(url, domain: domain), country]
  66. end
  67. end
  68. =begin
  69. get list of available timezones and UTC offsets
  70. list = Calendar.timezones
  71. returns
  72. {
  73. 'America/Los_Angeles' => -7
  74. ...
  75. }
  76. =end
  77. def self.timezones
  78. list = {}
  79. TZInfo::Timezone.all_country_zone_identifiers.each do |timezone|
  80. t = ActiveSupport::TimeZone.find_tzinfo(timezone)
  81. diff = t.current_period.utc_total_offset / 60 / 60
  82. list[ timezone ] = diff
  83. end
  84. list
  85. end
  86. =begin
  87. syn all calendars with ical feeds
  88. success = Calendar.sync
  89. returns
  90. true # or false
  91. =end
  92. def self.sync
  93. Calendar.find_each(&:sync)
  94. true
  95. end
  96. =begin
  97. syn one calendars with ical feed
  98. calendar = Calendar.find(4711)
  99. success = calendar.sync
  100. returns
  101. true # or false
  102. =end
  103. def sync(without_save = nil)
  104. return if !ical_url
  105. # only sync every 5 days
  106. if id
  107. cache_key = "CalendarIcal::#{id}"
  108. cache = Rails.cache.read(cache_key)
  109. return if !last_log && cache && cache[:ical_url] == ical_url
  110. end
  111. begin
  112. events = {}
  113. if ical_url.present?
  114. events = Calendar.fetch_parse(ical_url)
  115. end
  116. # sync with public_holidays
  117. self.public_holidays ||= {}
  118. # remove old ical entries if feed has changed
  119. public_holidays.each do |day, meta|
  120. next if !public_holidays[day]['feed']
  121. next if meta['feed'] == Digest::MD5.hexdigest(ical_url)
  122. public_holidays.delete(day)
  123. end
  124. # sync new ical feed dates
  125. events.each do |day, summary|
  126. public_holidays[day] ||= {}
  127. # ignore if already added or changed
  128. next if public_holidays[day].key?('active')
  129. # entry already exists
  130. next if summary == public_holidays[day][:summary]
  131. # create new entry
  132. public_holidays[day] = {
  133. active: true,
  134. summary: summary,
  135. feed: Digest::MD5.hexdigest(ical_url)
  136. }
  137. end
  138. self.last_log = nil
  139. if id
  140. Rails.cache.write(
  141. cache_key,
  142. { public_holidays: public_holidays, ical_url: ical_url },
  143. { expires_in: 1.day },
  144. )
  145. end
  146. rescue => e
  147. self.last_log = e.inspect
  148. end
  149. self.last_sync = Time.zone.now
  150. if !without_save
  151. save
  152. end
  153. true
  154. end
  155. def self.fetch_parse(location)
  156. if location.match?(%r{^http}i)
  157. result = UserAgent.get(location)
  158. if !result.success?
  159. raise result.error
  160. end
  161. cal_file = result.body
  162. else
  163. cal_file = File.open(location)
  164. end
  165. cals = Icalendar::Calendar.parse(cal_file)
  166. cal = cals.first
  167. events = {}
  168. cal.events.each do |event|
  169. if event.rrule
  170. # loop till days
  171. interval_frame_start = Date.parse("#{1.year.ago}-01-01")
  172. interval_frame_end = Date.parse("#{3.years.from_now}-12-31")
  173. occurrences = event.occurrences_between(interval_frame_start, interval_frame_end)
  174. if occurrences.present?
  175. occurrences.each do |occurrence|
  176. result = Calendar.day_and_comment_by_event(event, occurrence.start_time)
  177. next if !result
  178. events[result[0]] = result[1]
  179. end
  180. end
  181. end
  182. next if event.dtstart < 1.year.ago
  183. next if event.dtstart > 3.years.from_now
  184. result = Calendar.day_and_comment_by_event(event, event.dtstart)
  185. next if !result
  186. events[result[0]] = result[1]
  187. end
  188. events.sort.to_h
  189. end
  190. # get day and comment by event
  191. def self.day_and_comment_by_event(event, start_time)
  192. day = "#{start_time.year}-#{format('%<month>02d', month: start_time.month)}-#{format('%<day>02d', day: start_time.day)}"
  193. comment = event.summary || event.description
  194. comment = comment.to_utf8(fallback: :read_as_sanitized_binary)
  195. # ignore daylight saving time entries
  196. return if comment.match?(%r{(daylight saving|sommerzeit|summertime)}i)
  197. [day, comment]
  198. end
  199. =begin
  200. calendar = Calendar.find(123)
  201. calendar.business_hours_to_hash
  202. returns
  203. {
  204. mon: {'09:00' => '18:00'},
  205. tue: {'09:00' => '18:00'},
  206. wed: {'09:00' => '18:00'},
  207. thu: {'09:00' => '18:00'},
  208. sat: {'09:00' => '18:00'}
  209. }
  210. =end
  211. def business_hours_to_hash
  212. business_hours
  213. .filter { |_, value| value[:active] && value[:timeframes] }
  214. .each_with_object({}) do |(day, meta), days_memo|
  215. days_memo[day.to_sym] = meta[:timeframes]
  216. .each_with_object({}) do |(from, to), hours_memo|
  217. next if !from || !to
  218. # convert "last minute of the day" format from Zammad/UI to biz-gem
  219. hours_memo[from] = to == '23:59' ? '24:00' : to
  220. end
  221. end
  222. end
  223. =begin
  224. calendar = Calendar.find(123)
  225. calendar.public_holidays_to_array
  226. returns
  227. [
  228. Thu, 08 Mar 2020,
  229. Sun, 25 Mar 2020,
  230. Thu, 29 Mar 2020,
  231. ]
  232. =end
  233. def public_holidays_to_array
  234. holidays = []
  235. public_holidays&.each do |day, meta|
  236. next if !meta
  237. next if !meta['active']
  238. next if meta['removed']
  239. holidays.push Date.parse(day)
  240. end
  241. holidays
  242. end
  243. def biz(breaks: {})
  244. Biz::Schedule.new do |config|
  245. # get business hours
  246. hours = business_hours_to_hash
  247. raise "No configured hours found in calendar #{inspect}" if hours.blank?
  248. config.hours = hours
  249. # get holidays
  250. config.holidays = public_holidays_to_array
  251. config.time_zone = timezone
  252. config.breaks = breaks
  253. end
  254. end
  255. private
  256. # if changed calendar is default, set all others default to false
  257. def sync_default
  258. return true if !default
  259. Calendar.find_each do |calendar|
  260. next if calendar.id == id
  261. next if !calendar.default
  262. calendar.default = false
  263. calendar.save
  264. end
  265. true
  266. end
  267. # check if min one is set to default true
  268. def min_one_check
  269. if !Calendar.exists?(default: true)
  270. first = Calendar.order(:created_at, :id).limit(1).first
  271. return true if !first
  272. first.default = true
  273. first.save
  274. end
  275. # check if sla's are refer to an existing calendar
  276. if destroyed?
  277. default_calendar = Calendar.find_by(default: true)
  278. Sla.where(calendar_id: id).find_each do |sla|
  279. sla.calendar_id = default_calendar.id
  280. sla.save!
  281. end
  282. end
  283. true
  284. end
  285. # fetch ical feed
  286. def fetch_ical
  287. sync(true)
  288. true
  289. end
  290. # ensure integrity of details of public holidays
  291. def ensure_public_holidays_details
  292. # fillup feed info
  293. before = public_holidays_was
  294. public_holidays.each do |day, meta|
  295. if before && before[day] && before[day]['feed']
  296. meta['feed'] = before[day]['feed']
  297. end
  298. meta['active'] = if meta['active']
  299. true
  300. else
  301. false
  302. end
  303. end
  304. true
  305. end
  306. # validate business hours
  307. def validate_hours
  308. # get business hours
  309. hours = business_hours_to_hash
  310. if hours.blank?
  311. errors.add :base, __('There are no business hours configured.')
  312. return
  313. end
  314. # raise Exceptions::UnprocessableEntity, 'There are no business hours configured.' if hours.blank?
  315. # validate if business hours are usable by execute a try calculation
  316. begin
  317. Biz.configure do |config|
  318. config.hours = hours
  319. end
  320. Biz.time(10, :minutes).after(Time.zone.parse('Tue, 05 Feb 2019 21:40:28 UTC +00:00'))
  321. rescue => e
  322. errors.add :base, e.message
  323. end
  324. end
  325. end