Skip to content

Commit c745546

Browse files
Clupaimaplefoxgitdylanclealblankb0t
committed
feat(calendar): unit calendar and iCal feeds
Brings the T2 2026 calendar work from ontrack-features-t2-2026 11.0.x (reviewed and merged work) onto thoth-tech 11.0.x. Co-authored-by: maplefoxgit <s223932052@deakin.edu.au> Co-authored-by: Maple Fox <s223932052@deakin.edu.au> Co-authored-by: dylancleal <dylancleal@gmail.com> Co-authored-by: blankb0t <benee0394@gmail.com>
1 parent dc76a5a commit c745546

8 files changed

Lines changed: 184 additions & 2 deletions

File tree

‎app/api/entities/webcal_entity.rb‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ class WebcalEntity < Grape::Entity
33
expose :id, expose_nil: false
44
expose :guid, expose_nil: false
55
expose :include_start_dates, expose_nil: false
6+
expose :include_learning_sessions, expose_nil: false
67

78
expose :enabled do |webcal, options|
89
webcal.present?

‎app/api/webcal_api.rb‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ def present_webcal(webcal)
3333
requires :webcal, type: Hash do
3434
optional :enabled, type: Boolean, desc: 'Is the webcal enabled?'
3535
optional :should_change_guid, type: Boolean, desc: 'Should the GUID of the webcal be changed?'
36+
optional :include_learning_sessions, type: Boolean, desc: 'Include HelpHub and class times and their join links (opt in)'
3637
optional :include_start_dates, type: Boolean, desc: 'Should events for start dates be included?'
3738
optional :unit_exclusions, type: Array[Integer], desc: 'IDs of units that must be excluded from the webcal'
3839

@@ -89,6 +90,7 @@ def present_webcal(webcal)
8990
# Set any other properties that have to be updated verbatim.
9091
webcal_update_params.merge! ActionController::Parameters.new(webcal_params).permit(
9192
:include_start_dates,
93+
:include_learning_sessions,
9294
:reminder_time,
9395
:reminder_unit
9496
)

‎app/api/webcal_public_api.rb‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class WebcalPublicApi < Grape::API
1616
# Serve the iCalendar with the correct MIME type.
1717
env['api.format'] = :txt
1818
content_type 'text/calendar'
19+
header 'Cache-Control', 'private, no-store'
1920

2021
# Serve ical.
2122
webcal.to_ical.to_ical

‎app/models/webcal.rb‎

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,10 @@ def to_ical(task_defs = task_definitions)
147147
end
148148
end
149149

150+
add_learning_sessions_to_calendar(ical) if include_learning_sessions?
151+
150152
# Specify refresh interval.
151-
refresh_interval = Icalendar::Values::Duration.new('1D')
153+
refresh_interval = Icalendar::Values::Duration.new('PT4H')
152154
# https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxcical/1fc7b244-ecd1-4d28-ac0c-2bb4df855a1f
153155
ical.append_custom_property('X-PUBLISHED-TTL', refresh_interval)
154156
# https://tools.ietf.org/html/rfc7986#section-5.7
@@ -157,11 +159,45 @@ def to_ical(task_defs = task_definitions)
157159
ical
158160
end
159161

