my_banner 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|