diff --git a/reason_4.0/lib/core/function_libraries/course_functions.php b/reason_4.0/lib/core/function_libraries/course_functions.php index 8dbd6f41a..50eebd75a 100644 --- a/reason_4.0/lib/core/function_libraries/course_functions.php +++ b/reason_4.0/lib/core/function_libraries/course_functions.php @@ -9,12 +9,25 @@ */ include_once(CARL_UTIL_INC.'cache/object_cache.php'); - + +// If you need to extend the course entities for local use (which you probably do), just redefine +// these globals in your extending file with the name of your local classes. +$GLOBALS['course_template_class'] = 'CourseTemplateType'; +$GLOBALS['course_section_class'] = 'CourseSectionType'; +$GLOBALS['catalog_helper_class'] = 'CatalogHelper'; + +// Working with catalog data we need to be always in UTF8, so if we're in a context with a live +// db connection, set the charset. +if (function_exists('get_current_db_connection_name') && get_current_db_connection_name()) +{ + mysql_set_charset('utf8'); +} + class CourseTemplateEntityFactory { public function get_entity(&$row) { - return new CourseTemplateType($row['id']); + return new $GLOBALS['course_template_class']($row['id']); } } @@ -23,6 +36,23 @@ class CourseTemplateType extends Entity protected $sections; protected $limit_to_year = false; protected $external_data; + protected $helper; + + /** + * This is length of time (in minutes) that external data cached on template entities should + * persist before being refreshed. 0 means no refreshing, which is the preferred choice. + * Generally you want your import process to update the external data as needed. However, you + * can import on demand, and this setting will help manage that. + * + * @var int + */ + protected $cache_duration_minutes = 0; + + function CourseTemplateType($id, $cache=true) + { + parent::__construct($id, $cache); + $this->helper = new $GLOBALS['catalog_helper_class'](); + } public function set_academic_year_limit($year) { @@ -45,6 +75,55 @@ public function get_value($col, $refresh = false) } } + /** + * Return the best section value for a particular key, based on whether or not the year limit is set. + * If no year limit is set, you'll get back the value for the most recent term. + * If a year limit is set, and sections occur within that limit, you'll + * get back the value for the last section in that year. If a year limit is set and no sections occur + * in that year, you'll get the value from the most recent year the course was offered. + * + * @param string $key + * @param boolean $refresh + * @return array + */ + function get_best_value_from_sections($key, $refresh = false) + { + if ($refresh || empty($this->{$key}) || ($this->limit_to_year && empty($this->{$key}[$this->limit_to_year]))) + { + if (!isset($this->{$key})) $this->{$key} = array(); + $sections = $this->get_sections(false); + + // If there's no year limit, just return the most recent section + if (!$this->limit_to_year) + { + if ($section = reset($sections)) + { + $year = $this->helper->term_to_academic_year($section->get_value('academic_session')); + $this->{$key}[$year] = $section->get_value($key, $refresh); + return $this->{$key}[$year]; + } + } + + // Otherwise, look for the latest section for the requested year + foreach ( $sections as $section) + { + $year = $this->helper->term_to_academic_year($section->get_value('academic_session')); + if ($year !== $this->limit_to_year) continue; + + $this->{$key}[$year] = $section->get_value($key, $refresh); + return $this->{$key}[$year]; + } + } + else + { + krsort($this->{$key}); + if ($this->limit_to_year) + return $this->{$key}[$this->limit_to_year]; + else + return reset($this->{$key}); + } + } + public function get_sections($honor_limit = true) { if (!is_array($this->sections)) @@ -54,12 +133,31 @@ public function get_sections($honor_limit = true) { foreach ( $sections as $key => $section) { - $this->sections[$section->id()] = new CourseSectionType($section->id()); + $this->sections[$section->id()] = new $GLOBALS['course_section_class']($section->id()); + } + + // Loading all the data for all the sections is potentially a big memory hog, so to start + // with we just populate a few values on each section -- the ones we need to know which + // sections are going to require a full retrieval. + $dbq = new DBSelector; + $dbq->add_table( 's','course_section' ); + $dbq->add_field( 's','id' ); + $dbq->add_field( 's','academic_session' ); + $dbq->add_field( 's','course_number' ); + $dbq->add_field( 's','timeframe_begin' ); + $dbq->add_relation( 'id IN ('.join(',', array_keys($this->sections)).')'); + if ($result = $dbq->run()) + { + foreach ($result as $row) + { + $this->sections[$row['id']]->set_value('academic_session', $row['academic_session']); + $this->sections[$row['id']]->set_value('course_number', $row['course_number']); + $this->sections[$row['id']]->set_value('timeframe_begin', $row['timeframe_begin']); + } } } + uasort($this->sections, array($this->helper, 'sort_courses_by_number_and_date')); } - - uasort($this->sections, 'sort_courses_by_number_and_date'); // If an academic year limit has been set, only return those that match if ($this->limit_to_year && $honor_limit) @@ -67,12 +165,13 @@ public function get_sections($honor_limit = true) $sections = array(); foreach ( $this->sections as $key => $section) { - $year = term_to_academic_year($section->get_value('academic_session')); + $year = $this->helper->term_to_academic_year($section->get_value('academic_session')); if ($year != $this->limit_to_year) { continue; } - $sections[$key] = $section; + $this->sections[$key]->refresh_values(false); + $sections[$key] = $this->sections[$key]; } return $sections; } @@ -84,14 +183,14 @@ public function get_sections($honor_limit = true) * Return the distinct titles associated with this course's sections. Honors year * limits. Used primarily to find courses with sections that have different titles. */ - public function get_value_section_titles($refresh = true) + public function get_value_section_titles($refresh = false) { $titles = array(); if ($sections = $this->get_sections()) { foreach ($sections as $key => $section) { - if (!in_array($section->get_value('title'), $titles)) + if (!in_array($section->get_value('title', $refresh), $titles)) { $titles[$key] = $section->get_value('title'); } @@ -101,20 +200,23 @@ public function get_value_section_titles($refresh = true) } /** - * - * @todo if year limit is in effect, only grab description from that year or older sections - */ - public function get_value_long_description($refresh = true) + * Return the long description for a course. If year limit is in place, load description from + * sections offered that year, otherwise draw the description from the template. If no year limit + * is in place, grab the most recent section description. + * + * @param boolean $refresh + * @return string + */ + public function get_value_long_description($refresh = false) { - $start_date = 0; - foreach ( $this->get_sections(false) as $key => $section) + $limit = !empty($this->limit_to_year); + + foreach ( $this->get_sections($limit) as $section) { - if ($desc = $section->get_value('long_description')) + if ($desc = $section->get_value('long_description', $refresh)) { - if ($section->get_value('timeframe_begin') > $start_date) - { $long_description = $desc; - } + break; } } @@ -124,224 +226,51 @@ public function get_value_long_description($refresh = true) return $long_description; } - /** - * - * @todo if year limit is in effect, only grab description from that year or older sections - */ - public function get_value_credits($refresh = true) - { - $sections = $this->get_sections(false); - array_reverse($sections); - - $max = $min = 0; - foreach ( $sections as $key => $section) - { - if ($credits = $section->get_value('credits')) - { - if (preg_match('/(\d+)-(\d+)/', $credits, $matches)) - { - if (!$min || $matches[1] < $min) - $min = $matches[1]; - if (!$max || $matches[2] > $max) - $max = $matches[2]; - } - else - { - if (!$min || $credits < $min) - $min = $credits; - if (!$max || $credits > $max) - $max = $credits; - } - } - } - - if ($min) - { - if ($min != $max) - return $min .'-'. $max; - else - return $min; - } - - return parent::get_value('credits', $refresh); - } - - /** - * Return the requirements fulfilled by this course. If no year limit is set, - * you'll get back all requirements fulfilled by any section. If a year limit - * is set, and sections occur within that limit, you'll get back all the - * requirements met by that section. If a year limit is set and no sections occur - * in that year, you'll get the requirements met by the sections in the most - * recent year the course was offered. - * - * @param boolean $refresh - * @return array - */ - public function get_value_requirements($refresh = true) - { - $sections = $this->get_sections(false); - array_reverse($sections); - $requirements = array(); - foreach ( $sections as $key => $section) - { - if ($reqs = $section->get_value('requirements')) - { - if ($this->limit_to_year) - { - $year = term_to_academic_year($section->get_value('academic_session')); - if ($year != $this->limit_to_year) - { - if (!isset($latest_reqs[$year])) - $latest_reqs[$year] = $reqs; - else - $latest_reqs[$year] += $reqs; - continue; - } - } - $requirements += $reqs; - } - } - if (!empty($requirements)) - { - if ($this->limit_to_year) - return filter_requirements_by_academic_year($requirements, $this->limit_to_year); - else - return $requirements; - } - else if (isset($latest_reqs)) - { - ksort($latest_reqs); - if ($this->limit_to_year) - return filter_requirements_by_academic_year(end($latest_reqs), $this->limit_to_year); - else - return end($latest_reqs); - } - else - return array(); - } - - /** - * Return how this course is graded. If no year limit is set, you'll get back the value for - * the most recent term. If a year limit is set, and sections occur within that limit, you'll - * get back the value for the last section in that year. If a year limit is set and no sections occur - * in that year, you'll get the value from the most recent year the course was offered. - * - * @param boolean $refresh - * @return array - */ - public function get_value_grading($refresh = true) - { - $sections = $this->get_sections(false); - array_reverse($sections); - foreach ( $sections as $key => $section) - { - if ($grading = $section->get_value('grading', $refresh)) - { - if ($this->limit_to_year) - { - $year = term_to_academic_year($section->get_value('academic_session')); - if ($year > $this->limit_to_year) - continue; - else - return $grading; - } - } - } - } - - /** - * Return the course level for this course. If no year limit is set, you'll get back the value for - * the most recent term. If a year limit is set, and sections occur within that limit, you'll - * get back the value for the last section in that year. If a year limit is set and no sections occur - * in that year, you'll get the value from the most recent year the course was offered. - * +/** + * Return the title for a course. If year limit is in place, load the title from + * sections offered that year, otherwise draw the title from the template. If no year limit + * is in place, grab the most recent section title. + * * @param boolean $refresh - * @return array + * @return string */ - public function get_value_course_level($refresh = true) + public function get_value_title($refresh = false) { - $sections = $this->get_sections(false); - array_reverse($sections); - foreach ( $sections as $key => $section) + $limit = !empty($this->limit_to_year); + + foreach ( $this->get_sections($limit) as $section) { - if ($level = $section->get_value('course_level', $refresh)) + if ($title = $section->get_value('title', $refresh)) { - if ($this->limit_to_year) - { - $year = term_to_academic_year($section->get_value('academic_session')); - if ($year > $this->limit_to_year) - continue; - else - return $level; - } + break; } } + + if (empty($title)) + $title = parent::get_value('title', $refresh); + + return $title; } /** - * Return the gov codes for this course. If no year limit is set, you'll get back the values for - * the most recent term. If a year limit is set, and sections occur within that limit, you'll - * get back the values for the last section in that year. If a year limit is set and no sections occur - * in that year, you'll get the values from the most recent year the course was offered. - * + * Generate a default HTML snippet for the course title. Override this if you + * want to use a diffferent pattern. + * * @param boolean $refresh - * @return array + * @return string */ - public function get_value_gov_codes($refresh = true) - { - $sections = $this->get_sections(false); - array_reverse($sections); - foreach ( $sections as $key => $section) - { - if ($codes = $section->get_value('gov_codes', $refresh)) - { - if ($this->limit_to_year) - { - $year = term_to_academic_year($section->get_value('academic_session')); - if ($year > $this->limit_to_year) - continue; - else - return $codes; - } - } - } - return array(); - } - - /** - * Return an array of faculty who teach sections of this course. - * - * @return array id => name - */ - public function get_value_faculty($refresh = true) - { - $sections = $this->get_sections(); - $faculty = array(); - foreach ( $sections as $key => $section) - { - if ($fac = $section->get_value('faculty', $refresh)) - { - $faculty = $faculty + $fac; - } - } - return $faculty; - } - - public function get_value_start_date($refresh = true) + protected function get_value_display_title($refresh = false) { - $this->fetch_external_data($refresh); - if (isset($this->external_data['CRS_START_DATE'])) - return $this->external_data['CRS_START_DATE']; - } + $html = ''; + $html .= $this->get_value('org_id', $refresh).' '.$this->get_value('course_number', $refresh); + $html .= ' '; + $html .= ''; + $html .= $this->get_value('title', $refresh); + $html .= ' '; - public function get_value_end_date($refresh = true) - { - $this->fetch_external_data($refresh); - if (isset($this->external_data['CRS_END_DATE'])) - return $this->external_data['CRS_END_DATE']; + return $html; } - /** * Returns an array of the academic terms in which this course was offered, formatted as * ( start_date => term_name), sorted by date. Only one element exists per term, even if @@ -360,6 +289,34 @@ public function get_offer_history($honor_limit = true) return $history; } + /** + * Generate a default HTML snippet for displaying details about if and + * when the course is offered in the current academic year. Override this if you want to use a + * different pattern. + * + * @param boolean $honor_limit Whether to honor existing year restriction + * @return string + */ + function get_offer_history_html($honor_limit = true) + { + if ($terms = $this->get_offer_history($honor_limit)) + { + $term_names = array('SU'=>'Summer','FA'=>'Fall','WI'=>'Winter','SP'=>'Spring'); + + foreach ($terms as $term) + { + list(,$termcode) = explode('/', $term); + $offered[$termcode] = $term_names[$termcode]; + } + $history = join(', ', $offered); + } else if ($this->limit_to_year) { + $history = 'Not offered '.$this->helper->get_display_year($this->limit_to_year); + } + + if (!empty($history)) + return ''.$history.''; + } + /** * Returns the academic year in which this course was last offered. The value * is the year that the session started, e.g. courses offered in the 2013-14 academic @@ -371,7 +328,7 @@ public function get_last_offered_academic_year() { if ($history = $this->get_offer_history(false)) { - $year = term_to_academic_year(end($history)); + $year = $this->helper->term_to_academic_year(end($history)); } return (isset($year)) ? $year : 0; @@ -401,61 +358,119 @@ public function get_section_by_id($id) return false; } - protected function fetch_external_data() + /** + * Retrieve and cache course data from an external source. Used for values that are not defined + * in the base course template schema. You'll need to extend this class to provide your own + * retrieval routine. + */ + public function fetch_external_data($refresh = false, $update_cache = true) { - if (empty($this->external_data)) + if (!$this->external_data_is_valid($refresh)) { - // Do we want to check for the empty array here? if ($cache = $this->get_value('cache')) { $this->external_data = json_decode($cache, true); } - if (!isset($this->external_data)) + if ($refresh || !$this->external_data_is_valid()) { - $query = 'SELECT * FROM colleague_data.IDM_CRS WHERE COURSES_ID = '.$this->get_value('sourced_id'); - - if ($result = mysql_query($query)) - { - $this->external_data = mysql_fetch_assoc($result); - $this->external_data['timestamp'] = time(); - } else { - // Indicate that we've tried and failed to retrieve the data, so we don't keep trying - $this->external_data = array(); - } + // Insert your routine for retrieving external data here. + + // Indicate that we've tried and failed to retrieve the data, so we don't keep trying + $this->external_data = array(); - if (!empty($this->external_data)) - { - $this->set_value('cache', json_encode($this->external_data)); - reason_update_entity( - $this->id(), - $this->get_value('last_edited_by'), - array('cache' => $this->get_value('cache')), - false); - } + if ($update_cache && !empty($this->external_data)) + $this->update_cache(); } } + return $this->external_data; + } + + /** + * Update this entity's external data cache from the current value of $this->external_data + */ + protected function update_cache() + { + $this->external_data['timestamp'] = time(); + $encoded = json_encode($this->external_data); + if ($encoded != $this->get_value('cache')) + { + $this->set_value('cache', json_encode($this->external_data)); + reason_update_entity( + $this->id(), + $this->get_value('last_edited_by'), + array('cache' => $this->get_value('cache')), + false); + } + } + + /** + * Determine whether the cached external data needs to be refreshed. + * + * @return boolean + */ + protected function external_data_is_valid($refresh = false) + { + // If it hasn't been defined, it's invalid + if (!is_array($this->external_data)) + return false; + + // If the timestamp is too old, it's invalid + if ($this->cache_duration_minutes && isset($this->external_data['timestamp'])) + { + if ((time() - $this->cache_duration_minutes * 60) > $this->external_data['timestamp']) + return false; + } + // If there is no timestamp, it's invalid + else if ($this->cache_duration_minutes) + { + return false; + } + + // Otherwise, it's probably ok. + return !$refresh; } } + + + + class CourseSectionEntityFactory { public function get_entity(&$row) { - return new CourseSectionType($row['id']); + return new $GLOBALS['course_section_class']($row['id']); } } class CourseSectionType extends Entity { protected $external_data = array(); + protected $helper; + /** + * This is length of time (in minutes) that external data cached on section entities should + * persist before being refreshed. 0 means no refreshing, which is the preferred choice. + * Generally you want your import process to update the external data as needed. However, you + * can import on demand, and this setting will help manage that. + * + * @var int + */ + protected $cache_duration_minutes = 0; + + function CourseSectionType($id=null, $cache=true) + { + parent::__construct($id, $cache); + $this->helper = new $GLOBALS['catalog_helper_class'](); + } + public function get_template() { if ($entities = $this->get_right_relationship('course_template_to_course_section')) { $template = reset($entities); - return new CourseTemplateType($template->id()); + return new $GLOBALS['course_template_class']($template->id()); } } @@ -468,136 +483,41 @@ public function get_value($col, $refresh = false) } else { - return parent::get_value($col, $refresh); - } - } - - public function get_value_requirements($refresh = true) - { - $this->fetch_external_data($refresh); - if (isset($this->external_data['section']['XSEC_COURSE_TYPES_LIST'])) - { - $reqs = explode(' ', $this->external_data['section']['XSEC_COURSE_TYPES_LIST']); - - // Suppress any codes we shouldn't show - $reqs = array_diff($reqs, array('CX','IE','LP')); - - return $reqs; - } - } - - public function get_value_faculty($refresh = true) - { - $this->fetch_faculty_data($refresh); - $faculty = array(); - foreach ($this->external_data['faculty'] as $id => $data) - { - $faculty[$id] = $data['Carleton_Name']; + return parent::get_value($col); } - return $faculty; } /** - * @todo Find out why some 400 courses are marked S/CR/NC but most are S/NC - * - */ - public function get_value_grading($refresh = true) - { - $this->fetch_external_data($refresh); - if (isset($this->external_data['section']['SEC_ONLY_PASS_NOPASS_FLAG'])) - { - if (($this->external_data['section']['SEC_ONLY_PASS_NOPASS_FLAG'] == 'Y') && strpos('10', $this->external_data['section']['XSEC_SEC_COURSE_LEVELS_SV']) === false) - { - if ($this->external_data['section']['SEC_COURSE_NO'] == '400') - return 'S/NC'; - else - return 'S/CR/NC'; - } - } - } - - public function get_value_course_level($refresh = true) - { - $this->fetch_external_data($refresh); - if (isset($this->external_data['section']['XSEC_SEC_COURSE_LEVELS_SV'])) - { - return $this->external_data['section']['XSEC_SEC_COURSE_LEVELS_SV']; - } - } - - public function get_value_gov_codes($refresh = true) - { - $this->fetch_external_data($refresh); - if (isset($this->external_data['section']['XSEC_LOCAL_GOVT_CODES_SV'])) - { - return explode('|',$this->external_data['section']['XSEC_LOCAL_GOVT_CODES_SV']); - } - return array(); - } - - protected function fetch_external_data($refresh = false) + * Retrieve and cache section data from an external source. Used for values that are not defined + * in the base course section schema. You'll need to extend this class to provide your own + * retrieval routine. + */ + public function fetch_external_data($refresh = false, $update_cache = true) { - if (empty($this->external_data['section'])) + if ($refresh || empty($this->external_data['section'])) { if ($cache = $this->get_value('cache')) { $this->external_data = json_decode($cache, true); } - if ($refresh || !isset($this->external_data['section'])) - { - $query = 'SELECT * FROM colleague_data.IDM_COURSE WHERE COURSE_SECTIONS_ID = '.$this->get_value('sourced_id'); - - if ($result = mysql_query($query)) - { - $this->external_data['section'] = mysql_fetch_assoc($result); - $this->external_data['timestamp'] = time(); - } else { - // Indicate that we've tried and failed to retrieve the data, so we don't keep trying - $this->external_data['section'] = array(); - } - - if (!empty($this->external_data['section'])) - $this->update_cache(); - } - } - } - - protected function fetch_faculty_data($refresh = false) - { - if (empty($this->external_data['faculty'])) - { - if ($cache = $this->get_value('cache')) - { - $this->external_data = json_decode($cache, true); - } - - if ($refresh || !isset($this->external_data['faculty'])) + if ($refresh || !$this->external_data_is_valid()) { - $this->external_data['faculty'] = array(); - $query = 'SELECT Id, First_Name, NickName, Last_Name, Carleton_Name, Fac_Catalog_Name FROM colleague_data.IDM_CRS_SEC_FACULTY, colleague_data.EmployeesByPosition_All - WHERE CSF_COURSE_SECTION = '.$this->get_value('sourced_id') .' - AND CSF_FACULTY = EmployeesByPosition_All.Id'; - - mysql_set_charset('utf8'); - if ($result = mysql_query($query)) - { - while ($row = mysql_fetch_assoc($result)) - { - $this->external_data['faculty'][$row['Id']] = $row; - } - $this->external_data['timestamp'] = time(); - } + // Insert your routine for retrieving external data here. - if (!empty($this->external_data['faculty'])) + // Indicate that we've tried and failed to retrieve the data, so we don't keep trying + $this->external_data['section'] = array(); + + if ($update_cache && !empty($this->external_data['section'])) $this->update_cache(); } } - + return $this->external_data; } protected function update_cache() { + $this->external_data['timestamp'] = time(); $encoded = json_encode($this->external_data); if ($encoded != $this->get_value('cache')) { @@ -609,256 +529,620 @@ protected function update_cache() false); } } -} - -/** - * Find courses that have been attached to the specified page. - * - */ -function get_page_courses($page_id) -{ - $courses = array(); - $es = new entity_selector(); - $es->description = 'Selecting courses for this page'; - $factory = new CourseTemplateEntityFactory(); - $es->set_entity_factory($factory); - $es->add_type( id_of('course_template_type') ); - $es->add_left_relationship( $page_id, relationship_id_of('course_template_to_page') ); - $results = $es->run_one(); - foreach ($results as $id => $entity) - { - if (isset($courses[$id])) continue; - $entity->include_source = 'page_courses'; - $courses[$id] = $entity; - } - return $courses; -} + + /** + * Determine whether the cached external data needs to be refreshed. + * + * @return boolean + */ + protected function external_data_is_valid($refresh = false) + { + // If it hasn't been defined, it's invalid + if (!is_array($this->external_data)) + return false; + + // If the timestamp is too old, it's invalid + if ($this->cache_duration_minutes && isset($this->external_data['timestamp'])) + { + if ((time() - $this->cache_duration_minutes * 60) > $this->external_data['timestamp']) + return false; + } + // If there is no timestamp, it's invalid + else if ($this->cache_duration_minutes) + { + return false; + } + + // Otherwise, it's probably ok. + return !$refresh; + } +} + + + + /** - * Find courses that are owned or borrowed by the specified site. - * - */ -function get_site_courses($site) + * A collection of methods for working with catalog and course data. You will probably want to + * extend this class locally to meet your particular needs. + */ +class CatalogHelper { - $courses = array(); - if ($site && (is_int($site) || $site = id_of($site))) + protected $year; + protected $site; + + public function __construct($year = null) + { + if ($year) + { + $this->year = $year; + if (!$this->site = id_of('academic_catalog_'.$year.'_site')) + trigger_error('No catalog site for '.$year.' in CatalogHelper'); + } + else + { + $this->year = $this->get_latest_catalog_year(); + } + } + + /** + * Find courses that have been attached to the specified page. + * + */ + public function get_page_courses($page_id) { - $es = new entity_selector( $site ); - $es->description = 'Selecting courses on site'; + $courses = array(); + $es = new entity_selector(); + $es->description = 'Selecting courses for this page'; $factory = new CourseTemplateEntityFactory(); $es->set_entity_factory($factory); $es->add_type( id_of('course_template_type') ); + $es->add_left_relationship( $page_id, relationship_id_of('course_template_to_page') ); $results = $es->run_one(); foreach ($results as $id => $entity) { if (isset($courses[$id])) continue; - $entity->include_source = 'site_courses'; + $entity->include_source = 'page_courses'; $courses[$id] = $entity; } + return $courses; } - return $courses; -} -/** - * Find courses that use given categories and add them to our collection. - * - * @param $cats array category list (id => name) - */ -function get_courses_by_category($cats, $catalog_site = null) -{ - $courses = array(); - foreach ($cats as $id => $category) + /** + * Find courses that are owned or borrowed by the specified site. + * + */ + public function get_site_courses($site) { + $courses = array(); + if ($site && (is_int($site) || $site = id_of($site))) + { + $es = new entity_selector( $site ); + $es->description = 'Selecting courses on site'; + $factory = new CourseTemplateEntityFactory(); + $es->set_entity_factory($factory); + $es->add_type( id_of('course_template_type') ); + $results = $es->run_one(); + foreach ($results as $id => $entity) + { + if (isset($courses[$id])) continue; + $entity->include_source = 'site_courses'; + $courses[$id] = $entity; + } + } + return $courses; + } + + /** + * Find courses that use given categories and add them to our collection. + * + * @param $cats array category list (id => name) + */ + public function get_courses_by_category($cats, $catalog_site = null) + { + $courses = array(); + foreach ($cats as $id => $category) + { + if ($catalog_site && (is_int($catalog_site) || $catalog_site = id_of($catalog_site))) + $es = new entity_selector($catalog_site); + else + $es = new entity_selector(); + $es->description = 'Selecting courses by category'; + $factory = new CourseTemplateEntityFactory(); + $es->set_entity_factory($factory); + $es->add_type( id_of('course_template_type') ); + $es->add_left_relationship( $id, relationship_id_of('course_template_to_category') ); + $results = $es->run_one(); + foreach ($results as $id => $entity) + { + if (isset($courses[$id])) continue; + $entity->include_source = 'categories'; + $courses[$id] = $entity; + } + } + return $courses; + } + + /** + * Find courses with particular academic subjects and add them to our collection. + * + * @param array $codes Array of subject codes + * @param string $catalog_site Optional unique name of a catalog site. If you pass this, + * only courses associated with that site will be included. + */ + public function get_courses_by_subjects($codes, $catalog_site = null) + { + $courses = array(); if ($catalog_site && (is_int($catalog_site) || $catalog_site = id_of($catalog_site))) $es = new entity_selector($catalog_site); else $es = new entity_selector(); - $es->description = 'Selecting courses by category'; + $es->description = 'Selecting courses by subject'; $factory = new CourseTemplateEntityFactory(); $es->set_entity_factory($factory); $es->add_type( id_of('course_template_type') ); - $es->add_left_relationship( $id, relationship_id_of('course_template_to_category') ); + $es->add_relation('org_id in ("'.join('","', $codes).'")'); + $es->set_order('ABS(course_number), title'); $results = $es->run_one(); foreach ($results as $id => $entity) { if (isset($courses[$id])) continue; - $entity->include_source = 'categories'; + $entity->include_source = 'subjects'; $courses[$id] = $entity; } + return $courses; } - return $courses; -} -/** - * Find courses with particular academic subjects and add them to our collection. - * - * @param array $codes Array of subject codes - * @param string $catalog_site Optional unique name of a catalog site. If you pass this, - * only courses associated with that site will be included. - */ -function get_courses_by_subjects($codes, $catalog_site = null) -{ - $courses = array(); - if ($catalog_site && (is_int($catalog_site) || $catalog_site = id_of($catalog_site))) - $es = new entity_selector($catalog_site); - else - $es = new entity_selector(); - $es->description = 'Selecting courses by subject'; - $factory = new CourseTemplateEntityFactory(); - $es->set_entity_factory($factory); - $es->add_type( id_of('course_template_type') ); - $es->add_relation('org_id in ("'.join('","', $codes).'")'); - $es->set_order('ABS(course_number), title'); - $results = $es->run_one(); - foreach ($results as $id => $entity) - { - if (isset($courses[$id])) continue; - $entity->include_source = 'subjects'; - $courses[$id] = $entity; - } - return $courses; -} + /** + * Find courses with a particular subject and number + * + * @param string $code subject code + * @param string $number course number + * @param string $catalog_site Optional unique name of a catalog site. If you pass this, + * only courses associated with that site will be included. + */ + public function get_courses_by_subject_and_number($code, $number, $catalog_site = null) + { + $courses = array(); + if ($catalog_site && (is_int($catalog_site) || $catalog_site = id_of($catalog_site))) + $es = new entity_selector($catalog_site); + else + $es = new entity_selector(); + $es->description = 'Selecting courses by subject and number'; + $factory = new CourseTemplateEntityFactory(); + $es->set_entity_factory($factory); + $es->add_type( id_of('course_template_type') ); + $es->add_relation('org_id = "'.mysql_real_escape_string($code).'"'); + $es->add_relation('course_number = "'.mysql_real_escape_string($number).'"'); + $es->set_order('title'); + $results = $es->run_one(); + foreach ($results as $id => $entity) + { + if (isset($courses[$id])) continue; + $entity->include_source = 'subject and number'; + $courses[$id] = $entity; + } + return $courses; + } + + public function get_courses_by_org_id($codes, $catalog_site = null) + { + return $this->get_courses_by_subjects($codes, $catalog_site); + } -function get_courses_by_org_id($codes, $catalog_site = null) -{ - return get_courses_by_subjects($codes, $catalog_site); -} + /** + * Find courses by their id in the source data + * + * @param array $ids Array of ids + * @param string or int $catalog_site Optional unique name or id of a catalog site. If you pass this, + * only courses associated with that site will be included. + */ + public function get_courses_by_sourced_id($ids, $catalog_site = null) + { + $courses = array(); + if ($catalog_site && (is_int($catalog_site) || $catalog_site = id_of($catalog_site))) + $es = new entity_selector($catalog_site); + else + $es = new entity_selector(); + $es->description = 'Selecting courses by sourced_id'; + $factory = new CourseTemplateEntityFactory(); + $es->set_entity_factory($factory); + $es->add_type( id_of('course_template_type') ); + $es->add_relation('sourced_id in ("'.join('","', $ids).'")'); + $es->set_order('org_id, course_number'); + $results = $es->run_one(); + foreach ($results as $id => $entity) + { + if (isset($courses[$id])) continue; + $entity->include_source = 'ids'; + $courses[$id] = $entity; + } + return $courses; + } -/** - * Find courses by their id in the source data - * - * @param array $codes Array of ids - * @param string or int $catalog_site Optional unique name or id of a catalog site. If you pass this, - * only courses associated with that site will be included. - */ -function get_courses_by_sourced_id($ids, $catalog_site = null) -{ - $courses = array(); - if ($catalog_site && (is_int($catalog_site) || $catalog_site = id_of($catalog_site))) - $es = new entity_selector($catalog_site); - else - $es = new entity_selector(); - $es->description = 'Selecting courses by sourced_id'; - $factory = new CourseTemplateEntityFactory(); - $es->set_entity_factory($factory); - $es->add_type( id_of('course_template_type') ); - $es->add_relation('sourced_id in ("'.join('","', $ids).'")'); - $es->set_order('org_id, course_number'); - $results = $es->run_one(); - foreach ($results as $id => $entity) - { - if (isset($courses[$id])) continue; - $entity->include_source = 'subjects'; - $courses[$id] = $entity; - } - return $courses; -} + /** + * Find sections by their id in the source data + * + * @param array $ids Array of ids + * @param string or int $catalog_site Optional unique name or id of a catalog site. If you pass this, + * only sections associated with that site will be included. + */ + public function get_sections_by_sourced_id($ids, $catalog_site = null) + { + $sections = array(); + if ($catalog_site && (is_int($catalog_site) || $catalog_site = id_of($catalog_site))) + $es = new entity_selector($catalog_site); + else + $es = new entity_selector(); + $es->description = 'Selecting sections by sourced_id'; + $factory = new CourseSectionEntityFactory(); + $es->set_entity_factory($factory); + $es->add_type( id_of('course_section_type') ); + $es->add_relation('sourced_id in ("'.join('","', $ids).'")'); + $es->set_order('org_id, course_number'); + $results = $es->run_one(); + foreach ($results as $id => $entity) + { + if (isset($sections[$id])) continue; + $entity->include_source = 'ids'; + $sections[$id] = $entity; + } + return $sections; + } + + public function sort_courses_by_name($a, $b) + { + $a_name = $a->get_value('name'); + $b_name = $b->get_value('name'); + if ($a_name == $b_name) { + return 0; + } + return ($a_name < $b_name) ? -1 : 1; + } -function sort_courses_by_name($a, $b) -{ - $a_name = $a->get_value('name'); - $b_name = $b->get_value('name'); - if ($a_name == $b_name) { - return 0; + public function sort_courses_by_date($a, $b) + { + $a_name = $a->get_value('timeframe_begin'); + $b_name = $b->get_value('timeframe_begin'); + if ($a_name == $b_name) { + return 0; + } + return ($a_name > $b_name) ? -1 : 1; } - return ($a_name < $b_name) ? -1 : 1; -} -function sort_courses_by_date($a, $b) -{ - $a_name = $a->get_value('timeframe_begin'); - $b_name = $b->get_value('timeframe_begin'); - if ($a_name == $b_name) { - return 0; + public function sort_courses_by_number_and_date($a, $b) + { + $a_num = $a->get_value('course_number'); + $b_num = $b->get_value('course_number'); + $a_name = $a->get_value('timeframe_begin'); + $b_name = $b->get_value('timeframe_begin'); + if ($a_name.$a_num == $b_name.$b_num) { + return 0; + } + return ($a_name.$a_num > $b_name.$b_num) ? -1 : 1; } - return ($a_name > $b_name) ? -1 : 1; -} -function sort_courses_by_number_and_date($a, $b) -{ - $a_num = $a->get_value('course_number'); - $b_num = $b->get_value('course_number'); - $a_name = $a->get_value('timeframe_begin'); - $b_name = $b->get_value('timeframe_begin'); - if ($a_name.$a_num == $b_name.$b_num) { - return 0; - } - return ($a_name.$a_num > $b_name.$b_num) ? -1 : 1; -} + /** + * Get the list of possible course subjects by looking at the section data. If a year is + * passed, limit the result to those subjects with sections during that academic year. + * + * @param int $year + * + * @todo Generalize timespan query + */ + public function get_course_subjects($year = null) + { + $cache = new ObjectCache('course_subject_cache_'.$year, 60*24); + if ($subjects = $cache->fetch()) return $subjects; -/** - * Get the list of possible course subjects by looking at the section data. If a year is - * passed, limit the result to those subjects with sections during that academic year. - * - * @param int $year - * - * @todo Generalize timespan query - */ -function get_course_subjects($year = null) -{ - $cache = new ObjectCache('course_subject_cache_'.$year, 60*24); - if ($subjects = $cache->fetch()) return $subjects; + // If we're asking about a future academic year, use the previous year's sections, because + // the future year may not be fully populated yet. + if ($year && $this->get_catalog_year_start_date($year) > date('Y-m-d g:i:s')) + $startyear = $year - 1; + else + $startyear = $year; + + $subjects = array(); + $q = 'SELECT distinct org_id FROM course_section'; + if ($year) $q .= ' WHERE timeframe_begin > "'.$this->get_catalog_year_start_date($startyear).'" AND timeframe_end < "'.$this->get_catalog_year_end_date($year).'"'; + $q .= ' ORDER BY org_id'; + if ($result = db_query($q, 'Error selecting course subjects')) + { + while ($row = mysql_fetch_assoc($result)) + $subjects[$row['org_id']] = $row['org_id']; + } + $cache->set($subjects); + return $subjects; + } - $subjects = array(); - $q = 'SELECT distinct org_id FROM course_section'; - if ($year) $q .= ' WHERE timeframe_begin > "'.get_catalog_year_start_date($year).'" AND timeframe_end < "'.get_catalog_year_end_date($year).'"'; - $q .= ' ORDER BY org_id'; - if ($result = db_query($q, 'Error selecting course subjects')) + /** + * Get the list of years for which we have catalog sites. Assumes that catalog sites use the + * naming convention academic_catalog_YEAR_site. + * + * @return array + */ + public function get_catalog_years() { - while ($row = mysql_fetch_assoc($result)) - $subjects[$row['org_id']] = $row['org_id']; + static $catalog_years = array(); + if (empty($catalog_years)) + { + $names = array_flip(reason_get_unique_names()); + if ($catalogs = preg_grep('/^academic_catalog_\d{4}_site$/', $names)) + { + foreach ($catalogs as $catalog) + { + preg_match('/^academic_catalog_(\d{4})_site$/', $catalog, $matches); + $catalog_years[$matches[1]] = $catalog; + } + krsort($catalog_years); + } + } + return $catalog_years; } - $cache->set($subjects); - return $subjects; -} -/** - * Get the list of years for which we have catalog sites. - * - * @todo Finish - */ -function get_catalog_years() -{ - return array(2013=>2013, 2014=>2014, 2015=>2015); -} - -function get_display_year($year) -{ - return $year . '-' . ((int) substr($year, -2) + 1); -} - -function term_to_academic_year($term) -{ - list($year,$term) = explode('/',$term); - $year = 2000 + $year; - if (($term == 'WI') || ($term == 'SP')) $year--; - return $year; -} + /** + * Return the most recent year for which there is a live catalog site + * + * @return integer + */ + public function get_latest_catalog_year() + { + static $latest_year = 0; + + if (empty($latest_year) && $catalog_years = $this->get_catalog_years()) + { + foreach ($catalog_years as $year => $site) + { + $site = new entity(id_of($site)); + if ($site->get_value('site_state') === 'Live') + { + $latest_year = $year; + break; + } + } + } + + return $latest_year; + } + + /** + * Convert a bare year (e.g. 2015) into an academic year display (e.g. 2015-16) + * + * @param mixed $year + * @return string + */ + public function get_display_year($year) + { + return $year . '-' . ((int) substr($year, -2) + 1); + } -function get_catalog_year_start_date($year) -{ - return $year.'-09-01 00:00:00'; -} + /** + * Convert a term code into the catalog year in which it occurs. + * + * @param string $term + * @return integer + */ + public function term_to_academic_year($term) + { + if ($term) + { + list($year,$term) = explode('/',$term); + $year = 2000 + $year; + if (($term == 'WI') || ($term == 'SP')) $year--; + return $year; + } + } -function get_catalog_year_end_date($year) -{ - return ($year + 1).'-07-01 00:00:00'; -} + /** + * Given a catalog year, return a date representing the start of that academic year. Used to + * identify which year a section is offered in. This is a simple default, which you can + * override or extend to do a lookup or something. + * + * @param integer $year + * @return string + */ + public function get_catalog_year_start_date($year) + { + return $year.'-09-01 00:00:00'; + } + /** + * Given a catalog year, return a date representing the end of that academic year. Used to + * identify which year a section is offered in. This is a simple default, which you can + * override or extend to do a lookup or something. + * + * @param integer $year + * @return string + */ + public function get_catalog_year_end_date($year) + { + return ($year + 1).'-07-01 00:00:00'; + } + + public function get_catalog_blocks($year, $org_id, $type) + { + if ($site = id_of('academic_catalog_'.$year.'_site')) + { + $es = new entity_selector( $site ); + $es->description = 'Selecting catalog blocks on site'; + $es->add_type( id_of('course_catalog_block_type') ); + $es->add_relation('org_id = "'.carl_util_sql_string_escape($org_id).'"'); + $es->add_relation('block_type = "'.carl_util_sql_string_escape($type).'"'); + if ($results = $es->run_one()) + return $results; + } + else + { + trigger_error('No catalog site for '.$year.' in get_catalog_block()'); + } + return array(); + } + /** - * Some requirements only apply for some year ranges. This filters a list based on the provided year - * and gives you the ones you want. - */ -function filter_requirements_by_academic_year($reqs, $year) -{ - foreach($reqs as $key => $req) + * Catalog content can contain tags (in {}) that dynamically include lists of courses based on + * values on the course objects. This method detects those tags and calls get_course_list() to + * generate the appropriate list, which is then swapped in for the tag. + * + * @param string $content + * @return string + */ + public function expand_catalog_tags($content) { - if ($year > 2013) - if (in_array($req, array('AL','HU','SS','MS','RAD','WR','ND'))) - unset($reqs[$key]); + if (preg_match_all('/\{([^\}]+)\}/', $content, $tags, PREG_SET_ORDER)) + { + foreach ($tags as $tag) + { + // This is looking for {type key1="value" key2="value"} but it's very forgiving about + // extra spaces or failure to use the right quotes. + if (preg_match('/\s*([^\s=]+)(\s+([^\s=]+)\s*=\s*["\']?([^"\'\b]+)["\'\b])*/', $tag[1], $matches)) + { + $type = strtolower($matches[1]); + for ($i = 2; $i < count($matches); $i = $i + 3) + { + $keys[$matches[$i + 1]] = $matches[$i + 2]; + } + + if (!$courses = $this->get_course_list($type, $keys)) + $courses = 'No courses found for '.$tag[0].''; + + $content = str_replace($tag[0], $courses, $content); + + } + else + { + trigger_error('Badly formed catalog tag: '.$tag[1]); + } + } + } + + return $content; + } + + /** + * Catalog content can contain tags (in {}) that dynamically include lists of courses based on + * values on the course objects. This method takes the values from a tag and generates html for + * the corresponding courses. It uses an extension pattern so that custom methods/functions can + * be defined to handle the generation of course lists for particular keys. + * + * @param string $type What kind of list to generate (titles/descriptions) + * @param array $keys Key value pairs used to select the courses in the list + * @return string + */ + public function get_course_list($type, $keys) + { + $courses = array(); + foreach ($keys as $key => $val) + { + $function = 'get_courses_by_'.$key; + if (method_exists($this, $function)) + $courses = array_merge($courses, $this->$function(array($val), $this->site)); + //else if (method_exists($this->helper, $function)) + // $courses = array_merge($courses, $this->helper->$function(array($val), $this->site_id)); + else + { + trigger_error('No course function found: '.$function); + } + } + + if ($courses) + { + $html = ''; + + if ($type == 'descriptions') + { + foreach ($courses as $course) + { + $html .= $this->get_course_html($course); + } + } + else if ($type == 'titles') + { + $html .= ''."\n"; + } + else + { + trigger_error('Unrecognized catalog tag type: '.$type); + } + return $html; + } } - return $reqs; -} + /** + * Given a course object, generate a default HTML snippet for its description. Override this if you + * want to use a diffferent pattern. + * + * @param object $course + * @return string + */ + public function get_course_extended_description($course) + { + // Strip off surrounding paragraph tags + $description = preg_replace(array('/^]*>/','/<\/p>$/'), '', $course->get_value('long_description')); + if ($prereqs = $course->get_value('list_of_prerequisites')) + $description .= ' Prerequisite: '.trim($prereqs, " .").'. '; + + $html = ''. $description .''; + + if ($credit = $course->get_value('credits')) + { + $details[] = $credit . (($credit == 1) ? ' credit' : ' credits'); + } + + if ($grading = $course->get_value('grading')) + { + $details[] = $grading; + } + if ($requirements = $course->get_value('requirements')) + { + $details[] = join(', ', $requirements); + } + + if ($history = $course->get_offer_history_html()) + { + $details[] = $history; + } + + if ($faculty = $course->get_value('display_faculty')) + { + $details[] = $faculty; + } + + if (isset($details)) + { + $html .= ' '.ucfirst(join('; ', $details)); + } + + return $html; + } + + /** + * Given a course object, generate a default HTML block for displaying it. Override this if you + * want to use a diffferent pattern. + * + * @param object $course + * @return string + */ + public function get_course_html($course) + { + $course->set_academic_year_limit($this->year); + + $html = '
'."\n"; + $html .= $course->get_value('display_title'); + $html .= $this->get_course_extended_description($course); + $html .= '
'."\n"; + + return $html; + } + +} diff --git a/reason_4.0/lib/core/scripts/import/courses/course_import.php b/reason_4.0/lib/core/scripts/import/courses/course_import.php index f6d954eb7..fe03e9708 100644 --- a/reason_4.0/lib/core/scripts/import/courses/course_import.php +++ b/reason_4.0/lib/core/scripts/import/courses/course_import.php @@ -2,6 +2,16 @@ /** * @package reason * @subpackage scripts + * + * This is the framework for the Course Catalog data import. On its own, it doesn't do + * anything; the expectation is that an institution would create a local import class + * that extends this one, and run that on some regular schedule. The comments below explain + * the points at which you will need to extend the class. + * + * For a minimal implementation, you will need to extend get_course_template_data() and + * get_course_section_data(), and populate the class vars $template_data_map and + * $section_data_map (and define any mapping methods, as needed). Then just call the run() + * method to kick off the import. */ include_once('reason_header.php'); reason_include_once('classes/entity_selector.php'); @@ -12,41 +22,160 @@ reason_include_once('function_libraries/admin_actions.php'); reason_include_once('function_libraries/root_finder.php'); +class CourseImportEngine +{ + /** + * Course entities are all stored in a single container site and borrowed into catalog + * sites to indicated that they should be published. This value is the unique name of + * the site where you want to store your course entities. It can be a public site + * (if, for instance, you have a parent site that "contains" your separate catalog + * sites) or a hidden site. + */ + protected $courses_site = 'catalog_courses_site'; -ini_set('display_errors', 'stdout'); -ini_set('error_reporting', E_ALL); + /** + * This script generates a lot of entities. This value defines the user who should be + * set as the creator/editor for those entities. + */ + protected $entity_creator = 'causal_agent'; -$import = new CourseImportEngine(); -$import->run(); + /** + * Importing course data requires mapping values from your external system to the + * Reason course data structures. These data maps define how that mapping happens. + * You will extend the get_course_template_data() method below to bring in your raw + * data array, one row per course. That data is then processed based on the mapping + * defined here. On the left are Reason entity fields. On the right can be one of + * three things: + * 1. The array key of a value in your raw data row to map that value directly + * 2. "X", where template_map_X() is a local method that takes a data row and returns + * the value that should be assigned to the entity field. + * 3. A fixed value to be assigned to all entities (or null, to assign no value) + */ + protected $template_data_map = array( + 'course_number' => null, + 'org_id' => null, + 'title' => null, + 'short_description' => null, + 'long_description' => null, + 'credits' => null, + 'list_of_prerequisites' => null, + 'status' => 'Active', + 'data_source' => 'myERP', + 'sourced_id' => null, + ); -class CourseImportEngine -{ - protected $data_source_name = 'Colleague'; + /** + * If you have template values that are managed in Reason and should not be updated based + * on changes in your source data, add the field names to this array. These fields may still + * be populated when a new template is created, based on the settings in $template_data_map, + * but fields in this array will not be overwritten for existing entities. + * + * @var array + */ + protected $template_data_reason_managed = array(); - protected $course_table_year = 2015; - protected $current_site = 'academic_catalog_2014_site'; + /** + * See above; this array is the same as $template_data_map, only it applies to course + * section data. Custom mapping methods for sections should be named section_map_X(). + */ + protected $section_data_map = array( + 'course_number' => null, + 'org_id' => null, + 'title' => null, + 'short_description' => null, + 'long_description' => null, + 'credits' => null, + 'list_of_prerequisites' => null, + 'academic_session' => null, + 'timeframe_begin' => null, + 'timeframe_end' => null, + 'location' => null, + 'meeting' => null, + 'notes' => null, + 'status' => 'Active', + 'data_source' => 'myERP', + 'sourced_id' => null, + 'parent_template_id' => null, + ); + + /** + * If you have section values that are managed in Reason and should not be updated based + * on changes in your source data, add the field names to this array. These fields may still + * be populated when a new section is created, based on the settings in $section_data_map, + * but fields in this array will not be overwritten for existing entities. + * + * @var array + */ + protected $section_data_reason_managed = array(); + /** + * Whether to automatically delete course templates that disappear from the source data feed. + * @var boolean + */ + protected $delete_missing_templates = false; + + /** + * Whether to automatically delete course sections that disappear from the source data feed. + * @var boolean + */ + protected $delete_missing_sections = false; + + /** + * Import error logging; no customization required. + */ protected $errors = array(); - public function run() + protected $test_mode = false; + + /* + * This value sets the reporting verbosity level. + * 0 = Errors only + * 1 = Report data changes + * 2 = Full report + */ + protected $verbosity = 0; + + protected $helper; + + function __construct() + { + $this->helper = new $GLOBALS['catalog_helper_class'](); + } + + public function set_verbosity($value = 0) + { + $this->verbosity = $value; + } + + /** + * Run the full course import process. By default it will attempt to process all courses, but + * you can pass an array of org_ids to limit import to particular subjects. + * + * @param array $org_ids + */ + public function run($org_ids = array()) { + if ($this->verbosity == 2) + { + if (php_sapi_name() !== 'cli') echo '
';
+			echo "Running...\n";
+		}
+
 		connectDB(REASON_DB);
+		mysql_set_charset('utf8');
 		
 		$this->disable_output_buffering();
-		echo "
Running\n";
 		
-		//$this->import_course_blocks();
-		//return;
-		
-		//$this->delete_all_course_entities();
-		
-		//foreach ($this->get_section_org_ids() as $org_id)
-		//{
-			if ($raw_data = $this->get_course_template_data())
+		if (empty($org_ids) && !$org_ids = $this->get_template_org_ids()) $org_ids = array(null);
+		foreach ($org_ids as $org_id)
+		{
+			if ($raw_data = $this->get_course_template_data($org_id))
 			{
 				if ($mapped_data = $this->map_course_template_data($raw_data))
 				{
-					$this->build_course_template_entities($mapped_data, $this->get_existing_template_ids());
+					$this->build_course_template_entities($mapped_data, $this->get_existing_template_ids($org_id), $this->delete_missing_templates);
+					unset($raw_data);
+					unset($mapped_data);
 				}
 				else
 				{
@@ -55,18 +184,22 @@ public function run()
 			}
 			else
 			{
-				$this->errors[] = 'No course template data received for '.$org_id.'.';
+				if ($this->verbosity == 2) $this->errors[] = 'No course template data received for '.$org_id.'.';
 			}
-		//}
-		//return;
-		foreach ($this->get_section_org_ids() as $org_id)
-		{
+			if ($this->errors) echo join("\n", $this->errors)."\n";
+			$this->errors = array();
+		}
 
+		if (empty($org_ids) && !$org_ids = $this->get_section_org_ids()) $org_ids = array(null);
+		foreach ($org_ids as $org_id)
+		{
 			if ($raw_data = $this->get_course_section_data($org_id))
 			{
 				if ($mapped_data = $this->map_course_section_data($raw_data))
 				{
-					$this->build_course_section_entities($mapped_data);
+					$this->build_course_section_entities($mapped_data, $this->get_existing_section_ids($org_id), $this->delete_missing_sections);
+					unset($raw_data);
+					unset($mapped_data);
 				}
 				else
 				{
@@ -75,14 +208,105 @@ public function run()
 			}
 			else
 			{
-				$this->errors[] = 'No course section data received for '.$org_id.'.';	
+				if ($this->verbosity == 2) $this->errors[] = 'No course section data received for '.$org_id.'.';	
 			}
+			if ($this->errors) echo join("\n", $this->errors)."\n";
+			$this->errors = array();
 		}
 		
-		echo join("\n", $this->errors);
-		echo "Import Complete.\n";
+		if ($this->errors) echo join("\n", $this->errors)."\n";
+		if ($this->verbosity == 2) echo "Import Complete.\n";
+	}
+
+	/**
+	 * Retrieves a list of valid 'org_id' values (your department/subject codes).
+	 * Typically this is the same for templates and sections, but you can define them
+	 * separately if necessary.
+	 */
+	protected function get_template_org_ids()
+	{
+		return $this->get_section_org_ids();
 	}
 
+	/**
+	 * Retrieves a list of valid 'org_id' values (your department/subject codes).
+	 * Typically this is the same for templates and sections, but you can define them
+	 * separately if necessary.
+	 */
+	protected function get_section_org_ids()
+	{
+		return array();
+	}
+	
+	/**
+	 * This method does all the work of retrieving your raw course template data from
+	 * wherever it lives: via a query into your ERP, a query to a local data mirror,
+	 * retrieving data from flat files, etc. The result should be an array with one row
+	 * per course. It doesn't matter how that data is structured -- it will be massaged
+	 * into shape by the mapping step.
+	 *
+	 * You should allow for passing an optional org_id value (a department/subject code)
+	 * to retrieve only a subset of data.
+	 *
+	 * @param mixed $org_id
+	 */
+	protected function get_course_template_data($org_id = null)
+	{
+		return array();
+	}
+
+	/**
+	 * This is a set of rules for excluding course templates from being imported. The method is 
+	 * passed a single row of data produced by get_course_template_data(), and based on the values
+	 * in that row you can pass true or false to indicate whether the row should be discarded.
+	 * This is helpful if you need to exclude courses based on values that are difficult to
+	 * restrict in your initial query.
+	 * 
+	 * @param type $row
+	 * @return boolean
+	 */
+	protected function should_exclude_course_template($row)
+	{
+		return false;
+	}
+
+	/**
+	 * This method does all the work of retrieving your raw course section data from
+	 * wherever it lives: via a query into your ERP, a query to a local data mirror,
+	 * retrieving data from flat files, etc. The result should be an array with one row
+	 * per section. It doesn't matter how that data is structured -- it will be massaged
+	 * into shape by the mapping step.
+	 *
+	 * You should allow for passing an optional org_id value (a department/subject code)
+	 * to retrieve only a subset of data.
+	 *
+	 * @param mixed $org_id
+	 */
+	protected function get_course_section_data($org_id = null)
+	{
+		return array();
+	}
+
+	/**
+	 * This is a set of rules for excluding course templates from being imported. The method is 
+	 * passed a single row of data produced by get_course_template_data(), and based on the values
+	 * in that row you can pass true or false to indicate whether the row should be discarded.
+	 * This is helpful if you need to exclude courses based on values that are difficult to
+	 * restrict in your initial query.
+	 * 
+	 * @param type $row
+	 * @return boolean
+	 */
+	protected function should_exclude_course_section($row)
+	{
+		return false;
+	}
+
+	/**
+	 * Given the 'sourced_id' value of a course template, return the corresponding entity.
+	 *
+	 * @param mixed ID
+	 */
 	protected function get_section_parent($parent_template_id)
 	{
 		$es = new entity_selector();
@@ -95,14 +319,15 @@ protected function get_section_parent($parent_template_id)
 		return false;
 	}
 	
+	/**
+	 * Given a course section entity, create a relationship with its parent course template.
+	 *
+	 * @param object
+	 */
 	protected function link_section_to_parent($section)
 	{
-		//echo round(memory_get_usage()/1024,2)."K at point A\n"; 
-
 		if ($template = $this->get_section_parent($section->get_value('parent_template_id')))
 		{
-		//	echo round(memory_get_usage()/1024,2)."K at point B\n"; 
-
 			if (!$parents = $section->get_right_relationship('course_template_to_course_section'))
 			{
 				return create_relationship( $template->id(), $section->id(), relationship_id_of('course_template_to_course_section'),false,false);
@@ -110,7 +335,7 @@ protected function link_section_to_parent($section)
 			else if (is_array($parents))
 			{
 				$current_template = reset($parents);
-		//		echo round(memory_get_usage()/1024,2)."K at point C\n"; 
+
 				// verify that we have the correct parent, and fix if not.	
 				if ($current_template->get_value('sourced_id') == $template->get_value('sourced_id'))
 				{
@@ -118,142 +343,310 @@ protected function link_section_to_parent($section)
 				}
 				else
 				{
-					//$this->errors[] = 'Incorrect template attached to '.$section->get_value('name');	
-					echo 'Incorrect template attached to '.$section->get_value('name');	
+					$this->errors[] = 'Incorrect template attached to '.$section->get_value('name');	
 				}
 			}
 			else
 			{
-				//$this->errors[] = 'Non-array '.$parents.' returned from get_right_relationship';
-				echo 'Non-array '.$parents.' returned from get_right_relationship';
+				$this->errors[] = 'Non-array '.$parents.' returned from get_right_relationship';
 			}
 		}
 		else
 		{
-			//$this->errors[] = 'No template found for '.$section->get_value('name');
-			echo 'No template found for '.$section->get_value('name');
+			$this->errors[] = 'No template found for '.$section->get_value('name');
 			return false;
 		}
 	}
 	
-	protected function build_course_template_entities($data, $existing = array())
+	/**
+	 * Given the massaged array of data produced by map_course_template_data(), build or
+	 * update the corresponding entities. You can optionally pass an array of all the
+	 * existing course template entity ids, to generate a report of entities that have
+	 * dropped from the source feed.
+	 *
+	 * @param array Course data
+	 * @param array IDs of existing course template entities.
+	 * @param boolean Whether to delete entities dropped from data feed (not recommended)
+	 *
+	 * @todo Have deletion of templates pay attention to the time range selected
+	 */
+	protected function build_course_template_entities($data, $existing = array(), $delete = false)
 	{
-		echo "Building entities\n";
+		if ($this->verbosity == 2) echo "Building entities\n";
+		$creator = get_user_id($this->entity_creator);
 		foreach ($data as $key => $row)
 		{
-			$name = sprintf('%s %s %s', $row['org_id'], $row['course_number'], $row['title']);
-			//echo 'Adding '.$name ."\n"; continue;
+			$name = $this->build_course_template_entity_name($row);
 			$es = new entity_selector();
+			$factory = new CourseTemplateEntityFactory();
+			$es->set_entity_factory($factory);
 			$es->add_type(id_of('course_template_type'));
 			$es->add_relation('sourced_id = "'.$row['sourced_id'].'"');
 			if ($result = $es->run_one())
 			{
 				$course = reset($result);
-				// Find all the values that correspond to the data we're importing
-				$values = array_intersect_key($course->get_values(), $row);
-				if ($values != $row)
-				{
-					echo 'Updating '.$name ."\n";
-					reason_update_entity( $course->id(), get_user_id('causal_agent'), $row, false);
-				}
-				
+				$this->update_course_template($row, $course);
 				$key = array_search($course->id(), $existing);
 				if ($key !== false) unset($existing[$key]);
 			}
 			else
 			{
-				echo 'Adding '.$name ."\n";
-				reason_create_entity( id_of($this->current_site), id_of('course_template_type'), get_user_id('causal_agent'), $name, $row);
+				if ($this->verbosity > 0) $this->errors[] = 'Adding '.$name;
+				$row['new'] = 0;
+				$this->process_new_course_template($row);
+				if (!$this->test_mode)
+					reason_create_entity( id_of($this->courses_site), id_of('course_template_type'), $creator, $name, $row);
 			}
 		}
 		
 		if (count($existing))
 		{
-			$user = get_user_id('causal_agent');
 			foreach ($existing as $id)
 			{
-				$course = new CourseTemplateType($id);
-				echo 'Would Delete: '.$course->get_value('name')."\n";
-				//reason_expunge_entity($id, $user);
+				$course = new $GLOBALS['course_template_class']($id);
+				if ($this->verbosity == 2) $this->errors[] = 'No longer in feed: '.$course->get_value('name');
+				if ($delete && !$this->test_mode) reason_expunge_entity($id, $creator);
 			}
 		}
+	}
+
+	/**
+	 * Update an existing course template entity with new values. Extend this if you need to perform
+	 * additional actions on the data before or after an update action.
+	 * 
+	 * @param array $data Values to apply
+	 * @param object $entity Existing entity object
+	 */
+	protected function update_course_template($data, $entity)
+	{
+		if ($this->verbosity > 0) $name = $this->build_course_template_entity_name($data);
+		
+		// Remove any fields which are managed on the Reason side
+		foreach ($this->template_data_reason_managed as $key)
+		{
+			if (isset($data[$key])) unset($data[$key]);
+		}
+		
+		$current_values = $entity->get_values();
+		
+		// Extract the external data cache so that we can compare it separately with the current
+		// external data (the timestamp needs to be removed for an accurate comparison).
+		$current_cache = '';
+		if (isset($current_values['cache']))
+		{
+			$current_cache = json_decode($current_values['cache'], true);
+			if (empty($current_cache)) $current_cache = array();
+			unset($current_values['cache']);
+			if (isset($current_cache['timestamp'])) unset($current_cache['timestamp']);
+		}
+
+		$external_data = $entity->fetch_external_data(true, false);
+		
+		// Find all the values that correspond to the data we're importing
+		$values = array_intersect_assoc($current_values, $data);
+		if ($values != $data || $external_data != $current_cache)
+		{
+			$external_data['timestamp'] = time();
+			$data['cache'] = json_encode($external_data);
+			if ($this->verbosity > 0) $this->errors[] = 'Updating '.$name;
+			if (!$this->test_mode)
+				reason_update_entity( $entity->id(), get_user_id($this->entity_creator), $data, false);
+		}
+		else
+		{
+			if ($this->verbosity == 2) $this->errors[] = 'Unchanged: '.$name;
+		}
+	}
+	
+	/**
+	 * This method provides a hook that is run before a new course template is added to Reason.
+	 * If you want to modify values based on existing entities, or do something else when you know
+	 * that a template is new, you can do that here. The data row representing the template is
+	 * passed and updated by reference.
+	 * 
+	 * @param type $row
+	 */
+	protected function process_new_course_template(&$row)
+	{
 		
 	}
+	
+	/**
+	  * Construct the template entity name (only visible in the Reason admin). You can modify this 
+	  * method if you want your names constructed differently.
+	  *
+	  * @param array data row
+	  * @return string
+	  */
+	protected function build_course_template_entity_name($row)
+	{
+		return sprintf('%s %s %s', $row['org_id'], $row['course_number'], $row['title']);
+	}
 
-	protected function build_course_section_entities($data)
+	/**
+	 * Given the data assembled by map_course_section_data(), look to see if there's a matching
+	 * entity. If there is, update it if any values have changed. If no entity exists,
+	 * create one and link it to its parent course template entity.
+	 *
+	 * @param array data row
+	 * @return string
+	 * 
+	 * @todo Have deletion of sections pay attention to the time range selected
+	 */
+	protected function build_course_section_entities($data, $existing = array(), $delete = false)
 	{
-		echo "Building section entities\n";
+		if ($this->verbosity == 2) echo "Building section entities\n";
+		$creator = get_user_id($this->entity_creator);
 		foreach ($data as $key => $row)
 		{
-			//echo round(memory_get_usage()/1024,2)."K at point E\n"; 
 			$es = new entity_selector();
 			$es->add_type(id_of('course_section_type'));
-			$name = sprintf('%s %s %s', $row['course_number'], $row['academic_session'], $row['title'] );
+			$factory = new CourseSectionEntityFactory();
+			$es->set_entity_factory($factory);
+			$name = $this->build_course_section_entity_name($row);
 			$es->relations = array();
 			$es->add_relation('sourced_id = "'.$row['sourced_id'].'"');
 			if ($result = $es->run_one())
 			{
 				$section = reset($result);
-				// Find all the values that correspond to the data we're importing
-				$values = array_intersect_key($section->get_values(), $row);
-				if ($values != $row)
-				{
-					echo 'Updating: '.$name ."\n";
-					reason_update_entity( $section->id(), get_user_id('causal_agent'), $row, false);
-				}
-				else
-				{
-					echo 'Unchanged: '.$name ."\n";	
-				}
+				$this->update_course_section($row, $section);
+				$key = array_search($section->id(), $existing);
+				if ($key !== false) unset($existing[$key]);
 			}
 			else
 			{
 				if ($this->get_section_parent($row['parent_template_id']))
 				{
-					echo 'Adding: '.$name ."\n";
-					$id = reason_create_entity( id_of($this->current_site), id_of('course_section_type'), get_user_id('causal_agent'), $name, $row);
-					$section = new entity($id);
+					if ($this->verbosity > 0) $this->errors[] = 'Adding: '.$name;
+					$row['new'] = 0;
+					$this->process_new_course_section($row);
+					if (!$this->test_mode)
+					{
+						$id = reason_create_entity( id_of($this->courses_site), id_of('course_section_type'), $creator, $name, $row);
+						$section = new entity($id);
+					}
 				}
 				else
 				{
-					echo 'No course template found; skipping '.$name ."\n";
+					if ($this->verbosity == 2) $this->errors[] = 'No course template found; skipping '.$name;
 					continue;
 				}
 			}
 			
 			if (!empty($section))
 				$this->link_section_to_parent($section);
-			//echo round(memory_get_usage()/1024,2)."K at point D\n"; 
 		}
+		
+		if (count($existing))
+		{
+			foreach ($existing as $id)
+			{
+				$course = new $GLOBALS['course_section_class']($id);
+				if ($this->verbosity == 2) $this->errors[] = 'No longer in feed: '.$course->get_value('course_number').': '.$course->get_value('name');
+				if ($delete && !$this->test_mode) reason_expunge_entity($id, $creator);
+			}
+		}
+
 	}
 	
+	/**
+	 * Update an existing course section entity with new values. Extend this if you need to perform
+	 * additional actions on the data before or after an update action.
+	 * 
+	 * @param array $data Values to apply
+	 * @param object $entity Existing entity object
+	 */
+	protected function update_course_section($data, $entity)
+	{
+		if ($this->verbosity > 0) $name = $this->build_course_section_entity_name($data);
+		
+		// Remove any fields which are managed on the Reason side
+		foreach ($this->section_data_reason_managed as $key)
+		{
+			if (isset($data[$key])) unset($data[$key]);
+		}
+
+		$current_values = $entity->get_values();
+		
+		// Extract the external data cache so that we can compare it separately with the current
+		// external data (the timestamp needs to be removed for an accurate comparison).	
+		$current_cache = '';
+		if (isset($current_values['cache']))
+		{
+			$current_cache = json_decode($current_values['cache'], true);
+			if (empty($current_cache)) $current_cache = array();
+			unset($current_values['cache']);
+			if (isset($current_cache['timestamp'])) unset($current_cache['timestamp']);
+		}
+
+		$external_data = $entity->fetch_external_data(true, false);
+		
+		// Find all the values that correspond to the data we're importing
+		$values = array_intersect_assoc($current_values, $data);
+		if ($values != $data || $external_data != $current_cache)
+		{
+			$external_data['timestamp'] = time();
+			$data['cache'] = json_encode($external_data);
+			if ($this->verbosity > 0) $this->errors[] = 'Updating: '.$name;
+			if (!$this->test_mode)
+				reason_update_entity( $entity->id(), get_user_id($this->entity_creator), $data, false);
+		}
+		else
+		{
+			if ($this->verbosity == 2) $this->errors[] = 'Unchanged: '.$name;	
+		}
+	
+	}
+	
+	/**
+	 * This method provides a hook that is run before a new course template is added to Reason.
+	 * If you want to modify values based on existing entities, or do something else when you know
+	 * that a template is new, you can do that here. The data row representing the template is
+	 * passed and updated by reference.
+	 * 
+	 * @param type $row
+	 */
+	protected function process_new_course_section(&$row)
+	{
+		
+	}
+
+	/**
+	  * Construct the section entity name (only visible in the Reason admin). You can modify this 
+	  * method if you want your names constructed differently.
+	  *
+	  * @param array data row
+	  * @return string
+	  */
+	protected function build_course_section_entity_name($row)
+	{
+		return sprintf('%s %s %s', $row['course_number'], $row['academic_session'], $row['title'] );
+	}
+	
+	/**
+	  * This method accepts the raw data array generated in get_course_template_data() and
+	  * maps it to the Reason course schema based on the rules found in $this->template_data_map. 
+	  * Rules can specify a one-to-one mapping between a source field and a schema field, 
+	  * specify a function to be called to perform the mapping, or assign a static value.
+	  *
+	  * @param array Raw course template data
+	  */
 	protected function map_course_template_data($data)
 	{
-		echo "map_course_template_data\n";
-		$map = array(
-			'course_number' => 'SEC_COURSE_NO',
-			'org_id' => 'SEC_SUBJECT',
-			'title' => 'title',
-			'short_description' => null,
-			'long_description' => 'description',
-			'credits' => 'SEC_MAX_CRED',
-			'list_of_prerequisites' => 'prereq',
-			'status' => 'Active',
-			'data_source' => $this->data_source_name,
-			'sourced_id' => 'COURSES_ID',
-			);
+		if ($this->verbosity == 2) echo "map_course_template_data\n";
 		
 		foreach($data as $row)
 		{
 			unset($mapped_row);
-			foreach ($map as $key => $mapkey)
+			foreach ($this->template_data_map as $key => $mapkey)
 			{
 				if ($mapkey)
 				{
 					if (method_exists($this, 'template_map_'.$mapkey))
 					{
 						$method = 'template_map_'.$mapkey;
-						$mapped_row[$key] = $this->$method($row);
+						$value = $this->$method($row);
+						if ($value !== false) $mapped_row[$key] = $value;
 					}
 					else if (array_key_exists($mapkey, $row))
 					{
@@ -279,39 +672,30 @@ protected function map_course_template_data($data)
 		}
 	}
 
+	/**
+	  * This method accepts the raw data array generated in get_course_section_data() and
+	  * maps it to the Reason course schema based on the rules found in $this->section_data_map. 
+	  * Rules can specify a one-to-one mapping between a source field and a schema field, 
+	  * specify a function to be called to perform the mapping, or assign a static value.
+	  *
+	  * @param array Raw course section data
+	  */
 	protected function map_course_section_data($data)
 	{
-		echo "map_course_template_data\n";
-		$map = array(
-			'course_number' => 'SEC_NAME',
-			'org_id' => 'SEC_SUBJECT',
-			'title' => 'title',
-			'short_description' => null,
-			'long_description' => 'description',
-			'credits' => 'credits',
-			'academic_session' => 'SEC_TERM',
-			'timeframe_begin' => 'SEC_START_DATE',
-			'timeframe_end' => 'SEC_END_DATE',
-			'location' => 'location',
-			'meeting' => 'meeting',
-			'notes' => null,
-			'status' => 'Active',
-			'data_source' => $this->data_source_name,
-			'sourced_id' => 'COURSE_SECTIONS_ID',
-			'parent_template_id' => 'SEC_COURSE',
-			);
-		
+		if ($this->verbosity == 2) echo "map_course_section_data\n";
+
 		foreach($data as $row)
 		{
 			unset($mapped_row);
-			foreach ($map as $key => $mapkey)
+			foreach ($this->section_data_map as $key => $mapkey)
 			{
 				if ($mapkey)
 				{
 					if (method_exists($this, 'section_map_'.$mapkey))
 					{
 						$method = 'section_map_'.$mapkey;
-						$mapped_row[$key] = $this->$method($row);
+						$value = $this->$method($row);
+						if ($value !== false) $mapped_row[$key] = $value;
 					}
 					else if (array_key_exists($mapkey, $row))
 					{
@@ -339,189 +723,50 @@ protected function map_course_section_data($data)
 			return false;
 		}
 	}
-	
-	protected function get_course_template_data($org_id = null)
-	{
-		echo "get_course_template_data $org_id\n";
-		$restore_conn = get_current_db_connection_name();
-		connectDB('reg_catalog_new');	
-		mysql_set_charset('utf8');
-		$query = 'SELECT * FROM IDM_CRS WHERE CRS_END_DATE IS NULL OR CRS_END_DATE > NOW() ORDER BY CRS_NAME';
-		if ($result = mysql_query($query))
-		{
-			while ($row = mysql_fetch_assoc($result))
-			{
-				if (strpos($row['CRS_NAME'], 'OCP') === 0) continue;
-				if (strpos($row['CRS_NAME'], 'NORW') === 0) continue;
-				if (substr($row['CRS_NAME'], -3) == 'SAT') continue;
-				if (substr($row['CRS_NAME'], -2) == 'WL') continue;
-				if (substr($row['CRS_NAME'], -2) == 'IB') continue;
-				if (substr($row['CRS_NAME'], -2) == 'CC') continue;
-				if (substr($row['CRS_NAME'], -2) == 'MP') continue;
-				if (substr($row['CRS_NAME'], -2) == 'AP') continue;
-				if (substr($row['CRS_NAME'], -1) == 'S') continue;
-				if (substr($row['CRS_NAME'], -1) == 'L') continue;
-
-				$found = false;
-				$coursetableyear = $this->course_table_year;
-				while ($coursetableyear > 2009)
-				{
-					$coursetable = 'course'.$coursetableyear;
-					$query = 'SELECT IDM_COURSE.*, description, title, prereq FROM IDM_COURSE 
-						JOIN '.$coursetable.' 
-						ON '.$coursetable.'.course_id = IDM_COURSE.SEC_COURSE 
-						AND ('.$coursetable.'.match_title IS NULL 
-						OR '.$coursetable.'.match_title = IDM_COURSE.SEC_SHORT_TITLE)
-						WHERE SEC_COURSE = "'.$row['COURSES_ID'].'"';
-
-					if ($result2 = mysql_query($query))
-					{
-						if (mysql_num_rows($result2) == 1)
-						{
-							$row = array_merge($row, mysql_fetch_assoc($result2));
-							$found = true;
-						}
-						else if (mysql_num_rows($result2) > 1)
-						{
-							$row = array_merge($row, mysql_fetch_assoc($result2));
-							$found = true;
-						}
-						else
-						{
-							//echo "No data for $row[COURSES_ID] in $coursetable\n";
-							$coursetableyear--;
-							continue;
-						}
-						$data[] = $row;
-						break;
-					}
-					else
-					{
-						$this->errors[] = mysql_error();
-					}
-				}
-				if (!$found) echo "No data for $row[CRS_NAME] in catalog\n";
-			}
-		} 
-		else
-		{
-			$this->errors[] = mysql_error();
-		}
-		connectDB($restore_conn);
-		
-		if (isset($data)) return $data;
-	}
-
-	protected function get_course_section_data($org_id = null)
-	{
-		echo "get_course_section_data $org_id\n";
-		$data = array();
-		$coursetable = 'course'.$this->course_table_year;
-		$restore_conn = get_current_db_connection_name();
-		connectDB('reg_catalog_new');	
-		mysql_set_charset('utf8');
-		$found = false;
-		$coursetableyear = $this->course_table_year;
-		$org_id_limit = ($org_id) ? ' AND SEC_SUBJECT="'.$org_id.'" ' : '';
-		while ($coursetableyear > 2009)
-		{
-			$coursetable = 'course'.$coursetableyear;
-			$query = 'SELECT s.*, description, title FROM IDM_CRS c, IDM_COURSE s
-				JOIN '.$coursetable.' 
-				ON '.$coursetable.'.course_id = s.SEC_COURSE 
-				AND ('.$coursetable.'.match_title IS NULL 
-				OR '.$coursetable.'.match_title = s.SEC_SHORT_TITLE)
-				WHERE s.SEC_COURSE = c.COURSES_ID AND
-				(CRS_END_DATE IS NULL OR CRS_END_DATE > NOW()) 
-				AND SEC_START_DATE > "2009-09-01 00:00:00" '. $org_id_limit .'
-				ORDER BY SEC_NAME';
-
-			if ($result = mysql_query($query))
-			{
-				while ($row = mysql_fetch_assoc($result))
-				{
-					if (isset($row['SEC_SUBJECT']) && empty($data[$row['COURSE_SECTIONS_ID']]))
-					{
-						if ($row['SEC_SUBJECT'] == 'OCP' || $row['SEC_SUBJECT'] == 'NORW') continue;
-						if (strpos($row['SEC_NO'], 'WL') !== false) continue;
-						//if (strpos($row['SEC_TERM'], 'SU') !== false) continue;
-						
-						$data[$row['COURSE_SECTIONS_ID']] = $row;
-					}
-				}
-			}
-			else
-			{
-				$this->errors[] = mysql_error();
-			}
-			$coursetableyear--;
-		}
-		connectDB($restore_conn);
-		return $data;
-	}
-
-	protected function section_map_location($row)
-	{
-		$location = array();
-		if ($times = explode('|', $row['XSEC_CC_MEETING_TIMES_SV']))
-		{
-			foreach ($times as $time)
-			{
-				if (preg_match('/(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)/', $time, $matches))
-				{
-					$location[] = $matches[1] . ' '. $matches[2];
-				}
-			}
-		}
-		return join('|', $location);
-	}
-
-	protected function section_map_meeting($row)
-	{
-		$meeting = array();
-		if ($times = explode('|', $row['XSEC_CC_MEETING_TIMES_SV']))
-		{
-			foreach ($times as $time)
-			{
-				if (preg_match('/(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)/', $time, $matches))
-				{
-					$meeting[] = $matches[3] . ' '. $matches[4] . ' '. $matches[5];
-				}
-			}
-		}
-		return join('|', $meeting);
-	}
 
 	/**
-	  * If both min and max credits are set, return "MIN-MAX" -- otherwise, return
-	  * whichever one is set, or nothing.
-	  */
-	protected function section_map_credits($row)
-	{
-		$credits = array();
-		if ($row['SEC_MIN_CRED']) $credits[] = $row['SEC_MIN_CRED'];
-		if ($row['SEC_MAX_CRED']) $credits[] = $row['SEC_MAX_CRED'];
-		return join('-', $credits);
-	}
-	
-	protected function delete_all_course_entities()
+	 * Remove all existing course template entities. Used mostly for catalog setup and testing.
+	 */
+	public function delete_all_course_entities()
 	{
-		$user = get_user_id('causal_agent');
-		foreach ($this->get_existing_template_ids() as $id)
+		$user = get_user_id($this->entity_creator);
+		$ids = $this->get_existing_template_ids();
+		foreach ($ids as $id)
 		{
 			reason_expunge_entity($id, $user);
 		}
+		unset($ids);
 	}
 	
-	protected function delete_all_section_entities()
+	/**
+	 * Remove all existing course section entities. Used mostly for catalog setup and testing.
+	 */
+	public function delete_all_section_entities()
 	{
-		$user = get_user_id('causal_agent');
-		foreach ($this->get_existing_section_ids() as $id)
+		$user = get_user_id($this->entity_creator);
+		$ids = $this->get_existing_section_ids();
+		foreach ($ids as $id)
 		{
 			reason_expunge_entity($id, $user);
 		}
+		unset($ids);
+		
+		// Clear any defunct relationships
+		$q = 'DELETE FROM relationship WHERE type in ('.  
+			relationship_id_of('course_section_to_category') . ',' .
+			relationship_id_of('course_template_to_course_section') . ',' .
+			relationship_id_of('site_owns_course_section_type') . ',' .
+			relationship_id_of('site_borrows_course_section_type') . ')';
+		db_query( $q, 'Unable to delete relationships of entity '.$id );
 	}
 	
+	/**
+	 * Get all the ids of course template entities, optionally limited by the org_id value on 
+	 * the entity.
+	 * 
+	 * @param string $org_id
+	 * @return array
+	 */
 	protected function get_existing_template_ids($org_id = null)
 	{
 		$es = new entity_selector();
@@ -533,6 +778,13 @@ protected function get_existing_template_ids($org_id = null)
 			return array();
 	}
 	
+	/**
+	 * Get all the ids of course section entities, optionally limited by the org_id value on 
+	 * the entity.
+	 * 
+	 * @param string $org_id
+	 * @return array
+	 */
 	protected function get_existing_section_ids($org_id = null)
 	{
 		$es = new entity_selector();
@@ -544,35 +796,19 @@ protected function get_existing_section_ids($org_id = null)
 			return array();
 	}
 	
-	protected function get_template_org_ids()
-	{
-		return $this->get_section_org_ids();
-	}
-
-	protected function get_section_org_ids()
-	{
-		$org_ids = array();
-		$q = 'SELECT DISTINCT SEC_SUBJECT FROM IDM_COURSE ORDER BY SEC_SUBJECT';
-		connectDB('reg_catalog_new');	
-		if ($result = mysql_query($q))
-		{
-			while($row = mysql_fetch_assoc($result))
-				$org_ids[] = $row['SEC_SUBJECT'];
-		}
-		connectDB(REASON_DB);
-		return $org_ids;
-	}
-	
-	function disable_output_buffering()
+	protected function disable_output_buffering()
 	{
-		@apache_setenv('no-gzip', 1);
+		// We don't need to (and shouldn't) try to do this in command line mode
+		if (php_sapi_name() != "cli")
+			@apache_setenv('no-gzip', 1);
+		
 		@ini_set('zlib.output_compression', 0);
 		@ini_set('implicit_flush', 1);
 		for ($i = 0; $i < ob_get_level(); $i++) { ob_end_flush(); }
 		ob_implicit_flush(1);
 	}
 
-	function get_subjects()
+	public function get_reason_subjects()
 	{
 		$subjects = array();
 		$es = new entity_selector();
@@ -587,85 +823,29 @@ function get_subjects()
 		return $subjects;
 	}
 
-	function import_course_blocks()
+	/**
+	 * Remove all existing course blocks in a particular catalog site (designated by academic year). 
+	 * Used mostly for catalog setup and testing.
+	 * 
+	 * @param int $year
+	 */
+	public function delete_all_course_blocks($year)
 	{
-		$subjects = $this->get_subjects();
-		$root_id = root_finder(id_of($this->current_site));
-		$rows = array();
-	
-		connectDB('reg_catalog');	
-		mysql_set_charset('utf8');
-		$query = 'SELECT * FROM blocks JOIN block_types ON blocks.type = block_types.id
-					WHERE YEAR = 2015 ORDER BY dept, sequence';
-		if ($result = mysql_query($query))
+		if (!$site_id = id_of('academic_catalog_'.$year.'_site'))
 		{
-			while ($row = mysql_fetch_assoc($result))
-			{			
-				$rows[] = $row;
-			}
+			echo 'No site found: academic_catalog_'.$year.'_site';
+			return;
 		}
-		connectDB(REASON_DB);
-		
-		$subj = '';
-		$seq = $page_seq = 1;
-		foreach ($rows as $row)
-		{
-			if ($row['dept'] != $subj)
-			{
-				$subj = $row['dept'];
-				$seq = 1;
 
-				if (isset($subjects[$subj]))
-					$page_name = $subjects[$subj]->get_value('name');
-				else
-					$page_name = $subj;
-			
-				$page_values = array(
-					'new' => 0,
-					'url_fragment' => strtolower($subj),
-					'custom_page' => 'catalog_subject_page',
-					'link_name' => $page_name,
-					'nav_display' => 'Yes',
-					);
-			
-				$page_id = reason_create_entity( id_of($this->current_site), id_of('minisite_page'), get_user_id('causal_agent'), $page_name, $page_values);
-				create_relationship( $page_id, $root_id, relationship_id_of('minisite_page_parent'), array('rel_sort_order'=>$page_seq),false);
-
-				$page_seq++;
-			}
-			
-			$block_name = $row['dept'] . ' ' . $row['name'];
-			
-			// Fix up include tags
-			if (preg_match_all('/(\{\{?)([^}]+)\}\}?/', $row['content'], $matches, PREG_SET_ORDER))
+		$es = new entity_selector($site_id);
+		$es->add_type(id_of('course_catalog_block_type'));
+		if ($result = $es->get_ids())
+		{
+			$user = get_user_id($this->entity_creator);
+			foreach ($result as $id)
 			{
-				foreach ($matches as $match)
-				{
-					if (preg_match('/(.*) ALL/', $match[2], $submatch))
-						$key = 'org_id="'.$submatch[1].'"';
-					else
-						$key = 'gov_code="'.$match[2].'"';
-					
-					if ($match[1] == '{')
-						$replace = '{descriptions '.$key.'}';
-					else
-						$replace = '{titles '.$key.'}';
-					
-					$row['content'] = str_replace($match[0], $replace, $row['content']);
-				}
+				reason_expunge_entity($id, $user);
 			}
-			
-			$block_values = array(
-				'new' => 0,
-				'org_id' => $row['dept'],
-				'title' => $row['title'],
-				'block_type' => $row['name'],
-				'content' => $row['content'],
-			);
-			
-			$block_id = reason_create_entity( id_of($this->current_site), id_of('course_catalog_block_type'), get_user_id('causal_agent'), $block_name, $block_values);
-			create_relationship( $page_id, $block_id, relationship_id_of('page_to_course_catalog_block'), array('rel_sort_order'=>$seq), false);
-			
 		}
 	}
 }
diff --git a/reason_4.0/lib/core/scripts/upgrade/4.4_to_4.5/setup_course_support.php b/reason_4.0/lib/core/scripts/upgrade/4.4_to_4.5/setup_course_support.php
index 061d4851a..97d7d3ec9 100644
--- a/reason_4.0/lib/core/scripts/upgrade/4.4_to_4.5/setup_course_support.php
+++ b/reason_4.0/lib/core/scripts/upgrade/4.4_to_4.5/setup_course_support.php
@@ -55,6 +55,7 @@ class ReasonUpgrader_45_SetupCourseSupport implements reasonUpgraderInterface
 		'short_description' => array('db_type' => 'text'),
 		'long_description' => array('db_type' => 'text'),
 		'credits' => array('db_type' => 'varchar(50)'),
+		'list_of_prerequisites' => array('db_type' => 'text'),
 		'academic_session' => array('db_type' => 'varchar(20)'),
 		'timeframe_begin' => array('db_type' => 'datetime'),
 		'timeframe_end' => array('db_type' => 'datetime'),
@@ -155,11 +156,6 @@ public function test()
 			$str = '';
 			if (!$this->course_template_type_exists()) $str .= '

Would create course_template type.

'; if (!$this->course_section_type_exists()) $str .= '

Would create course_section type.

'; - if (empty($str)) - { - $str .= $this->add_indexes(); // only attempt test if both types exist to avoid crash - } - else $str .= '

Would attempt to add indexes to type tables.

'; return $str; } } @@ -320,4 +316,4 @@ protected function course_section_type_exists() } } -?> +?> \ No newline at end of file diff --git a/reason_4.0/lib/core/scripts/upgrade/4.6_to_4.7/relationship_metadata.php b/reason_4.0/lib/core/scripts/upgrade/4.6_to_4.7/relationship_metadata.php index ec5bc1c3b..ecdb541c6 100644 --- a/reason_4.0/lib/core/scripts/upgrade/4.6_to_4.7/relationship_metadata.php +++ b/reason_4.0/lib/core/scripts/upgrade/4.6_to_4.7/relationship_metadata.php @@ -43,10 +43,12 @@ public function description() return "

This upgrade:

    " . "
  • modifies the type entity, adding a 'variety' field to distinguish between content, structural, and metadata types
  • " . "
  • modifies the field entity, adding a 'is_required' and 'admin_only' fields" . - "
  • modifies the allowable_relationship table, adding a 'meta_type' column to specify the type of entity that can be associated with this sort of relationship
  • " . + "
  • modifies the allowable_relationship table, adding a 'meta_type' and 'meta_availability' column to specify the type of entity that can be associated with this sort of relationship
  • " . "
  • modifies the relationship table, adding a 'meta_id' column to associate a particular entity with a particular relationship, as well as 'last_edited_by', 'last_modified', and 'creation_date' to enhance relationship history.
  • " . - "

"; + "". + "NOTE: Because this upgrade modifies the relationship table, it can take several minutes to complete. You ". + "may want to run it during a downtime or low-traffic situation.

"; } /** @@ -66,6 +68,7 @@ public function test() "'relationship' table has column 'last_modified': " . ($this->columnExistsOnTable("relationship", "last_modified") ? "yes" : "no") . "
" . "'relationship' table has column 'creation_date': " . ($this->columnExistsOnTable("relationship", "creation_date") ? "yes" : "no") . "
" . "'allowable_relationship' table has column 'meta_type': " . ($this->columnExistsOnTable("allowable_relationship", "meta_type") ? "yes" : "no") . "
" . + "'allowable_relationship' table has column 'meta_availability': " . ($this->columnExistsOnTable("allowable_relationship", "meta_availability") ? "yes" : "no") . "
" . "

"; } @@ -101,6 +104,7 @@ public function run() "creation_date" => "timestamp", "last_modified" => "timestamp")); $this->addColumnToTable("allowable_relationship", "meta_type", "int (10) unsigned not null default 0"); // id of potential metadata's required type + $this->addColumnToTable("allowable_relationship", "meta_availability", "enum('global','by_site') not null default 'global'"); // id of potential metadata's required type $this->addFieldsToEntity("type", $newTypeFields); $this->addFieldsToEntity("field", $newFieldFields); } diff --git a/reason_4.0/www/modules/courses/manage_courses.css b/reason_4.0/www/modules/courses/manage_courses.css index 670749ecd..48a8dd50c 100644 --- a/reason_4.0/www/modules/courses/manage_courses.css +++ b/reason_4.0/www/modules/courses/manage_courses.css @@ -55,15 +55,15 @@ span.termHighlight { } #wrapper ul.courseListInactive a.activateCourse, #wrapper ul.courseListActive a.deactivateCourse { - border-radius: 50%; - color: white; - display: none; - height: 1.5em; - left: 2em; - padding: 0 0.4em; - position: absolute; - text-align: center; - width: 0.7em; + border-radius: 50%; + color: white; + display: none; + height: 1.5em; + left: 2em; + padding: 0 0.4em; + position: absolute; + text-align: center; + width: 0.7em; } a.activateCourse { @@ -74,6 +74,10 @@ a.deactivateCourse { background-color: #dd7575; } +#wrapper ul.courseListInactive a.showControl, #wrapper ul.courseListActive a.showControl { + display: inline-block; +} + li.courseListRow a.activateCourse:hover, li.courseListRow a.deactivateCourse:hover { text-decoration: none; } @@ -101,6 +105,8 @@ div#requirementsItem, div#gradingItem, div#creditsItem { div#sectionsItem { width: 45%; float: right; + font-size: 0.85em; + line-height: 1.2; } ul#courseInfo { diff --git a/reason_4.0/www/modules/courses/manage_courses.js b/reason_4.0/www/modules/courses/manage_courses.js index ee1154a50..14a83fbf4 100644 --- a/reason_4.0/www/modules/courses/manage_courses.js +++ b/reason_4.0/www/modules/courses/manage_courses.js @@ -16,7 +16,7 @@ $(document).ready(function() }) $('li.courseListRow').hover(function() { - $('a.activateCourse, a.deactivateCourse', $(this)).toggle(); + $('a.activateCourse, a.deactivateCourse', $(this)).toggleClass('showControl'); }); }); diff --git a/reason_4.0/www/modules/courses/subject_page.js b/reason_4.0/www/modules/courses/subject_page.js index e3c46f381..cf56b06ce 100644 --- a/reason_4.0/www/modules/courses/subject_page.js +++ b/reason_4.0/www/modules/courses/subject_page.js @@ -10,7 +10,7 @@ $(document).ready(function() // to be lists of courses. If they are, add the appropriate classes so that they'll // get picked up by the linking process below. var course_regex = /\b([A-Z]{2,4} [0-9]{2,3}\w?)\b/g; - $("ul:not(.courseList) li").each(function(){ + $("ul:not(.courseList) li, p").each(function(){ if ($(this).html().match(course_regex)) { $(this).html($(this).html().replace(course_regex, function(match, p1){ @@ -22,19 +22,23 @@ $(document).ready(function() // For each course number in a list of titles, make the number clickable and // fire off a request for the course description to be opened in a modal dialog. - $("ul.courseList span.courseNumber") + $("ul.courseList span.courseNumber, p span.courseNumber") .addClass("clickable") .click(function(){ if ( $(this).attr('course') ) + { var course = $(this).attr('course'); + } else - var course = $(this).text(); + { + var course = $(this).text().replace(' ','_') + '_' + $("div#subjectPageModule").attr("year"); + } $.getJSON(document.URL, { module_identifier: module_id, module_api: "standalone", - get_course: course, + get_course: course }) .done(function(response){ var courseDialog = $('
' + response.description + '
'); @@ -42,8 +46,7 @@ $(document).ready(function() title: response.title, modal: true }); - }) - + }); }); // Close open modal dialogs when the background is clicked.