123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436 |
- # Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
- class Calendar < ApplicationModel
- include ChecksClientNotification
- include CanUniqName
- include HasEscalationCalculationImpact
- store :business_hours
- store :public_holidays
- validates :name, uniqueness: { case_sensitive: false }
- validate :validate_hours
- before_save :ensure_public_holidays_details, :fetch_ical
- after_destroy :min_one_check
- after_save :min_one_check
- after_save :sync_default
- =begin
- set initial default calendar
- calendar = Calendar.init_setup
- returns calendar object
- =end
- def self.init_setup(ip = nil)
- # ignore client ip if not public ip
- if ip && ip =~ %r{^(::1|127\.|10\.|172\.1[6-9]\.|172\.2[0-9]\.|172\.3[0-1]\.|192\.168\.)}
- ip = nil
- end
- # prevent multiple setups for same ip
- cache = Rails.cache.read('Calendar.init_setup.done')
- return if cache && cache[:ip] == ip
- Rails.cache.write('Calendar.init_setup.done', { ip: ip }, { expires_in: 1.hour })
- # call for calendar suggestion
- calendar_details = Service::GeoCalendar.location(ip)
- return if calendar_details.blank?
- return if calendar_details['name'].blank?
- return if calendar_details['business_hours'].blank?
- calendar_details['name'] = Calendar.generate_uniq_name(calendar_details['name'])
- calendar_details['default'] = true
- calendar_details['created_by_id'] = 1
- calendar_details['updated_by_id'] = 1
- # find if auto generated calendar exists
- calendar = Calendar.find_by(default: true, updated_by_id: 1, created_by_id: 1)
- if calendar
- calendar.update!(calendar_details)
- return calendar
- end
- create(calendar_details)
- end
- =begin
- get default calendar
- calendar = Calendar.default
- returns calendar object
- =end
- def self.default
- find_by(default: true)
- end
- =begin
- returns preset of ical feeds
- feeds = Calendar.ical_feeds
- returns
- {
- 'http://www.google.com/calendar/ical/en.usa%23holiday%40group.v.calendar.google.com/public/basic.ics' => 'US',
- ...
- }
- =end
- def self.ical_feeds
- data = YAML.load_file(Rails.root.join('config/holiday_calendars.yml'))
- url = data['url']
- data['countries'].to_h do |country, domain|
- [format(url, domain: domain), country]
- end
- end
- =begin
- get list of available timezones and UTC offsets
- list = Calendar.timezones
- returns
- {
- 'America/Los_Angeles' => -7
- ...
- }
- =end
- def self.timezones
- list = {}
- TZInfo::Timezone.all_identifiers.each do |timezone|
- t = ActiveSupport::TimeZone.find_tzinfo(timezone)
- diff = t.current_period.utc_total_offset / 60 / 60
- list[ timezone ] = diff
- end
- list
- end
- =begin
- syn all calendars with ical feeds
- success = Calendar.sync
- returns
- true # or false
- =end
- def self.sync
- Calendar.find_each(&:sync)
- true
- end
- =begin
- syn one calendars with ical feed
- calendar = Calendar.find(4711)
- success = calendar.sync
- returns
- true # or false
- =end
- def sync(without_save = nil)
- return if !ical_url
- # only sync every 5 days
- if id
- cache_key = "CalendarIcal::#{id}"
- cache = Rails.cache.read(cache_key)
- return if !last_log && cache && cache[:ical_url] == ical_url
- end
- begin
- events = {}
- if ical_url.present?
- events = Calendar.fetch_parse(ical_url)
- end
- # sync with public_holidays
- self.public_holidays ||= {}
- # remove old ical entries if feed has changed
- public_holidays.each do |day, meta|
- next if !public_holidays[day]['feed']
- next if meta['feed'] == Digest::MD5.hexdigest(ical_url)
- public_holidays.delete(day)
- end
- # sync new ical feed dates
- events.each do |day, summary|
- public_holidays[day] ||= {}
- # ignore if already added or changed
- next if public_holidays[day].key?('active')
- # entry already exists
- next if summary == public_holidays[day][:summary]
- # create new entry
- public_holidays[day] = {
- active: true,
- summary: summary,
- feed: Digest::MD5.hexdigest(ical_url)
- }
- end
- self.last_log = nil
- if id
- Rails.cache.write(
- cache_key,
- { public_holidays: public_holidays, ical_url: ical_url },
- { expires_in: 1.day },
- )
- end
- rescue => e
- self.last_log = e.inspect
- end
- self.last_sync = Time.zone.now
- if !without_save
- save
- end
- true
- end
- def self.fetch_parse(location)
- if location.match?(%r{^http}i)
- result = UserAgent.get(location)
- if !result.success?
- raise result.error
- end
- cal_file = result.body
- else
- cal_file = File.read(location)
- end
- cals = Icalendar::Calendar.parse(cal_file)
- cal = cals.first
- events = {}
- cal.events.each do |event|
- if event.rrule.present?
- # loop till days
- interval_frame_start = Date.parse("#{1.year.ago}-01-01")
- interval_frame_end = Date.parse("#{3.years.from_now}-12-31")
- occurrences = event.occurrences_between(interval_frame_start, interval_frame_end)
- if occurrences.present?
- occurrences.each do |occurrence|
- result = Calendar.day_and_comment_by_event(event, occurrence.start_time)
- next if !result
- events[result[0]] = result[1]
- end
- end
- end
- next if event.dtstart < 1.year.ago
- next if event.dtstart > 3.years.from_now
- result = Calendar.day_and_comment_by_event(event, event.dtstart)
- next if !result
- events[result[0]] = result[1]
- end
- events.sort.to_h
- end
- # get day and comment by event
- def self.day_and_comment_by_event(event, start_time)
- day = "#{start_time.year}-#{format('%<month>02d', month: start_time.month)}-#{format('%<day>02d', day: start_time.day)}"
- comment = event.summary || event.description
- comment = comment.to_utf8(fallback: :read_as_sanitized_binary)
- # ignore daylight saving time entries
- return if comment.match?(%r{(daylight saving|sommerzeit|summertime)}i)
- [day, comment]
- end
- =begin
- calendar = Calendar.find(123)
- calendar.business_hours_to_hash
- returns
- {
- mon: {'09:00' => '18:00'},
- tue: {'09:00' => '18:00'},
- wed: {'09:00' => '18:00'},
- thu: {'09:00' => '18:00'},
- sat: {'09:00' => '18:00'}
- }
- =end
- def business_hours_to_hash
- business_hours
- .filter { |_, value| value[:active] && value[:timeframes] }
- .each_with_object({}) do |(day, meta), days_memo|
- days_memo[day.to_sym] = meta[:timeframes]
- .each_with_object({}) do |(from, to), hours_memo|
- next if !from || !to
- # convert "last minute of the day" format from Zammad/UI to biz-gem
- hours_memo[from] = to == '23:59' ? '24:00' : to
- end
- end
- end
- =begin
- calendar = Calendar.find(123)
- calendar.public_holidays_to_array
- returns
- [
- Thu, 08 Mar 2020,
- Sun, 25 Mar 2020,
- Thu, 29 Mar 2020,
- ]
- =end
- def public_holidays_to_array
- holidays = []
- public_holidays&.each do |day, meta|
- next if !meta
- next if !meta['active']
- next if meta['removed']
- holidays.push Date.parse(day)
- end
- holidays
- end
- def biz(breaks: {})
- Biz::Schedule.new do |config|
- # get business hours
- hours = business_hours_to_hash
- raise "No configured hours found in calendar #{inspect}" if hours.blank?
- config.hours = hours
- # get holidays
- config.holidays = public_holidays_to_array
- config.time_zone = timezone
- config.breaks = breaks
- end
- end
- private
- # if changed calendar is default, set all others default to false
- def sync_default
- return true if !default
- Calendar.find_each do |calendar|
- next if calendar.id == id
- next if !calendar.default
- calendar.default = false
- calendar.save
- end
- true
- end
- # check if min one is set to default true
- def min_one_check
- if !Calendar.exists?(default: true)
- first = Calendar.reorder(:created_at, :id).limit(1).first
- return true if !first
- first.default = true
- first.save
- end
- # check if sla's are refer to an existing calendar
- if destroyed?
- default_calendar = Calendar.find_by(default: true)
- Sla.where(calendar_id: id).find_each do |sla|
- sla.calendar_id = default_calendar.id
- sla.save!
- end
- end
- true
- end
- # fetch ical feed
- def fetch_ical
- sync(true)
- true
- end
- # ensure integrity of details of public holidays
- def ensure_public_holidays_details
- # fillup feed info
- before = public_holidays_was
- public_holidays.each do |day, meta|
- if before && before[day] && before[day]['feed']
- meta['feed'] = before[day]['feed']
- end
- meta['active'] = if meta['active']
- true
- else
- false
- end
- end
- true
- end
- # validate business hours
- def validate_hours
- # get business hours
- hours = business_hours_to_hash
- if hours.blank?
- errors.add :base, __('There are no business hours configured.')
- return
- end
- # raise Exceptions::UnprocessableEntity, 'There are no business hours configured.' if hours.blank?
- # validate if business hours are usable by execute a try calculation
- begin
- Biz.configure do |config|
- config.hours = hours
- end
- Biz.time(10, :minutes).after(Time.zone.parse('Tue, 05 Feb 2019 21:40:28 UTC +00:00'))
- rescue => e
- errors.add :base, e.message
- end
- end
- end
|