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
168 changes: 168 additions & 0 deletions app/api/unit_hub_api.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
# frozen_string_literal: true

require 'grape'
require 'time'

class UnitHubApi < Grape::API
helpers AuthenticationHelpers

helpers do
def hub_unit!
UnitHub::Access.units_for(current_user).find(params[:unit_id])
end

def manageable_hub_unit!
unit = hub_unit!
error!({ error: 'Only teaching staff assigned to this unit can manage its hub.' }, 403) unless UnitHub::Access.manage?(current_user, unit)
unit
end

def hub_attributes(key, fields, date_fields: [])
attributes = declared(params, include_missing: false).fetch(key).slice(*fields).to_h.symbolize_keys
date_fields.each do |field|
value = attributes[field]
next unless value

unless value.match?(/T.*(?:Z|[+-]\d{2}:\d{2})\z/)
error!({ error: "#{field} must include an explicit time zone offset." }, 400)
end
attributes[field] = Time.iso8601(value)
rescue ArgumentError
error!({ error: "#{field} must be a valid ISO 8601 date and time." }, 400)
end
attributes
end

params :announcement_fields do
requires :announcement, type: Hash do
optional :title, type: String
optional :body, type: String
optional :source_url, type: String
optional :pinned, type: Boolean
optional :published_at, type: String
optional :expires_at, type: String
end
end

params :session_fields do
requires :session, type: Hash do
optional :title, type: String
optional :description, type: String
optional :kind, type: String, values: UnitLearningSession::KINDS
optional :start_at, type: String
optional :end_at, type: String
optional :timezone, type: String
optional :location, type: String
optional :join_url, type: String
optional :source_url, type: String
optional :published, type: Boolean
optional :cancelled, type: Boolean
optional :recurrence, type: String, values: UnitLearningSession::RECURRENCES
optional :recurrence_until, type: Date
end
end

def announcement_attributes
hub_attributes(:announcement, %i[title body source_url pinned published_at expires_at], date_fields: %i[published_at expires_at])
end

def session_attributes
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])
end
end

before do
authenticated?
header 'Cache-Control', 'private, no-store'
end

desc 'Published announcements and learning sessions for the current user units'
get '/unit_hub' do
units = UnitHub::Access.units_for(current_user).order(:code, :id).to_a
teams_configuration = UnitHub::Teams::Configuration.new
announcements = UnitAnnouncement.where(unit_id: units.map(&:id)).visible_at(Time.current).includes(:unit).recent_first.limit(101).to_a
from = 1.day.ago
to = 90.days.from_now
schedules = UnitLearningSession.where(unit_id: units.map(&:id), published: true)
.where('start_at <= ?', to)
.where('end_at >= ? OR recurrence_until >= ?', from, from.to_date)
.includes(:unit)
sessions = schedules.flat_map do |schedule|
schedule.occurrences(from: from, to: to).map { |occurrence| UnitHub::Serializer.session(schedule, occurrence) }
end
sessions.sort_by! { |occurrence| [Time.iso8601(occurrence[:start_at]), occurrence[:occurrence_id]] }

{
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' } },
announcements: announcements.first(100).map { |record| UnitHub::Serializer.announcement(record) },
sessions: sessions,
announcements_truncated: announcements.length > 100,
window_start: from.iso8601, window_end: to.iso8601
}
end

resource :units do
route_param :unit_id, type: Integer do
resource :announcements do
get do
scope = manageable_hub_unit!.unit_announcements
records = scope.where(source_provider: 'manual').or(scope.visible_at(Time.current)).includes(:unit).recent_first
records.map { |record| UnitHub::Serializer.announcement(record) }
end

params { use :announcement_fields }
post do
record = manageable_hub_unit!.unit_announcements.create!(announcement_attributes.merge(author: current_user))
UnitHub::Serializer.announcement(record)
end

