Skip to content

Commit 6839ac8

Browse files
authored
feat: keep banner's link identifier and publish paired sections (#535)
1 parent 725020d commit 6839ac8

28 files changed

Lines changed: 1149 additions & 64 deletions

Gemfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ gem "tailwindcss-rails"
99
gem "turbo-rails"
1010
gem "stimulus-rails"
1111
gem "jbuilder"
12+
13+
# Renders the public API reference from the markdown file in docs/
14+
gem "redcarpet"
1215
gem "bcrypt", "~> 3.1.7"
1316
gem "tzinfo-data", platforms: %i[ windows jruby ]
1417

Gemfile.lock

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,7 @@ GEM
446446
prism (>= 1.6.0)
447447
rbs (>= 4.0.0)
448448
tsort
449+
redcarpet (3.6.1)
449450
regexp_parser (2.12.0)
450451
reline (0.7.0)
451452
io-console (~> 0.5)
@@ -650,6 +651,7 @@ DEPENDENCIES
650651
rack-attack
651652
rack-cors
652653
rails (~> 8.1.3)
654+
redcarpet
653655
rspec-rails
654656
rubocop-rails-omakase
655657
selenium-webdriver
@@ -821,6 +823,7 @@ CHECKSUMS
821823
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
822824
rbs (4.1.3) sha256=0c4474a9751cdc14364bfad0b3e53678323bbdc2c31683b0445932867dbab8c4
823825
rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469
826+
redcarpet (3.6.1) sha256=d444910e6aa55480c6bcdc0cdb057626e8a32c054c29e793fa642ba2f155f445
824827
regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb
825828
reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d
826829
representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace

app/controllers/api/v1/catalog/sections_controller.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ def filters
4747
subject: array_param(:subject),
4848
course_number: array_param(:course_number),
4949
crns: array_param(:crn),
50+
pub_ids: array_param(:pub_id),
5051
q: params[:q],
5152
schedule_types: array_param(:schedule_type),
5253
meets_on: array_param(:meets_on),

app/controllers/docs_controller.rb

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# frozen_string_literal: true
2+
3+
# Publishes the public catalog API reference at /docs/api.
4+
#
5+
# The page renders docs/public-catalog-api.md, the same file the repository
6+
# keeps, so the site and the repository cannot say different things.
7+
#
8+
# It inherits ActionController::Base, not ApplicationController. The page needs
9+
# no sign-in and no Pundit, and the modern-browser guard on the app pages would
10+
# refuse curl and any other script that reads the reference.
11+
class DocsController < ActionController::Base
12+
layout "docs"
13+
14+
SOURCE = Rails.root.join("docs/public-catalog-api.md")
15+
CACHE_AGE = 1.hour
16+
17+
def api
18+
raise ActionController::RoutingError, "No API reference" unless SOURCE.exist?
19+
20+
document = self.class.render_markdown(SOURCE.read)
21+
@body = document[:html]
22+
@headings = document[:headings]
23+
24+
expires_in CACHE_AGE, public: true
25+
end
26+
27+
# @return [Hash] the rendered HTML and the top-level headings, for the menu.
28+
def self.render_markdown(text)
29+
html = markdown.render(text)
30+
doc = Nokogiri::HTML.fragment(html)
31+
32+
headings = doc.css("h2").filter_map do |node|
33+
{ id: node["id"], text: node.text } if node["id"].present?
34+
end
35+
36+
{ html: doc.to_html, headings: headings }
37+
end
38+
39+
# escape_html keeps any raw HTML in the source out of the page. The file is
40+
# ours, so this is a guard, not a filter that the document depends on.
41+
def self.markdown
42+
Redcarpet::Markdown.new(
43+
Redcarpet::Render::HTML.new(with_toc_data: true, escape_html: true),
44+
tables: true,
45+
fenced_code_blocks: true,
46+
autolink: true,
47+
strikethrough: true,
48+
no_intra_emphasis: true,
49+
space_after_headers: true
50+
)
51+
end
52+
private_class_method :markdown
53+
end
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# frozen_string_literal: true
2+
3+
module Types
4+
class LinkedSectionsType < BaseObject
5+
description "Sections that Banner requires a student to register together, " \
6+
"most often a lecture and its lab."
7+
8+
field :required, Boolean, null: false,
9+
description: "True when Banner marks this section as part of a pairing."
10+
field :identifier, String, null: true,
11+
description: "Banner's link identifier, e.g. \"A1\" for a lecture and \"B1\" for its labs."
12+
field :crns, [ Integer ], null: false,
13+
description: "CRNs of the partner sections in the same term. Empty when the section stands alone."
14+
field :pub_ids, [ String ], null: false,
15+
description: "Public ids of the same partner sections, in the same order as crns."
16+
end
17+
end

