Skip to content

Commit

Permalink
Merge pull request #17377 from opf/feature/recurring-meeting-ics
Browse files Browse the repository at this point in the history
ICS download option for recurring meeting
  • Loading branch information
mrmir authored Dec 9, 2024
2 parents 865aa61 + 07ae967 commit f8494bc
Show file tree
Hide file tree
Showing 13 changed files with 411 additions and 53 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@
'aria-label': t(:label_recurring_meeting_series_edit),
test_selector: "edit-meeting-details-button",
)

menu.with_item(
label: t(:label_icalendar_download),
href: download_ics_recurring_meeting_path(@meeting)
)

menu.with_item(
label: I18n.t(:label_recurring_meeting_series_delete),
href: polymorphic_path([@project, @meeting]),
Expand Down
13 changes: 12 additions & 1 deletion modules/meeting/app/controllers/recurring_meetings_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ class RecurringMeetingsController < ApplicationController
include OpTurbo::FlashStreamHelper
include OpTurbo::DialogStreamHelper

before_action :find_meeting, only: %i[show update details_dialog destroy edit init delete_scheduled template_completed]
before_action :find_meeting,
only: %i[show update details_dialog destroy edit init delete_scheduled template_completed download_ics]
before_action :find_optional_project, only: %i[index show new create update details_dialog destroy edit delete_scheduled]
before_action :authorize_global, only: %i[index new create]
before_action :authorize, except: %i[index new create]
Expand Down Expand Up @@ -167,6 +168,16 @@ def delete_scheduled
redirect_to polymorphic_path([@project, @recurring_meeting]), status: :see_other
end

def download_ics
::RecurringMeetings::ICalService
.new(user: current_user, series: @recurring_meeting)
.call
.on_failure { |call| render_500(message: call.message) }
.on_success do |call|
send_data call.result, filename: filename_for_content_disposition("#{@recurring_meeting.title}.ics")
end
end

private

def upcoming_meetings
Expand Down
2 changes: 1 addition & 1 deletion modules/meeting/app/models/recurring_meeting.rb
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ def count_rule(rule)
if end_after_iterations?
rule.count(iterations)
else
rule.until(end_date)
rule.until(end_date.to_time(:utc))
end
end
end
3 changes: 3 additions & 0 deletions modules/meeting/app/models/scheduled_meeting.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ class ScheduledMeeting < ApplicationRecord
scope :upcoming, -> { where(start_time: Time.current..) }
scope :past, -> { where(start_time: ...Time.current) }

scope :instantiated, -> { where.not(meeting_id: nil) }
scope :not_instantiated, -> { where(meeting_id: nil) }

scope :cancelled, -> { where(cancelled: true) }
scope :not_cancelled, -> { where(cancelled: false) }

Expand Down
81 changes: 81 additions & 0 deletions modules/meeting/app/services/meetings/ical_helpers.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require "icalendar"
require "icalendar/tzinfo"

module Meetings
module ICalHelpers
def ical_event(start_time, &)
calendar = build_icalendar(start_time)
calendar.event(&)
calendar.publish
calendar.to_ical
end

def build_icalendar(start_time)
::Icalendar::Calendar.new.tap do |calendar|
calendar.prodid = "-//OpenProject GmbH//#{OpenProject::VERSION}//Meeting//EN"
ical_timezone = timezone.tzinfo.ical_timezone start_time
calendar.add_timezone ical_timezone
end
end

def add_attendees(event, meeting)
meeting.participants.includes(:user).find_each do |participant|
user = participant.user
next unless user

address = Icalendar::Values::CalAddress.new(
"mailto:#{user.mail}",
{
"CN" => user.name,
"EMAIL" => user.mail,
"PARTSTAT" => "NEEDS-ACTION",
"RSVP" => "TRUE",
"CUTYPE" => "INDIVIDUAL",
"ROLE" => "REQ-PARTICIPANT"
}
)

event.append_attendee(address)
end
end

def ical_uid(suffix)
"#{Setting.app_title}-#{Setting.host_name}-#{suffix}".dasherize
end

def ical_datetime(time, timezone_id)
Icalendar::Values::DateTime.new time.in_time_zone(timezone_id), "tzid" => timezone_id
end

def ical_organizer(meeting)
Icalendar::Values::CalAddress.new("mailto:#{meeting.author.mail}", cn: meeting.author.name)
end
end
end
48 changes: 4 additions & 44 deletions modules/meeting/app/services/meetings/ical_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@

module Meetings
class ICalService
include ICalHelpers
attr_reader :user, :meeting, :timezone, :url_helpers

def initialize(meeting:, user:)
Expand All @@ -52,7 +53,7 @@ def call

# rubocop:disable Metrics/AbcSize
def generate_ical
ical_event do |e|
ical_event(meeting.start_time) do |e|
tzinfo = timezone.tzinfo
tzid = tzinfo.canonical_identifier

Expand All @@ -62,57 +63,16 @@ def generate_ical
e.summary = "[#{meeting.project.name}] #{meeting.title}"
e.description = ical_subject
e.uid = "#{meeting.id}@#{meeting.project.identifier}"
e.organizer = ical_organizer
e.organizer = ical_organizer(meeting)
e.location = meeting.location.presence

add_attendees(e)
add_attendees(e, meeting)
end
end
# rubocop:enable Metrics/AbcSize

def ical_event(&)
calendar = ::Icalendar::Calendar.new

ical_timezone = @timezone.tzinfo.ical_timezone meeting.start_time
calendar.add_timezone ical_timezone

calendar.event(&)

calendar.publish

calendar.to_ical
end

def add_attendees(event)
meeting.participants.includes(:user).find_each do |participant|
user = participant.user
next unless user

address = Icalendar::Values::CalAddress.new(
"mailto:#{user.mail}",
{
"CN" => user.name,
"PARTSTAT" => "NEEDS-ACTION",
"RSVP" => "TRUE",
"CUTYPE" => "INDIVIDUAL",
"ROLE" => "REQ-PARTICIPANT"
}
)

event.append_attendee(address)
end
end

def ical_subject
"[#{meeting.project.name}] #{I18n.t(:label_meeting)}: #{meeting.title}"
end

def ical_datetime(time, timezone_id)
Icalendar::Values::DateTime.new time.in_time_zone(timezone_id), "tzid" => timezone_id
end

def ical_organizer
Icalendar::Values::CalAddress.new("mailto:#{meeting.author.mail}", cn: meeting.author.name)
end
end
end
137 changes: 137 additions & 0 deletions modules/meeting/app/services/recurring_meetings/ical_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require "icalendar"
require "icalendar/tzinfo"

module RecurringMeetings
class ICalService
include ::Meetings::ICalHelpers
attr_reader :user,
:series,
:schedule,
:timezone,
:calendar,
:url_helpers

delegate :template, to: :series

def initialize(series:, user:)
@user = user
@series = series
@url_helpers = OpenProject::StaticRouting::StaticUrlHelpers.new
end

def call # rubocop:disable Metrics/AbcSize
User.execute_as(user) do
@timezone = Time.zone || Time.zone_default
@calendar = build_icalendar(series.start_time)
@schedule = series.schedule
ServiceResult.success(result: generate_ical)
end
rescue StandardError => e
Rails.logger.error("Failed to generate ICS for meeting series #{@series.id}: #{e.message}")
ServiceResult.failure(message: e.message)
end

private

def tzinfo
timezone.tzinfo
end

def tzid
tzinfo.canonical_identifier
end

def generate_ical
series_event
occurrences_events

calendar.publish
calendar.to_ical
end

def series_event # rubocop:disable Metrics/AbcSize
calendar.event do |e|
base_series_attributes(e)

e.dtstart = ical_datetime template.start_time, tzid
e.dtend = ical_datetime template.end_time, tzid
e.url = url_helpers.project_recurring_meeting_url(series.project, series)
e.location = template.location.presence

add_attendees(e, template)
e.exdate = cancelled_schedules
end
end

def occurrences_events
upcoming_instantiated_schedules.find_each do |schedule|
occurrence_event(schedule.start_time, schedule.meeting)
end
end

def occurrence_event(schedule_start_time, meeting) # rubocop:disable Metrics/AbcSize
calendar.event do |e|
base_series_attributes(e)

e.recurrence_id = ical_datetime schedule_start_time, tzid
e.dtstart = ical_datetime meeting.start_time, tzid
e.dtend = ical_datetime meeting.end_time, tzid
e.url = url_helpers.project_meeting_url(meeting.project, meeting)
e.location = meeting.location.presence

add_attendees(e, meeting)
end
end

def upcoming_instantiated_schedules
series
.scheduled_meetings
.not_cancelled
.instantiated
.includes(:meeting)
end

def base_series_attributes(event) # rubocop:disable Metrics/AbcSize
event.uid = ical_uid("meeting-series-#{series.id}")
event.rrule = schedule.rrules.first.to_ical # We currently only have one recurrence rule
event.summary = "[#{series.project.name}] #{series.title}"
event.description = "[#{series.project.name}] #{I18n.t(:label_meeting_series)}: #{series.title}"
event.organizer = ical_organizer(series)
end

def cancelled_schedules
series
.scheduled_meetings
.cancelled
.pluck(:start_time)
.map { |time| Icalendar::Values::DateTime.new time.in_time_zone(tzid), "tzid" => tzid }
end
end
end
1 change: 1 addition & 0 deletions modules/meeting/config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
resources :recurring_meetings do
member do
get :details_dialog
get :download_ics
post :init
post :delete_scheduled
post :template_completed
Expand Down
2 changes: 1 addition & 1 deletion modules/meeting/lib/open_project/meeting/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class Engine < ::Rails::Engine
meeting_minutes: %i[history show diff],
"meetings/menus": %i[show],
work_package_meetings_tab: %i[index count],
recurring_meetings: %i[index show new create] },
recurring_meetings: %i[index show new create download_ics] },
permissible_on: :project
permission :create_meetings,
{
Expand Down
5 changes: 4 additions & 1 deletion modules/meeting/spec/factories/recurring_meeting_factory.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@
recurring_meeting.project = project

# create template
template = create(:structured_meeting_template, recurring_meeting:, project:)
template = create(:structured_meeting_template,
author: recurring_meeting.author,
recurring_meeting:,
project:)

# create agenda item
create(:meeting_agenda_item, meeting: template, title: "My template item")
Expand Down
Loading

0 comments on commit f8494bc

Please sign in to comment.