Skip to content

Commit bd9601a

Browse files
committed
leopardwebservice
1 parent d064486 commit bd9601a

9 files changed

Lines changed: 278 additions & 43 deletions

File tree

.idea/wit-calendar-backend.iml

Lines changed: 17 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Gemfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,5 @@ group :development do
120120
gem "rack-mini-profiler", "~> 3.3", require: false
121121
gem "stackprof"
122122
end
123+
124+
gem 'faraday', '~> 2.14'

Gemfile.lock

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,12 @@ GEM
186186
railties (>= 6.1.0)
187187
faker (3.5.2)
188188
i18n (>= 1.8.11, < 2)
189+
faraday (2.14.0)
190+
faraday-net_http (>= 2.0, < 3.5)
191+
json
192+
logger
193+
faraday-net_http (3.4.1)
194+
net-http (>= 0.5.0)
189195
ffi (1.17.2-aarch64-linux-gnu)
190196
ffi (1.17.2-aarch64-linux-musl)
191197
ffi (1.17.2-arm-linux-gnu)
@@ -295,6 +301,8 @@ GEM
295301
stimulus-rails
296302
turbo-rails
297303
msgpack (1.8.0)
304+
net-http (0.6.0)
305+
uri
298306
net-imap (0.5.12)
299307
date
300308
net-protocol
@@ -634,6 +642,7 @@ DEPENDENCIES
634642
email_reply_parser
635643
factory_bot_rails
636644
faker
645+
faraday (~> 2.14)
637646
flipper
638647
flipper-active_record
639648
flipper-active_support_cache_store
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
class ApplicationController < ActionController::Base
22
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
33
allow_browser versions: :modern
4+
before_action :set_paper_trail_whodunnit
45
end

app/models/academic_class.rb

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,13 @@ class AcademicClass < ApplicationRecord
66
has_many :rooms, through: :meeting_times
77

88
enum :schedule_type, {
9-
lecture: 1, # LEC
10-
laboratory: 2 # LAB
9+
hybrid: "HYB",
10+
laboratory: "LAB",
11+
lecture: "LEC",
12+
online_sync_lab: "OLB",
13+
online_sync_lecture: "OLC",
14+
rotating_lab: "RLB",
15+
rotating_lecture: "RLC"
1116
}
1217

