From c745546ee57a0fa72f7ba80fe74dc9a5ff509637 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Sun, 27 Sep 2026 19:58:34 +1000 Subject: [PATCH] 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 Co-authored-by: Maple Fox Co-authored-by: dylancleal Co-authored-by: blankb0t --- app/api/entities/webcal_entity.rb | 1 + app/api/webcal_api.rb | 2 + app/api/webcal_public_api.rb | 1 + app/models/webcal.rb | 40 ++++++++++++++- test/api/webcal_api_test.rb | 59 ++++++++++++++++++++++ test/models/unit_calendar_test.rb | 11 +++++ test/models/unit_hub_calendar_test.rb | 70 +++++++++++++++++++++++++++ test/models/webcal_test.rb | 2 + 8 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 test/models/unit_calendar_test.rb create mode 100644 test/models/unit_hub_calendar_test.rb diff --git a/app/api/entities/webcal_entity.rb b/app/api/entities/webcal_entity.rb index 54cfd12ee5..017d8c65f4 100644 --- a/app/api/entities/webcal_entity.rb +++ b/app/api/entities/webcal_entity.rb @@ -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? diff --git a/app/api/webcal_api.rb b/app/api/webcal_api.rb index 7b7c56bbaf..b379f4c5c9 100644 --- a/app/api/webcal_api.rb +++ b/app/api/webcal_api.rb @@ -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' @@ -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 ) diff --git a/app/api/webcal_public_api.rb b/app/api/webcal_public_api.rb index 4c18aebc1f..828f93508b 100644 --- a/app/api/webcal_public_api.rb +++ b/app/api/webcal_public_api.rb @@ -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 diff --git a/app/models/webcal.rb b/app/models/webcal.rb index d0ca3c3e3b..d00b64f635 100644 --- a/app/models/webcal.rb +++ b/app/models/webcal.rb @@ -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 @@ -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 diff --git a/test/api/webcal_api_test.rb b/test/api/webcal_api_test.rb index 735b3cae6b..1ddb1acef5 100644 --- a/test/api/webcal_api_test.rb +++ b/test/api/webcal_api_test.rb @@ -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 diff --git a/test/models/unit_calendar_test.rb b/test/models/unit_calendar_test.rb new file mode 100644 index 0000000000..d89f3c85c8 --- /dev/null +++ b/test/models/unit_calendar_test.rb @@ -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 diff --git a/test/models/unit_hub_calendar_test.rb b/test/models/unit_hub_calendar_test.rb new file mode 100644 index 0000000000..35d353eec9 --- /dev/null +++ b/test/models/unit_hub_calendar_test.rb @@ -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 diff --git a/test/models/webcal_test.rb b/test/models/webcal_test.rb index a04c787eae..57b57f9dc4 100644 --- a/test/models/webcal_test.rb +++ b/test/models/webcal_test.rb @@ -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