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.
@@ -0,0 +1,8 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
7
+
8
+ import "./lib/tasks/my_banner.rake"
@@ -0,0 +1,2 @@
1
+ *
2
+ !.gitignore
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "my_banner"
5
+
6
+ require "pry"
7
+ Pry.start
8
+
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -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