calendar.rb 9.5 KB

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