import sys, os, re, copy import Exam, Record mylib = os.path.join(os.environ['HOME'], 'lib', 'python') grlib = os.path.join(os.environ['HOME'], 'lib', 'grading') sys.path.extend([mylib, grlib]) from Util import * GRADES = Exam.GRADES GRADE2INDEX = Exam.GRADE2INDEX class Course: # # Attributes: # name # exams, a dic of exam objects. # The exams called # rst, rst-phd, rst-nonphd # serve as lists of students. # def get_exam_list(self): return self.exams.keys() def exists_exam(self, exam_name): return self.exams.has_key(exam_name) def get_exam(self, exam_name): if not self.exams.has_key(exam_name): die('I do not know about exam '+exam_name) return self.exams[exam_name] def get_cut(self, exam_name, grade): if self.exams.has_key(exam_name): return self.exams[exam_name].get_cut(grade) def set_cuts(self, exam_name, cuts): if self.exams.has_key(exam): self.exams[exam_name].cuts = cuts return self def get_student_list(self, name="rst"): if not self.exams.has_key(name): die('There must be an exam named '+name) return self.exams[name].get_student_list() def exists_student(self, student_name): return self.exams['rst'].student_records.has_key(student_name) def create_exam_student(self, exam_name, student_name): # # Better than the exam's create_student method, since it coordinates # with rst. # if not self.exists_exam(exam_name): die('create_exam_student: There is no exam named '+exam_name) exam = self.get_exam(exam_name) if exam.exists_student(student_name): return self rst = self.get_exam('rst') if None == rst: die('The course has no exam named rst') if not rst.exists_student(student_name): rst.create_student(student_name) exam.student_records[student_name] = copy.copy( rst.get_record(student_name)) return self def get_record(self, exam_name, student_name): if self.exists_exam(exam_name): return self.exams[exam_name].student_records.get(student_name) def get_score(self, exam_name, student_name, complain = 0): if not self.exists_exam(exam_name): warn('Exam '+exam_name+' does not exist.') return if not self.exams[exam_name].exists_student(student_name): if complain: warn('student_name '+student_name+ ' does not exist in exam '+exam_name) return student_record = self.exams[exam_name].student_records[student_name] if not hasattr(student_record, "score"): warn("No score (replaced with 0) in exam "+exam_name+" by student "+student_name+".") return float(0) else: return student_record.score def set_score(self, exam_name, student_name, score, rounding=0): # # Creates the record if it does not exist. # Better than the exam's set_score method since it uses the course's # create_student. # if not self.exists_exam(exam_name): die('set_score: There is no exam named '+exam_name) exam = self.get_exam(exam_name) if not exam.exists_student(student_name): self.create_exam_student(exam_name, student_name) if rounding: exam.get_record(student_name).score = int(score + 0.5) else: exam.get_record(student_name).score = float(score) return self def get_grade(self, exam_name, student_name): if (self.exists_exam(exam_name) and self.exams[exam_name].exists_student(student_name)): return self.exams[exam_name].get_grade(student_name) def get_comment(self, exam_name, student_name): if (self.exists_exam(exam_name) and self.exams[exam_name].exists_student(student_name)): return self.exams[exam_name].get_comment(student_name) def __init__(self, name = "", student_name = "", exam_names = ["rst", "final_grade", "course_grade"]): ''' The optional argument exam_names = ["rst", "final_grade", "course_grade"] allows you to load only the list of exams mentioned. The exam "rst" will always be loaded. student_name names that are not in rst will not be processed. ''' if not name: die('The course must have a name.') self.name = name home = os.environ['HOME'] self.grading_dir = os.path.join(home, 'courses', name, 'grading') backup_dir = os.path.join(self.grading_dir, '.backups') if not os.path.exists(backup_dir): os.mkdir(backup_dir, 0700) if not os.path.isdir(backup_dir): die('Could not create backup directory.') self.backup_dir = backup_dir self.exams = {} exam_list = exam_names print "Initial exam list for Course = ", exam_list print "Reading file exams" exams_file = os.path.join(self.grading_dir, 'exams') try: fh = open(exams_file,"rU") except OSError, detail: die('Cannot open '+ exams_file, detail) while 1: line = fh.readline() if not line: break # Part of lines after # are comments in 'exams': hit = re.match(r'^([^#]*)(?:#|$)', line) line = hit.groups()[0] sep_rx = r'(?:\s+|$)' # The first optional number is the weight (can be negative). weight_rx = r'(\-?\d*\.?\d+|\-?\d+)?' # The second one is the value to normalize with (normally the max). denom_rx = r'(\d*\.\d+|\d+)?' # The third one is the maximum score, in case it is different from denom. max_score_rx = r'(\d*\.?\d+)?' exam_rx = r'^\s*(\S+)' + sep_rx + weight_rx + sep_rx + denom_rx + sep_rx + max_score_rx # If the line is for a target exam followed by its sources, let us separate it: hit = re.match(r'^([^:]*):(.*)$', line) sources = [] if hit: exam_line, sources_line = hit.groups() sources = sources_line.split() else: exam_line = line hit = re.match(exam_rx, exam_line) if not hit: continue name, weight_s, denom_s, max_score_s = hit.groups() if not name: continue # if ('rst' == name or not exam_list or ( # exam_list and name in exam_list)): # warn('Reading '+name+' ...') exam = Exam.Exam(name, grading_dir = self.grading_dir, backup_dir = self.backup_dir, student_name = student_name) if weight_s: exam.weight = float(weight_s) else: print "No weight for exam "+name if denom_s: exam.denom = float(denom_s) else: print "No denominator for exam "+name if max_score_s: exam.set_cut("max", float(max_score_s)) else: print "No max_score for exam "+name if sources: exam.sources = sources self.exams[name] = exam for exam in self.exams.keys(): if 'rst' != exam: self.purge_exam(exam) print "exam "+exam+" purged." # self.complete_and_purge_exam(exam) print "Done with reading file exams." # ---------------------------- Applications -------------------- def complete_and_purge_exam(self, exam_name, rst_name = 'rst'): # # Complete the records from rst (typically, name and buid). # Delete the records not in rst. # exam = self.get_exam(exam_name) rst = self.get_exam(rst_name) exam.update_from_exam(rst) exam.purge_using_exam(rst) return self def purge_exam(self, exam_name, rst_name = 'rst'): # # Complete the records from rst (typically, name and buid). # Delete the records not in rst. # exam = self.get_exam(exam_name) rst = self.get_exam(rst_name) exam.purge_using_exam(rst) return self def proc_exam(self, exam = '', print_grade = 1, only_by_score = 0, precision = 0): warn('Processing exam '+exam+'...') exam_name = exam if not exam_name: die('Exam argument missing in proc_exam.') exam = self.get_exam(exam_name) if not exam: die('There is no exam '+exam_name) print "Looking for cuts." if hasattr(exam, 'cuts'): print "Calculating grades." exam.calc_grades() exam.print_records(sorted_by = 'score', target = exam_name, print_grade = print_grade, print_name = 1, precision = precision ) if not only_by_score: exam.print_records(sorted_by = 'name', target = exam_name+'.name', print_grade = print_grade, print_name = 1, precision = precision) if None != exam.get_cut('D'): exam.print_cuts() return self def average_cuts(self, exam_names): exams = map(lambda x, self = self: self.get_exam(x), exam_names) comb_cuts = {} for grade in GRADES: if grade == 'weight': continue x = 0 for exam in exams: B_cut = exam.get_cut('B') if not B_cut: warn('No nonzero cut for B in '+exam) break denom = B_cut # Used to be median of scores above F. if hasattr(exam, 'weight'): weight = exam.weight else: warn('Exam '+exam.name+' has no weight.') weight = 1 x = x + exam.get_cut(grade) * weight / denom comb_cuts[grade] = int(x + 0.5) return comb_cuts # # For a given student_name, collect the scores of all exams in the list. # If some exam has a note 'Delete.' then that score is omitted. # When there is a show_student argument, then the results for the student in # question will also be displayed on the screen. # def collect_score_list(self, student_name, exam_list, show_student = ''): output = [] for exam_name in exam_list: score = self.get_score(exam_name, student_name) or float(0) comment = self.get_comment(exam_name, student_name) or '' if 'Delete.' == comment.rstrip().lstrip(): continue output.append(float(score)) if show_student == student_name: warn('Existing scores for %s in exams %s:\n%s' % (student_name, ', '.join(exam_list), ', '.join(map(str, output)))) return output # # Delete the worst delete_worst exam scores. # When there is a student_name argument, then the results for the student in # question will also be displayed on the screen. # # Also, compute the max_score as the sum of all the max_scores, even if delete_worst>0, # since the max_score values may not be all the same. def exams_sum_best_scores(self, target = "", exam_names = [], delete_worst = 0, show_student = "", student_list="rst"): if not exam_names: die('No exams given in average_exams.') if not target: die('No target exam given in average_exams') max_score = 0 for exam_name in exam_names: exam=self.get_exam(exam_name) if not hasattr(exam, "cuts"): print "There are no cuts for exam "+exam.name max_score=0 break else: exam_max = exam.get_cut("max") if None == exam_max: print "There is no max_score for exam "+exam.name max_score=0 break else: max_score += exam.get_cut("max") if max_score>0: self.get_exam(target).set_cut("max", max_score) for student_name in self.get_student_list(student_list): score_list = self.collect_score_list(student_name, exam_names, show_student = show_student) score_list.sort() if show_student == student_name: warn('Sorted scores for %s in exams %s:\n%s' % (student_name, ', '.join(exam_names), ', '.join(map(str, score_list)))) s = float(0) for i in range(delete_worst, len(score_list)): s += score_list[i] if student_name==show_student: print "%f added" % score_list[i] self.set_score(target, student_name, s) return self def exams_calc_average(self, target = "", exam_names = [], show_student = "", grades = 1, student_list="rst"): '''The result is written in target. The average of the scores is computed. Default weight is 1. There are two possibilities. - There are no grades (no cuts). This will be seen from the fact that the exams have no cuts. In this case, the average of the scores is simply computed. - There are grades. Then the scores are normalized by the B cuts. The new cuts are computed using average_cuts. Then the new grades are computed. When there is a show_student argument, then the results for the student in question will also be displayed on the screen. ''' if not exam_names: die('No exams given in average_exams') if not target: die('No target exam given in average_exams') target_exam = self.get_exam(target) if not target_exam: die('Exam '+target+' not loaded') # Check whether there are cuts for B in each exam. # We assume that if there is a cut for B then all cuts are there. if grades: for exam_name in exam_names: if not self.get_cut(exam_name, 'B'): grades = 0 warn('There is no nonzero cut for B in exam '+exam_name) break if grades: target_exam.set_cuts(self.average_cuts(exam_names)) for student_name in self.get_student_list(student_list): show = student_name == show_student if show: warn(student_name+':') x = 0 for exam_name in exam_names: if show: warn(exam_name+': ') exam = self.get_exam(exam_name) score = exam.get_score(student_name) or 0 if hasattr(exam, 'weight'): weight = exam.weight else: weight = 1 comment = exam.get_comment(student_name) if comment: hit = re.search(r'weight:\s+(-?\d+)', comment) if hit: weight = int(hit.groups()[0]) denom = 1 if hasattr(exam, 'denom'): denom = exam.denom if grades: denom = exam.get_cut('B') if not denom: die('No cut for B in '+exam) contrib = score * weight / denom if show: warn('score = %.1f, weight = %.1f' % (score, weight)) warn('denom = %.1f, contrib = %.1f' % (denom, contrib)) x = x + contrib if 0>x: # We do not want negative totals. x=0 if show: warn('total = %.1f' % x) self.set_score(target, student_name, x) if grades: target_exam.calc_grades() return self def sum_extra_info(self, target = "absences", comment_regexp=r'[\*]', exam_names = [], show_student = "", student_list="rst"): '''The result is written in target. The comment of each student contains a * if the exam or homework was not picked up. When there is a show_student argument, then the results for the student in question will also be displayed on the screen. ''' if not exam_names: die('No exams given in sum_absences') print exam_names if not target: die('No target exam given in sum_absences') target_exam = self.get_exam(target) if not target_exam: die('Exam '+target+' not loaded') for student_name in self.get_student_list(student_list): show = show_student == student_name if show: warn(student_name+':') x = 0 for exam_name in exam_names: if show: warn(exam_name+': ') exam = self.get_exam(exam_name) comment = exam.get_comment(student_name) if comment: hit = re.search(comment_regexp, comment) if hit: x = x+1 if show: warn('total = %.1f' % x) self.set_score(target, student_name, x) return self def normal_score(self, student_name, source_exam, target_exam): ''' The score of a student on source_exam, as measured in the scaling of target_exam. ''' score = source_exam.get_score(student_name) grade = source_exam.get_grade(student_name) next_grade = GRADES[1 + GRADE2INDEX[grade]] lb = source_exam.get_cut(grade) ub = source_exam.get_cut(next_grade) target_lb = target_exam.get_cut(grade) target_ub = target_exam.get_cut(next_grade) return target_lb + (score - lb)*(target_ub - target_lb)/(ub - lb) def exams_better(self, exam_names = [], target = '', student_list="rst"): ''' Take the better of two exams given as a list argument, for everybody. The grade is the better one. The exams need weights that sum up to the target weight. The cuts of the new exam are the average of the cuts of both. The score value of the new grade is the point between the ends of its interval of average cuts obtained proportionally from the score of the better grade. The arguments and return values are as in exams_calc_average. ''' exams = map(lambda x, self = self: self.get_exam(x), exam_names) if not exam_names: die('No exams given in exams_better') if not target: die('No target exam given in exams_better') target_exam = self.get_exam(target) target_exam.set_cuts(self.average_cuts(exam_names)) # This is not really needed: # for exam in exams: # exam.calc_median_above() for student_name in self.get_student_list(student_list): out_exams = [] for exam in exams: if exam.get_grade(student_name): out_exams.append(exam) if not out_exams: warn('student_name '+student_name+' has neither of the exams '+ ', '.join(exam_names)) # Better than the exam's set_score method. self.set_score(target, student_name, 0) continue normal_scores = map(lambda exam, self = self, student_name = student_name, target_exam = target_exam: self.normal_score(student_name, exam, target_exam), out_exams) if (2 == len(normal_scores) and normal_scores[0] < normal_scores[1]): normal_scores[0]= normal_scores[1] self.set_score(target, student_name, normal_scores[0]) def mail_grades(self, exam_names = [], student_name = '', intro = ''): if not exam_names: die('No exams specified in mail_grades') if not student_name: die('No student specified in mail_grades') course = self.name try: mh = os.popen('mail -s %s %s' % (course, student_name), 'w') except OSError: warn('could not open mail pipe to '+student_name) return mh.write(intro+'\n') mh.write('Your grades/scores on some exams/homeworks\n\n') for exam_name in exam_names: score = self.get_score(exam_name, student_name) if None == score: continue score_str = 'score: %4d' % score grade = self.get_grade(exam_name, student_name) or '' if grade: grade_str = 'grade: %s' % grade else: grade_str = '' mh.write(' %-20s %s %s\n' % (exam_name, score_str, grade_str)) mh.close() def submit_grades(self, exam_names = [], students = [], student_list="rst"): """ Puts for each student of the course his/her grade on the exams into the student's submission directory, in a file called GRADE-, etc. If the option for students is nonempty, the submission will be done only for those students. """ if not exam_names: die('No exams specified in submit_grade') if not students: students = self.get_student_list(student_list) course = self.name spool = os.path.join(os.sep, 'cs', 'course', course, 'current', 'homework', 'spool') for exam_name in exam_names: for student_name in students: grade_file = os.path.join(spool, student_name, 'GRADE-'+exam_name) try: fh = open(grade_file, 'w') except IOError, detail: warn('open '+grade_file+' failed') continue os.chmod(grade_file, 0640) fh.write('%20s %5s %5s %s\n' % ('Exam', 'Score', 'Grade', 'Comment')) score = self.get_score(exam_name, student_name) or 0 grade = self.get_grade(exam_name, student_name) or '' comment = self.get_comment(exam_name, student_name) or '' fh.write('%20s %5d %-5s %s\n' % (exam_name, score, grade, comment))