source: main/waeup.kofa/trunk/src/waeup/kofa/students/studylevel.py @ 17628

Last change on this file since 17628 was 17378, checked in by Henrik Bettermann, 21 months ago

Bugfix: Rectified GPA calculation was wrong.

  • Property svn:keywords set to Id
File size: 15.6 KB
RevLine 
[7191]1## $Id: studylevel.py 17378 2023-04-13 07:49:55Z henrik $
2##
[6775]3## Copyright (C) 2011 Uli Fouquet & Henrik Bettermann
4## This program is free software; you can redistribute it and/or modify
5## it under the terms of the GNU General Public License as published by
6## the Free Software Foundation; either version 2 of the License, or
7## (at your option) any later version.
8##
9## This program is distributed in the hope that it will be useful,
10## but WITHOUT ANY WARRANTY; without even the implied warranty of
11## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12## GNU General Public License for more details.
13##
14## You should have received a copy of the GNU General Public License
15## along with this program; if not, write to the Free Software
16## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
17##
18"""
19Container which holds the data of a student study level
20and contains the course tickets.
21"""
22import grok
[13031]23import pytz
24from datetime import datetime
[7536]25from zope.component.interfaces import IFactory
[10539]26from zope.catalog.interfaces import ICatalog
[14574]27from zope.component import createObject, queryUtility, getUtility
[8330]28from zope.interface import implementedBy
[16027]29from zope.event import notify
[14574]30from waeup.kofa.interfaces import academic_sessions_vocab, VALIDATED, IKofaUtils
[7811]31from waeup.kofa.students.interfaces import (
[6781]32    IStudentStudyLevel, IStudentNavigation, ICourseTicket)
[7811]33from waeup.kofa.utils.helpers import attrs_to_fields
34from waeup.kofa.students.vocabularies import StudyLevelSource
[14247]35from waeup.kofa.interfaces import MessageFactory as _
[6775]36
[16027]37@grok.subscribe(IStudentStudyLevel, grok.IObjectModifiedEvent)
38def handle_update_coursetickets(studylevel, event):
39    """If level_session has changed, coursetickets_catalog
40    must be informed.
41    """
42    # Catalog must be informed
43    for ticket in studylevel.values():
44        notify(grok.ObjectModifiedEvent(ticket))
45    return
46
[10276]47def find_carry_over(ticket):
[10277]48    studylevel = ticket.__parent__
49    studycourse = ticket.__parent__.__parent__
50    levels = sorted(studycourse.keys())
51    index = levels.index(str(studylevel.level))
52    try:
53        next_level = levels[index+1]
54    except IndexError:
55        return None
56    next_studylevel = studycourse[next_level]
57    co_ticket = next_studylevel.get(ticket.code, None)
58    return co_ticket
[10276]59
[6775]60class StudentStudyLevel(grok.Container):
61    """This is a container for course tickets.
62    """
63    grok.implements(IStudentStudyLevel, IStudentNavigation)
64    grok.provides(IStudentStudyLevel)
65
66    def __init__(self):
67        super(StudentStudyLevel, self).__init__()
68        return
69
[8736]70    @property
71    def student(self):
72        try:
73            return self.__parent__.__parent__
74        except AttributeError:
75            return None
[6775]76
[9235]77    @property
[9253]78    def certcode(self):
79        try:
80            return self.__parent__.certificate.code
81        except AttributeError:
82            return None
83
84    @property
[9235]85    def number_of_tickets(self):
86        return len(self)
87
[9257]88    @property
[9532]89    def total_credits(self):
90        total = 0
91        for ticket in self.values():
[14574]92            if not ticket.outstanding:
93                total += ticket.credits
[9532]94        return total
95
96    @property
[9912]97    def getSessionString(self):
[13006]98        try:
99            session_string = academic_sessions_vocab.getTerm(
100                self.level_session).title
101        except LookupError:
102            return None
103        return session_string
[9912]104
105    @property
[10479]106    def gpa_params_rectified(self):
[13002]107        """Calculate corrected level (sessional) gpa parameters.
[10598]108        The corrected gpa is displayed on transcripts only.
109        """
110        credits_weighted = 0.0
[9687]111        credits_counted = 0
[10276]112        level_gpa = 0.0
[9687]113        for ticket in self.values():
[14133]114            if ticket.carry_over is False and ticket.total_score:
115                if ticket.total_score < ticket.passmark:
[10276]116                    co_ticket = find_carry_over(ticket)
[10313]117                    if co_ticket is not None and co_ticket.weight is not None:
[10276]118                        credits_counted += co_ticket.credits
[10598]119                        credits_weighted += co_ticket.credits * co_ticket.weight
[17378]120                        continue
[9687]121                credits_counted += ticket.credits
[10598]122                credits_weighted += ticket.credits * ticket.weight
[9687]123        if credits_counted:
[14382]124            level_gpa = credits_weighted/credits_counted
[10598]125        return level_gpa, credits_counted, credits_weighted
[9687]126
127    @property
[10479]128    def gpa_params(self):
[10598]129        """Calculate gpa parameters for this level.
130        """
131        credits_weighted = 0.0
[10479]132        credits_counted = 0
133        level_gpa = 0.0
134        for ticket in self.values():
[14133]135            if ticket.total_score is not None:
[10479]136                credits_counted += ticket.credits
[10598]137                credits_weighted += ticket.credits * ticket.weight
[10479]138        if credits_counted:
[15102]139            level_gpa = credits_weighted / credits_counted
[14200]140        # Override level_gpa if value has been imported
141        # (not implemented in base package)
142        imported_gpa = getattr(self, 'imported_gpa', None)
143        if imported_gpa:
144            level_gpa = imported_gpa
[10598]145        return level_gpa, credits_counted, credits_weighted
[10479]146
147    @property
148    def gpa(self):
[14574]149        """Return string formatted gpa value.
150        """
151        format_float = getUtility(IKofaUtils).format_float
152        return format_float(self.gpa_params[0], 2)
[10479]153
154    @property
[10553]155    def passed_params(self):
[10598]156        """Determine the number and credits of passed and failed courses.
[14368]157        Count the number of courses registered but not taken.
[15963]158        This method is used for level reports and the
159        OutstandingCoursesExporter.
[10598]160        """
[10553]161        passed = failed = 0
[13868]162        courses_failed = ''
[10616]163        credits_failed = 0
164        credits_passed = 0
[14392]165        courses_not_taken = ''
[16662]166        courses_passed = ''
[10553]167        for ticket in self.values():
[14133]168            if ticket.total_score is not None:
169                if ticket.total_score < ticket.passmark:
[10553]170                    failed += 1
[10616]171                    credits_failed += ticket.credits
[14123]172                    if ticket.mandatory:
173                        courses_failed += 'm_%s_m ' % ticket.code
174                    else:
175                        courses_failed += '%s ' % ticket.code
[10553]176                else:
177                    passed += 1
[10616]178                    credits_passed += ticket.credits
[16662]179                    courses_passed += '%s ' % ticket.code
[14368]180            else:
[14392]181                courses_not_taken += '%s ' % ticket.code
[14368]182        return (passed, failed, credits_passed,
183                credits_failed, courses_failed,
[16662]184                courses_not_taken, courses_passed)
[10553]185
186    @property
[10598]187    def cumulative_params(self):
188        """Calculate the cumulative gpa and other cumulative parameters
189        for this level.
190        All levels below this level are taken under consideration
[14154]191        (including repeating levels).
192        This method is used for level reports and meanwhile also
193        for session results presentations.
[10598]194        """
[10616]195        credits_passed = 0
[10598]196        total_credits = 0
197        total_credits_counted = 0
198        total_credits_weighted = 0
[10618]199        cgpa = 0.0
[13002]200        if self.__parent__:
201            for level in self.__parent__.values():
202                if level.level > self.level:
203                    continue
204                credits_passed += level.passed_params[2]
205                total_credits += level.total_credits
206                gpa_params = level.gpa_params
207                total_credits_counted += gpa_params[1]
208                total_credits_weighted += gpa_params[2]
209            if total_credits_counted:
[14382]210                cgpa = total_credits_weighted/total_credits_counted
[14200]211            # Override cgpa if value has been imported
212            # (not implemented in base package)
213            imported_cgpa = getattr(self, 'imported_cgpa', None)
214            if imported_cgpa:
215                cgpa = imported_cgpa
[10598]216        return (cgpa, total_credits_counted, total_credits_weighted,
[10616]217               total_credits, credits_passed)
[10598]218
219    @property
[9257]220    def is_current_level(self):
221        try:
222            return self.__parent__.current_level == self.level
223        except AttributeError:
224            return False
225
[8735]226    def writeLogMessage(self, view, message):
[16029]227        ob_class = view.__implemented__.__name__.replace('waeup.kofa.','')
228        self.__parent__.__parent__.__parent__.logger.info(
229            '%s - %s - level %s - %s' % (
230                ob_class,
231                self.__parent__.__parent__.__name__,
232                self.__name__,
233                message))
234        return
[8735]235
[6775]236    @property
237    def level_title(self):
238        studylevelsource = StudyLevelSource()
239        return studylevelsource.factory.getTitle(self.__parent__, self.level)
240
[13031]241    @property
[14247]242    def course_registration_forbidden(self):
[13031]243        try:
244            deadline = grok.getSite()['configuration'][
[13057]245                str(self.level_session)].coursereg_deadline
246        except (TypeError, KeyError):
[14247]247            return
[13069]248        if not deadline or deadline > datetime.now(pytz.utc):
[14247]249            return
[13031]250        if len(self.student['payments']):
251            for ticket in self.student['payments'].values():
252                if ticket.p_category == 'late_registration' and \
253                    ticket.p_session == self.level_session and \
254                    ticket.p_state == 'paid':
[14247]255                        return
256        return _("Course registration has ended. "
257                 "Please pay the late registration fee.")
[13031]258
[8920]259    def addCourseTicket(self, ticket, course):
[6781]260        """Add a course ticket object.
261        """
[8920]262        if not ICourseTicket.providedBy(ticket):
[6781]263            raise TypeError(
[16024]264                'StudentStudyLevels contain only ICourseTicket instances')
[8920]265        ticket.code = course.code
266        ticket.title = course.title
267        ticket.fcode = course.__parent__.__parent__.__parent__.code
268        ticket.dcode = course.__parent__.__parent__.code
269        ticket.credits = course.credits
270        ticket.passmark = course.passmark
271        ticket.semester = course.semester
272        self[ticket.code] = ticket
[6781]273        return
274
[9501]275    def addCertCourseTickets(self, cert):
276        """Collect all certificate courses and create course
277        tickets automatically.
278        """
279        if cert is not None:
280            for key, val in cert.items():
281                if val.level != self.level:
282                    continue
283                ticket = createObject(u'waeup.CourseTicket')
284                ticket.automatic = True
285                ticket.mandatory = val.mandatory
286                ticket.carry_over = False
[14642]287                ticket.course_category = val.course_category
[9501]288                self.addCourseTicket(ticket, val.course)
289        return
290
[14684]291    def updateCourseTicket(self, ticket, course):
292        """Updates a course ticket object and return code
293        if ticket has been invalidated.
294        """
295        if not course:
296            if ticket.title.endswith('cancelled)'):
297                # Skip this tiket
298                return
299            # Invalidate course ticket
300            ticket.title += u' (course cancelled)'
301            ticket.credits = 0
302            ticket.passmark = 0
303            return ticket.code
304        ticket.code = course.code
305        ticket.title = course.title
306        ticket.fcode = course.__parent__.__parent__.__parent__.code
307        ticket.dcode = course.__parent__.__parent__.code
308        ticket.credits = course.credits
309        ticket.passmark = course.passmark
310        ticket.semester = course.semester
311        return
312
[9690]313StudentStudyLevel = attrs_to_fields(
[10479]314    StudentStudyLevel, omit=['total_credits', 'gpa'])
[6781]315
[7536]316class StudentStudyLevelFactory(grok.GlobalUtility):
317    """A factory for student study levels.
318    """
319    grok.implements(IFactory)
320    grok.name(u'waeup.StudentStudyLevel')
321    title = u"Create a new student study level.",
322    description = u"This factory instantiates new student study level instances."
323
324    def __call__(self, *args, **kw):
325        return StudentStudyLevel()
326
327    def getInterfaces(self):
328        return implementedBy(StudentStudyLevel)
329
[6781]330class CourseTicket(grok.Model):
331    """This is a course ticket which allows the
332    student to attend the course. Lecturers will enter scores and more at
333    the end of the term.
[6783]334
335    A course ticket contains a copy of the original course and
[12882]336    certificate course data. If the courses and/or the referring certificate
[8920]337    courses are removed, the corresponding tickets remain unchanged.
[12882]338    So we do not need any event triggered actions on course tickets.
[6781]339    """
340    grok.implements(ICourseTicket, IStudentNavigation)
341    grok.provides(ICourseTicket)
342
[6795]343    def __init__(self):
[6781]344        super(CourseTicket, self).__init__()
[6795]345        self.code = None
[6781]346        return
347
[8736]348    @property
349    def student(self):
[8338]350        """Get the associated student object.
351        """
352        try:
353            return self.__parent__.__parent__.__parent__
[13003]354        except AttributeError: # in unit tests
[8338]355            return None
[6781]356
[9253]357    @property
358    def certcode(self):
359        try:
360            return self.__parent__.__parent__.certificate.code
[13003]361        except AttributeError: # in unit tests
[9253]362            return None
363
[9698]364    @property
365    def removable_by_student(self):
[13046]366        """True if student is allowed to remove the ticket.
367        """
[9698]368        return not self.mandatory
369
[10631]370    @property
371    def editable_by_lecturer(self):
[13046]372        """True if lecturer is allowed to edit the ticket.
373        """
[13003]374        try:
[13031]375            cas = grok.getSite()[
376                'configuration'].current_academic_session
377            if self.student.state == VALIDATED and \
378                self.student.current_session == cas:
[13003]379                return True
[13008]380        except (AttributeError, TypeError): # in unit tests
[13003]381            pass
[10631]382        return False
383
[8735]384    def writeLogMessage(self, view, message):
[13031]385        return self.__parent__.__parent__.__parent__.writeLogMessage(
386            view, message)
[8735]387
[9925]388    @property
389    def level(self):
[7633]390        """Returns the id of the level the ticket has been added to.
391        """
[8338]392        try:
393            return self.__parent__.level
[13003]394        except AttributeError: # in unit tests
[8338]395            return None
[7633]396
[9925]397    @property
398    def level_session(self):
[7633]399        """Returns the session of the level the ticket has been added to.
400        """
[8338]401        try:
402            return self.__parent__.level_session
[13003]403        except AttributeError: # in unit tests
[8338]404            return None
[7633]405
[9684]406    @property
[14133]407    def total_score(self):
408        """Returns the total score of this ticket. In the base package
409        this is simply the score. In customized packages this could be
410        something else.
411        """
412        return self.score
413
414    @property
[14531]415    def _getGradeWeightFromScore(self):
416        """Nigerian Course Grading System
417        """
418        if self.total_score is None:
419            return (None, None)
420        if self.total_score >= 70:
421            return ('A',5)
422        if self.total_score >= 60:
423            return ('B',4)
424        if self.total_score >= 50:
425            return ('C',3)
426        if self.total_score >= 45:
427            return ('D',2)
428        if self.total_score >= self.passmark: # passmark changed in 2013 from 40 to 45
429            return ('E',1)
430        return ('F',0)
431
432    @property
[9684]433    def grade(self):
[14133]434        """Returns the grade calculated from total score.
[9684]435        """
[14531]436        return self._getGradeWeightFromScore[0]
[7633]437
[9684]438    @property
439    def weight(self):
[14133]440        """Returns the weight calculated from total score.
[9684]441        """
[14531]442        return self._getGradeWeightFromScore[1]
[9684]443
[6782]444CourseTicket = attrs_to_fields(CourseTicket)
[7548]445
446class CourseTicketFactory(grok.GlobalUtility):
447    """A factory for student study levels.
448    """
449    grok.implements(IFactory)
450    grok.name(u'waeup.CourseTicket')
451    title = u"Create a new course ticket.",
452    description = u"This factory instantiates new course ticket instances."
453
454    def __call__(self, *args, **kw):
455        return CourseTicket()
456
457    def getInterfaces(self):
458        return implementedBy(CourseTicket)
Note: See TracBrowser for help on using the repository browser.