1318
end
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# app/services/application_service.rb
2+
class ApplicationService
3+
class << self
4+
def call(*, **)
5+
new(*, **).call
6+
end
7+
8+
def call!(*, **)
9+
new(*, **).call!
10+
end
11+
end
12+
13+
def initialize(*, **); end
14+
15+
def call
16+
raise NotImplementedError
17+
end
18+
19+
def call!
20+
raise NotImplementedError
21+
end
22+
end
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
# app/services/leopard_web_service.rb
2+
class LeopardWebService < ApplicationService
3+
require "faraday"
4+
require "nokogiri"
5+
6+
BASE_URL = "https://selfservice.wit.edu/StudentRegistrationSsb/ssb/searchResults/".freeze
7+
8+
attr_reader :term, :course_reference_number, :action
9+
10+
def initialize(action:, term: nil, course_reference_number: nil)
11+
@action = action
12+
@term = term
13+
@course_reference_number = course_reference_number
14+
super()
15+
end
16+
17+
def call
18+
case action
19+
when :get_class_details
20+
get_class_details
21+
when :get_enrollment_info
22+
get_enrollment_info
23+
when :get_faculty_meeting_times
24+
get_faculty_meeting_times
25+
else
26+
raise ArgumentError, "Unknown action: #{action}"
27+
end
28+
end
29+
30+
def call!
31+
call
32+
end
33+
34+
# Class method convenience wrappers
35+
def self.get_class_details(term:, course_reference_number:)
36+
new(
37+
action: :get_class_details,
38+
term: term,
39+
course_reference_number: course_reference_number
40+
).call
41+
end
42+
43+
def self.get_enrollment_info
44+
new(action: :get_enrollment_info).call
45+
end
46+
47+
def self.get_faculty_meeting_times(term:, course_reference_number:)
48+
new(
49+
action: :get_faculty_meeting_times,
50+
term: term,
51+
course_reference_number: course_reference_number
52+
).call
53+
end
54+
55+
private
56+
57+
def get_class_details
58+
raise ArgumentError, "term is required" unless term
59+
raise ArgumentError, "course_reference_number is required" unless course_reference_number
60+
61+
response = connection.get("getClassDetails", {
62+
term: term,
63+
courseReferenceNumber: course_reference_number
64+
})
65+
66+
handle_response(response, :class_details)
67+
end
68+
69+
def get_enrollment_info
70+
response = connection.get("getEnrollmentInfo")
71+
handle_response(response, :enrollment_info)
72+
end
73+
74+
def get_faculty_meeting_times
75+
raise ArgumentError, "term is required" unless term
76+
raise ArgumentError, "course_reference_number is required" unless course_reference_number
77+
78+
response = connection.get("getFacultyMeetingTimes", {
79+
term: term,
80+
courseReferenceNumber: course_reference_number
81+
})
82+
83+
handle_response(response, :json)
84+
end
85+
86+
def connection
87+
@connection ||= Faraday.new(url: BASE_URL) do |faraday|
88+
faraday.request :url_encoded
89+
faraday.response :json, content_type: /\bjson$/
90+
faraday.adapter Faraday.default_adapter
91+
end
92+
end
93+
94+
def handle_response(response, format = :json)
95+
if response.success?
96+
case format
97+
when :class_details
98+
parse_class_details_html(response.body)
99+
when :enrollment_info
100+
parse_enrollment_info_html(response.body)
101+
when :json
102+
parse_json_response(response.body)
103+
else
104+
parse_response_body(response.body)
105+
end
106+
else
107+
handle_error(response)
108+
end
109+
end
110+
111+
def parse_response_body(body)
112+
body
113+
end
114+
115+
def parse_json_response(body)
116+
# If Faraday has already parsed it, return as-is
117+
# Otherwise, parse it
118+
body.is_a?(String) ? JSON.parse(body) : body
119+
end
120+
121+
def parse_class_details_html(html)
122+
doc = Nokogiri::HTML(html)
123+
section = doc.at_css('section[aria-labelledby="classDetails"]')
124+
125+
return nil unless section
126+
127+
{
128+
associated_term: extract_labeled_value(section, "Associated Term:"),
129+
crn: section.at_css('#courseReferenceNumber').text.strip,
130+
campus: extract_labeled_value(section, "Campus:"),
131+
schedule_type: extract_labeled_value(section, "Schedule Type:"),
132+
section_number: section.at_css('#sectionNumber').text.strip,
133+
subject: section.at_css('#subject').text.strip,
134+
course_number: section.at_css('#courseNumber').text.strip,
135+
title: section.at_css('#courseTitle').text.strip,
136+
credit_hours: extract_labeled_value(section, "Credit Hours:")&.to_i,
137+
grade_mode: extract_labeled_value(section, "Grade Mode:")
138+
}
139+
end
140+
141+
def parse_enrollment_info_html(html)
142+
doc = Nokogiri::HTML(html)
143+
section = doc.at_css('section[aria-labelledby="enrollmentInfo"]')
144+
145+
return nil unless section
146+
147+
{
148+
enrollment: {
149+
actual: extract_span_value(section, "Enrollment Actual:")&.to_i,
150+
maximum: extract_span_value(section, "Enrollment Maximum:")&.to_i,
151+
seats_available: extract_span_value(section, "Enrollment Seats Available:")&.to_i
152+
},
153+
waitlist: {
154+
capacity: extract_span_value(section, "Waitlist Capacity:")&.to_i,
155+
actual: extract_span_value(section, "Waitlist Actual:")&.to_i,
156+
seats_available: extract_span_value(section, "Waitlist Seats Available:")&.to_i
157+
}
158+
}
159+
end
160+
161+
def extract_labeled_value(section, label)
162+
# Find the span with the label text
163+
label_span = section.xpath(".//span[@class='status-bold'][contains(text(), '#{label}')]").first
164+
return nil unless label_span
165+
166+
# Get the text that comes after the label span and before the next <br> or <span>
167+
next_node = label_span.next_sibling
168+
value = ""
169+
170+
while next_node && next_node.name != "br" && next_node.name != "span"
171+
value += next_node.text if next_node.text?
172+
next_node = next_node.next_sibling
173+
end
174+
175+
value.strip.empty? ? nil : value.strip
176+
end
177+
178+
def extract_span_value(section, label)
179+
# Find the bold label span
180+
label_span = section.xpath(".//span[@class='status-bold'][contains(text(), '#{label}')]").first
181+
return nil unless label_span
182+
183+
# Find the next span with dir="ltr" which contains the value
184+
value_span = label_span.xpath("following-sibling::span[@dir='ltr'][1]").first
185+
return nil unless value_span
186+
187+
value_span.text.strip
188+
end
189+
190+
def handle_error(response)
191+
raise "Request failed with status #{response.status}: #{response.body}"
192+
end
193+
end

db/migrate/20251026191220_create_academic_classes.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ def change
1010
t.integer :credit_hours
1111
t.string :grade_mode
1212

13-
t.integer :schedule_type, null: false # 1: Lecture (LEC), 2: Laboratory (LAB)
13+
t.string :schedule_type, null: false
1414

1515
t.timestamps
1616
end

0 commit comments

Comments
 (0)