162+
# Learning sessions have an independent scope: a unit does not need task
163+
# definitions to have a HelpHub. Re-check enrolment every time the URL refreshes.
164+
def learning_sessions
165+
UnitLearningSession.joins(unit: :projects).includes(:unit)
166+
.where(published: true, projects: { user_id: user_id, enrolled: true }, units: { active: true })
167+
.where('? BETWEEN units.start_date AND units.end_date', Time.zone.today)
168+
.where.not(unit_id: webcal_unit_exclusions.select(:unit_id)).distinct
169+
end
170+
171+
def add_learning_sessions_to_calendar(ical)
172+
learning_sessions.each do |session|
173+
session.occurrences(from: 30.days.ago, to: 6.months.from_now).each do |occurrence|
174+
ical.event do |event|
175+
event.uid = "ontrack-session-#{occurrence[:occurrence_id]}"
176+
event.summary = "#{session.unit.code}: #{session.title}"
177+
event.dtstart = Icalendar::Values::DateTime.new(occurrence[:start_at].utc)
178+
event.dtend = Icalendar::Values::DateTime.new(occurrence[:end_at].utc)
179+
event.dtstamp = Icalendar::Values::DateTime.new(session.updated_at.utc)
180+
event.last_modified = Icalendar::Values::DateTime.new(session.updated_at.utc)
181+
event.sequence = session.lock_version
182+
event.status = session.cancelled? ? 'CANCELLED' : 'CONFIRMED'
183+
event.location = session.location if session.location.present?
184+
event.description = session.cancelled? ? 'This session has been cancelled.' : 'Join details for your OnTrack unit session.'
185+
unless session.cancelled? || session.join_url.blank?
186+
event.url = session.join_url
187+
event.description = "Join: #{session.join_url}"
188+
end
189+
event.append_custom_property('X-DOUBTFIRE-UNIT', session.unit_id.to_s)
190+
event.append_custom_property('X-DOUBTFIRE-SESSION', session.id.to_s)
191+
end
192+
end
193+
end
194+
end
195+
160196
#
161197
# Returns the target/extended date for the specified task definition.
162198
#
163199
def self.end_date_for_task_definition(task_def, task = nil, project = nil)
164-
return task.local_due_date if task.present?
200+
return task.effective_deadline_date if task.present?
165201

166202
flexible_grade_date_for_task_definition(task_def, project, :target_date) || task_def.target_date
167203
end

‎test/api/webcal_api_test.rb‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,65 @@ def app
9494
assert_not last_response.body.start_with?('"')
9595
end
9696

97+
test 'Unknown guid returns 404' do
98+
# No webcal has ever been created with this guid.
99+
get "/api/webcal/#{SecureRandom.uuid}"
100+
101+
assert_equal 404, last_response.status
102+
end
103+
104+
test 'Guid returns 404 after should_change_guid rotates it, new guid returns 200' do
105+
add_auth_header_for user: @student
106+
# Enable webcal, get GUID
107+
put_json '/api/webcal', { webcal: { enabled: true } }
108+
assert_equal 200, last_response.status
109+
old_guid = last_response_body['guid']
110+
assert old_guid.present?
111+
112+
# Rotate the GUID
113+
add_auth_header_for user: @student
114+
put_json '/api/webcal', { webcal: { should_change_guid: true } }
115+
assert_equal 200, last_response.status
116+
new_guid = last_response_body['guid']
117+
assert new_guid.present?
118+
assert_not_equal old_guid, new_guid
119+
120+
# Old guid must no longer resolve...
121+
get "/api/webcal/#{old_guid}"
122+
assert_equal 404, last_response.status
123+
124+
# ...only the new one does.
125+
get "/api/webcal/#{new_guid}"
126+
assert_equal 200, last_response.status
127+
end
128+
129+
test 'Guid of a disabled webcal returns 404' do
130+
add_auth_header_for user: @student
131+
# Enable webcal, get GUID
132+
put_json '/api/webcal', { webcal: { enabled: true } }
133+
assert_equal 200, last_response.status
134+
guid = last_response_body['guid']
135+
assert guid.present?
136+
get "/api/webcal/#{guid}"
137+
assert_equal 200, last_response.status
138+
139+
# Disable webcal
140+
add_auth_header_for user: @student
141+
put_json '/api/webcal', { webcal: { enabled: false } }
142+
assert_equal 200, last_response.status
143+
144+
get "/api/webcal/#{guid}"
145+
assert_equal 404, last_response.status
146+
end
147+
148+
test '404 response body is not a VCALENDAR' do
149+
get "/api/webcal/#{SecureRandom.uuid}"
150+
151+
assert_equal 404, last_response.status
152+
assert_includes last_response['Content-Type'], 'text/error'
153+
assert_not last_response.body.start_with?('BEGIN:VCALENDAR')
154+
end
155+
97156
test 'Reminder must be specified with both time & unit' do
98157
add_auth_header_for user: @student
99158

