Skip to content

Commit 36ff253

Browse files
feat(unit-hub): unit hub and Teams sync
Brings the T2 2026 unit-hub work from ontrack-features-t2-2026 integration/t2-2026 onto 11.0.x. Co-authored-by: maplefoxgit <s223932052@deakin.edu.au>
1 parent dc76a5a commit 36ff253

21 files changed

Lines changed: 1593 additions & 0 deletions

‎app/api/unit_hub_api.rb‎

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
# frozen_string_literal: true
2+
3+
require 'grape'
4+
require 'time'
5+
6+
class UnitHubApi < Grape::API
7+
helpers AuthenticationHelpers
8+
9+
helpers do
10+
def hub_unit!
11+
UnitHub::Access.units_for(current_user).find(params[:unit_id])
12+
end
13+
14+
def manageable_hub_unit!
15+
unit = hub_unit!
16+
error!({ error: 'Only teaching staff assigned to this unit can manage its hub.' }, 403) unless UnitHub::Access.manage?(current_user, unit)
17+
unit
18+
end
19+
20+
def hub_attributes(key, fields, date_fields: [])
21+
attributes = declared(params, include_missing: false).fetch(key).slice(*fields).to_h.symbolize_keys
22+
date_fields.each do |field|
23+
value = attributes[field]
24+
next unless value
25+
26+
unless value.match?(/T.*(?:Z|[+-]\d{2}:\d{2})\z/)
27+
error!({ error: "#{field} must include an explicit time zone offset." }, 400)
28+
end
29+
attributes[field] = Time.iso8601(value)
30+
rescue ArgumentError
31+
error!({ error: "#{field} must be a valid ISO 8601 date and time." }, 400)
32+
end
33+
attributes
34+
end
35+
36+
params :announcement_fields do
37+
requires :announcement, type: Hash do
38+
optional :title, type: String
39+
optional :body, type: String
40+
optional :source_url, type: String
41+
optional :pinned, type: Boolean
42+
optional :published_at, type: String
43+
optional :expires_at, type: String
44+
end
45+
end
46+
47+
params :session_fields do
48+
requires :session, type: Hash do
49+
optional :title, type: String
50+
optional :description, type: String
51+
optional :kind, type: String, values: UnitLearningSession::KINDS
52+
optional :start_at, type: String
53+
optional :end_at, type: String
54+
optional :timezone, type: String
55+
optional :location, type: String
56+
optional :join_url, type: String
57+
optional :source_url, type: String
58+
optional :published, type: Boolean
59+
optional :cancelled, type: Boolean
60+
optional :recurrence, type: String, values: UnitLearningSession::RECURRENCES
61+
optional :recurrence_until, type: Date
62+
end
63+
end
64+
65+
def announcement_attributes
66+
hub_attributes(:announcement, %i[title body source_url pinned published_at expires_at], date_fields: %i[published_at expires_at])
67+
end
68+
69+
def session_attributes
70+
hub_attributes(:session, %i[title description kind start_at end_at timezone location join_url source_url published cancelled recurrence recurrence_until], date_fields: %i[start_at end_at])
71+
end
72+
end
73+
74+
before do
75+
authenticated?
76+
header 'Cache-Control', 'private, no-store'
77+
end
78+
79+
desc 'Published announcements and learning sessions for the current user units'
80+
get '/unit_hub' do
81+
units = UnitHub::Access.units_for(current_user).order(:code, :id).to_a
82+
teams_configuration = UnitHub::Teams::Configuration.new
83+
announcements = UnitAnnouncement.where(unit_id: units.map(&:id)).visible_at(Time.current).includes(:unit).recent_first.limit(101).to_a
84+
from = 1.day.ago
85+
to = 90.days.from_now
86+
schedules = UnitLearningSession.where(unit_id: units.map(&:id), published: true)
87+
.where('start_at <= ?', to)
88+
.where('end_at >= ? OR recurrence_until >= ?', from, from.to_date)
89+
.includes(:unit)
90+
sessions = schedules.flat_map do |schedule|
91+
schedule.occurrences(from: from, to: to).map { |occurrence| UnitHub::Serializer.session(schedule, occurrence) }
92+
end
93+
sessions.sort_by! { |occurrence| [Time.iso8601(occurrence[:start_at]), occurrence[:occurrence_id]] }
94+
95+
{
96+
units: units.map { |unit| { id: unit.id, code: unit.code, name: unit.name, can_manage: UnitHub::Access.manage?(current_user, unit), teams_sync: teams_configuration.configured_for?(unit.id) ? 'configured' : 'not_configured' } },
97+
announcements: announcements.first(100).map { |record| UnitHub::Serializer.announcement(record) },
98+
sessions: sessions,
99+
announcements_truncated: announcements.length > 100,
100+
window_start: from.iso8601, window_end: to.iso8601
101+
}
102+
end
103+
104+
resource :units do
105+
route_param :unit_id, type: Integer do
106+
resource :announcements do
107+
get do
108+
scope = manageable_hub_unit!.unit_announcements
109+
records = scope.where(source_provider: 'manual').or(scope.visible_at(Time.current)).includes(:unit).recent_first
110+
records.map { |record| UnitHub::Serializer.announcement(record) }
111+
end
112+
113+
params { use :announcement_fields }
114+
post do
115+
record = manageable_hub_unit!.unit_announcements.create!(announcement_attributes.merge(author: current_user))
116+
UnitHub::Serializer.announcement(record)
117+
end
118+
119+
route_param :id, type: Integer do
120+
params { use :announcement_fields }
121+
put do
122+
record = manageable_hub_unit!.unit_announcements.find(params[:id])
123+
error!({ error: 'Manage this imported announcement in Teams.' }, 403) if record.source_provider == 'microsoft_teams'
124+
record.update!(announcement_attributes)
125+
UnitHub::Serializer.announcement(record)
126+
end
127+
128+
delete do
129+
record = manageable_hub_unit!.unit_announcements.find(params[:id])
130+
error!({ error: 'Manage this imported announcement in Teams.' }, 403) if record.source_provider == 'microsoft_teams'
131+
record.destroy!
132+
{ success: true }
133+
end
134+
end
135+
end
136+
137+
resource :sessions do
138+
get do
139+
records = manageable_hub_unit!.unit_learning_sessions.includes(:unit).order(:start_at, :id)
140+
records.map { |record| UnitHub::Serializer.session(record) }
141+
end
142+
143+
params { use :session_fields }
144+
post do
145+
record = manageable_hub_unit!.unit_learning_sessions.create!(session_attributes.merge(author: current_user))
146+
UnitHub::Serializer.session(record)
147+
end
148+
149+
route_param :id, type: Integer do
150+
params { use :session_fields }
151+
put do
152+
record = manageable_hub_unit!.unit_learning_sessions.find(params[:id])
153+
record.update!(session_attributes)
154+
UnitHub::Serializer.session(record)
155+
end
156+
157+
delete do
158+
# Preserve the UID and schedule so subscribed calendars receive an
159+
# explicit cancellation instead of retaining an old working join link.
160+
record = manageable_hub_unit!.unit_learning_sessions.find(params[:id])
161+
record.update!(cancelled: true)
162+
{ success: true }
163+
end
164+
end
165+
end
166+
end
167+
end
168+
end
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# frozen_string_literal: true
2+
3+
require 'uri'
4+
5+
# Links are displayed, never fetched by the server. Reject executable protocols
6+
# and embedded credentials before staff can publish a link.
7+
module UnitHubLinks
8+
extend ActiveSupport::Concern
9+
10+
included do
11+
validate :safe_unit_hub_links
12+
end
13+
14+
private
15+
16+
def safe_unit_hub_links
17+
%i[source_url join_url].each do |field|
18+
next unless respond_to?(field)
19+
20+
value = public_send(field)
21+
next if value.blank?
22+
23+
valid = value.length <= 2048 && !value.match?(/[[:space:][:cntrl:]]/)
24+
uri = URI.parse(value) if valid
25+
valid &&= uri.is_a?(URI::HTTPS) && uri.host.present? && uri.userinfo.nil?
26+
errors.add(field, 'must be a complete HTTPS link without embedded credentials') unless valid
27+
rescue URI::InvalidURIError
28+
errors.add(field, 'must be a complete HTTPS link without embedded credentials')
29+
end
30+
end
31+
end
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# frozen_string_literal: true
2+
3+
class TeamsAnnouncementSyncState < ApplicationRecord
4+
belongs_to :unit
5+
validates :mapping_key, presence: true
6+
validates :status, inclusion: { in: %w[pending synced failed throttled] }
7+
end