app/graphql/types/section_filter_input.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ class SectionFilterInput < BaseInputObject
99
description: "Subject label or short code, e.g. \"COMP\""
1010
argument :course_number, [ Integer ], required: false
1111
argument :crns, [ Integer ], required: false
12+
argument :pub_ids, [ String ], required: false,
13+
description: "Public ids, which stay unique across terms"
1214
argument :q, String, required: false, description: "Free text over title, subject, and number"
1315
argument :schedule_types, [ ScheduleTypeEnum ], required: false
1416
argument :meets_on, [ DayOfWeekEnum ], required: false,

app/graphql/types/section_type.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class SectionType < BaseObject
2121
field :grade_mode, String, null: true
2222
field :status, String, null: false
2323
field :seats, SeatsType, null: false
24+
field :linked, LinkedSectionsType, null: false
2425
field :start_date, GraphQL::Types::ISO8601Date, null: false
2526
field :end_date, GraphQL::Types::ISO8601Date, null: false
2627
field :instructors, [ InstructorType ], null: false, method: :faculties
@@ -39,6 +40,17 @@ def seats
3940
{ capacity: object.seats_capacity, available: object.seats_available }
4041
end
4142

43+
def linked
44+
partners = object.linked_sections.pluck(:crn, :id)
45+
46+
{
47+
required: object.is_section_linked,
48+
identifier: object.link_identifier,
49+
crns: partners.map(&:first),
50+
pub_ids: partners.map { |(_crn, id)| Course.public_id_for(id) }
51+
}
52+
end
53+
4254
# filtered_meeting_times drops the duplicate rows a concurrent ingest can
4355
# leave behind, preferring the copy that has a real room.
4456
def meeting_times

app/models/course.rb

Lines changed: 65 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,32 @@
44
#
55
# Table name: courses
66
#
7-
# id :bigint not null, primary key
8-
# course_number :integer not null
9-
# credit_hours :integer
10-
# crn :integer not null
11-
# end_date :date not null
12-
# grade_mode :string
13-
# schedule_type :string not null
14-
# seats_available :integer
15-
# seats_capacity :integer
16-
# section_number :string not null
17-
# start_date :date not null
18-
# status :string default("active"), not null
19-
# subject :string not null
20-
# title :string not null
21-
# created_at :datetime not null
22-
# updated_at :datetime not null
23-
# term_id :bigint not null
7+
# id :bigint not null, primary key
8+
# course_number :integer not null
9+
# credit_hours :integer
10+
# crn :integer not null
11+
# end_date :date not null
12+
# grade_mode :string
13+
# is_section_linked :boolean default(FALSE), not null
14+
# link_identifier :string
15+
# schedule_type :string not null
16+
# seats_available :integer
17+
# seats_capacity :integer
18+
# section_number :string not null
19+
# start_date :date not null
20+
# status :string default("active"), not null
21+
# subject :string not null
22+
# title :string not null
23+
# created_at :datetime not null
24+
# updated_at :datetime not null
25+
# term_id :bigint not null
2426
#
2527
# Indexes
2628
#
27-
# index_courses_on_crn_and_term_id (crn,term_id) UNIQUE
28-
# index_courses_on_status (status)
29-
# index_courses_on_term_id (term_id)
29+
# index_courses_on_course_and_link_identifier (term_id,subject,course_number,link_identifier)
30+
# index_courses_on_crn_and_term_id (crn,term_id) UNIQUE
31+
# index_courses_on_status (status)
32+
# index_courses_on_term_id (term_id)
3033
#
3134
# Foreign Keys
3235
#
@@ -70,6 +73,48 @@ def prefix
7073
subject =~ /\(([^)]+)\)/ ? $1 : subject
7174
end
7275