‎test/models/unit_calendar_test.rb‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# frozen_string_literal: true
2+
3+
require 'test_helper'
4+
5+
class UnitCalendarTest < ActiveSupport::TestCase
6+
def test_date_for_week_and_day_keeps_earlier_weekday_in_the_requested_week
7+
unit = Unit.new(start_date: Time.zone.local(2026, 8, 7)) # Friday
8+
9+
assert_equal Time.zone.local(2026, 8, 9), unit.date_for_week_and_day(1, 'Sun')
10+
end
11+
end
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# frozen_string_literal: true
2+
3+
require 'test_helper'
4+
5+
class UnitHubCalendarTest < ActiveSupport::TestCase
6+
setup do
7+
@student = FactoryBot.create(:user, :student)
8+
@unit = FactoryBot.create(:unit, with_students: false, task_count: 0)
9+
@project = @unit.enrol_student(@student, @unit.tutorials.first.campus)
10+
@webcal = Webcal.create!(user: @student, guid: SecureRandom.uuid)
11+
@session = @unit.unit_learning_sessions.create!(title: 'HelpHub', start_at: 1.day.from_now, end_at: 1.day.from_now + 1.hour,
12+
published: true, join_url: 'https://teams.microsoft.com/l/meetup-join/example')
13+
end
14+
15+
def session_events
16+
Icalendar::Calendar.parse(@webcal.to_ical.to_ical).first.events.select { |event| event.uid.to_s.start_with?('ontrack-session-') }
17+
end
18+
19+
def test_calendar_is_explicit_opt_in_and_works_for_units_without_tasks
20+
assert_not @webcal.include_learning_sessions?
21+
assert_empty session_events
22+
@webcal.update!(include_learning_sessions: true)
23+
event = session_events.first
24+
assert_equal "ontrack-session-#{@session.id}-0", event.uid.to_s
25+
assert_equal @session.join_url, event.url.to_s
26+
assert_equal @session.start_at.utc.to_i, event.dtstart.to_time.to_i
27+
end
28+
29+
def test_calendar_rechecks_withdrawal_and_exclusions
30+
@webcal.update!(include_learning_sessions: true)
31+
assert_equal 1, session_events.length
32+
@webcal.webcal_unit_exclusions.create!(unit: @unit)
33+
assert_empty session_events
34+
@webcal.webcal_unit_exclusions.destroy_all
35+
@project.update!(enrolled: false)
36+
assert_empty session_events
37+
end
38+
39+
def test_other_units_and_drafts_never_enter_subscription
40+
@webcal.update!(include_learning_sessions: true)
41+
other = FactoryBot.create(:unit, with_students: false, task_count: 0)
42+
other.unit_learning_sessions.create!(title: 'Secret', start_at: 1.day.from_now, end_at: 1.day.from_now + 1.hour, published: true)
43+
@unit.unit_learning_sessions.create!(title: 'Draft', start_at: 1.day.from_now, end_at: 1.day.from_now + 1.hour)
44+
assert_equal 1, session_events.length
45+
@unit.update!(active: false)
46+
assert_empty session_events
47+
end
48+
49+
def test_sessions_remain_available_on_the_final_day_of_a_unit
50+
@webcal.update!(include_learning_sessions: true)
51+
@unit.update!(end_date: Time.zone.today)
52+
assert_equal [@session.id], @webcal.learning_sessions.pluck(:id)
53+
end
54+
55+
def test_cancellation_and_rescheduling_keep_stable_uid_and_remove_join_link
56+
@webcal.update!(include_learning_sessions: true)
57+
first = session_events.first
58+
@session.update!(start_at: @session.start_at + 1.hour, end_at: @session.end_at + 1.hour)
59+
moved = session_events.first
60+
assert_equal first.uid, moved.uid
61+
assert_not_equal first.dtstart, moved.dtstart
62+
assert_operator moved.sequence.to_i, :>, first.sequence.to_i
63+
@session.update!(cancelled: true)
64+
cancelled = session_events.first
65+
assert_equal first.uid, cancelled.uid
66+
assert_equal 'CANCELLED', cancelled.status.to_s
67+
assert_nil cancelled.url
68+
assert_not_includes cancelled.description.to_s, 'https://'
69+
end
70+
end

‎test/models/webcal_test.rb‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,8 @@ class WebcalTest < ActiveSupport::TestCase
182182

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

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

0 commit comments

Comments
 (0)