Skip to content

Commit 152af87

Browse files
move from current and next term to all active terms (#408)
* move from current and next term to all active terms * Fix/369/terms take2 (#431) * hi * fix codespace * apply jasper's suggestions --------- Signed-off-by: Daniel Miretsky <miretskyd@wit.edu> * Fix/369/terms take2 (#433) * hi * fix codespace * apply jasper's suggestions * fix misc spec * fix misc spec * fix term start and end dates --------- Signed-off-by: Daniel Miretsky <miretskyd@wit.edu> * fix misc spec * apply fixes from jasper's review --------- Signed-off-by: Daniel Miretsky <miretskyd@wit.edu>
1 parent 80ed763 commit 152af87

12 files changed

Lines changed: 712 additions & 109 deletions

app/controllers/api/misc_controller.rb

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,14 @@
22

33
module Api
44
class MiscController < ApiController
5-
skip_before_action :authenticate_user_from_token!, only: [:get_current_terms]
6-
skip_before_action :check_beta_access, only: [:get_current_terms]
5+
skip_before_action :authenticate_user_from_token!, only: [:get_active_terms]
6+
skip_before_action :check_beta_access, only: [:get_active_terms]
77

8-
def get_current_terms
9-
current_term = Term.current
10-
next_term = Term.next
8+
def get_active_terms
9+
active_terms = Term.active
1110

1211
render json: {
13-
current_term: TermSerializer.new(current_term).as_json,
14-
next_term: TermSerializer.new(next_term).as_json
12+
active_terms: active_terms.map { |term| TermSerializer.new(term).as_json }
1513
}, status: :ok
1614
end
1715

app/jobs/course_data_sync_job.rb

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ class CourseDataSyncJob < ApplicationJob
88
# Run with low concurrency to avoid overwhelming LeopardWeb API
99
limits_concurrency to: 1, key: -> { "course_data_sync" }
1010

11-
# Sync course data for current and next term by default
11+
# Sync course data for all active terms by default
1212
def perform(term_uids: nil)
1313
term_uids ||= default_term_uids
1414

@@ -26,10 +26,7 @@ def perform(term_uids: nil)
2626
private
2727

2828
def default_term_uids
29-
uids = []
30-
uids << Term.current_uid if Term.current_uid
31-
uids << Term.next_uid if Term.next_uid
32-
uids.compact.uniq
29+
Term.active_uids.uniq
3330
end
3431

3532
def sync_term_courses(term_uid)

app/jobs/university_calendar_sync_job.rb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,12 @@ def trigger_sync_for_opted_in_users
8181

8282
# Attempt to update term dates from university calendar events
8383
def update_term_dates_from_events
84-
# Only update terms that don't have dates set
85-
Term.where(start_date: nil).or(Term.where(end_date: nil)).find_each do |term|
84+
# Only check recent and future terms — no need to re-evaluate historical terms
85+
# from years ago. Include the prior year in case we're early in the calendar
86+
# year and a recently ended term needs a correction.
87+
current_year = Time.zone.today.year
88+
89+
Term.where(year: (current_year - 1)..).find_each do |term|
8690
dates = UniversityCalendarEvent.detect_term_dates(term.year, term.season)
8791

8892
updates = {}

app/models/term.rb

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,47 @@ def self.with_deferred_date_updates
5151
summer: 3
5252
}
5353

54+
# Returns active term UIDs from LeopardWeb
55+
# @return [Array<Integer>] UIDs of terms LeopardWeb considers active, empty array on failure
56+
def self.active_uids
57+
result = LeopardWebService.get_active_terms
58+
return [] unless result[:success]
59+
60+
result[:terms].map { |t| t[:code].to_i }
61+
end
62+
63+
# Returns the registration start date for this term.
64+
# Queries LeopardWeb to determine if registration is open for this term.
65+
# Once the term first appears as active, that date is considered the open date.
66+
# Falls back to start_date when LeopardWeb is unavailable or does not list the term.
67+
# @return [Date, nil] the registration start date
68+
def registration_start
69+
result = LeopardWebService.get_active_terms
70+
if result[:success]
71+
active_codes = result[:terms].map { |t| (t[:code] || t["code"]).to_i }
72+
if active_codes.include?(uid)
73+
return start_date if start_date.present? && start_date <= Time.zone.today
74+
75+
return Time.zone.today
76+
end
77+
end
78+
79+
start_date
80+
rescue => e
81+
Rails.logger.warn("LeopardWebService unavailable for registration_start on #{name}: #{e.message}")
82+
start_date
83+
end
84+
85+
# Scope for active terms (classes have started and term hasn't ended)
86+
# Returns up to 3 terms if 3 are active, otherwise returns 2
87+
scope :active, -> {
88+
today = Time.zone.today
89+
90+
where(start_date: ..today)
91+
.where(end_date: today..)
92+
.order(year: :desc, season: :desc)
93+
}
94+
5495
# Scope for current and future terms (for finals schedule uploads)
5596
scope :current_and_future, -> {
5697
current_term = Term.current

app/models/university_calendar_event.rb

Lines changed: 159 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -95,36 +95,174 @@ def self.no_class_days_between(start_date, end_date)
9595
where(category: %w[holiday finals]).in_date_range(start_date, end_date).order(:start_time)
9696
end
9797

98-
# Detect term dates from university calendar events
99-
# Looks for "Classes Begin" and "Classes End"/"Final Exam" patterns
98+
# Detect term dates for a term.
99+
#
100+
# Date rules:
101+
# - start_date: The day LeopardWeb reports registration open for the term.
102+
# Once captured, keep the stored past start_date stable across
103+
# later syncs.
104+
# - end_date: Last day of finals period for the term.
105+
# Falls back to latest imported course end_date for the term
106+
# when finals period events are unavailable.
107+
#
100108
# @param year [Integer] The academic year
101109
# @param season [Symbol] The season (:fall, :spring, :summer)
102110
# @return [Hash] Hash with :start_date and :end_date keys
103111
def self.detect_term_dates(year, season)
104-
term_name = "#{season.to_s.capitalize} #{year}"
105-
term_name_alt = season.to_s.capitalize.to_s
106-
107-
# Find "Classes Begin" events (now in term_dates category)
108-
classes_begin = term_dates.where("summary ILIKE ?", "%classes begin%")
109-
.where("academic_term ILIKE ? OR summary ILIKE ?", "%#{term_name_alt}%", "%#{year}%")
110-
.where(start_time: Date.new(year - 1, 7, 1)..Date.new(year + 1, 2, 1))
111-
.order(:start_time)
112-
.first
113-
114-
# Find term end indicators - check both term_dates and finals categories
115-
term_end = where(category: %w[term_dates finals])
116-
.where("summary ILIKE ? OR summary ILIKE ?", "%final exam%", "%classes end%")
117-
.where("academic_term ILIKE ? OR summary ILIKE ?", "%#{term_name_alt}%", "%#{year}%")
118-
.where(start_time: Date.new(year - 1, 7, 1)..Date.new(year + 1, 7, 1))
119-
.order(start_time: :desc)
120-
.first
112+
term = Term.find_by(year: year, season: season)
113+
114+
candidate_events = where(start_time: Date.new(year - 1, 1, 1).beginning_of_day..Date.new(year + 1, 12, 31).end_of_day)
115+
matching_events = candidate_events.select { |event| event_matches_term_for_date_detection?(event, year, season, term) }
116+
117+
leopard_web_start_date = leopard_web_registration_open_date(year, season, term)
118+
119+
# Prefer explicit schedule-available events when LeopardWeb does not expose
120+
# the term as currently open for registration.
121+
schedule_available_event = matching_events
122+
.select { |event| schedule_available_summary?(event.summary) }
123+
.min_by(&:start_time)
124+
125+
126+
estimated_schedule_available_date = registration_event&.start_time&.to_date&.-(14)
127+
128+
finals_period_event = matching_events
129+
.select { |event| event.category == "finals" && finals_period_summary?(event.summary) }
130+
.max_by { |event| event.end_time || event.start_time }
131+
132+
finals_end_date = finals_period_event&.end_time&.to_date || finals_period_event&.start_time&.to_date
133+
course_end_fallback = fallback_end_date_from_courses(term)
121134

122135
{
123-
start_date: classes_begin&.start_time&.to_date,
124-
end_date: term_end&.end_time&.to_date || term_end&.start_time&.to_date
136+
start_date: leopard_web_start_date || schedule_available_event&.start_time&.to_date || estimated_schedule_available_date,
137+
end_date: finals_end_date || course_end_fallback
125138
}
126139
end
127140

141+
def self.leopard_web_registration_open_date(year, season, term)
142+
result = LeopardWebService.get_active_terms
143+
return nil unless result[:success]
144+
145+
target_uid = term&.uid || generated_term_uid(year, season)
146+
return nil unless target_uid
147+
148+
active_term_found = Array(result[:terms]).any? do |active_term|
149+
(active_term[:code] || active_term["code"]).to_i == target_uid
150+
end
151+
return nil unless active_term_found
152+
153+
existing_start_date = term&.start_date
154+
return existing_start_date if existing_start_date.present? && existing_start_date <= Time.zone.today
155+
156+
Time.zone.today
157+
rescue => e
158+
Rails.logger.warn("Failed to determine LeopardWeb registration-open date for #{season} #{year}: #{e.message}")
159+
nil
160+
end
161+
162+
def self.generated_term_uid(year, season)
163+
case season.to_sym
164+
when :fall
165+
((year + 1) * 100) + 10
166+
when :spring
167+
(year * 100) + 20
168+
when :summer
169+
(year * 100) + 30
170+
end
171+
end
172+
173+
# Returns true when an event can be considered part of the target term for
174+
# date detection purposes.
175+
def self.event_matches_term_for_date_detection?(event, year, season, term)
176+
season_name = season.to_s.capitalize
177+
summary = event.summary.to_s
178+
academic_term = event.academic_term.to_s
179+
180+
# If summary explicitly names a term, trust that first.
181+
explicit_summary_term = extract_explicit_term_from_summary(summary)
182+
if explicit_summary_term
183+
return explicit_summary_term[:season] == season.to_sym && explicit_summary_term[:year] == year
184+
end
185+
186+
# Strongest signal: explicit DB term linkage from sync processing.
187+
return true if term && event.term_id == term.id
188+
189+
# Next strongest signal: summary explicitly mentions season + year.
190+
return true if summary.match?(/\b#{Regexp.escape(season_name)}\b/i) && summary.match?(/\b#{year}\b/)
191+
192+
# Weak signal: academic term only. Restrict with season-specific date windows
193+
# to avoid cross-year contamination.
194+
return false unless academic_term.match?(/\b#{Regexp.escape(season_name)}\b/i)
195+
196+
event_date = event.start_time&.to_date
197+
return false unless event_date
198+
199+
event_date.in?(term_detection_date_window(year, season))
200+
end
201+
202+
# Extract explicit term mention like "Fall 2026" from summary text.
203+
# Returns nil when no explicit term/year is present.
204+
def self.extract_explicit_term_from_summary(summary)
205+
match = summary.to_s.match(/\b(Fall|Spring|Summer)\s+(\d{4})\b/i)
206+
return nil unless match
207+
208+
season = case match[1].downcase
209+
when "fall" then :fall
210+
when "spring" then :spring
211+
when "summer" then :summer
212+
end
213+
214+
return nil unless season
215+
216+
{ season: season, year: match[2].to_i }
217+
end
218+
219+
# Date windows tuned for academic term event timing (including preregistration).
220+
def self.term_detection_date_window(year, season)
221+
case season.to_sym
222+
when :spring
223+
Date.new(year - 1, 8, 1)..Date.new(year, 6, 30)
224+
when :summer
225+
Date.new(year, 1, 1)..Date.new(year, 8, 31)
226+
when :fall
227+
Date.new(year, 1, 1)..Date.new(year, 12, 31)
228+
else
229+
Date.new(year - 1, 1, 1)..Date.new(year + 1, 12, 31)
230+
end
231+
end
232+
233+
def self.schedule_available_summary?(summary)
234+
normalized = summary.to_s
235+
return false if registration_summary?(normalized)
236+
237+
normalized.match?(/course\s+schedule/i) ||
238+
normalized.match?(/schedule\s+(available|release|released|posted|opens|open|begins|begin)/i)
239+
end
240+
241+
def self.registration_summary?(summary)
242+
summary.to_s.match?(/registration\s+(opens|open|begins|begin)|registration/i)
243+
end
244+
245+
# Finals period events should represent actual exam days, not announcement-only
246+
# "schedule available" events.
247+
def self.finals_period_summary?(summary)
248+
normalized = summary.to_s
249+
return false if normalized.match?(/schedule\s+(available|online|release|released|posted)/i)
250+
251+
normalized.match?(/final\s+exam\s+period|final\s+exams?|finals\s+week|examination\s+period|study\s+day/i)
252+
end
253+
254+
# Fallback end date from imported course data for the same term.
255+
# This is used only when finals period events are not available yet.
256+
def self.fallback_end_date_from_courses(term)
257+
return nil unless term
258+
259+
term.courses.where.not(start_date: nil)
260+
.where.not(end_date: nil)
261+
.where("EXTRACT(YEAR FROM start_date) >= ? AND EXTRACT(YEAR FROM start_date) <= ?", term.year - 1, term.year)
262+
.where("EXTRACT(YEAR FROM end_date) >= ? AND EXTRACT(YEAR FROM end_date) <= ?", term.year, term.year + 1)
263+
.maximum(:end_date)
264+
end
265+
128266
# Infer category from event summary and raw event type
129267
# @param summary [String] The event summary/title
130268
# @param event_type_raw [String] The raw event type from ICS

app/services/leopard_web_service.rb

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ def call
2626
get_faculty_meeting_times
2727
when :get_course_catalog
2828
get_course_catalog
29-
when :get_available_terms
30-
get_available_terms
29+
when :get_active_terms
30+
get_active_terms
3131
else
3232
raise ArgumentError, "Unknown action: #{action}"
3333
end
@@ -65,8 +65,8 @@ def self.get_course_catalog(term:)
6565
).call
6666
end
6767

68-
def self.get_available_terms
69-
new(action: :get_available_terms).call
68+
def self.get_active_terms
69+
new(action: :get_active_terms).call
7070
end
7171

7272
private
@@ -256,11 +256,11 @@ def handle_error(response)
256256
raise "Request failed with status #{response.status}: #{response.body}"
257257
end
258258

259-
def get_available_terms
260-
response = terms_connection.get("classSearch/getTerms", {
259+
def get_active_terms
260+
response = terms_connection.get("classRegistration/getTerms", {
261261
searchTerm: "",
262262
offset: 1,
263-
max: 50
263+
max: 10
264264
})
265265

266266
if response.success?

config/routes.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,7 @@
388388

389389
get "faculty/by_rmp", to: "faculty#get_info_by_rmp_id"
390390

391-
get "terms/current_and_next", to: "misc#get_current_terms"
391+
get "terms/active", to: "misc#get_active_terms"
392392

393393
# Course events
394394
post "process_courses", to: "courses#process_courses"

0 commit comments

Comments
 (0)