Commit 1a8b57eb authored by Zac Wood's avatar Zac Wood

Merge branch '2-pretty-馃拝' into 'master'

Merge for deployment

Closes #2

See merge request srct/schedules!10
parents 0e0da85c 2b631356
.vscode
\ No newline at end of file
.vscode
schedules.code-workspace
stages:
- test
test_api:
image: ruby:2.5
stage: test
script:
- cd schedules_api
- bundle install
- rails db:migrate
- rails test
test_web:
image: node:9
stage: test
script:
- cd schedules_web
- yarn
- yarn build
\ No newline at end of file
version: "3"
services:
api:
image: schedules_api
ports:
- "3000:3000"
command: ./start.sh
volumes:
- ./schedules_api:/api
web:
image: schedules_web
ports:
- "8080:8080"
command: yarn start
volumes:
- ./schedules_web:/web
#! /bin/bash
cd schedules_api/
docker build . -t 'schedules_api'
cd ../schedules_web
docker build . -t 'schedules_web'
cd ..
docker-compose up
FROM ruby:2.5
RUN mkdir /api
WORKDIR /api
ADD . /api
EXPOSE 3000
RUN bundle install
RUN rails db:migrate
RUN rails db:seed
......@@ -12,11 +12,6 @@ gem 'sqlite3'
# Use Puma as the app server
gem 'puma', '~> 3.7'
# Use SCSS for stylesheets
gem 'sass-rails', '~> 5.0'
# Use Uglifier as compressor for JavaScript assets
gem 'uglifier', '>= 1.3.0'
# See https://github.com/rails/execjs#readme for more supported runtimes
# gem 'therubyracer', platforms: :ruby
# Turbolinks makes navigating your web application faster. Read more: https://github.com/turbolinks/turbolinks
gem 'turbolinks', '~> 5'
......
......@@ -57,7 +57,6 @@ GEM
concurrent-ruby (1.0.5)
crass (1.0.4)
erubi (1.7.1)
execjs (2.7.0)
ffi (1.9.25)
globalid (0.4.1)
activesupport (>= 4.2.0)
......@@ -131,17 +130,6 @@ GEM
rubyzip (>= 1.1.6)
ruby_dep (1.5.0)
rubyzip (1.2.1)
sass (3.5.6)
sass-listen (~> 4.0.0)
sass-listen (4.0.0)
rb-fsevent (~> 0.9, >= 0.9.4)
rb-inotify (~> 0.9, >= 0.9.7)
sass-rails (5.0.7)
railties (>= 4.0.0, < 6)
sass (~> 3.1)
sprockets (>= 2.8, < 4.0)
sprockets-rails (>= 2.0, < 4.0)
tilt (>= 1.1, < 3)
selenium-webdriver (3.13.0)
childprocess (~> 0.5)
rubyzip (~> 1.2)
......@@ -160,14 +148,11 @@ GEM
sqlite3 (1.3.13)
thor (0.20.0)
thread_safe (0.3.6)
tilt (2.0.8)
turbolinks (5.1.1)
turbolinks-source (~> 5.1)
turbolinks-source (5.1.0)
tzinfo (1.2.5)
thread_safe (~> 0.1)
uglifier (4.1.12)
execjs (>= 0.3.0, < 3)
web-console (3.6.2)
actionview (>= 5.0)
activemodel (>= 5.0)
......@@ -197,15 +182,13 @@ DEPENDENCIES
rack-cors
rails (~> 5.1.6)
rubyXL
sass-rails (~> 5.0)
selenium-webdriver
spring
spring-watcher-listen (~> 2.0.0)
sqlite3
turbolinks (~> 5)
tzinfo-data
uglifier (>= 1.3.0)
web-console (>= 3.3.0)
BUNDLED WITH
1.16.1
1.16.2
require 'icalendar'
require 'time'
# Contains functionality for generating schedules.
class CalendarGeneratorController < ApplicationController
def generate
# Render an iCal file containing the schedules of all the
# course sections with the given CRNs.
def new
cal = Icalendar::Calendar.new
params[:_json].each do |crn|
section = Section.find_by_crn(crn)
event = generate_event_from_section(section)
cal.add_event(event)
# the intended format for the json is a list of CRNs
params[:_json].each do |crn| # for each CRN sent by the post request
section = CourseSection.find_by_crn(crn)
unless section.start_time == "TBA" || section.end_time == "TBA"
event = generate_event_from_section(section)
cal.add_event(event)
end
end
render plain: cal.to_ical
render plain: cal.to_ical # render a plaintext iCal file
end
private
# Configures a calendar event from a given section
# @param section [CourseSection]
def generate_event_from_section(section)
event = Icalendar::Event.new
......@@ -30,6 +38,10 @@ class CalendarGeneratorController < ApplicationController
event
end
# Format a DateTime string based on a given date and time
# @param date [String]
# @param time [String]
# @return [String]
def formatted_datetime_str(date, time)
formatted_date = date.to_s.tr('-', '')
formatted_time = Time.parse(time).strftime("%H%M%S")
......@@ -37,6 +49,7 @@ class CalendarGeneratorController < ApplicationController
"#{formatted_date}T#{formatted_time}"
end
# Mapping of days as represented by GMU to the iCal standard
DAYS = {
"M" => "MO",
"T" => "TU",
......@@ -47,6 +60,10 @@ class CalendarGeneratorController < ApplicationController
"U" => "SU"
}.freeze
# Generates a recurrence rule string descripting which day the class event
# should take place on
# @param section [CourseSection]
# @return [String]
def recurrence_rule_str(section)
days = section.days.split("").map do |day|
DAYS[day]
......@@ -55,8 +72,12 @@ class CalendarGeneratorController < ApplicationController
"FREQ=WEEKLY;UNTIL=#{formatted_datetime_str(section.end_date, section.end_time)};BYDAY=#{days.join(',')}"
end
# Get all dates that should excluded from the schedule
# @param section [CourseSection]
# @return [Array]
def exdates_for_section(section)
exdates = Closure.where(semester: section.course.semester).map { |closure|
# Generate exdates for all closures in a semester
exdates = Closure.where(semester: section.course.semester).map { |closure|
generate_exdate(closure.date.to_formatted_s(:number), section.start_time)
}
......@@ -72,7 +93,12 @@ class CalendarGeneratorController < ApplicationController
exdates
end
# Generate a DataTime to use as an exdate
# @param date [String]
# @param time [String]
# @return [Icalendar::Values::DateTime]
def generate_exdate(date, time)
# format the time for use in a DateTime
formatted_time = Time.parse(time).strftime("%H%M%S")
Icalendar::Values::DateTime.new("#{date}T#{formatted_time}")
end
......
# Contains all actions having to do with Sections.
# Contains all actions having to do with CourseSections.
# This is a nested controller -- see +config/routes.rb+ for details
class SectionsController < ApplicationController
class CourseSectionsController < ApplicationController
# Render JSON of all Sections belonging to a given Course.
def index
@course = Course.find(params[:course_id])
@sections = @course.sections
@sections = CourseSection.all
@sections = @sections.where(course_id: params[:course_id]) if params.key?(:course_id)
@sections = @sections.where(crn: params[:crn]) if params.key?(:crn)
render json: @sections
end
end
# Contains all actions having to do with Courses.
class CoursesController < ApplicationController
# Renders JSON of all courses.
# Renders JSON of courses matching params.
def index
@courses = Course.all
# filter by subject + course number if the params are included
@courses = @courses.where(subject: params[:subject].upcase) if params.key?(:subject)
@courses = @courses.where(course_number: params[:course_number]) if params.key?(:course_number)
render json: @courses
end
# Renders JSON of details of a singluar course, such as its sections
def show
@sections = CourseSection.where(course_id: params[:id])
render json: @sections
end
end
# Defines actions for the homepage
class HomeController < ApplicationController
def index; end
end
class SearchController < ApplicationController
def index
if params.key?(:crn)
crn = params[:crn]
@sections = Section.find_by_crn(crn)
render json: @sections
else
render status: 404
end
end
end
......@@ -4,14 +4,16 @@
class Course < ApplicationRecord
# Each course belongs to a +Semester+
belongs_to :semester
has_many :course_sections
# Ensure all necessary fields are present.
# Ensure all necessary are fields present.
validates :course_number, presence: true
validates :subject, presence: true
validates :semester_id, presence: true
# Returns all +Section+ objects that belong to this course.
def sections
Section.where course_id: id
# Returns all +CourseSection+ objects that belong to this course.
# @return [Array]
def course_sections
CourseSection.where course_id: id
end
end
# Contains logic belonging to the +Section+ model.
# Contains logic belonging to the +CourseSection+ model.
#
# TODO: Add more docs
class Section < ApplicationRecord
# Each +Section+ belongs to a +Course+.
class CourseSection < ApplicationRecord
# Each +CourseSection+ belongs to a +Course+.
belongs_to :course
# Ensure all necessary fields are present.
validates :name, presence: true
validates :crn, presence: true
# Unsure if necessary
# validates :section_type, presence: true
validates :title, presence: true
# validates :start_date, presence: true
# validates :end_date, presence: true
# validates :days, presence: true
validates :course_id, presence: true
end
......@@ -2,7 +2,13 @@
#
# A +Semester+ is a simple model that consists of a +year+ and a +season+, e.g. "Fall 2018".
class Semester < ApplicationRecord
has_many :courses
# Ensure necessary fields are present.
validates :year, presence: true
validates :season, presence: true
def courses
Course.where semester_id: id
end
end
......@@ -17,7 +17,7 @@ Rails.application.configure do
# Attempt to read encrypted secrets from `config/secrets.yml.enc`.
# Requires an encryption key in `ENV["RAILS_MASTER_KEY"]` or
# `config/secrets.yml.key`.
config.read_encrypted_secrets = true
# config.read_encrypted_secrets = true
# Disable serving static files from the `/public` folder by default since
# Apache or NGINX already handles this.
......
# Registers all routes for the app.
Rails.application.routes.draw do
scope :api do # Register /api routes
resources :courses, only: [:index] do # GET /api/courses
resources :sections, only: [:index] # GET /api/courses/:course_id/sections
end
resources :courses, only: [:index, :show]
resources :course_sections, only: [:index]
get 'search', controller: 'search', action: 'index'
post 'generate', controller: 'calendar_generator', action: 'generate'
post 'generate', controller: 'calendar_generator', action: 'new'
end
root 'courses#index' # Set the root to be the courses API endpoint
......
# This file is no longer being used.
# Data is now being parsed from Patriot Web.
require 'rubyXL'
# Provides utilities for loading schedules from GMU's excel files.
......@@ -35,7 +38,7 @@ class ExcelLoader
def delete_all_records
Semester.delete_all
Course.delete_all
Section.delete_all
CourseSection.delete_all
end
# Tries to create a course from a given row.
......@@ -67,7 +70,7 @@ class ExcelLoader
# So, split the times string by the - character
times = row.cells[23]&.value
time_strs = times.split('-')
section = Section.create name: section_name,
section = CourseSection.create name: section_name,
course: @current_course,
crn: row.cells[6]&.value,
section_type: row.cells[8]&.value,
......
class ChangeSectionToCourseSection < ActiveRecord::Migration[5.1]
def change
rename_table :sections, :course_sections
end
end
require 'httparty'
module PatriotWeb
# Contains utilities for making HTTP requests to PatriotWeb
class Networker
def fetch_page_containing_semester_data
HTTParty.get('https://patriotweb.gmu.edu/pls/prod/bwckschd.p_disp_dyn_sched')
......
......@@ -2,118 +2,118 @@ require_relative 'patriot_web_networker'
require 'nokogiri'
class String
# Checks if a String is a alphanumeric
def alpha?
!!match(/^[[:alpha:]]+$/)
end
end
module PatriotWeb
# Contains methods for parsing data retrieved from Patriot Web
class Parser
def initialize
@networker = PatriotWeb::Networker.new
end
# Parses all semesters avaliable on Patriot Web
def parse_semesters
response = @networker.fetch_page_containing_semester_data
searcher = Nokogiri::HTML(response)
response = @networker.fetch_page_containing_semester_data
document = Nokogiri::HTML(response) # parse the document from the HTTP response
get_semesters_from_option_values(searcher).compact
get_semesters_from_option_values(document).compact
end
# Parses subjects belonging to a given semester id
# @param semester_id [Integer]
def parse_subjects(semester_id)
response = @networker.fetch_subjects(semester_id)
searcher = Nokogiri::HTML(response)
get_alpha_option_values(searcher)
document = Nokogiri::HTML(response)
get_subject_codes_from_option_values(document)
end
# Parses all courses belonging to a given subject
# @param subject [String]
def parse_courses_in_subject(subject)
resp = @networker.fetch_courses_in_subject(subject)
searcher = Nokogiri::HTML(resp)
feed_course_info(searcher)
response = @networker.fetch_courses_in_subject(subject)
document = Nokogiri::HTML(response)
get_courses(document)
end
private
def get_alpha_option_values(searcher)
searcher.xpath('//*[@id="subj_id"]/option').map do |opt|
if opt.attr('value').strip.alpha?
opt.attr('value')
# Parse the values of all different options on the Patriot Web
# semester select page
# @param document [Nokogiri::HTML::Document]
def get_semesters_from_option_values(document)
document.css('option').map do |opt| # for each option value
if opt.attr('value').start_with? '20' # ensure it is a semester value
opt.attr('value') # return the value
end
end
end
def get_semesters_from_option_values(searcher)
searcher.css('option').map do |opt|
if opt.attr('value').start_with? '20'
opt.attr('value')
# Parse all subject codes from the select element on the Patriot Web
# subject select page
# @param document [Nokogiri::HTML::Document]
def get_subject_codes_from_option_values(document)
document.xpath('//*[@id="subj_id"]/option').map do |opt| # for each option value under "subj_id"
if opt.attr('value').strip.alpha? # if the value is alphanumeric
opt.attr('value') # return the value
end
end
end
def feed_course_info(searcher)
table = searcher.css('html body div.pagebodydiv table.datadisplaytable')
data = {}
currentobj = nil
table.css('table.datadisplaytable').first.children.each do |row|
next unless row.name == 'tr'
row.children.each do |item|
currentobj = sort_item(item, currentobj, data)
end
end
data
end
# Parse all courses from the subject search page
# @param document [Nokogiri::HTML::Document]
# @return [Array] courses
def get_courses(document)
table = document.css('html body div.pagebodydiv table.datadisplaytable').first
rows = table.children.drop 2 # first two elements are junk
# each section is represented by 6 rows in the table
(0..(rows.length/6 - 1)).map do |i|
start = i*6
data = {}
title = rows[start].text
# the title looks this: Survey of Accounting - 71117 - ACCT 203 - 001
# so split it by ' - ' and extract
title_elements = title.split(' - ')
data[:title] = title_elements[0].strip
data[:crn] = title_elements[1]
full_name = title_elements[2].split(' ')
next unless full_name.length == 2
data[:subj] = title_elements[2].split(' ')[0]
data[:course_number] = title_elements[2].split(' ')[1]
data[:section] = title_elements[3].strip
def sort_item(item, currentobj, data)
if item.name == 'th'
if item.to_html.include? '-'
titletxt = item.text
if item.text.include? ' - Honors'
titletxt = titletxt.gsub(' - Honors', ' (Honors)')
end
titledetails = titletxt.split(' - ')
if titledetails.count > 4
titledetails = ["#{titledetails[0]} #{titledetails[1]}", titledetails[2], titledetails[3], titledetails[4]]
end
titledata = titledetails[2].split(' ')
begin
data = get_details(data, titledetails, titledata)[0]
currentobj = get_details(data, titledetails, titledata)[1]
rescue StandardError => e
puts item
puts e
exit(1)
end
currentobj[:fields] = []
# rows 1 to 3 contain info about registration and drop dates.
# for now we're gonna ignore them and skip to row 4, which contains details
detail_rows = rows[start+4].css('tr')
next unless detail_rows.length > 0 # if there are no details, skip this item
details = detail_rows.last.text.split("\n").compact.reject(&:empty?) # skip empty strings
times = details[1].split(' - ')
if (times.length == 1)
data[:start_time] = 'TBA'
data[:end_time] = 'TBA'
else
data[:start_time] = times[0]
data[:end_time] = times[1]
end
elsif item.is_a? Nokogiri::XML::Element
item.css('th').each do |field|
currentobj[:fields].push(field.text.downcase.tr(' ', '_'))
end
iter = 0
if currentobj
if currentobj[:fields]
upper = currentobj[:fields].count - 1
while iter <= upper
assign = item.css('td')[iter].text
currentobj[currentobj[:fields][iter]] = assign
iter += 1
end
end
end
end
currentobj
end
def get_details(data, titledetails, titledata)
crn = titledetails[1].strip
data[crn] = {} unless data[titledetails[1]]
crsinfo = { 'name': titledetails[0].strip }
uniquedata = { 'sect': titledetails[3].strip, 'crn': titledetails[1].strip }
general = { 'subj': titledata[0].strip, 'code': titledata[1].strip }
data[crn] = general.merge(uniquedata.merge(crsinfo))
data[crn][:code] = titledetails[2].split(' ')[1]
[data, data[crn]]
data[:days] = details[2].strip
data[:location] = details[3].strip
dates = details[4].split(' - ')
data[:start_date] = dates[0]
data[:end_date] = dates[1]
data[:type] = details[5]
data[:instructor] = details[6]
data
end
end
end
end