my_banner 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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