route_param :id, type: Integer do
params { use :announcement_fields }
put do
record = manageable_hub_unit!.unit_announcements.find(params[:id])
error!({ error: 'Manage this imported announcement in Teams.' }, 403) if record.source_provider == 'microsoft_teams'
record.update!(announcement_attributes)
UnitHub::Serializer.announcement(record)
end

delete do
record = manageable_hub_unit!.unit_announcements.find(params[:id])
error!({ error: 'Manage this imported announcement in Teams.' }, 403) if record.source_provider == 'microsoft_teams'
record.destroy!
{ success: true }
end
end
end

resource :sessions do
get do
records = manageable_hub_unit!.unit_learning_sessions.includes(:unit).order(:start_at, :id)
records.map { |record| UnitHub::Serializer.session(record) }
end

params { use :session_fields }
post do
record = manageable_hub_unit!.unit_learning_sessions.create!(session_attributes.merge(author: current_user))
UnitHub::Serializer.session(record)
end

route_param :id, type: Integer do
params { use :session_fields }
put do
record = manageable_hub_unit!.unit_learning_sessions.find(params[:id])
record.update!(session_attributes)
UnitHub::Serializer.session(record)
end

delete do
# Preserve the UID and schedule so subscribed calendars receive an
# explicit cancellation instead of retaining an old working join link.
record = manageable_hub_unit!.unit_learning_sessions.find(params[:id])
record.update!(cancelled: true)
{ success: true }
end
end
end
end
end
end
31 changes: 31 additions & 0 deletions app/models/concerns/unit_hub_links.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# frozen_string_literal: true

require 'uri'

# Links are displayed, never fetched by the server. Reject executable protocols
# and embedded credentials before staff can publish a link.
module UnitHubLinks
extend ActiveSupport::Concern

included do
validate :safe_unit_hub_links
end

private

def safe_unit_hub_links
%i[source_url join_url].each do |field|
next unless respond_to?(field)

value = public_send(field)
next if value.blank?

valid = value.length <= 2048 && !value.match?(/[[:space:][:cntrl:]]/)
uri = URI.parse(value) if valid
valid &&= uri.is_a?(URI::HTTPS) && uri.host.present? && uri.userinfo.nil?
errors.add(field, 'must be a complete HTTPS link without embedded credentials') unless valid
rescue URI::InvalidURIError
errors.add(field, 'must be a complete HTTPS link without embedded credentials')
end
end
end
7 changes: 7 additions & 0 deletions app/models/teams_announcement_sync_state.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class TeamsAnnouncementSyncState < ApplicationRecord
belongs_to :unit
validates :mapping_key, presence: true
validates :status, inclusion: { in: %w[pending synced failed throttled] }
end
42 changes: 42 additions & 0 deletions app/models/unit_announcement.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# frozen_string_literal: true

class UnitAnnouncement < ApplicationRecord
include UnitHubLinks

belongs_to :unit
belongs_to :author, class_name: 'User', optional: true

validates :title, presence: true, length: { maximum: 200 }
validates :body, presence: true, length: { maximum: 20_000 }
validates :pinned, inclusion: { in: [true, false] }
validates :source_provider, inclusion: { in: %w[manual microsoft_teams] }
validate :expiry_follows_publication

scope :allowed_sources, lambda {
where(source_provider: 'manual').or(where(source_provider: 'microsoft_teams', source_mapping_key: UnitHub::Teams::Configuration.new.visible_mapping_keys))
}
scope :visible_at, lambda { |at|
allowed_sources.where('published_at <= ?', at).where('expires_at IS NULL OR expires_at > ?', at)
}
scope :recent_first, -> { order(pinned: :desc, published_at: :desc, id: :desc) }

# After commit, so the job that fans out never runs before the row it reads
# is visible, and a rolled back save tells nobody anything.
after_commit(on: :create) { queue_hub_notifications(created: true) }
after_commit(on: :update) { queue_hub_notifications(created: false) }