‎app/models/unit_announcement.rb‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# frozen_string_literal: true
2+
3+
class UnitAnnouncement < ApplicationRecord
4+
include UnitHubLinks
5+
6+
belongs_to :unit
7+
belongs_to :author, class_name: 'User', optional: true
8+
9+
validates :title, presence: true, length: { maximum: 200 }
10+
validates :body, presence: true, length: { maximum: 20_000 }
11+
validates :pinned, inclusion: { in: [true, false] }
12+
validates :source_provider, inclusion: { in: %w[manual microsoft_teams] }
13+
validate :expiry_follows_publication
14+
15+
scope :allowed_sources, lambda {
16+
where(source_provider: 'manual').or(where(source_provider: 'microsoft_teams', source_mapping_key: UnitHub::Teams::Configuration.new.visible_mapping_keys))
17+
}
18+
scope :visible_at, lambda { |at|
19+
allowed_sources.where('published_at <= ?', at).where('expires_at IS NULL OR expires_at > ?', at)
20+
}
21+
scope :recent_first, -> { order(pinned: :desc, published_at: :desc, id: :desc) }
22+
23+
private
24+
25+
def expiry_follows_publication
26+
return unless published_at && expires_at && expires_at <= published_at
27+
28+
errors.add(:expires_at, 'must be after publication')
29+
end
30+
end
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# frozen_string_literal: true
2+
3+
class UnitLearningSession < ApplicationRecord
4+
include UnitHubLinks
5+
6+
KINDS = %w[helphub lecture seminar workshop other].freeze
7+
RECURRENCES = %w[none weekly].freeze
8+
9+
belongs_to :unit
10+
belongs_to :author, class_name: 'User', optional: true
11+
12+
validates :title, presence: true, length: { maximum: 200 }
13+
validates :description, length: { maximum: 20_000 }
14+
validates :location, length: { maximum: 300 }
15+
validates :kind, inclusion: { in: KINDS }
16+
validates :recurrence, inclusion: { in: RECURRENCES }
17+
validates :start_at, :end_at, :timezone, presence: true
18+
validates :published, :cancelled, inclusion: { in: [true, false] }
19+
validate :valid_schedule
20+
21+
# Add calendar weeks in the named zone, not 604800 seconds in UTC: the local
22+
# HelpHub time stays constant across daylight saving changes.
23+
def occurrences(from:, to:)
24+
return [] unless start_at && end_at
25+
26+
local_start = start_at.in_time_zone(timezone)
27+
local_end = end_at.in_time_zone(timezone)
28+
result = []
29+
27.times do |index|
30+
occurrence_start = local_start + index.weeks
31+
occurrence_end = local_end + index.weeks
32+
break if index.positive? && recurrence != 'weekly'
33+
break if recurrence == 'weekly' && occurrence_start.to_date > recurrence_until
34+
break if occurrence_start > to
35+
36+
if occurrence_end >= from
37+
result << { occurrence_id: "#{id}-#{index}", start_at: occurrence_start, end_at: occurrence_end }
38+
end
39+
end
40+
result
41+
end
42+
43+
private
44+
45+
def valid_schedule
46+
begin
47+
TZInfo::Timezone.get(timezone.to_s)
48+
rescue TZInfo::InvalidTimezoneIdentifier
49+
errors.add(:timezone, 'must be an IANA time zone such as Australia/Melbourne')
50+
return
51+
end
52+
return unless start_at && end_at
53+
54+
errors.add(:end_at, 'must be after the start and within 24 hours') unless end_at > start_at && end_at <= start_at + 24.hours
55+
return unless recurrence == 'weekly'
56+
57+
first_date = start_at.in_time_zone(timezone).to_date
58+
unless recurrence_until && recurrence_until >= first_date && recurrence_until <= first_date + 6.months
59+
errors.add(:recurrence_until, 'must be on or after the first session and within six months')
60+
end
61+
end
62+
end