76+
# Banner writes linked sections as <slot><key>. The slot says what the section
77+
# is for within the pairing, and the key says which pairing it belongs to. In
78+
# CHEM 1000 the lecture "A1" goes with the labs "B1", and the lecture "A2"
79+
# with the labs "B2".
80+
# The public id is derived from the numeric id, not stored, so a list of them
81+
# is built rather than plucked.
82+
def self.public_id_for(id)
83+
"#{get_public_id_prefix}#{EncodedIds.configuration.separator}#{encode_id(id)}"
84+
end
85+
86+
# Turns "crs_kw7coe30" back into the numeric id. Returns nil when the prefix
87+
# or the hash does not belong to a course.
88+
def self.id_from_public_id(value)
89+
separator = EncodedIds.configuration.separator
90+
parts = value.to_s.strip.split(separator)
91+
hash = parts.pop
92+
return nil unless parts.join(separator) == get_public_id_prefix
93+
94+
decode_id(hash)
95+
end
96+
97+
def link_slot
98+
link_identifier&.first
99+
end
100+
101+
def link_key
102+
link_identifier.presence && link_identifier[1..].presence
103+
end
104+
105+
# Sections a student has to register alongside this one: same course, same
106+
# link key, a different slot. Returns none when the section is not linked.
107+
def linked_sections
108+
return Course.none if link_key.blank?
109+
110+
Course.active
111+
.where(term_id: term_id, subject: subject, course_number: course_number)
112+
.where("SUBSTRING(link_identifier FROM 2) = ?", link_key)
113+
.where.not(id: id)
114+
.where.not(link_identifier: nil)
115+
.where("LEFT(link_identifier, 1) <> ?", link_slot)
116+
end
117+
73118
# Returns deduplicated meeting times, preferring non-TBD locations when there are duplicates.
74119
def filtered_meeting_times
75120
mts = meeting_times.loaded? ? meeting_times : meeting_times.includes(rooms: :building)

app/queries/catalog/section_query.rb

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ class FilterError < StandardError; end
1515
DAYS = Course::MeetingTime.day_of_weeks.freeze
1616

1717
FILTERS = %i[
18-
term_uid subject course_number crns q schedule_types
18+
term_uid subject course_number crns pub_ids q schedule_types
1919
meets_on free_days begins_after ends_before
2020
credit_hours instructor include_cancelled
2121
].freeze
@@ -35,6 +35,7 @@ def call(**filters)
3535
relation = apply_subject(relation, filters[:subject])
3636
relation = apply_course_number(relation, filters[:course_number])
3737
relation = apply_crns(relation, filters[:crns])
38+
relation = apply_pub_ids(relation, filters[:pub_ids])
3839
relation = apply_search(relation, filters[:q])
3940
relation = apply_schedule_types(relation, filters[:schedule_types])
4041
relation = apply_credit_hours(relation, filters[:credit_hours])
@@ -105,6 +106,21 @@ def apply_crns(relation, crns)
105106
relation.where(crn: Array(crns).map(&:to_i))
106107
end
107108

109+
# The public id is stable and unique across terms, so it needs no term. It
110+
# is derived from the numeric id, so it is decoded rather than matched.
111+
def apply_pub_ids(relation, pub_ids)
112+
return relation if pub_ids.blank?
113+
114+
values = Array(pub_ids).map { |value| value.to_s.strip }.reject(&:empty?)
115+
return relation if values.empty?
116+
117+
ids = values.map do |value|
118+
Course.id_from_public_id(value) || raise(FilterError, "Unknown pub_id #{value.inspect}")
119+
end
120+
121+
relation.where(id: ids)
122+
end
123+
108124
def apply_search(relation, query)
109125
return relation if query.blank?
110126

app/serializers/catalog/section_serializer.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ def as_json(*)
2626
grade_mode: @course.grade_mode,
2727
status: @course.status,
2828
seats: seats,
29+
linked: linked,
2930
start_date: @course.start_date,
3031
end_date: @course.end_date,
3132
instructors: @course.faculties.map { |f| InstructorSerializer.new(f).as_json },
@@ -56,6 +57,20 @@ def seats
5657
{ capacity: @course.seats_capacity, available: @course.seats_available }
5758
end
5859

60+
# Banner marks the sections a student has to register together, most often a
61+
# lecture and its lab. `crns` names the partners in the same term, so a
62+
# client does not have to guess the pairing from the section number.
63+
def linked
64+
partners = @course.linked_sections.pluck(:crn, :id)
65+
66+
{
67+
required: @course.is_section_linked,
68+
identifier: @course.link_identifier,
69+
crns: partners.map(&:first),
70+
pub_ids: partners.map { |(_crn, id)| Course.public_id_for(id) }
71+
}
72+
end
73+
5974
def meeting_times
6075
@course.filtered_meeting_times
6176
.sort_by { |mt| [ Course::MeetingTime.day_of_weeks[mt.day_of_week], mt.begin_time ] }

0 commit comments

Comments
 (0)