diff --git a/student_auto_feed/config.php b/student_auto_feed/config.php index 9092883..ab107bc 100644 --- a/student_auto_feed/config.php +++ b/student_auto_feed/config.php @@ -193,5 +193,10 @@ // 'tmp' or the current semester code. define('ADD_DROP_FILES_PATH', "path/to/reports/"); +/* CRN Copymap ------------------------------------------------------------- */ + +// Where is the crn copymap CSV located. Set to NULL is this is not used. +define('CRN_COPYMAP_FILE', "path/to/csv"); + //EOF ?> diff --git a/student_auto_feed/crn_copymap.php b/student_auto_feed/crn_copymap.php new file mode 100644 index 0000000..01d2a92 --- /dev/null +++ b/student_auto_feed/crn_copymap.php @@ -0,0 +1,158 @@ +#!/usr/bin/env php +main(); +exit; + +class crn_copy { + public $err; + + public function __construct() { + $this->err = ""; + } + + public function __destruct() { + if ($this->err !== "") fprintf(STDERR, $this->err); + } + + public function main() { + // Reminder: cli::parse_args() returns an array captured by regex, + // so we need to always look at index [0] when reading $args data. + $args = cli::parse_args(); + $args['source']['sections'][0] = $this->get_mappings($args['source']['sections'][0]); + $args['dest']['sections'][0] = $this->get_mappings($args['dest']['sections'][0]); + if (count($args['source']['sections'][0]) !== count($args['dest']['sections'][0])) { + $this->err = "One course has more sections than the other. Sections need to map 1:1.\n"; + exit(1); + } + + $this->write_mappings($args); + } + + private function write_mappings($args) { + $term = $args['term'][0]; + $source_course = $args['source']['course'][0]; + $source_sections = $args['source']['sections'][0]; + $dest_course = $args['dest']['course'][0]; + $dest_sections = $args['dest']['sections'][0]; + + // Insert "_{$term}" right before file extension. + // e.g. "/path/to/crn_copymap.csv" for term f23 becomes "/path/to/crn_copymap_f23.csv" + $filename = preg_replace("/([^\/]+?)(\.[^\/\.]*)?$/", "$1_{$term}$2", CRN_COPYMAP_FILE, 1); + + $fh = fopen($filename, "a"); + if ($fh === false) { + $this->err = "Could not open crn copymap file for writing.\n"; + exit(1); + } + + $len = count($source_sections); + for ($i = 0; $i < $len; $i++) { + $row = array($source_course, $source_sections[$i], $dest_course, $dest_sections[$i]); + fputcsv($fh, $row, ","); + } + + fclose($fh); + } + + private function get_mappings($sections) { + if ($sections === "" || $sections === "all") return array($sections); + + $arr = explode(",", $sections); + $expanded = array(); + foreach($arr as $val) { + if (preg_match("/(\d+)\-(\d+)/", $val, $matches) === 1) { + $expanded = array_merge($expanded, range((int) $matches[1], (int) $matches[2])); + } else { + $expanded[] = $val; + } + } + + return $expanded; + } +} + +/** class to parse command line arguments */ +class cli { + /** @var string usage help message */ + private static $help_usage = "Usage: crn_copymap.php [-h | --help | help] (term) (course-a) (sections) (course-b) (sections)\n"; + /** @var string short description help message */ + private static $help_short_desc = "Create duplicate enrollment mapping of courses and semesters.\n"; + /** @var string long description help message */ + private static $help_long_desc = << 1 && ($argv[1] === "-h" || $argv[1] === "--help" || $argv[1] === "help"): + self::print_help(); + exit; + // Validate CLI arguments. Something is wrong (invalid) when a case condition is true. + case $argc < 5 || $argc > 6: + case $argv[3] === "all" && (array_key_exists(5, $argv) && $argv[5] !== "all"): + case $argv[3] !== "all" && (!array_key_exists(5, $argv) || $argv[5] === "all"): + case preg_match("/^[a-z][\d]{2}$/", $argv[1], $matches['term']) !== 1: + case preg_match("/^[\w\d\-]+$/", $argv[2], $matches['source']['course']) !== 1: + case preg_match("/^\d+(?:(?:,|\-)\d+)*$|^all$/", $argv[3], $matches['source']['sections']) !== 1: + case preg_match("/^[\w\d\-]+$/", $argv[4], $matches['dest']['course']) !== 1: + case preg_match("/^\d+(?:(?:,|\-)\d+)*$|^(?:all)?$/", $argv[5], $matches['dest']['sections']) !== 1: + self::print_usage(); + exit; + } + + // $matches['dest']['sections'][0] must be "all" when ['source']['sections'][0] is "all". + if ($matches['source']['sections'][0] === "all") $matches['dest']['sections'][0] = "all"; + return $matches; + } + + /** Print complete help */ + private static function print_help() { + $msg = self::$help_usage . PHP_EOL; + $msg .= self::$help_short_desc . PHP_EOL; + $msg .= self::$help_long_desc . PHP_EOL; + $msg .= self::$help_args_list . PHP_EOL; + print $msg; + } + + /** Print CLI usage */ + private static function print_usage() { + print self::$help_usage . PHP_EOL; + } +} +// EOF +?> diff --git a/student_auto_feed/readme.md b/student_auto_feed/readme.md index 80e8114..f6e1d3a 100644 --- a/student_auto_feed/readme.md +++ b/student_auto_feed/readme.md @@ -1,5 +1,5 @@ # Submitty Student Auto Feed Script -Readme last updated Nov 17, 2021 +Readme last updated Sept 1, 2023 This is a code example for any University to use as a basis to have Submitty course's enrollment data added or updated on an automated schedule with a student enrollment CSV datasheet. @@ -134,7 +134,7 @@ the first (prior to autofeed) or second (after auto feed) run. Second cli parameter is the term code. -For example: +For example: ``` $ ./add_drop_report.php 1 f21 ``` @@ -145,3 +145,29 @@ $ ./add_drop_report.php 2 f21 ``` Will invoke the _second_ run to create the report of student enrollments for the Fall 2021 term. + +## crn_copymap.php + +Create a mapping of CRNs (course, term) that are to be duplicated. This is +useful if a professor wishes to have a course enrollment, by section, +duplicated to another course. Particularly when the duplicated course has +no enrollment data provided by IT. + +Sections can be a comma separated list, a range denoted by a hyphen, or the +word "all" for all sections. Note that "all" sections will copy sections +respectively. i.e. section 1 is copied as section 1, section 2 is copied as +section 2, etc. + +### Usage +```bash +$ crn_copymap.php (term) (original_course) (original_sections) (copied_course) (copied_sections) +``` +For example: +Copy enrollments of term "f23" (fall 2023) of course CSCI 1000, +sections 1, 3, and 5 through 9 to course CSCI 2000 as sections 2, 4, and 6 through 10 +respectively. +```bash +$ crn_copymap.php f23 csci1000 1,3,5-9 csci2000 2,4,6-10 +``` + +EOF diff --git a/student_auto_feed/submitty_student_auto_feed.php b/student_auto_feed/submitty_student_auto_feed.php index 4792141..cae91c0 100755 --- a/student_auto_feed/submitty_student_auto_feed.php +++ b/student_auto_feed/submitty_student_auto_feed.php @@ -5,7 +5,7 @@ * * This script will read a student enrollment CSV feed provided by the campus * registrar or data warehouse and "upsert" (insert/update) the feed into - * Submitty's course databases. Requires PHP 7.1 and pgsql extension. + * Submitty's course databases. Requires PHP 7.3 and pgsql extension. * * @author Peter Bailie, Rensselaer Polytechnic Institute */ @@ -18,7 +18,7 @@ // Important: Make sure we are running from CLI if (php_sapi_name() !== "cli") { - die("This is a command line tool."); + die("This is a command line tool.\n"); } $proc = new submitty_student_auto_feed(); @@ -35,6 +35,8 @@ class submitty_student_auto_feed { private $course_list; /** @var array Describes how courses are mapped from one to another */ private $mapped_courses; + /** @var array Describes courses/sections that are duplicated to other courses/sections */ + private $crn_copymap; /** @var array Courses with invalid data. */ private $invalid_courses; /** @var array All CSV data to be upserted */ @@ -44,6 +46,8 @@ class submitty_student_auto_feed { /** Init properties. Open DB connection. Open CSV file. */ public function __construct() { + $this->log_msg_queue = ""; + // Get semester from CLI arguments. $opts = cli_args::parse_args(); if (array_key_exists('l', $opts)) { @@ -63,8 +67,8 @@ public function __construct() { $db_host = DB_HOST; } - if (!$this->open_csv()) { - $this->log_it("Error: Cannot open CSV file"); + if (!$this->open_data_csv(CSV_FILE)) { + // Error is already in log queue. exit(1); } @@ -88,15 +92,17 @@ public function __construct() { exit(1); } + // Get CRN shared courses/sections (when a course/section is copied to another course/section) + $this->crn_copymap = $this->read_crn_copymap(); + // Init other properties. $this->invalid_courses = array(); $this->data = array(); - $this->log_msg_queue = ""; } public function __destruct() { db::close(); - $this->close_csv(); + $this->close_data_csv(); //Output logs, if any. if ($this->log_msg_queue !== "") { @@ -127,6 +133,9 @@ public function go() { // Should do nothing when $this->invalid_courses is empty $this->log_it("Error when removing data from invalid courses."); break; + case $this->process_crn_copymap(): + // Never returns false, so no error to log. + break; case $this->upsert_data(): $this->log_it("Error during upsert."); break; @@ -205,7 +214,7 @@ private function get_csv_data() { // There is a problem with $row, so log the problem and skip. $this->invalid_courses[$course] = true; $this->log_it(validate::$error); - } + } // END if (validate::validate_row()) break; // Check that the $row is associated with a mapped course. @@ -260,7 +269,7 @@ private function get_csv_data() { $this->data = array_filter($this->data, function($course) { return !empty($course); }, 0); // Most runtime involves the database, so we'll release the CSV now. - $this->close_csv(); + $this->close_data_csv(); // Done. return true; @@ -366,7 +375,7 @@ private function upsert_data() { * Both $this->data and $this->invalid_courses are indexed by course code, * so removing course data is trivially accomplished by array_diff_key(). * - * @param string $course Course being removed from process records. + * @return bool true upon completion. */ private function invalidate_courses() { if (!empty($this->invalid_courses)) { @@ -384,24 +393,122 @@ private function invalidate_courses() { } /** - * Open auto feed CSV data file. + * Read crn copymap csv into array. + * + * CRN copymap is a csv that maps what courses/sections are duplicated to + * other courses/sections. This is useful for duplicating enrollments to + * "practice" courses (of optional participation) that are not officially + * in the school's course catalog. + * *** MUST BE RUN AFTER FILLING $this->course_list + * + * @see CRN_COPYMAP_FILE located in config.php + * @see db::get_course_list() located in ssaf_db.php + * @return array copymap array, or empty array when copymap disabled or copymap file open failure. + */ + private function read_crn_copymap() { + // Skip this function and return empty copymap array when CRN_COPYMAP_FILE is null + if (is_null(CRN_COPYMAP_FILE)) return array(); + + // Insert "_{$this->semester}" right before file extension. + // e.g. When term is "f23", "/path/to/copymap.csv" becomes "/path/to/copymap_f23.csv" + $filename = preg_replace("/([^\/]+?)(\.[^\/\.]*)?$/", "$1_{$this->semester}$2", CRN_COPYMAP_FILE); + + if (!is_file($filename)) { + $this->log_it("crn copymap file not found: {$filename}"); + return array(); + } + + $fh = fopen($filename, 'r'); + if ($fh === false) { + $this->log_it("Failed to open crn copymap file: {$filename}"); + return array(); + } + + // source course == $row[0] + // source section == $row[1] + // dest course == $row[2] + // dest section == $row[3] + $arr = array(); + $row = fgetcsv($fh, 0, ","); + while (!feof($fh)) { + if (in_array($row[2], $this->course_list, true)) { + $arr[$row[0]][$row[1]] = array('course' => $row[2], 'section' => $row[3]); + } else { + $this->log_it("Duplicated course {$row[2]} not created in Submitty."); + } + $row = fgetcsv($fh, 0, ","); + } + + fclose($fh); + + if (empty($arr)) $this->log_it("No CRN copymap data could be read."); + return $arr; + } + + /** + * Duplicate course enrollment data based on crn copymap. + * + * This should be run after all validation and error checks have been + * performed on enrollment data. This function will duplicate + * enrollment data as shown in $this->crn_copymap array. + * + * @return bool always true. + */ + private function process_crn_copymap() { + // Skip when there is no crn copymap data. i.e. There are no courses being duplicated. + if (is_null(CRN_COPYMAP_FILE) || empty($this->crn_copymap)) return true; + + foreach($this->data as $course=>$course_data) { + // Is the course being duplicated? + if (array_key_exists($course, $this->crn_copymap)) { + foreach($course_data as $row) { + $section = $row[COLUMN_SECTION]; + // What section(s) are being duplicated? + if (array_key_exists('all', $this->crn_copymap[$course])) { + $copymap_course = $this->crn_copymap[$course]['all']['course']; + $this->data[$copymap_course][] = $row; + $key = array_key_last($this->data[$copymap_course]); + // We are not duplicating the CRN data column. + $this->data[$copymap_course][$key][COLUMN_REG_ID] = ""; + } elseif (array_key_exists($section, $this->crn_copymap[$course])) { + $copymap_course = $this->crn_copymap[$course][$section]['course']; + $copymap_section = $this->crn_copymap[$course][$section]['section']; + $this->data[$copymap_course][] = $row; + $key = array_key_last($this->data[$copymap_course]); + $this->data[$copymap_course][$key][COLUMN_SECTION] = $copymap_section; + // We are not duplicating the CRN data column. + $this->data[$copymap_course][$key][COLUMN_REG_ID] = ""; + } + } + } + } + + return true; + } + + /** + * Open a CSV file. * - * @return boolean Indicates success/failure of opening and locking CSV file. + * Multiple files may be opened as a stack of file handles. + * + * @return boolean|int False when file couldn't be opened, or Int $key of the opened file handle. */ - private function open_csv() { + private function open_data_csv() { $this->fh = fopen(CSV_FILE, "r"); if ($this->fh === false) { - $this->log_it("Could not open CSV file."); + $this->log_it(sprintf("Could not open file: %s", CSV_FILE)); return false; } return true; } - /** Close CSV file */ - private function close_csv() { + /** Close most recent opened CSV file */ + private function close_data_csv() { if (is_resource($this->fh) && get_resource_type($this->fh) === "stream") { fclose($this->fh); + } else { + $this->fh = null; } }