private

# A notification must never stop an announcement being saved.
def queue_hub_notifications(created:)
UnitHub::Notifications.announcement_committed(self, created: created)
rescue StandardError => e
Rails.logger.error("Failed to queue Unit Hub notifications for UnitAnnouncement #{id}: #{e.class}")
end

def expiry_follows_publication
return unless published_at && expires_at && expires_at <= published_at

errors.add(:expires_at, 'must be after publication')
end
end
73 changes: 73 additions & 0 deletions app/models/unit_learning_session.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# frozen_string_literal: true

class UnitLearningSession < ApplicationRecord
include UnitHubLinks

KINDS = %w[helphub lecture seminar workshop other].freeze
RECURRENCES = %w[none weekly].freeze

belongs_to :unit
belongs_to :author, class_name: 'User', optional: true

validates :title, presence: true, length: { maximum: 200 }
validates :description, length: { maximum: 20_000 }
validates :location, length: { maximum: 300 }
validates :kind, inclusion: { in: KINDS }
validates :recurrence, inclusion: { in: RECURRENCES }
validates :start_at, :end_at, :timezone, presence: true
validates :published, :cancelled, inclusion: { in: [true, false] }
validate :valid_schedule

# Only updates: a new session is not one of the changes people are told
# about, and the delete endpoint cancels rather than destroys.
after_commit :queue_hub_notifications, on: :update

# Add calendar weeks in the named zone, not 604800 seconds in UTC: the local
# HelpHub time stays constant across daylight saving changes.
def occurrences(from:, to:)
return [] unless start_at && end_at

local_start = start_at.in_time_zone(timezone)
local_end = end_at.in_time_zone(timezone)
result = []
27.times do |index|
occurrence_start = local_start + index.weeks
occurrence_end = local_end + index.weeks
break if index.positive? && recurrence != 'weekly'
break if recurrence == 'weekly' && occurrence_start.to_date > recurrence_until
break if occurrence_start > to

if occurrence_end >= from
result << { occurrence_id: "#{id}-#{index}", start_at: occurrence_start, end_at: occurrence_end }
end
end
result
end

private

# A notification must never stop a session being saved.
def queue_hub_notifications
UnitHub::Notifications.session_committed(self)
rescue StandardError => e
Rails.logger.error("Failed to queue Unit Hub notifications for UnitLearningSession #{id}: #{e.class}")
end

def valid_schedule
begin
TZInfo::Timezone.get(timezone.to_s)
rescue TZInfo::InvalidTimezoneIdentifier
errors.add(:timezone, 'must be an IANA time zone such as Australia/Melbourne')
return
end
return unless start_at && end_at

errors.add(:end_at, 'must be after the start and within 24 hours') unless end_at > start_at && end_at <= start_at + 24.hours
return unless recurrence == 'weekly'

first_date = start_at.in_time_zone(timezone).to_date
unless recurrence_until && recurrence_until >= first_date && recurrence_until <= first_date + 6.months
errors.add(:recurrence_until, 'must be on or after the first session and within six months')
end
end
end
18 changes: 18 additions & 0 deletions app/services/unit_hub/access.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# frozen_string_literal: true

module UnitHub
# Authorisation is always derived from current enrolments and assigned unit
# roles. A global staff role alone never opens an unrelated unit's content.
class Access
def self.units_for(user)
student_ids = Project.where(user_id: user.id, enrolled: true).select(:unit_id)
staff_ids = UnitRole.where(user_id: user.id, role_id: [Role.tutor.id, Role.convenor.id]).select(:unit_id)
Unit.where(active: true).where(id: student_ids).or(Unit.where(active: true, id: staff_ids))
end

def self.manage?(user, unit)
UnitRole.exists?(user_id: user.id, unit_id: unit.id,
role_id: [Role.tutor.id, Role.convenor.id], observer_only: false)
end
end
end
Loading