class Student(User, CustomFieldModel): mname = models.CharField(max_length=150, blank=True, null=True, verbose_name="Middle Name") grad_date = models.DateField(blank=True, null=True, validators=settings.DATE_VALIDATORS) pic = ImageWithThumbsField(upload_to="student_pics", blank=True, null=True, sizes=((70,65),(530, 400))) alert = models.CharField(max_length=500, blank=True, help_text="Warn any user who accesses this record with this text") sex = models.CharField(max_length=1, choices=(('M', 'Male'), ('F', 'Female')), blank=True, null=True) bday = models.DateField(blank=True, null=True, verbose_name="Birth Date", validators=settings.DATE_VALIDATORS) year = models.ForeignKey( GradeLevel, blank=True, null=True, on_delete=models.SET_NULL, verbose_name="Grade level") class_of_year = models.ForeignKey(ClassYear, verbose_name="Graduating Class", blank=True, null=True) date_dismissed = models.DateField(blank=True, null=True, validators=settings.DATE_VALIDATORS) reason_left = models.ForeignKey(ReasonLeft, blank=True, null=True) unique_id = models.IntegerField(blank=True, null=True, unique=True, help_text="For integration with outside databases") ssn = models.CharField(max_length=11, blank=True, null=True) #Once 1.1 is out USSocialSecurityNumberField(blank=True) # These fields are cached from emergency contacts parent_guardian = models.CharField(max_length=150, blank=True, editable=False) street = models.CharField(max_length=150, blank=True, editable=False) state = USStateField(blank=True, editable=False, null=True) city = models.CharField(max_length=255, blank=True) zip = models.CharField(max_length=10, blank=True, editable=False) parent_email = models.EmailField(blank=True, editable=False) family_preferred_language = models.ForeignKey(LanguageChoice, blank=True, null=True, default=get_default_language) family_access_users = models.ManyToManyField( family_ref, blank=True, related_name="+", ) alt_email = models.EmailField(blank=True, help_text="Alternative student email that is not their school email.") notes = models.TextField(blank=True) emergency_contacts = models.ManyToManyField(EmergencyContact, verbose_name="Student Contact", blank=True) siblings = models.ManyToManyField('Student', blank=True) cohorts = models.ManyToManyField(Cohort, through='StudentCohort', blank=True) cache_cohort = models.ForeignKey(Cohort, editable=False, blank=True, null=True, on_delete=models.SET_NULL, help_text="Cached primary cohort.", related_name="cache_cohorts") individual_education_program = models.BooleanField(default=False) gpa = CachedDecimalField(editable=False, max_digits=5, decimal_places=2, blank=True, null=True) class Meta: permissions = ( ("view_student", "View student"), ("view_ssn_student", "View student ssn"), ("view_mentor_student", "View mentoring information student"), ("reports", "View reports"), ) ordering = ("last_name", "first_name") def __unicode__(self): return u"{0}, {1}".format(self.last_name, self.first_name) def get_absolute_url(): pass # TC requested this for transcript template def get_long_grad_date(self): return self.grad_date.strftime('%B %d, %Y') def get_gpa(self, rounding=2, numeric_scale=False, boost=True): """ Get cached gpa but with rounding and scale options """ gpa = self.gpa if numeric_scale == True: # Get the scale for the last year the student was active in grade_scale = GradeScale.objects.filter( schoolyear__markingperiod__coursesection__courseenrollment__user=self).last() if grade_scale: gpa = grade_scale.to_numeric(gpa) enrollments = self.courseenrollment_set.filter( course_section__course__course_type__weight__gt=0) if boost: boost_sum = enrollments.aggregate(boost_sum=Sum('course_section__course__course_type__boost'))['boost_sum'] boost_factor = boost_sum / enrollments.count() gpa += boost_factor if rounding: gpa = round_as_decimal(gpa, rounding) return gpa def calculate_gpa(self, date_report=None, rounding=2, prescale=False, boost=True): """ Use StudentYearGrade calculation No further weighting needed. """ total = Decimal(0) years_with_grade = 0 grade_years = self.studentyeargrade_set.filter(year__markingperiod__show_reports=True) if date_report: grade_years = grade_years.filter(year__start_date__lt=date_report) for grade_year in grade_years.distinct(): # grade = grade_year.calculate_grade(date_report=date_report, prescale=prescale) grade = grade_year.get_grade( date_report = date_report, numeric_scale = True, rounding = rounding, prescale = prescale, boost = boost ) if grade: # Is this an incomplete complete year? if date_report and date_report < grade_year.year.end_date: # This year hasn't finished. What fraction is complete? all_mps = grade_year.year.markingperiod_set.count() complete_mps = grade_year.year.markingperiod_set.filter( end_date__lte=date_report).count() fraction = Decimal(complete_mps) / all_mps total += grade * grade_year.credits * fraction years_with_grade += grade_year.credits * fraction else: total += grade * grade_year.credits years_with_grade += grade_year.credits if years_with_grade: gpa = total / years_with_grade return round_as_decimal(gpa, decimal_places=rounding) @property def primary_cohort(self): return self.cache_cohort @property def phone(self): try: parent = self.emergency_contacts.order_by('-primary_contact')[0] return parent.emergencycontactnumber_set.all()[0].number except IndexError: return None @property def he_she(self): """ returns "he" or "she" """ return self.gender_to_word("he", "she") @property def homeroom(self): """ Returns homeroom for student """ from schedule.models import CourseSection try: courses = self.coursesection_set.filter(course__homeroom=True) homeroom = self.coursesection_set.get(course__homeroom=True) except: return "" @property def son_daughter(self): """ returns "son" or "daughter" """ return self.gender_to_word("son", "daughter") @property def get_email(self): """ Returns email address using various configurable methods """ email_method = Configuration.get_or_default( "How to obtain student email", default="append", help_text="append, user, or student.").value if email_method == "append": email_end = Configuration.get_or_default("email", default="@change.me").value return '%s%s' % (self.student.username, email_end) elif email_method == "user": if User.objects.filter(username=self.student.username): return User.objects.filter(username=self.student.username)[0].email return None return self.alt_email def get_phone_number(self): if self.studentnumber_set.filter(type="C"): return self.studentnumber_set.filter(type="C")[0] elif self.studentnumber_set.all(): return self.studentnumber_set.all()[0] def get_primary_emergency_contact(self): if self.emergency_contacts.filter(primary_contact=True): return self.emergency_contacts.filter(primary_contact=True)[0] def get_disciplines(self, mps, action_name=None, count=True): """ Shortcut to look up discipline records mp: Marking Period action_name: Discipline action name count: Boolean - Just the count of them """ if hasattr(mps,'db'): # More than one? if len(mps): start_date = mps.order_by('start_date')[0].start_date end_date = mps.order_by('-end_date')[0].end_date disc = self.studentdiscipline_set.filter(date__range=(start_date,end_date)) else: disc = self.studentdiscipline_set.none() else: disc = self.studentdiscipline_set.filter(date__range=(mps.start_date,mps.end_date)) if action_name: disc = disc.filter(action__name=action_name) if count: return disc.count() else: return disc def gender_to_word(self, male_word, female_word): """ returns a string based on the sex of student """ if self.sex == "M": return male_word elif self.sex == "F": return female_word else: return male_word + "/" + female_word def cache_cohorts(self): cohorts = StudentCohort.objects.filter(student=self) if cohorts.filter(primary=True).count(): self.cache_cohort = cohorts.filter(primary=True)[0].cohort elif cohorts.count(): self.cache_cohort = cohorts[0].cohort else: self.cache_cohort = None def get_year(self, active_year): """ get the year (fresh, etc) from the class of XX year. """ if self.class_of_year: try: this_year = active_year.end_date.year school_last_year = GradeLevel.objects.order_by('-id')[0].id class_of_year = self.class_of_year.year target_year = school_last_year - (class_of_year - this_year) return GradeLevel.objects.get(id=target_year) except: return None def get_scaled_multiple_mp_average_by_indices(self, indices, rounding=2): """ Get a scaled mulitple marking period average for this student Requires that the property mps be set previously. This function exists mainly for appy based report cards where speed, and simplicity (in the template) are important. """ from ecwsp.grades.models import Grade mps = [ self.mps[i] for i in indices ] return Grade.get_scaled_multiple_mp_average(self, mps, rounding) def determine_year(self): """ Set the year (fresh, etc) from the class of XX year. """ if self.class_of_year: try: active_year = SchoolYear.objects.filter(active_year=True)[0] self.year = self.get_year(active_year) except: return None def save(self, creating_worker=False, *args, **kwargs): self.cache_cohorts() if self.is_active == False and (Configuration.get_or_default("Clear Placement for Inactive Students","False").value == "True" \ or Configuration.get_or_default("Clear Placement for Inactive Students","False").value == "true" \ or Configuration.get_or_default("Clear Placement for Inactive Students","False").value == "T"): try: self.studentworker.placement = None self.studentworker.save() except: pass # Check year self.determine_year() super(Student, self).save(*args, **kwargs) # Create student worker if the app is installed. # https://code.djangoproject.com/ticket/7623 if 'ecwsp.work_study' in settings.INSTALLED_APPS: if not creating_worker and not hasattr(self, 'studentworker'): from ecwsp.work_study.models import StudentWorker worker = StudentWorker(user_ptr_id=self.user_ptr_id) worker.__dict__.update(self.__dict__) worker.save(creating_worker=True) group, gcreated = Group.objects.get_or_create(name="students") self.user_ptr.groups.add(group) def clean(self, *args, **kwargs): """ Check if a Faculty exists, can't have someone be a Student and Faculty """ if Faculty.objects.filter(id=self.id).count(): raise ValidationError('Cannot have someone be a student AND faculty!') super(Student, self).clean(*args, **kwargs) def graduate_and_create_alumni(self): self.inactive = True self.reason_left = ReasonLeft.objects.get_or_create(reason="Graduated")[0] if not self.grad_date: self.grad_date = date.today() if 'ecwsp.alumni' in settings.INSTALLED_APPS: from ecwsp.alumni.models import Alumni Alumni.objects.get_or_create(student=self) self.save() def promote_to_worker(self): """ Promote student object to a student worker keeping all fields, does nothing on duplicate. """ try: cursor = connection.cursor() cursor.execute("insert into work_study_studentworker (student_ptr_id) values (" + str(self.id) + ");") except: return
class CourseEnrollment(models.Model): course_section = models.ForeignKey('CourseSection') user = models.ForeignKey('sis.Student') attendance_note = models.CharField( max_length=255, blank=True, help_text="This note will appear when taking attendance.") exclude_days = models.CharField( max_length=100, blank=True, help_text= "Student does not need to attend on this day. Note course sections already specify meeting days; this field is for students who have a special reason to be away." ) grade = CachedCharField(max_length=8, blank=True, verbose_name="Final Course Section Grade", editable=False) numeric_grade = CachedDecimalField(max_digits=5, decimal_places=2, blank=True, null=True) is_active = models.BooleanField(default=True) class Meta: unique_together = (("course_section", "user"), ) def save(self, populate_all_grades=True, *args, **kwargs): """ populate_all_grades (default True) is intended to recalculate any related grades to this enrollment. It can be disabled to stop a recursive save. """ super(CourseEnrollment, self).save(*args, **kwargs) if populate_all_grades is True: self.course_section.populate_all_grades() def cache_grades(self): """ Set cache on both grade and numeric_grade """ grade = self.calculate_grade_real() if isinstance(grade, Decimal): grade = grade.quantize(Decimal(".01"), rounding=ROUND_HALF_UP) self.numeric_grade = grade else: self.numeric_grade = None if grade == None: grade = '' self.grade = grade self.grade_recalculation_needed = False self.numeric_grade_recalculation_needed = False self.save(populate_all_grades=False) # Causes recursion otherwise. return grade def get_average_for_marking_periods(self, marking_periods, letter=False, numeric=False): """ Get the average for only some marking periods marking_periods - Queryset or optionally pass ids only as an optimization letter - Letter grade scale numeric - non linear numeric scale """ if isinstance(marking_periods, QuerySet): marking_periods = tuple( marking_periods.values_list('id', flat=True)) else: # Check marking_periods because we can't use sql parameters because sqlite and django suck if all(isinstance(item, int) for item in marking_periods) != True: raise ValueError( 'marking_periods must be list or tuple of ints') marking_periods = tuple(marking_periods) cursor = connection.cursor() sql_string = ''' SELECT Sum(grade * weight) {over} / Sum(weight) {over} AS ave_grade FROM grades_grade LEFT JOIN schedule_markingperiod ON schedule_markingperiod.id = grades_grade.marking_period_id WHERE grades_grade.course_section_id = %s AND grades_grade.student_id = %s AND schedule_markingperiod.id in {marking_periods} AND ( grade IS NOT NULL OR letter_grade IS NOT NULL )''' if settings.DATABASES['default']['ENGINE'] in [ 'django.db.backends.postgresql_psycopg2', 'tenant_schemas.postgresql_backend' ]: sql_string = sql_string.format(over='over ()', marking_periods=marking_periods) else: sql_string = sql_string.format(over='', marking_periods=marking_periods) cursor.execute(sql_string, (self.course_section_id, self.user_id)) result = cursor.fetchone() if result: grade = result[0] else: return None if grade is not None: if letter: grade = self.optimized_grade_to_scale(grade, letter=True) elif numeric: grade = self.optimized_grade_to_scale(grade, letter=False) return grade def optimized_grade_to_scale(self, grade, letter=True): """ letter - True for letter grade, false for numeric (ex: 4.0 scale) """ # not sure how this was working before, but I'm just commenting it out # if something else relies on the old method I have just broke it! # -Q '''rule = GradeScaleRule.objects.filter( grade_scale__schoolyear__markingperiod__coursesection=self, min_grade__lte=grade, max_grade__gte=grade).first()''' grade = round_to_standard(grade) rule = GradeScaleRule.objects.filter( min_grade__lte=grade, max_grade__gte=grade, ).select_related('course_section').first() if letter: return rule.letter_grade return rule.numeric_scale def get_grade(self, date_report=None, rounding=2, letter=False): """ Get the grade, use cache when no date change present date_report: rounding: Round to this many decimal places letter: Convert to letter grade scale """ if date_report is None or date_report >= datetime.date.today(): # Cache will always have the latest grade, so it's fine for # today's date and any future date if self.numeric_grade: grade = self.numeric_grade else: grade = self.grade else: grade = self.calculate_grade_real(date_report=date_report) if rounding and isinstance(grade, (int, long, float, complex, Decimal)): grade = round_as_decimal(grade, rounding) if letter == True and isinstance(grade, (int, long, float, complex, Decimal)): return self.optimized_grade_to_scale(grade) return grade def calculate_grade(self): return self.cache_grades() def calculate_numeric_grade(self): grade = self.cache_grades() if isinstance(grade, Decimal): return grade return None def calculate_grade_real(self, date_report=None, ignore_letter=False): """ Calculate the final grade for a course section ignore_letter can be useful when computing averages when you don't care about letter grades """ cursor = connection.cursor() # postgres requires a over () to run # http://stackoverflow.com/questions/19271646/how-to-make-a-sum-without-group-by sql_string = ''' SELECT case when Sum(override_final{postgres_type_cast}) {over} = 1 then -9001 else (Sum(grade * weight) {over} / Sum(weight) {over}) end AS ave_grade FROM grades_grade LEFT JOIN schedule_markingperiod ON schedule_markingperiod.id = grades_grade.marking_period_id WHERE (grades_grade.course_section_id = %s AND grades_grade.student_id = %s {extra_where} ) AND ( grade IS NOT NULL OR letter_grade IS NOT NULL )''' if date_report: if settings.DATABASES['default']['ENGINE'] in [ 'django.db.backends.postgresql_psycopg2', 'tenant_schemas.postgresql_backend' ]: cursor.execute( sql_string.format( postgres_type_cast='::int', over='over ()', extra_where= 'AND (schedule_markingperiod.end_date <= %s OR override_final = true)' ), (self.course_section_id, self.user_id, date_report)) else: cursor.execute( sql_string.format( postgres_type_cast='', over='', extra_where= 'AND (schedule_markingperiod.end_date <= %s OR grades_grade.override_final = true)' ), (self.course_section_id, self.user_id, date_report)) else: if settings.DATABASES['default']['ENGINE'] in [ 'django.db.backends.postgresql_psycopg2', 'tenant_schemas.postgresql_backend' ]: cursor.execute( sql_string.format(postgres_type_cast='::int', over='over ()', extra_where=''), (self.course_section_id, self.user_id)) else: cursor.execute( sql_string.format(postgres_type_cast='', over='', extra_where=''), (self.course_section_id, self.user_id)) result = cursor.fetchone() if result: ave_grade = result[0] else: # No grades at all. The average of no grades is None return None # -9001 = override. We can't mix text and int in picky postgress. if str(ave_grade) == "-9001": course_section_grade = Grade.objects.get( override_final=True, student=self.user, course_section=self.course_section) grade = course_section_grade.get_grade() if ignore_letter and not isinstance(grade, (int, Decimal, float)): return None return grade elif ave_grade: return Decimal(ave_grade) # about 0.5 s # Letter Grade if ignore_letter == False: final = 0.0 grades = self.course_section.grade_set.filter(student=self.user) if date_report: grades = grades.filter( marking_period__end_date__lte=date_report) if grades: total_weight = Decimal(0) for grade in grades: get_grade = grade.get_grade() if get_grade in ["I", "IN", "YT"]: return get_grade elif get_grade in ["P", "HP", "LP"]: if grade.marking_period: final += float(100 * grade.marking_period.weight) total_weight += grade.marking_period.weight elif get_grade in ['F', 'M']: if grade.marking_period: total_weight += grade.marking_period.weight elif get_grade: try: final += get_grade except TypeError: return get_grade if total_weight: final /= float(total_weight) final = Decimal(final).quantize(Decimal("0.01"), ROUND_HALF_UP) if final > config.LETTER_GRADE_REQUIRED_FOR_PASS: return "P" else: return "F" return None
class StudentMarkingPeriodGrade(models.Model): """ Stores marking period grades for students, only used for cache """ student = models.ForeignKey('sis.Student') marking_period = models.ForeignKey('schedule.MarkingPeriod', blank=True, null=True) grade = CachedDecimalField(max_digits=5, decimal_places=2, blank=True, null=True, verbose_name="MP Average") class Meta: unique_together = ('student', 'marking_period') def get_average(self, rounding=2): """ Returns cached average """ return round_as_decimal(self.grade, rounding) def get_scaled_average(self, rounding=2, boost=True): """ Convert to scaled grade first, then average Burke Software does not endorse this as a precise way to calculate averages """ grade_total = 0.0 total_credits = 0.0 grades = self.student.grade_set.filter( marking_period=self.marking_period, grade__isnull=False, course_section__course__course_type__weight__gt=0, enrollment__is_active=True, ) for grade in grades: grade_value = float(grade.optimized_grade_to_scale(letter=False)) if grade_value > 0 and boost: # only add boost for non-failing grades grade_value += float( grade.course_section.course.course_type.boost) num_credits = float(grade.course_section.course.credits) grade_total += grade_value * num_credits total_credits += num_credits if total_credits > 0: average = grade_total / total_credits return round_as_decimal(average, rounding) else: return None @staticmethod def build_all_cache(): """ Create object for each student * possible marking periods """ for student in Student.objects.all(): marking_periods = student.courseenrollment_set.values( 'course_section__marking_period').annotate( Count('course_section__marking_period')) for marking_period in marking_periods: StudentMarkingPeriodGrade.objects.get_or_create( student=student, marking_period_id=marking_period[ 'course_section__marking_period']) def calculate_grade(self): cursor = connection.cursor() sql_string = """ select sum(grade * credits) / sum(credits * 1.0) from grades_grade left join schedule_coursesection on schedule_coursesection.id=grades_grade.course_section_id left join schedule_course on schedule_coursesection.course_id=schedule_course.id where marking_period_id=%s and student_id=%s and grade is not null;""" cursor.execute(sql_string, (self.marking_period_id, self.student_id)) result = cursor.fetchone() if result: return result[0]
class CourseEnrollment(models.Model): course = models.ForeignKey('Course') user = models.ForeignKey('auth.User') role = models.CharField(max_length=255, default="Student", blank=True) attendance_note = models.CharField( max_length=255, blank=True, help_text="This note will appear when taking attendance.") year = models.ForeignKey('sis.GradeLevel', blank=True, null=True) exclude_days = models.ManyToManyField('Day', blank=True, \ help_text="Student does not need to attend on this day. Note courses already specify meeting days; this field is for students who have a special reason to be away.") grade = CachedCharField(max_length=8, blank=True, verbose_name="Final Course Grade", editable=False) numeric_grade = CachedDecimalField(max_digits=5, decimal_places=2, blank=True, null=True) class Meta: unique_together = (("course", "user", "role"), ) def cache_grades(self): """ Set cache on both grade and numeric_grade """ grade = self.calculate_grade_real() if isinstance(grade, Decimal): grade = grade.quantize(Decimal(".01"), rounding=ROUND_HALF_UP) self.numeric_grade = grade else: self.numeric_grade = None if grade == None: grade = '' self.grade = grade self.grade_recalculation_needed = False self.numeric_grade_recalculation_needed = False self.save() return grade def get_grade(self, date_report=None, rounding=2): """ Get the grade, use cache when no date change present """ if date_report is None or date_report >= datetime.date.today(): # Cache will always have the latest grade, so it's fine for # today's date and any future date grade = self.grade else: grade = self.calculate_grade_real(date_report=date_report) if rounding and isinstance(grade, (int, long, float, complex, Decimal)): return round_as_decimal(grade, rounding) return grade def calculate_grade(self): return self.cache_grades() def calculate_numeric_grade(self): grade = self.cache_grades() if isinstance(grade, Decimal): return grade return None def calculate_grade_real(self, date_report=None, ignore_letter=False): """ Calculate the final grade for a course ignore_letter can be useful when computing averages when you don't care about letter grades """ cursor = connection.cursor() # postgres requires a over () to run # http://stackoverflow.com/questions/19271646/how-to-make-a-sum-without-group-by sql_string = ''' SELECT ( Sum(grade * weight) {over} / Sum(weight) {over} ) AS ave_grade, grades_grade.id, grades_grade.override_final FROM grades_grade LEFT JOIN schedule_markingperiod ON schedule_markingperiod.id = grades_grade.marking_period_id WHERE ( grades_grade.course_id = %s AND grades_grade.student_id = %s {extra_where} ) AND ( grade IS NOT NULL OR letter_grade IS NOT NULL ) ORDER BY grades_grade.override_final DESC limit 1''' if date_report: if settings.DATABASES['default'][ 'ENGINE'] == 'django.db.backends.postgresql_psycopg2': cursor.execute( sql_string.format( over='over ()', extra_where= 'AND (schedule_markingperiod.end_date <= %s OR override_final = 1)' ), (self.course_id, self.user_id, date_report)) else: cursor.execute( sql_string.format( over='', extra_where= 'AND (schedule_markingperiod.end_date <= %s OR grades_grade.override_final = 1)' ), (self.course_id, self.user_id, date_report)) else: if settings.DATABASES['default'][ 'ENGINE'] == 'django.db.backends.postgresql_psycopg2': cursor.execute( sql_string.format(over='over ()', extra_where=''), (self.course_id, self.user_id)) else: cursor.execute(sql_string.format(over='', extra_where=''), (self.course_id, self.user_id)) result = cursor.fetchone() if result: (ave_grade, grade_id, override_final) = result else: # No grades at all. The average of no grades is None return None if override_final: course_grades = ecwsp.grades.models.Grade.objects.get(id=grade_id) grade = course_grades.get_grade() if ignore_letter and not isinstance(grade, (int, Decimal, float)): return None return grade if ave_grade: # database math always comes out as a float :( return Decimal(ave_grade) # about 0.5 s # Letter Grade if ignore_letter == False: final = 0.0 grades = self.course.grade_set.filter(student=self.user) if date_report: grades = grades.filter( marking_period__end_date__lte=date_report) if grades: total_weight = Decimal(0) for grade in grades: get_grade = grade.get_grade() if get_grade in ["I", "IN", "YT"]: return get_grade elif get_grade in ["P", "HP", "LP"]: if grade.marking_period: final += float(100 * grade.marking_period.weight) total_weight += grade.marking_period.weight elif get_grade in ['F', 'M']: if grade.marking_period: total_weight += grade.marking_period.weight elif get_grade: final += get_grade if total_weight: final /= float(total_weight) final = Decimal(final).quantize(Decimal("0.01"), ROUND_HALF_UP) if final > int( Configuration.get_or_default( 'letter_grade_required_for_pass').value): return "P" else: return "F" return None
class StudentYearGrade(models.Model): """ Stores the grade for an entire year, only used for cache """ student = models.ForeignKey('sis.Student') year = models.ForeignKey('sis.SchoolYear') grade = CachedDecimalField(max_digits=5, decimal_places=2, blank=True, null=True, verbose_name="Year average") credits = CachedDecimalField(max_digits=5, decimal_places=2, blank=True, null=True) class Meta: unique_together = ('student', 'year') @staticmethod def build_cache_student(student): years = student.courseenrollment_set.values( 'course_section__marking_period__school_year').annotate( Count('course_section__marking_period__school_year')) for year in years: if year['course_section__marking_period__school_year']: year_grade = StudentYearGrade.objects.get_or_create( student=student, year_id=year['course_section__marking_period__school_year'] )[0] if year_grade.credits_recalculation_needed: year_grade.recalculate_credits() if year_grade.grade_recalculation_needed: year_grade.recalculate_grade() @staticmethod def build_all_cache(*args, **kwargs): """ Create object for each student * possible years """ if 'instance' in kwargs: StudentYearGrade.build_cache_student(kwargs['instance']) else: for student in Student.objects.all(): StudentYearGrade.build_cache_student(student) def calculate_credits(self): """ The number of credits a student has earned in 1 year """ return self.calculate_grade_and_credits()[1] def calculate_grade_and_credits(self, date_report=None, prescale=False): """ Just recalculate them both at once returns (grade, credits) if "prescale=True" grades are scaled (i.e. 4.0) before averaged """ total = Decimal(0) credits = Decimal(0) prescaled_grade = Decimal(0) grade_scale = self.year.grade_scale for course_enrollment in self.student.courseenrollment_set.filter( course_section__marking_period__show_reports=True, course_section__marking_period__school_year=self.year, course_section__course__credits__isnull=False, course_section__course__course_type__weight__gt=0, ).distinct(): grade = course_enrollment.calculate_grade_real( date_report=date_report, ignore_letter=True) if grade: num_credits = course_enrollment.course_section.course.credits if prescale: # scale the grades before averaging them if requested if grade_scale: grade = grade_scale.to_numeric(grade) prescaled_grade += grade * num_credits total += grade * num_credits credits += num_credits if credits > 0: grade = total / credits if prescale: prescaled_grade = prescaled_grade / credits else: grade = None if date_report == None: # If set would indicate this is not for cache! self.grade = grade self.credits = credits self.grade_recalculation_needed = False self.credits_recalculation_needed = False self.save() if prescale: return (prescaled_grade, credits) else: return (grade, credits) def calculate_grade(self, date_report=None, prescale=False): """ Calculate grade considering MP weights and course credits course_enrollment.calculate_real_grade returns a MP weighted result, so just have to consider credits """ return self.calculate_grade_and_credits(date_report=date_report, prescale=prescale)[0] def get_grade(self, date_report=None, rounding=2, numeric_scale=False, prescale=False, boost=True): if numeric_scale == False and (date_report is None or date_report >= datetime.date.today()): # Cache will always have the latest grade, so it's fine for # today's date and any future date return self.grade grade = self.calculate_grade(date_report=date_report, prescale=prescale) if numeric_scale == True: grade_scale = self.year.grade_scale if grade_scale and not prescale: grade = grade_scale.to_numeric(grade) if boost: enrollments = self.student.courseenrollment_set.filter( course_section__marking_period__show_reports=True, course_section__marking_period__school_year=self.year, course_section__course__credits__isnull=False, course_section__course__course_type__weight__gt=0, ) if not grade_scale: boost_sum = enrollments.aggregate(boost_sum=Sum( 'course_section__course__course_type__boost') )['boost_sum'] if not boost_sum: boost_sum = 0.0 try: boost_factor = boost_sum / enrollments.count() except ZeroDivisionError: boost_factor = 0.0 else: boost_sum = 0.0 total_credits = 0.0 for enrollment in enrollments: course_credits = enrollment.course_section.course.credits course_boost = enrollment.course_section.course.course_type.boost if enrollment.numeric_grade: course_grade = Decimal(enrollment.numeric_grade) total_credits += float(course_credits) if grade_scale.to_numeric(course_grade) > 0: # only add boost to grades that are not failing... boost_sum += float(course_boost * course_credits) try: boost_factor = boost_sum / total_credits except ZeroDivisionError: boost_factor = None if enrollments.count() > 0 and boost_factor and grade: grade = float(grade) + float(boost_factor) if rounding: grade = round_as_decimal(grade, rounding) return grade