Commit d2dd1444 authored by Zac Wood's avatar Zac Wood

Merge branch '26-model-filters' into 'dev-v2'

Resolve "Model filter functionality"

See merge request !32
parents 69e7ffaa 5b489e50
Pipeline #3213 passed with stage
in 2 minutes and 12 seconds
......@@ -5,7 +5,7 @@ const sectionWithCrn = crn => document.getElementById('search-list').querySelect
const addCourse = (event, id) => {
const courseCard = document.getElementById(`course-${id}`);
const title = courseCard.querySelector('#title').innerText;
const title = courseCard.querySelector('.title').innerText;
const sectionsItems = Array.from(courseCard.querySelectorAll('li'));
const sections = sectionsItems.map(li => ({ ...li.dataset }));
......@@ -48,11 +48,12 @@ const removeFromSchedule = section => {
* Toggles the display of the schedule
*/
const toggleSections = course => {
const sections = course.querySelector('#sections');
if (sections.style.display === 'block') {
course.querySelector('#sections').style.display = 'none';
const sections = course.querySelector('.sections');
console.log(sections);
if (sections.style.display === 'flex') {
sections.style.display = 'none';
} else {
course.querySelector('#sections').style.display = 'block';
sections.style.display = 'flex';
}
};
......
......@@ -27,6 +27,28 @@ body {
box-shadow: 0 0 5px rgba(0,0,0,0.2);
transition: 0.3s;
.card-header {
display: flex;
flex-direction: column;
}
.card-body {
.attr-list {
display: flex;
flex-direction: row;
justify-content: flex-start;
padding-bottom: 10px;
.attr {
margin-right: 13px;
}
}
}
}
.unpadded {
padding: 0px;
}
/* On mouse-over, add a deeper shadow */
......
class CourseListingsController < ApplicationController
resource_description do
short 'Working with courses and associated sections'
end
api :GET, '/course_listings', "Get all available courses and their sections"
param :subject, String, desc: 'Course subject, e.g. "CS" or "ACCT"'
param :number, Integer, desc: 'Course number, e.g. "112"'
def index
# Make a separate list so that we can include sections
@courses = CourseListingsHelper::CourseListing.wrap(Course.fetch(params).all)
render json: @courses
end
end
......@@ -5,16 +5,14 @@ class CourseSectionsController < ApplicationController
short 'Working with course sections, e.g. CS 112 001'
end
api :GET, '/courses_sections', 'Get a list of course sections'
api :GET, '/course_sections', 'Get a list of course sections'
param :course_id, Integer, desc: "Only get the course sections belonging to the course with this ID"
param :crn, String, desc: "Get the course section with this CRN"
param :instructor, String, desc: "Get course sections being taught by this instructor"
def index
@sections = CourseSection.with_instructor(name: params[:instructor])
@sections = @sections.where(course_id: params[:course_id]) if params.key?(:course_id)
@sections = @sections.where(crn: params[:crn]) if params.key?(:crn)
param :query, String, desc: 'A generic query ex. "CS 110"'
def index
@sections = CourseSection.fetch(params).all
render json: @sections
end
end
......@@ -8,19 +8,14 @@ class CoursesController < ApplicationController
param :subject, String, desc: 'Course subject, e.g. "CS" or "ACCT"'
param :course_number, Integer, desc: 'Course number, e.g. "112"'
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)
@courses = Course.fetch(params).all
render json: @courses
end
api :GET, '/courses/:id', "Get a list of all course sections for the course with the given id."
param :id, :number, desc: 'Course ID', required: true
def show
@sections = CourseSection.where(course_id: params[:id])
@sections = CourseSection.where(course_id: params[:id]).all
render json: @sections
end
......
class SearchController < ApplicationController
def index
@courses = Course.where(subject: params[:q], semester: @semester).select do |course|
course.course_sections.count.positive?
end
@results = SearchHelper::GenericItem.fetchall(params[:query], semester: @semester)
end
end
module CourseListingsHelper
class CourseListing
def initialize(course)
@course = course
@sections = course.course_sections
end
def self.name
:course
end
def self.wrap(course_list)
course_listings = []
course_list.each do |course|
course_listings.push(CourseListing.new(course))
end
course_listings
end
end
end
# Replaces various strings with other strings when needed
module CourseReplacementHelper
@replacements = {
"BIO" => "BIOL",
"HONORS" => "_HONORS_", # Sometimes the honors will be in parenthesis so this accounts for that -> lol no it doesn't jk this needs fixed
"ACADEMIC ENGLISH" => "AE",
"ACCOUNTING" => "ACCT",
"AFRICAN AND AFRICAN AMERICAN STUDIES" => "AFAM",
"ANTHROPOLOGY" => "ANTH",
"APPLIED INFORMATION TECHNOLOGY" => "AIT",
"ARABIC" => "ARAB",
"ART AND VISUAL TECHNOLOGY" => "AVT",
"ART HISTORY" => "ARTH",
"ARTS MANAGEMENT" => "AMGT",
"ASSISTIVE TECHNOLOGY" => "EDAT",
"ASTRONOMY" => "ASTR",
"ATHLETIC TRAINING EDUCATION PROGRAM" => "ATEP",
"BACHELOR OF INDIVIDUALIZED STUDY" => "BIS",
"BACHELOR'S OF APPLIED SCIENCE" => "BAS",
"BIODEFENSE" => "BIOD",
"BIOENGINEERING" => "BENG",
"BIOINFORMATICS" => "BINF",
"BIOLOGY" => "BIOL",
"BIOMEDICAL SCIENCES" => "BMED",
"BIOSCIENCES" => "BIOS",
"BUSINESS AND LEGAL STUDIES" => "BULE",
"BUSINESS" => "BUS",
"BUSINESS MANAGEMENT" => "BMGT",
"BUSINESS MANAGEMENT OF SECURE INFORMATION SYSTEMS" => "MSIS",
"CHEMISTRY" => "CHEM",
"CHINESE" => "CHIN",
"CIVIL AND INFRASTRUCTURE ENGINEERING" => "CEIE",
"CLASSICS" => "CLAS",
"CLIMATE DYNAMICS" => "CLIM",
"COLLEGE OF SCIENCE" => "COS",
"COLLEGE OF VISUAL AND PERFORMING ARTS" => "CVPA",
"COMMUNICATION" => "COMM",
"COMPUTATIONAL AND DATA SCIENCES" => "CDS",
"COMPUTATIONAL SCIENCE AND INFORMATICS" => "CSI",
"COMPUTATIONAL SOCIAL SCIENCE" => "CSS",
"COMPUTER FORENSICS" => "CFRS",
"COMPUTER GAME DESIGN" => "GAME",
"COMPUTER SCIENCE" => "CS",
"COMP SCI" => "CS",
"CONFLICT ANALYSIS AND RESOLUTION" => "CONF",
"CONSERVATION STUDIES" => "CONS",
"COUNSELING AND DEVELOPMENT" => "EDCD",
"CRIMINOLOGY" => "CRIM",
"CULTURAL STUDIES" => "CULT",
"CURRICULUM AND INSTRUCTION" => "EDCI",
"CYBER SECURITY ENGINEERING" => "CYSE",
"DANCE" => "DANC",
"DATA ANALYTICS ENGINEERING" => "DAEN",
"EARLY CHILDHOOD EDUCATION" => "ECED",
"ECONOMICS" => "ECON",
"EDUCATIONAL PSYCHOLOGY" => "EDEP",
"EDUCATION" => "EDUC",
"EDUCATION INSTRUCTIONAL TECHNOLOGY" => "EDIT",
"EDUCATION LEADERSHIP" => "EDLE",
"EDUCATION RESEARCH" => "EDRS",
"ELECTRICAL AND COMPUTER ENGINEERING" => "ECE",
"ENGINEERING" => "ENGR",
"ENGLISH" => "ENGH",
"ENGLISH FOR ACADEMIC PURPOSES" => "EAP",
"ENVIRONMENTAL SCIENCE AND POLICY" => "EVPP",
"EXECUTIVE MBA" => "EMBA",
"EXERCISE, FITNESS, AND HEALTH PROMOTION" => "EFHP",
"FILM AND VIDEO STUDIES" => "FAVS",
"FINANCE" => "FNAN",
"FOREIGN LANGUAGE" => "FRLN",
"FORENSIC SCIENCE" => "FRSC",
"FRENCH" => "FREN",
"GEOGRAPHY AND GEOINFORMATION SCIENCE" => "GGS",
"GEOLOGY" => "GEOL",
"GERMAN" => "GERM",
"GLOBAL AFFAIRS" => "GLOA",
"GLOBAL AND COMMUNITY HEALTH" => "GCH",
"GOVERNMENT" => "GOVT",
"GRADUATE SCHOOL OF BUSINESS" => "GBUS",
"GREEK" => "GREE",
"HEALTH ADMINISTRATION AND POLICY" => "HAP",
"HEALTH AND HUMAN SERVICES" => "HHS",
"HEALTH" => "HEAL",
"HEBREW" => "HEBR",
"HIGHER EDUCATION" => "HE",
"HISTORY" => "HIST",
"HONORS COLLEGE" => "HNRS",
"HUMAN DEVELOPMENT AND FAMILY SCIENCE" => "HDFS",
"INFORMATION SECURITY ASSURANCE" => "ISA",
"INFORMATION SYSTEMS" => "INFS",
"INFORMATION TECHNOLOGY" => "IT",
"INITIATIVES IN EDUCATIONAL TRANSFORMATION-TEACHING" => "IETT",
"INTEGRATIVE STUDIES" => "INTS",
"INTERDISCIPLINARY STUDIES" => "MAIS",
"INTERNATIONAL COMMERCE AND POLICY" => "ITRN",
"INTERNATIONAL YEAR ONE" => "INYO",
"ITALIAN" => "ITAL",
"JAPANESE" => "JAPA",
"KINESIOLOGY" => "KINE",
"KOREAN" => "KORE",
"LATIN AMERICAN STUDIES" => "LAS",
"LATIN" => "LATN",
"LINGUISTICS" => "LING",
"MANAGEMENT" => "MGMT",
"MANAGEMENT OF INFORMATION SYSTEMS" => "MIS",
"MANAGEMENT OF SECURE INFORMATION SYSTEMS" => "MSEC",
"MARKETING" => "MKTG",
"MATHEMATICS" => "MATH",
"MECHANICAL ENGINEERING" => "ME",
"MEDICAL LABORATORY SCIENCE" => "MLAB",
"MIDDLE EAST AND ISLAMIC STUDIES" => "MEIS",
"MILITARY SCIENCE" => "MLSC",
"MINOR IN BUSINESS" => "MBUS",
"MUSIC" => "MUSI",
"NANOTECHNOLOGY AND NANOSCIENCE" => "NANO",
"NATIVE AMERICAN AND INDIGENOUS STUDIES" => "NAIS",
"NEUROSCIENCE" => "NEUR",
"NURSING" => "NURS",
"NUTRITION AND FOOD STUDIES" => "NUTR",
"OPERATIONS MANAGEMENT" => "OM",
"OPERATIONS RESEARCH" => "OR",
"ORGANIZATION DEVELOPMENT AND KNOWLEDGE MANAGEMENT" => "ODKM",
"PARKS, RECREATION, AND LEISURE STUDIES" => "PRLS",
"PERSIAN" => "PERS",
"PHILOSOPHY" => "PHIL",
"PHYSICAL EDUCATION" => "PHED",
"PHYSICS" => "PHYS",
"POLICY AND GOVERNMENT" => "POGO",
"PORTUGUESE" => "PORT",
"PROFESSIONAL DEVELOPMENT IN EDUCATION" => "EDPD",
"PROVOST" => "PROV",
"PSYCHOLOGY" => "PSYC",
"PUBLIC ADMINISTRATION" => "PUAD",
"PUBLIC POLICY" => "PUBP",
"READING" => "EDRD",
"REAL ESTATE DEVELOPMENT" => "REAL",
"RECREATION" => "RECR",
"REHABILITATION SCIENCE" => "RHBS",
"RELIGIOUS STUDIES" => "RELI",
"RUSSIAN" => "RUSS",
"SCHOOL OF MANAGEMENT" => "SOM",
"SCHOOL PSYCHOLOGY" => "SPSY",
"SOCIAL WORK" => "SOCW",
"SOCIOLOGY AND ANTHROPOLOGY" => "SOAN",
"SOCIOLOGY" => "SOCI",
"SOFTWARE ENGINEERING" => "SWE",
"SPANISH" => "SPAN",
"SPECIAL EDUCATION" => "EDSE",
"SPORT MANAGEMENT" => "SPMT",
"SPORTS AND RECREATION STUDIES" => "SRST",
"STATISTICS" => "STAT",
"SYSTEM ENGINEERING" => "SYST",
"SYSTEMS ENGINEERING AND OPERATIONS RESEARCH" => "SEOR",
"TECHNOLOGY MANAGEMENT" => "TECM",
"TELECOMMUNICATIONS" => "TCOM",
"THEATER" => "THR",
"TOURISM AND EVENTS MANAGEMENT" => "TOUR",
"TURKISH" => "TURK",
"UNIVERSITY STUDIES" => "UNIV",
"URBAN AND SUBURBAN STUDIES" => "USST",
"VOLGENAU SCHOOL OF ENGINEERING" => "VSE",
"WOMEN AND GENDER STUDIES" => "WMS",
"1" => "I",
"2" => "II",
"3" => "III",
"4" => "IV",
"5" => "V"
}
def self.replace!(input)
@replacements.each do |thing, replacement|
# We want each instance of thing to be it's own word
input.gsub!(/(?<= |^)#{thing}(?= |$)/, replacement)
end
end
end
module SearchHelper
class GenericQueryData
attr_reader :semester
attr_reader :sort_mode
attr_reader :search_string
def initialize(search_string, sort_mode, semester)
@semester = semester
@sort_mode = sort_mode
@search_string = search_string
end
end
class GenericItem
attr_reader :data
attr_reader :type
def initialize(type, data)
@type = type
@data = data
end
def self.fetchall(search_string, sort_mode: :auto, semester: :fall2018)
query_data = GenericQueryData.new(search_string, sort_mode, semester)
models = []
models += fetch_instructors query_data
models += fetch_courses query_data
build_list(models)
end
def self.fetch_instructors(query_data)
Instructor.from_name(Instructor.select("instructors.*, COUNT(course_sections.id) AS section_count"), query_data.search_string)
.left_outer_joins(:course_sections)
.group("instructors.id")
end
def self.fetch_courses(query_data)
query_string = query_data.search_string
query_string.upcase!
CourseReplacementHelper.replace!(query_string)
base_query = Course.select("courses.*, count(course_sections.id) AS section_count")
.left_outer_joins(:course_sections)
.having("section_count > 0")
.where("courses.semester_id = ?", query_data.semester)
.group("courses.id")
subj = nil
query_string.scan(/(?<= |^)([a-zA-Z]{2,4})(?=$| )/).each do |a|
s = a[0]
next unless get_count(Course.from_subject(base_query, s)).positive?
subj = s
base_query = Course.from_subject(base_query, subj)
query_string.remove!(s)
end
query_string.scan(/(?<= |^)(\d{3})(?=$| )/).each do |a|
s = a[0]
next unless !subj.nil? && get_count(Course.from_course_number(base_query, s)).positive?
base_query = Course.from_course_number(base_query, s)
return base_query.all
end
stripped_query_string = query_string.gsub(/ +/, " ").strip
# There's more to parse
base_query = if stripped_query_string.length.positive?
Course.from_title(base_query, stripped_query_string)
.order("section_count DESC")
else
base_query.order("courses.course_number ASC")
end
base_query.all
end
# Given a set of models, create a list of GenericItems for each model's data
def self.build_list(models)
list = []
models.each do |model|
list.push(GenericItem.new(model.class.name.underscore.to_sym, model))
end
list
end
def self.get_count(base_query)
# I think I finally hit a limit of active record
ActiveRecord::Base.connection.execute("SELECT COUNT(*) AS count FROM (#{base_query.to_sql})")[0]["count"]
end
def to_s
@type
end
end
end
# Contains logic regarding the +Course+ model.
#
# TODO: Add more docs
require("course_replacement_helper.rb")
class Course < ApplicationRecord
# Each course belongs to a +Semester+
belongs_to :semester
......@@ -10,4 +13,50 @@ class Course < ApplicationRecord
validates :course_number, presence: true
validates :subject, presence: true
validates :semester_id, presence: true
# Returns all +CourseSection+ objects that belong to this course.
# @return [Array]
def course_sections
CourseSection.where course_id: id
end
def self.from_subject(base_query, subject)
base_query.where("courses.subject = ?", subject.upcase)
end
def self.from_course_number(base_query, course_number)
base_query.where("courses.course_number = ?", course_number)
end
def self.from_title(base_query, title)
puts title
# Temporary really disgusting regex that I hate with all my heart
title = (title + " ").upcase.gsub(/(I+) +/, '\1$').gsub(/ +/, "% ").tr('$', ' ')
base_query.where("UPPER(courses.title) LIKE UPPER(?) or UPPER(courses.title) LIKE UPPER(?)", "%#{title.strip}", "%#{title}%")
end
# Given a list of filters, collect a list of matching elements. This makes it
# so you can just pass the arguments straight thru
def self.fetch(filters)
# join with course_sections so that we can get a section count for each course and then sort by that
query = Course.left_outer_joins(:course_sections)
.select("courses.*, COUNT(course_sections.id) AS section_count")
.group("courses.id")
.order("section_count DESC")
filters.each do |filter, value|
case filter
when "subject"
query = from_subject(query, value)
when "course_number"
query = from_course_number(query, value)
when "title"
query = from_title(query, value)
when "instructor"
query = Instructor.from_name(query.joins("INNER JOIN instructors ON course_sections.instructor_id = instructors.id"), value)
end
end
query
end
end
......@@ -14,4 +14,34 @@ class CourseSection < ApplicationRecord
def self.with_instructor(name: "")
joins(:instructor).where("instructors.name LIKE ?", "%#{name}%").select('course_sections.*, instructors.name as instructor_name')
end
def self.from_crn(base_query, crn)
base_query.where("course_sections.crn = ?", crn)
end
def self.from_course_id(base_query, course_id)
base_query.where("course_sections.course_id = ?", course_id)
end
# Select all revelevant course sections given the provided filters
def self.fetch(filters)
query = CourseSection.joins(:course).select("course_sections.*")
filters.each do |filter, value|
case filter
when "crn"
query = from_crn(query, value)
when "course_id"
query = from_course_id(query, value)
when "course_number"
query = Course.from_course_number(query, value)
when "subject"
query = Course.from_subject(query, value)
when "title"
query = Course.from_title(query, value)
end
end
query
end
end
class Instructor < ApplicationRecord
has_many :course_sections
def self.named(name)
where("name LIKE ?", "%#{name}%")
def self.from_name(base_query, name)
base_query.where("instructors.name LIKE ?", "%#{name}%")
end
end
<% if @courses.any? %>
<%= render partial: 'shared/course', collection: @courses %>
<% if @results.any? %>
<% @results.each do |result| %>
<% if result.type == :course %>
<%= render partial: 'shared/course', object: result.data %>
<% elsif result.type == :instructor %>
<%= render partial: 'shared/instructor', object: result.data %>
<% end %>
<% end %>
<% else %>
<h1>Sorry, we couldn't find anything matching your search.</h1>
<p>Please try again!</p>
<h1>Sorry, we couldn't find anything matching your search.</h1>
<p>Please try again!</p>
<% end %>
<% expanded = false unless defined? expanded %>
<div class="card" id="course-<%= course.id %>" onclick="toggleSections(this)">
<div class="card-body">
<div style="display: flex; justify-content: space-between">
<h3 id="title"><%= "#{course.subject} #{course.course_number}" %></h3>
<div style="display: flex; flex-direction: column; justify-content: center;">
<div style="display: flex">
<h5><em><%= course.title %></em>. <%= course.credits %> credits.</h5>
&nbsp;&nbsp;&nbsp;
<h4 id="add-course-btn" onclick="addCourse(event, '<%= course.id %>');">
<div class="card-header">
<h4 class="title"><%= "#{course.subject} #{course.course_number}" %></h4>
<h5><em><%= course.title %></em>. <%= course.credits %> credits.</h5>
<h4 id="add-course-btn" onclick="addCourse(event, '<%= course.id %>');">
<i class="fas fa-plus" style="color: green"></i>
</h4>
</div>
</div>
</div>
<div style="clear: both"> </div>
<p class="description"><%= course.description %></p>
<% unless course.prereqs.nil? || course.prereqs.empty? %>
<% first, rest = course.prereqs.split(':') %>
<% prereqs, note = rest.split('.') %>
<p><strong><%= first %>:</strong> <%= prereqs %> <sub><%= note %></sub></p>
<% end %>
<div class="d-block" style="text-align: center">
<p style="margin-bottom:-4px; font-size: 10px;">Expand</p>
<i class="fas fa-chevron-down"></i>
</div>
</h4>
</div>
<div class="card-body">
<div class="attr-list">
<div class="attr"><i class="fa fa-book"></i> 3 credits</div>
<div class="attr"><i class="fa fa-bars"></i> 3 sections</div>
</div>
<p class="description"><%= course.description %></p>
<!-- List of Course Sections -->
<ul class="list-group list-group-flush" id="sections" style="display: <%= expanded ? "block" : "none" %>">
<% unless course.prereqs.nil? || course.prereqs.empty? %>
<% first, rest = course.prereqs.split(':') %>
<% prereqs, note = rest.split('.') %>
<p><strong><%= first %>:</strong> <%= prereqs %> <sub><%= note %></sub></p>
<% end %>
<div class="d-block" style="text-align: center">
<p style="margin-bottom:-4px; font-size: 10px;">Expand</p>
<i class="fas fa-chevron-down"></i>
</div>
<!-- List of Course Sections -->
<div class="list-group list-group-flush sections" style="display: <%= expanded ? "flex" : "none" %>">
<% if defined?(@instructor) %>
<%= render partial: 'shared/section', collection: course.course_sections.where(instructor: @instructor), locals: {course: course} %>
<%= render partial: 'shared/section', collection: course.course_sections.where(instructor: @instructor), locals: { course: course } %>
<% else %>
<%= render partial: 'shared/section', collection: course.course_sections, locals: {course: course} %>
<%= render partial: 'shared/section', collection: course.course_sections, locals: { course: course } %>
<% end %>
</ul>
</div>
</div>
</div>
</div>
\ No newline at end of file
<div class="card">
<div class="card-header">
<h4><%= "#{instructor.name}" %></h4>
</div>
<div class="card-body">
<div class="attr-list">
<div class="attr"><i class="fa fa-user"></i> Instructor</div>
<div class="attr"><i class="fa fa-bars"></i> <%= "#{instructor.section_count}" %> sections</div>
</div>
</div>
</div>
\ No newline at end of file
<div class="container-fluid">
<div class="row align-left align-sm-center align-md-right" id="navbar">
<div class="col-8 col-sm align-center">
<div class="col align-center">
<div>
<a href="/" id="logo">
<i class="fas fa-calendar-alt"></i>
......@@ -16,7 +16,7 @@
</div>
</div>
<div class="col-4 col-sm align-center order-0 order-sm-1" onclick="window.schedule.toggle()">
<div class="col-sm align-center order-0 order-sm-1" onclick="window.schedule.toggle()">
<h1 style="margin-top:24px">
<span class="fa-layers fa-fw" id="schedule-icon">
<i class="fas fa-shopping-cart"></i>
......@@ -30,11 +30,12 @@
</h1>
</div>
<div class="col-12 col-sm align-center order-1 order-sm-0">
<div class="col order-1 order-sm-0">
<form action="/search" class="form-inline">