‎app/services/unit_hub/access.rb‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# frozen_string_literal: true
2+
3+
module UnitHub
4+
# Authorisation is always derived from current enrolments and assigned unit
5+
# roles. A global staff role alone never opens an unrelated unit's content.
6+
class Access
7+
def self.units_for(user)
8+
student_ids = Project.where(user_id: user.id, enrolled: true).select(:unit_id)
9+
staff_ids = UnitRole.where(user_id: user.id, role_id: [Role.tutor.id, Role.convenor.id]).select(:unit_id)
10+
Unit.where(active: true).where(id: student_ids).or(Unit.where(active: true, id: staff_ids))
11+
end
12+
13+
def self.manage?(user, unit)
14+
UnitRole.exists?(user_id: user.id, unit_id: unit.id,
15+
role_id: [Role.tutor.id, Role.convenor.id], observer_only: false)
16+
end
17+
end
18+
end
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# frozen_string_literal: true
2+
3+
module UnitHub
4+
class Serializer
5+
def self.announcement(record)
6+
record.attributes.slice('id', 'unit_id', 'title', 'body', 'source_url', 'pinned').symbolize_keys.merge(
7+
unit_code: record.unit.code, unit_name: record.unit.name,
8+
published_at: record.published_at&.iso8601, expires_at: record.expires_at&.iso8601,
9+
updated_at: record.updated_at.iso8601, source_provider: record.source_provider,
10+
managed_externally: record.source_provider == 'microsoft_teams',
11+
author_name: record.source_provider == 'microsoft_teams' ? 'Teaching team' : nil,
12+
source_imported_at: record.source_imported_at&.iso8601
13+
)
14+
end
15+
16+
def self.session(record, occurrence = nil)
17+
values = record.attributes.slice('id', 'unit_id', 'title', 'description', 'kind', 'timezone',
18+
'location', 'join_url', 'source_url', 'published', 'cancelled',
19+
'recurrence').symbolize_keys
20+
values.merge!(unit_code: record.unit.code, unit_name: record.unit.name,
21+
start_at: record.start_at.iso8601, end_at: record.end_at.iso8601,
22+
recurrence_until: record.recurrence_until&.iso8601, updated_at: record.updated_at.iso8601)
23+
if occurrence
24+
values.merge!(occurrence_id: occurrence[:occurrence_id], original_start_at: record.start_at.iso8601,
25+
start_at: occurrence[:start_at].iso8601, end_at: occurrence[:end_at].iso8601)
26+
values[:join_url] = nil if record.cancelled?
27+
end
28+
values
29+
end
30+
end
31+
end

0 commit comments

Comments
 (0)