my_banner 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.codeclimate.yml +12 -0
- data/.gitignore +22 -0
- data/.rspec +3 -0
- data/.ruby_version +1 -0
- data/.travis.yml +17 -0
- data/CONTRIBUTING.md +25 -0
- data/CREDITS.md +86 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +128 -0
- data/LICENSE.md +21 -0
- data/README.md +116 -0
- data/Rakefile +8 -0
- data/auth/.gitignore +2 -0
- data/bin/console +8 -0
- data/bin/setup +8 -0
- data/lib/my_banner.rb +32 -0
- data/lib/my_banner/calendar_authorization.rb +16 -0
- data/lib/my_banner/calendar_client.rb +26 -0
- data/lib/my_banner/calendar_service.rb +116 -0
- data/lib/my_banner/drive_authorization.rb +17 -0
- data/lib/my_banner/drive_client.rb +16 -0
- data/lib/my_banner/google_authorization.rb +47 -0
- data/lib/my_banner/schedule.rb +54 -0
- data/lib/my_banner/schedule/tableset.rb +115 -0
- data/lib/my_banner/section.rb +58 -0
- data/lib/my_banner/section/meeting.rb +27 -0
- data/lib/my_banner/spreadsheet_authorization.rb +16 -0
- data/lib/my_banner/spreadsheet_client.rb +16 -0
- data/lib/my_banner/spreadsheet_service.rb +75 -0
- data/lib/my_banner/version.rb +3 -0
- data/lib/tasks/my_banner.rake +120 -0
- data/my_banner.gemspec +36 -0
- data/pages/.gitignore +2 -0
- metadata +234 -0
data/Rakefile
ADDED
data/auth/.gitignore
ADDED
data/bin/console
ADDED
data/bin/setup
ADDED
data/lib/my_banner.rb
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
require "active_support/core_ext/array"
|
2
|
+
require "active_support/core_ext/numeric/time"
|
3
|
+
require "active_support/core_ext/object/try"
|
4
|
+
require "active_support/core_ext/string/conversions"
|
5
|
+
#require "googleauth"
|
6
|
+
#require "googleauth/stores/file_token_store"
|
7
|
+
#require "google/apis/calendar_v3"
|
8
|
+
#require "google/apis/drive_v3"
|
9
|
+
#require "google/apis/sheets_v4"
|
10
|
+
#require "nokogiri"
|
11
|
+
require "pry"
|
12
|
+
|
13
|
+
require "my_banner/version"
|
14
|
+
|
15
|
+
require "my_banner/google_authorization"
|
16
|
+
|
17
|
+
require "my_banner/calendar_authorization"
|
18
|
+
require "my_banner/calendar_client"
|
19
|
+
require "my_banner/calendar_service"
|
20
|
+
require "my_banner/drive_authorization"
|
21
|
+
require "my_banner/drive_client"
|
22
|
+
require "my_banner/spreadsheet_authorization"
|
23
|
+
require "my_banner/spreadsheet_client"
|
24
|
+
require "my_banner/spreadsheet_service"
|
25
|
+
|
26
|
+
require "my_banner/schedule"
|
27
|
+
require "my_banner/schedule/tableset"
|
28
|
+
require "my_banner/section" # consider "my_banner/schedule/tableset/section"
|
29
|
+
require "my_banner/section/meeting" # consider "my_banner/schedule/tableset/section/meeting"
|
30
|
+
|
31
|
+
module MyBanner
|
32
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
require "google/apis/calendar_v3"
|
2
|
+
|
3
|
+
module MyBanner
|
4
|
+
class CalendarAuthorization < GoogleAuthorization
|
5
|
+
|
6
|
+
AUTH_SCOPE = Google::Apis::CalendarV3::AUTH_CALENDAR #> "https://www.googleapis.com/auth/calendar"
|
7
|
+
|
8
|
+
def initialize(options={})
|
9
|
+
options[:scope] ||= AUTH_SCOPE
|
10
|
+
options[:credentials_filepath] ||= "auth/calendar_credentials.json"
|
11
|
+
options[:token_filepath] ||= "auth/calendar_token.yaml"
|
12
|
+
super(options)
|
13
|
+
end
|
14
|
+
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
require "google/apis/calendar_v3"
|
2
|
+
|
3
|
+
module MyBanner
|
4
|
+
class CalendarClient < Google::Apis::CalendarV3::CalendarService
|
5
|
+
|
6
|
+
# @param authorization [CalendarAuthorization]
|
7
|
+
def initialize(authorization=nil)
|
8
|
+
super()
|
9
|
+
self.client_options.application_name = "MyBanner Calendar Client"
|
10
|
+
self.client_options.application_version = VERSION
|
11
|
+
authorization ||= CalendarAuthorization.new
|
12
|
+
self.authorization = authorization.stored_credentials || authorization.user_provided_credentials
|
13
|
+
end
|
14
|
+
|
15
|
+
def calendars
|
16
|
+
@calendars ||= list_calendar_lists.items.sort_by { |cal| cal.summary }
|
17
|
+
end
|
18
|
+
|
19
|
+
# @param calendar [Google::Apis::CalendarV3::Calendar]
|
20
|
+
def upcoming_events(calendar)
|
21
|
+
request_options = {max_results: 100, single_events: true, order_by: "startTime", time_min: Time.now.iso8601, show_deleted: false}
|
22
|
+
list_events(calendar.id, request_options).items
|
23
|
+
end
|
24
|
+
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,116 @@
|
|
1
|
+
module MyBanner
|
2
|
+
class CalendarService
|
3
|
+
|
4
|
+
attr_accessor :section, :calendar_name, :time_zone, :location, :meetings
|
5
|
+
|
6
|
+
def initialize(section)
|
7
|
+
@section = section
|
8
|
+
validate_section
|
9
|
+
@calendar_name = section.abbreviation
|
10
|
+
@time_zone = section.time_zone
|
11
|
+
@location = section.location
|
12
|
+
@meetings = section.meetings
|
13
|
+
end
|
14
|
+
|
15
|
+
def execute
|
16
|
+
meetings.map do |meeting|
|
17
|
+
event = find_event(meeting.to_h)
|
18
|
+
event ? update_event(event, meeting.to_h) : create_event(meeting.to_h)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def events
|
23
|
+
@events ||= client.upcoming_events(calendar)
|
24
|
+
end
|
25
|
+
|
26
|
+
def calendar
|
27
|
+
@calendar ||= (find_calendar || create_calendar)
|
28
|
+
end
|
29
|
+
|
30
|
+
def client
|
31
|
+
@client = CalendarClient.new
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
#
|
37
|
+
# EVENT OPERATIONS
|
38
|
+
#
|
39
|
+
|
40
|
+
def delete_events
|
41
|
+
events.map { |event| client.delete_event(calendar.id, event.id) }
|
42
|
+
end
|
43
|
+
|
44
|
+
def update_event(event, meeting_attrs)
|
45
|
+
client.update_event(calendar.id, event.id, new_event(meeting_attrs))
|
46
|
+
end
|
47
|
+
|
48
|
+
def find_event(meeting_attrs)
|
49
|
+
events.find do |e|
|
50
|
+
# match datetime events
|
51
|
+
(
|
52
|
+
e.start.date_time.try(:strftime, "%Y-%m-%dT%H:%M:%S") == meeting_attrs[:start_at].try(:strftime, "%Y-%m-%dT%H:%M:%S") &&
|
53
|
+
e.end.date_time.try(:strftime, "%Y-%m-%dT%H:%M:%S") == meeting_attrs[:end_at].try(:strftime, "%Y-%m-%dT%H:%M:%S")
|
54
|
+
) ||
|
55
|
+
# match date events
|
56
|
+
(
|
57
|
+
e.start.date.try(:strftime, "%Y-%m-%dT%H:%M:%S") == meeting_attrs[:start_at].try(:strftime, "%Y-%m-%dT%H:%M:%S") &&
|
58
|
+
e.end.date.try(:strftime, "%Y-%m-%dT%H:%M:%S") == meeting_attrs[:end_at].try(:strftime, "%Y-%m-%dT%H:%M:%S")
|
59
|
+
)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def create_event(meeting_attrs)
|
64
|
+
client.insert_event(calendar.id, new_event(meeting_attrs))
|
65
|
+
end
|
66
|
+
|
67
|
+
def new_event(meeting_attrs)
|
68
|
+
Google::Apis::CalendarV3::Event.new(event_attributes(meeting_attrs))
|
69
|
+
end
|
70
|
+
|
71
|
+
def event_attributes(meeting_attrs)
|
72
|
+
{
|
73
|
+
summary: calendar_name,
|
74
|
+
location: location,
|
75
|
+
start: {
|
76
|
+
date_time: meeting_attrs[:start_at].strftime("%Y-%m-%-dT%H:%M:%S"), # excludes offset, regardless of tz presence, to avoid maladjustment
|
77
|
+
time_zone: time_zone
|
78
|
+
},
|
79
|
+
end: {
|
80
|
+
date_time: meeting_attrs[:end_at].strftime("%Y-%m-%-dT%H:%M:%S"), # excludes offset, regardless of tz presence, to avoid maladjustment
|
81
|
+
time_zone: time_zone
|
82
|
+
},
|
83
|
+
# description: "Agenda: https://.../units/1 \n \n Objectives: \n 1: .... \n 2: .... \n 3: ....", # todo
|
84
|
+
# attendees: ["hello@gmail.com", "prof@my-school.edu", "student@my-school.edu"],
|
85
|
+
# source: {title: "External link", url: "https://.../units/1"}
|
86
|
+
}
|
87
|
+
end
|
88
|
+
|
89
|
+
#
|
90
|
+
# CALENDAR OPERATIONS
|
91
|
+
#
|
92
|
+
|
93
|
+
def find_calendar
|
94
|
+
client.calendars.find { |cal| cal.summary == calendar_name }
|
95
|
+
end
|
96
|
+
|
97
|
+
def create_calendar
|
98
|
+
client.insert_calendar(new_calendar)
|
99
|
+
end
|
100
|
+
|
101
|
+
def new_calendar
|
102
|
+
Google::Apis::CalendarV3::Calendar.new(calendar_attributes)
|
103
|
+
end
|
104
|
+
|
105
|
+
def calendar_attributes
|
106
|
+
{ summary: calendar_name, time_zone: time_zone }
|
107
|
+
end
|
108
|
+
|
109
|
+
private
|
110
|
+
|
111
|
+
def validate_section
|
112
|
+
raise "OOPS, expecting a section object" unless section && section.is_a?(Section)
|
113
|
+
end
|
114
|
+
|
115
|
+
end
|
116
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
require "google/apis/drive_v3"
|
2
|
+
require "google/apis/drive_v3"
|
3
|
+
|
4
|
+
module MyBanner
|
5
|
+
class DriveAuthorization < GoogleAuthorization
|
6
|
+
|
7
|
+
AUTH_SCOPE = Google::Apis::DriveV3::AUTH_DRIVE_FILE #> "https://www.googleapis.com/auth/drive.file"
|
8
|
+
|
9
|
+
def initialize(options={})
|
10
|
+
options[:scope] ||= AUTH_SCOPE
|
11
|
+
options[:credentials_filepath] ||= "auth/drive_credentials.json"
|
12
|
+
options[:token_filepath] ||= "auth/drive_token.yaml"
|
13
|
+
super(options)
|
14
|
+
end
|
15
|
+
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
require "google/apis/drive_v3"
|
2
|
+
|
3
|
+
module MyBanner
|
4
|
+
class DriveClient < Google::Apis::DriveV3::DriveService
|
5
|
+
|
6
|
+
# @param authorization [DriveAuthorization]
|
7
|
+
def initialize(authorization=nil)
|
8
|
+
super()
|
9
|
+
self.client_options.application_name = "MyBanner Drive Client"
|
10
|
+
self.client_options.application_version = VERSION
|
11
|
+
authorization ||= DriveAuthorization.new
|
12
|
+
self.authorization = authorization.stored_credentials || authorization.user_provided_credentials
|
13
|
+
end
|
14
|
+
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
require "googleauth"
|
2
|
+
require "googleauth/stores/file_token_store"
|
3
|
+
|
4
|
+
module MyBanner
|
5
|
+
class GoogleAuthorization
|
6
|
+
# returns authorization code in browser title bar and promps user to copy the code
|
7
|
+
# @see https://developers.google.com/api-client-library/python/auth/installed-app#choosingredirecturi
|
8
|
+
BASE_URL = "urn:ietf:wg:oauth:2.0:oob"
|
9
|
+
|
10
|
+
attr_reader :scope, :credentials_filepath, :token_filepath, :user_id
|
11
|
+
|
12
|
+
def initialize(options={})
|
13
|
+
@scope = options[:scope]
|
14
|
+
@credentials_filepath = options[:credentials_filepath]
|
15
|
+
@token_filepath = options[:token_filepath]
|
16
|
+
@user_id = options[:user_id] || "default"
|
17
|
+
end
|
18
|
+
|
19
|
+
def user_authorizer
|
20
|
+
client_id = Google::Auth::ClientId.from_file(credentials_filepath) # will throw an error without credentials file
|
21
|
+
token_store = Google::Auth::Stores::FileTokenStore.new(file: token_filepath)
|
22
|
+
Google::Auth::UserAuthorizer.new(client_id, scope, token_store)
|
23
|
+
end
|
24
|
+
|
25
|
+
# @return [Google::Auth::UserRefreshCredentials] OAuth2 credentials
|
26
|
+
def stored_credentials
|
27
|
+
user_authorizer.get_credentials(user_id)
|
28
|
+
end
|
29
|
+
|
30
|
+
# makes a request to https://oauth2.googleapis.com/token
|
31
|
+
def user_provided_credentials
|
32
|
+
user_authorizer.get_and_store_credentials_from_code(user_id: user_id, code: user_provided_code, base_url: BASE_URL)
|
33
|
+
end
|
34
|
+
|
35
|
+
# prompt user for results of redirected auth flow
|
36
|
+
def user_provided_code
|
37
|
+
puts "Please visit ... \n\n #{authorization_url} \n\n ... login to your google account, get a code, paste it here, and press enter: "
|
38
|
+
code = $stdin.gets.chomp
|
39
|
+
return code
|
40
|
+
end
|
41
|
+
|
42
|
+
def authorization_url
|
43
|
+
user_authorizer.get_authorization_url(base_url: BASE_URL)
|
44
|
+
end
|
45
|
+
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
require "nokogiri"
|
2
|
+
require "active_support/core_ext/array"
|
3
|
+
|
4
|
+
module MyBanner
|
5
|
+
class Schedule # consider Page, SchedulePage, DetailSchedulePage, FacultyDetailSchedulePage
|
6
|
+
|
7
|
+
attr_reader :filepath
|
8
|
+
|
9
|
+
def initialize(filepath=nil)
|
10
|
+
@filepath = filepath || "pages/my-detail-schedule.html"
|
11
|
+
validate_file_exists
|
12
|
+
end
|
13
|
+
|
14
|
+
def sections
|
15
|
+
@sections ||= tablesets.map{ |tableset| tableset.section }
|
16
|
+
end
|
17
|
+
|
18
|
+
def tablesets
|
19
|
+
@tablesets ||= tables.to_a.in_groups_of(3).map do |batch|
|
20
|
+
summaries = batch.map { |t| t.attributes["summary"].value.squish }
|
21
|
+
raise "Unexpected tableset: #{summaries}" unless summaries.sort == TABLE_SUMMARIES.values.sort
|
22
|
+
info_table = batch.find { |t| t.attributes["summary"].value.squish == TABLE_SUMMARIES[:info].squish }
|
23
|
+
enrollment_table = batch.find { |t| t.attributes["summary"].value == TABLE_SUMMARIES[:enrollment] }
|
24
|
+
schedule_table = batch.find { |t| t.attributes["summary"].value == TABLE_SUMMARIES[:schedule] }
|
25
|
+
Tableset.new(info_table, enrollment_table, schedule_table)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
TABLE_SUMMARIES = {
|
30
|
+
info: "This layout table is used to present instructional assignments for the selected Term..",
|
31
|
+
enrollment: "This table displays enrollment and waitlist counts.",
|
32
|
+
schedule: "This table lists the scheduled meeting times and assigned instructors for this class.."
|
33
|
+
}
|
34
|
+
|
35
|
+
# @return Nokogiri::XML::NodeSet
|
36
|
+
def tables
|
37
|
+
@tables ||= doc.css(".pagebodydiv").css("table").css(".datadisplaytable") # ignores the last table
|
38
|
+
end
|
39
|
+
|
40
|
+
# @return Nokogiri::XML::Document
|
41
|
+
def doc
|
42
|
+
@doc ||= File.open(filepath) { |f| Nokogiri::XML(f) }
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def validate_file_exists
|
48
|
+
unless filepath && File.exists?(filepath)
|
49
|
+
raise "Oh, couldn't find an HTML file at #{filepath}. Please download one and copy it to the expected location."
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,115 @@
|
|
1
|
+
module MyBanner
|
2
|
+
class Schedule::Tableset
|
3
|
+
|
4
|
+
attr_reader :info_table, :enrollment_table, :schedule_table
|
5
|
+
|
6
|
+
def initialize(info_table, enrollment_table, schedule_table)
|
7
|
+
@info_table = info_table
|
8
|
+
@enrollment_table = enrollment_table
|
9
|
+
@schedule_table = schedule_table
|
10
|
+
end
|
11
|
+
|
12
|
+
def section
|
13
|
+
@section ||= Section.new(metadata)
|
14
|
+
end
|
15
|
+
|
16
|
+
def metadata
|
17
|
+
@metadata ||= info.merge(enrollment_counts: enrollment_counts, scheduled_meeting_times: scheduled_meeting_times)
|
18
|
+
end
|
19
|
+
|
20
|
+
def info
|
21
|
+
@info ||= {
|
22
|
+
title: info_link_text[0],
|
23
|
+
crn: info_link_text[1],
|
24
|
+
course: info_link_text[2],
|
25
|
+
section: info_link_text[3].to_i,
|
26
|
+
status: info_rows[1].css("td").text,
|
27
|
+
registration: info_rows[2].css("td").text,
|
28
|
+
college: info_rows[3].css("td").text.squish,
|
29
|
+
department: info_rows[4].css("td").text.squish,
|
30
|
+
part_of_term: info_rows[5].css("td").text,
|
31
|
+
credits: info_rows[6].css("td").text.squish.to_f,
|
32
|
+
levels: info_rows[7].css("td").text.split(", "),
|
33
|
+
campus: info_rows[8].css("td").text,
|
34
|
+
override: info_rows[9].css("td").text
|
35
|
+
}
|
36
|
+
end
|
37
|
+
|
38
|
+
def enrollment_counts
|
39
|
+
@enrollment_counts ||= {
|
40
|
+
maximum: enrollment_data[0].text.to_i,
|
41
|
+
actual: enrollment_data[1].text.to_i,
|
42
|
+
remaining: enrollment_data[2].text.to_i
|
43
|
+
}
|
44
|
+
end
|
45
|
+
|
46
|
+
def scheduled_meeting_times
|
47
|
+
{
|
48
|
+
type: schedule_data[0].text,
|
49
|
+
time: schedule_data[1].text,
|
50
|
+
days: schedule_data[2].text,
|
51
|
+
where: schedule_data[3].text,
|
52
|
+
date_range: schedule_data[4].text,
|
53
|
+
schedule_type: schedule_data[5].text,
|
54
|
+
instructors: schedule_data[6].text.squish.split(",")
|
55
|
+
}
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
def info_link_text
|
61
|
+
@info_link_text ||= info_rows[0].css("a").first.text.split("Status:").first.squish.split(" - ")
|
62
|
+
end
|
63
|
+
|
64
|
+
def enrollment_data
|
65
|
+
@enrollment_data ||= begin
|
66
|
+
enrollment_row = enrollment_rows[1]
|
67
|
+
raise "Unexpected enrollment table row" unless enrollment_row.css("th").text == "Enrollment:"
|
68
|
+
raise "Unexpected enrollment table data" unless enrollment_row.css("td").count == 3
|
69
|
+
enrollment_row.css("td")
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def schedule_data
|
74
|
+
@schedule_data ||= begin
|
75
|
+
schedule_row = schedule_rows[1]
|
76
|
+
raise "Unexpected schedule table data" unless schedule_row.css("td").count == 7 # schedule_table_headers.count
|
77
|
+
schedule_data = schedule_row.css("td")
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def info_rows
|
82
|
+
@info_rows ||= begin
|
83
|
+
table = Nokogiri::XML(info_table.to_html) #> workaround because info_table.css("tr") seems to return too many rows (52). the raw html looks good though.
|
84
|
+
table_rows = table.css("tr")
|
85
|
+
raise "Unexpected number of info table rows: #{table_rows.count}" unless table_rows.count == 12
|
86
|
+
table_rows
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def enrollment_rows
|
91
|
+
@enrollment_rows ||= begin
|
92
|
+
table_rows = enrollment_table.css("tr")
|
93
|
+
raise "Unexpected enrollment table row count: #{table_rows.count}" unless table_rows.count == 3
|
94
|
+
expected_headers = ["", "Maximum", "Actual", "Remaining"]
|
95
|
+
table_headers = table_rows[0].css("th").map(&:text)
|
96
|
+
raise "Unexpected enrollment table headers" unless table_headers == expected_headers
|
97
|
+
table_rows
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def schedule_rows
|
102
|
+
@schedule_rows ||= begin
|
103
|
+
table = Nokogiri::XML(schedule_table.to_html)#> workaround because schedule_table.css("tr") seems to return too many rows (71). the raw html looks good though.
|
104
|
+
table_rows = table.css("tr")
|
105
|
+
raise "Unexpected schedule table row count: #{table_rows.count}" unless table_rows.count == 2
|
106
|
+
# consider also validating table caption == "Scheduled Meeting Times"
|
107
|
+
expected_headers = ["Type", "Time", "Days", "Where", "Date Range", "Schedule Type", "Instructors"]
|
108
|
+
table_headers = table_rows[0].css("th").map(&:text)
|
109
|
+
raise "Unexpected schedule table headers" unless table_headers == expected_headers
|
110
|
+
table_rows
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
end
|
115
|
+
end
|