Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/api/entities/webcal_entity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ class WebcalEntity < Grape::Entity
expose :id, expose_nil: false
expose :guid, expose_nil: false
expose :include_start_dates, expose_nil: false
expose :include_learning_sessions, expose_nil: false

expose :enabled do |webcal, options|
webcal.present?
Expand Down
2 changes: 2 additions & 0 deletions app/api/webcal_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ def present_webcal(webcal)
requires :webcal, type: Hash do
optional :enabled, type: Boolean, desc: 'Is the webcal enabled?'
optional :should_change_guid, type: Boolean, desc: 'Should the GUID of the webcal be changed?'
optional :include_learning_sessions, type: Boolean, desc: 'Include HelpHub and class times and their join links (opt in)'
optional :include_start_dates, type: Boolean, desc: 'Should events for start dates be included?'
optional :unit_exclusions, type: Array[Integer], desc: 'IDs of units that must be excluded from the webcal'

Expand Down Expand Up @@ -89,6 +90,7 @@ def present_webcal(webcal)
# Set any other properties that have to be updated verbatim.
webcal_update_params.merge! ActionController::Parameters.new(webcal_params).permit(
:include_start_dates,
:include_learning_sessions,
:reminder_time,
:reminder_unit
)
Expand Down
1 change: 1 addition & 0 deletions app/api/webcal_public_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class WebcalPublicApi < Grape::API
# Serve the iCalendar with the correct MIME type.
env['api.format'] = :txt
content_type 'text/calendar'
header 'Cache-Control', 'private, no-store'

# Serve ical.
webcal.to_ical.to_ical
Expand Down
40 changes: 38 additions & 2 deletions app/models/webcal.rb
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,10 @@ def to_ical(task_defs = task_definitions)
end
end

add_learning_sessions_to_calendar(ical) if include_learning_sessions?

# Specify refresh interval.
refresh_interval = Icalendar::Values::Duration.new('1D')
refresh_interval = Icalendar::Values::Duration.new('PT4H')
# https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxcical/1fc7b244-ecd1-4d28-ac0c-2bb4df855a1f
ical.append_custom_property('X-PUBLISHED-TTL', refresh_interval)
# https://tools.ietf.org/html/rfc7986#section-5.7
Expand All @@ -157,11 +159,45 @@ def to_ical(task_defs = task_definitions)
ical
end

# Learning sessions have an independent scope: a unit does not need task
# definitions to have a HelpHub. Re-check enrolment every time the URL refreshes.
def learning_sessions
UnitLearningSession.joins(unit: :projects).includes(:unit)
.where(published: true, projects: { user_id: user_id, enrolled: true }, units: { active: true })
.where('? BETWEEN units.start_date AND units.end_date', Time.zone.today)
.where.not(unit_id: webcal_unit_exclusions.select(:unit_id)).distinct
end

def add_learning_sessions_to_calendar(ical)
learning_sessions.each do |session|
session.occurrences(from: 30.days.ago, to: 6.months.from_now).each do |occurrence|
ical.event do |event|
event.uid = "ontrack-session-#{occurrence[:occurrence_id]}"
event.summary = "#{session.unit.code}: #{session.title}"
event.dtstart = Icalendar::Values::DateTime.new(occurrence[:start_at].utc)
event.dtend = Icalendar::Values::DateTime.new(occurrence[:end_at].utc)
event.dtstamp = Icalendar::Values::DateTime.new(session.updated_at.utc)
event.last_modified = Icalendar::Values::DateTime.new(session.updated_at.utc)
event.sequence = session.lock_version
event.status = session.cancelled? ? 'CANCELLED' : 'CONFIRMED'
event.location = session.location if session.location.present?
event.description = session.cancelled? ? 'This session has been cancelled.' : 'Join details for your OnTrack unit session.'
unless session.cancelled? || session.join_url.blank?
event.url = session.join_url
event.description = "Join: #{session.join_url}"
end
event.append_custom_property('X-DOUBTFIRE-UNIT', session.unit_id.to_s)
event.append_custom_property('X-DOUBTFIRE-SESSION', session.id.to_s)
end
end
end
end

#
# Returns the target/extended date for the specified task definition.
#
def self.end_date_for_task_definition(task_def, task = nil, project = nil)
return task.local_due_date if task.present?
return task.effective_deadline_date if task.present?

flexible_grade_date_for_task_definition(task_def, project, :target_date) || task_def.target_date
end
Expand Down
59 changes: 59 additions & 0 deletions test/api/webcal_api_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,65 @@ def app
assert_not last_response.body.start_with?('"')
end

test 'Unknown guid returns 404' do
# No webcal has ever been created with this guid.
get "/api/webcal/#{SecureRandom.uuid}"

assert_equal 404, last_response.status
end

test 'Guid returns 404 after should_change_guid rotates it, new guid returns 200' do
add_auth_header_for user: @student
# Enable webcal, get GUID
put_json '/api/webcal', { webcal: { enabled: true } }
assert_equal 200, last_response.status
old_guid = last_response_body['guid']
assert old_guid.present?

# Rotate the GUID
add_auth_header_for user: @student
put_json '/api/webcal', { webcal: { should_change_guid: true } }
assert_equal 200, last_response.status
new_guid = last_response_body['guid']
assert new_guid.present?
assert_not_equal old_guid, new_guid

# Old guid must no longer resolve...
get "/api/webcal/#{old_guid}"
assert_equal 404, last_response.status

