import sys, os, re, copy from . import Exam, Record mylib = os.path.join(os.environ['HOME'], 'src', 'python') grlib = os.path.join(os.environ['HOME'], 'src', 'grading') sys.path.extend([mylib, grlib]) from Util import * GRADES = Exam.GRADES GRADE2INDEX = Exam.GRADE2INDEX # Sort method: # def try_name(a): # if hasattr(a, 'name'): # return a.name # else: # return a.login class Course: """ Attributes: from the configuration file: course_name exam_data, a dic of exam information generated: exams, a dic of exam objects. The exams called rst, rst-phd, rst-nonphd serve as lists of students. default_list is normally rst. """ """ The optional argument exam_names allows you to load only the list of exams mentioned. The exam 'rst' will always be loaded. student names that are not in rst will not be processed. """ def __init__(self, course_name = '', exam_data = {}, student_name = "", default_list = 'rst', exam_names = ['rst']): self.default_list = default_list self.course_name = course_name self.exam_data = exam_data home = os.environ['HOME'] self.grading_dir = os.path.join(home, 'courses', course_name, 'grading') backup_dir = os.path.join(self.grading_dir, '.backups') if not os.path.exists(backup_dir): os.mkdir(backup_dir, 0o700) if not os.path.isdir(backup_dir): raise Exception('Could not create backup directory.') self.backup_dir = backup_dir self.exams = {} for exam_name in self.exam_data.keys(): exam = Exam.Exam(exam_name, self.exam_data[exam_name], grading_dir = self.grading_dir, backup_dir = self.backup_dir, student_name = student_name) self.exams[exam_name] = exam ''' This must be done in a separate loop, when the lists are already established: ''' for exam_name in self.exam_data.keys(): self.complete_and_purge_exam(exam_name) def get_exam_list(self): return self.exams.keys() def exists_exam(self, exam_name): return exam_name in self.exams def get_exam(self, exam_name): if exam_name not in self.exams: raise Exception('I do not know about exam '+exam_name) return self.exams[exam_name] def set_cuts(self, exam_name, cuts): if exam in self.exams: self.exams[exam_name].cuts = cuts return self def get_student_list(self, exam_name = 'rst'): if exam_name not in self.exams: raise Exception('There must be an exam named '+exam_name) return self.exams[exam_name].get_student_list() def print_student_data_list(self, exam_names, target, list_name='rst'): student_record_list = self.exams[list_name].get_record_list() for student_record in student_record_list: for exam_name in exam_names: exam = self.exams[exam_name] exam_record = exam.get_record(student_record.login) if exam_record: exam_result = {"score": exam_record.score} else: exam_result = {"score": 0} print("No record for exam "+exam_name+" for student "+student_record.login) student_record.other[exam_name] = exam_result # student_records.sort(key=try_name) target = target or self.name+'.name' file = os.path.join(self.grading_dir, target) try: fh = open(file, 'w') except IOError as detail: raise Exception('Cannot open'+file+' for writing', detail) for student_record in student_record_list: for exam_name in exam_names: fh.write("%5d \t" % student_record.other[exam_name]["score"]) fh.write("\n") fh.close() return self def exists_student(self, student_name): return student_name in self.exams['rst'].student_records 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): raise Exception('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: raise Exception('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): raise Exception('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) # ---------------------------- Applications -------------------- def purge_exam(self, exam_name): # # Complete the records from rst (typically, name and buid). # Delete the records not in rst. # exam = self.get_exam(exam_name) exam.purge_using_exam(self.get_exam(exam.rst_name)) return self def complete_and_purge_exam(self, exam_name): # # 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(exam.rst_name) # print("Purging "+exam_name+" using "+exam.rst_name) exam.update_from_exam(rst) exam.purge_using_exam(rst) # print(exam_name+" size = "+str(len(exam.get_student_list()))) # if 'grading' == exam_name: # print(exam_name+":") # print(exam.get_student_list()) return self def proc_exam(self, exam_name = '', print_grade = True, only_by_score = False, precision = 0): if not exam_name: raise Exception('Exam argument missing in proc_exam.') exam = self.get_exam(exam_name) if not exam: raise Exception('There is no exam '+exam_name) # print("Looking for cuts.") if hasattr(exam, 'cuts'): # print("Calculating grades.") exam.calc_grades() # print(exam_name+" size = "+str(len(exam.get_student_list()))) exam.print_records(sorted_by = 'score', target = exam_name, # target = exam_name+".score", print_grade = print_grade, print_name = True, precision = precision ) if not only_by_score: exam.print_records(sorted_by = 'name', target = exam_name+'.name', print_grade = print_grade, print_name = True, precision = precision) if None != exam.get_cut('D'): exam.print_cuts() return self """ Sources is a dictionary indexed by exam names. Each value is a dictionary, that might contain a property 'weight'. """ def average_cuts(self, sources): """ We average only the cuts of those sources that have cuts. The ones that don't may be 'extra-credit'. """ # warn("Averaging cuts from sources {}.".format(sources)) comb_cuts = {} for exam_name in sources.keys(): if 'weight' not in sources[exam_name]: warn('Exam '+exam_name+' has no weight.') for grade in GRADES: x = 0 for exam_name in sources.keys(): source_data = sources[exam_name] weight = source_data.get('weight', 1) exam = self.get_exam(exam_name) cut = exam.get_cut(grade) if None == cut: # warn("Grade {}: Exam {} is not used to average the cuts'.".format(grade,exam_name)) continue if hasattr(exam,'denom'): denom = exam.denom else: denom = exam.get_cut('B') # warn("denom for exam {} is {}:".format(exam_name,denom)) if not denom: raise Exception('average_cuts: no cut for B in exam {}.'.format(exam_name)) x += cut * 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 = "", sources = {}, delete_worst = 0, show_student = "", student_list="rst"): if show_student: warn("Showing sum results for "+show_student) if not sources: raise Exception('No exams given in exams_sum_best_scores.') if not target: raise Exception('No target exam given in exams_sum_best_scores') max_score = 0 exam_names = sources.keys() for exam_name in exam_names: exam=self.get_exam(exam_name) exam_max = exam.max_score if None == exam_max: print("There is no max_score for exam "+exam_name) max_score=0 break else: max_score += exam.max_score if max_score>0: exam = self.get_exam(target) exam.max_score = max_score exam.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 = "", sources = {}, show_student = "", grades = True, 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 for some of the scores. Then the scores are normalized by the B cuts. The grades without cuts must take their cuts from the score of their 'cut from' property. 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 sources: raise Exception('No exams given in average_exams') if not target: raise Exception('No target exam given in average_exams') target_exam = self.get_exam(target) if not target_exam: raise Exception('Exam '+target+' not loaded') exam_names = sources.keys() # 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: grades = False for exam_name in exam_names: exam = self.exams[exam_name] if None != exam.get_cut('B'): grades = True # warn('There is at least one cut for B in exam '+exam_name) break # else: # warn("calc_average: no cut for B in exam {}.".format(exam_name)) # warn("grades = {}".format(grades)) if grades: target_exam.set_cuts(self.average_cuts(sources)) 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: data = self.exam_data[exam_name] source_data = sources[exam_name] if show: warn(exam_name+': ') # if "p4" == exam_name: # warn("Exam data = {}".format(data)) exam = self.get_exam(exam_name) score = exam.get_score(student_name) or 0 grade = exam.get_grade(student_name) or "no-grade" weight = source_data.get('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 elif grades: # warn("Finding denom for exam {}".format(exam_name)) denom = exam.get_cut('B') # Exams without their own cuts (probably just extra credit): if None == denom: # warn("Exam data for {}: {}.".format(exam_name,data)) other_exam_name = data.get("cuts from") if None == other_exam_name: raise Exception("Exam {} has no denominator and no 'cut from' detail.".format(exam_name)) other_exam = self.get_exam(other_exam_name) denom = other_exam.get_cut('B') if None == denom: raise Exception("Exam {} has no cut for B either.".format(other_exam_name)) else: denom = 1 contrib = score * weight / denom if show: warn('score = %5.1f, grade %-8s weight = %.1f' % (score, grade, weight)) warn('denom = %5.1f, contrib = %-3.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) self.purge_exam(target) if grades: target_exam.calc_grades() return self """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. """ def sum_extra_info(self, target = "absences", comment_regexp=r'[\*]', exam_names = [], show_student = "", student_list="rst"): if not exam_names: raise Exception('No exams given in sum_absences') print(exam_names) if not target: raise Exception('No target exam given in sum_absences') target_exam = self.get_exam(target) if not target_exam: raise Exception('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 """ The score of a student on source_exam, as measured in the scaling of target_exam. """ def normal_score(self, student_name, source_exam, 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) """ Take the better of two exams given in the sources,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. """ def exams_better(self, sources, target = '', student_list="rst"): exams = map(lambda x, self = self: self.get_exam(x), sources.keys(), show_student = "") if not sources: raise Exception('No exams given in exams_better') if not target: raise Exception('No target exam given in exams_better') target_exam = self.get_exam(target) target_exam.set_cuts(self.average_cuts(sources)) 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: raise Exception('No exams specified in mail_grades') if not student_name: raise Exception('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() """ 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. """ def submit_grades(self, exam_names = [], students = [], student_list="rst"): if not exam_names: raise Exception('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 as detail: warn('open '+grade_file+' failed') continue os.chmod(grade_file, 0o640) 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))