# ...only the new one does.
get "/api/webcal/#{new_guid}"
assert_equal 200, last_response.status
end

test 'Guid of a disabled webcal returns 404' do
add_auth_header_for user: @student
# Enable webcal, get GUID
put_json '/api/webcal', { webcal: { enabled: true } }
assert_equal 200, last_response.status
guid = last_response_body['guid']
assert guid.present?
get "/api/webcal/#{guid}"
assert_equal 200, last_response.status

# Disable webcal
add_auth_header_for user: @student
put_json '/api/webcal', { webcal: { enabled: false } }
assert_equal 200, last_response.status

get "/api/webcal/#{guid}"
assert_equal 404, last_response.status
end

test '404 response body is not a VCALENDAR' do
get "/api/webcal/#{SecureRandom.uuid}"

assert_equal 404, last_response.status
assert_includes last_response['Content-Type'], 'text/error'
assert_not last_response.body.start_with?('BEGIN:VCALENDAR')
end

test 'Reminder must be specified with both time & unit' do
add_auth_header_for user: @student

Expand Down
11 changes: 11 additions & 0 deletions test/models/unit_calendar_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true

require 'test_helper'

class UnitCalendarTest < ActiveSupport::TestCase
def test_date_for_week_and_day_keeps_earlier_weekday_in_the_requested_week
unit = Unit.new(start_date: Time.zone.local(2026, 8, 7)) # Friday

assert_equal Time.zone.local(2026, 8, 9), unit.date_for_week_and_day(1, 'Sun')
end
end
70 changes: 70 additions & 0 deletions test/models/unit_hub_calendar_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# frozen_string_literal: true

require 'test_helper'

class UnitHubCalendarTest < ActiveSupport::TestCase
setup do
@student = FactoryBot.create(:user, :student)
@unit = FactoryBot.create(:unit, with_students: false, task_count: 0)
@project = @unit.enrol_student(@student, @unit.tutorials.first.campus)
@webcal = Webcal.create!(user: @student, guid: SecureRandom.uuid)
@session = @unit.unit_learning_sessions.create!(title: 'HelpHub', start_at: 1.day.from_now, end_at: 1.day.from_now + 1.hour,
published: true, join_url: 'https://teams.microsoft.com/l/meetup-join/example')
end

def session_events
Icalendar::Calendar.parse(@webcal.to_ical.to_ical).first.events.select { |event| event.uid.to_s.start_with?('ontrack-session-') }
end

def test_calendar_is_explicit_opt_in_and_works_for_units_without_tasks
assert_not @webcal.include_learning_sessions?
assert_empty session_events
@webcal.update!(include_learning_sessions: true)
event = session_events.first
assert_equal "ontrack-session-#{@session.id}-0", event.uid.to_s
assert_equal @session.join_url, event.url.to_s
assert_equal @session.start_at.utc.to_i, event.dtstart.to_time.to_i
end

def test_calendar_rechecks_withdrawal_and_exclusions
@webcal.update!(include_learning_sessions: true)
assert_equal 1, session_events.length
@webcal.webcal_unit_exclusions.create!(unit: @unit)
assert_empty session_events
@webcal.webcal_unit_exclusions.destroy_all
@project.update!(enrolled: false)
assert_empty session_events
end

def test_other_units_and_drafts_never_enter_subscription
@webcal.update!(include_learning_sessions: true)
other = FactoryBot.create(:unit, with_students: false, task_count: 0)
other.unit_learning_sessions.create!(title: 'Secret', start_at: 1.day.from_now, end_at: 1.day.from_now + 1.hour, published: true)
@unit.unit_learning_sessions.create!(title: 'Draft', start_at: 1.day.from_now, end_at: 1.day.from_now + 1.hour)
assert_equal 1, session_events.length
@unit.update!(active: false)
assert_empty session_events
end

def test_sessions_remain_available_on_the_final_day_of_a_unit
@webcal.update!(include_learning_sessions: true)
@unit.update!(end_date: Time.zone.today)
assert_equal [@session.id], @webcal.learning_sessions.pluck(:id)
end

def test_cancellation_and_rescheduling_keep_stable_uid_and_remove_join_link
@webcal.update!(include_learning_sessions: true)
first = session_events.first
@session.update!(start_at: @session.start_at + 1.hour, end_at: @session.end_at + 1.hour)
moved = session_events.first
assert_equal first.uid, moved.uid
assert_not_equal first.dtstart, moved.dtstart
assert_operator moved.sequence.to_i, :>, first.sequence.to_i
@session.update!(cancelled: true)
cancelled = session_events.first
assert_equal first.uid, cancelled.uid
assert_equal 'CANCELLED', cancelled.status.to_s
assert_nil cancelled.url
assert_not_includes cancelled.description.to_s, 'https://'
end
end
2 changes: 2 additions & 0 deletions test/models/webcal_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,8 @@ class WebcalTest < ActiveSupport::TestCase

test 'Includes webcal reminders correctly' do
cal = @webcal.to_ical
assert_includes cal.to_ical, 'X-PUBLISHED-TTL;VALUE=DURATION:PT4H'
assert_includes cal.to_ical, 'REFRESH-INTERVAL;VALUE=DURATION:PT4H'
all_task_defs = @current_unit1.task_definitions + @current_unit2.task_definitions

# Calls `fn` per task definition in `all_task_defs` with 2 args---the `TaskDefinition`, and the corresponding
Expand Down