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

Last change on this file since 16028 was 16027, checked in by Henrik Bettermann, 5 years ago
  • Bugfix: If level_session of a studylevel object has changed, coursetickets_catalog must be informed. Otherwise lecturers don't see their students.
  • Property svn:keywords set to Id
File size: 15.3 KB
RevLine 
[7191]1## $Id: studylevel.py 16027 2020-03-06 19:56:56Z 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
[10276]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 = ''
[10553]166        for ticket in self.values():
[14133]167            if ticket.total_score is not None:
168                if ticket.total_score < ticket.passmark:
[10553]169                    failed += 1
[10616]170                    credits_failed += ticket.credits
[14123]171                    if ticket.mandatory:
172                        courses_failed += 'm_%s_m ' % ticket.code
173                    else:
174                        courses_failed += '%s ' % ticket.code
[10553]175                else:
176                    passed += 1
[10616]177                    credits_passed += ticket.credits
[14368]178            else:
[14392]179                courses_not_taken += '%s ' % ticket.code
[14368]180        return (passed, failed, credits_passed,
181                credits_failed, courses_failed,
[14392]182                courses_not_taken)
[10553]183
184    @property
[10598]185    def cumulative_params(self):
186        """Calculate the cumulative gpa and other cumulative parameters
187        for this level.
188        All levels below this level are taken under consideration
[14154]189        (including repeating levels).
190        This method is used for level reports and meanwhile also
191        for session results presentations.
[10598]192        """
[10616]193        credits_passed = 0
[10598]194        total_credits = 0
195        total_credits_counted = 0
196        total_credits_weighted = 0
[10618]197        cgpa = 0.0
[13002]198        if self.__parent__:
199            for level in self.__parent__.values():
200                if level.level > self.level:
201                    continue
202                credits_passed += level.passed_params[2]
203                total_credits += level.total_credits
204                gpa_params = level.gpa_params
205                total_credits_counted += gpa_params[1]
206                total_credits_weighted += gpa_params[2]
207            if total_credits_counted:
[14382]208                cgpa = total_credits_weighted/total_credits_counted
[14200]209            # Override cgpa if value has been imported
210            # (not implemented in base package)
211            imported_cgpa = getattr(self, 'imported_cgpa', None)
212            if imported_cgpa:
213                cgpa = imported_cgpa
[10598]214        return (cgpa, total_credits_counted, total_credits_weighted,
[10616]215               total_credits, credits_passed)
[10598]216
217    @property
[9257]218    def is_current_level(self):
219        try:
220            return self.__parent__.current_level == self.level
221        except AttributeError:
222            return False
223
[8735]224    def writeLogMessage(self, view, message):
225        return self.__parent__.__parent__.writeLogMessage(view, message)
226
[6775]227    @property
228    def level_title(self):
229        studylevelsource = StudyLevelSource()
230        return studylevelsource.factory.getTitle(self.__parent__, self.level)
231
[13031]232    @property
[14247]233    def course_registration_forbidden(self):
[13031]234        try:
235            deadline = grok.getSite()['configuration'][
[13057]236                str(self.level_session)].coursereg_deadline
237        except (TypeError, KeyError):
[14247]238            return
[13069]239        if not deadline or deadline > datetime.now(pytz.utc):
[14247]240            return
[13031]241        if len(self.student['payments']):
242            for ticket in self.student['payments'].values():
243                if ticket.p_category == 'late_registration' and \
244                    ticket.p_session == self.level_session and \
245                    ticket.p_state == 'paid':
[14247]246                        return
247        return _("Course registration has ended. "
248                 "Please pay the late registration fee.")
[13031]249
[8920]250    def addCourseTicket(self, ticket, course):
[6781]251        """Add a course ticket object.
252        """
[8920]253        if not ICourseTicket.providedBy(ticket):
[6781]254            raise TypeError(
[16024]255                'StudentStudyLevels contain only ICourseTicket instances')
[8920]256        ticket.code = course.code
257        ticket.title = course.title
258        ticket.fcode = course.__parent__.__parent__.__parent__.code
259        ticket.dcode = course.__parent__.__parent__.code
260        ticket.credits = course.credits
261        ticket.passmark = course.passmark
262        ticket.semester = course.semester
263        self[ticket.code] = ticket
[6781]264        return
265
[9501]266    def addCertCourseTickets(self, cert):
267        """Collect all certificate courses and create course
268        tickets automatically.
269        """
270        if cert is not None:
271            for key, val in cert.items():
272                if val.level != self.level:
273                    continue
274                ticket = createObject(u'waeup.CourseTicket')
275                ticket.automatic = True
276                ticket.mandatory = val.mandatory
277                ticket.carry_over = False
[14642]278                ticket.course_category = val.course_category
[9501]279                self.addCourseTicket(ticket, val.course)
280        return
281
[14684]282    def updateCourseTicket(self, ticket, course):
283        """Updates a course ticket object and return code
284        if ticket has been invalidated.
285        """
286        if not course:
287            if ticket.title.endswith('cancelled)'):
288                # Skip this tiket
289                return
290            # Invalidate course ticket
291            ticket.title += u' (course cancelled)'
292            ticket.credits = 0
293            ticket.passmark = 0
294            return ticket.code
295        ticket.code = course.code
296        ticket.title = course.title
297        ticket.fcode = course.__parent__.__parent__.__parent__.code
298        ticket.dcode = course.__parent__.__parent__.code
299        ticket.credits = course.credits
300        ticket.passmark = course.passmark
301        ticket.semester = course.semester
302        return
303
[9690]304StudentStudyLevel = attrs_to_fields(
[10479]305    StudentStudyLevel, omit=['total_credits', 'gpa'])
[6781]306
[7536]307class StudentStudyLevelFactory(grok.GlobalUtility):
308    """A factory for student study levels.
309    """
310    grok.implements(IFactory)
311    grok.name(u'waeup.StudentStudyLevel')
312    title = u"Create a new student study level.",
313    description = u"This factory instantiates new student study level instances."
314
315    def __call__(self, *args, **kw):
316        return StudentStudyLevel()
317
318    def getInterfaces(self):
319        return implementedBy(StudentStudyLevel)
320
[6781]321class CourseTicket(grok.Model):
322    """This is a course ticket which allows the
323    student to attend the course. Lecturers will enter scores and more at
324    the end of the term.
[6783]325
326    A course ticket contains a copy of the original course and
[12882]327    certificate course data. If the courses and/or the referring certificate
[8920]328    courses are removed, the corresponding tickets remain unchanged.
[12882]329    So we do not need any event triggered actions on course tickets.
[6781]330    """
331    grok.implements(ICourseTicket, IStudentNavigation)
332    grok.provides(ICourseTicket)
333
[6795]334    def __init__(self):
[6781]335        super(CourseTicket, self).__init__()
[6795]336        self.code = None
[6781]337        return
338
[8736]339    @property
340    def student(self):
[8338]341        """Get the associated student object.
342        """
343        try:
344            return self.__parent__.__parent__.__parent__
[13003]345        except AttributeError: # in unit tests
[8338]346            return None
[6781]347
[9253]348    @property
349    def certcode(self):
350        try:
351            return self.__parent__.__parent__.certificate.code
[13003]352        except AttributeError: # in unit tests
[9253]353            return None
354
[9698]355    @property
356    def removable_by_student(self):
[13046]357        """True if student is allowed to remove the ticket.
358        """
[9698]359        return not self.mandatory
360
[10631]361    @property
362    def editable_by_lecturer(self):
[13046]363        """True if lecturer is allowed to edit the ticket.
364        """
[13003]365        try:
[13031]366            cas = grok.getSite()[
367                'configuration'].current_academic_session
368            if self.student.state == VALIDATED and \
369                self.student.current_session == cas:
[13003]370                return True
[13008]371        except (AttributeError, TypeError): # in unit tests
[13003]372            pass
[10631]373        return False
374
[8735]375    def writeLogMessage(self, view, message):
[13031]376        return self.__parent__.__parent__.__parent__.writeLogMessage(
377            view, message)
[8735]378
[9925]379    @property
380    def level(self):
[7633]381        """Returns the id of the level the ticket has been added to.
382        """
[8338]383        try:
384            return self.__parent__.level
[13003]385        except AttributeError: # in unit tests
[8338]386            return None
[7633]387
[9925]388    @property
389    def level_session(self):
[7633]390        """Returns the session of the level the ticket has been added to.
391        """
[8338]392        try:
393            return self.__parent__.level_session
[13003]394        except AttributeError: # in unit tests
[8338]395            return None
[7633]396
[9684]397    @property
[14133]398    def total_score(self):
399        """Returns the total score of this ticket. In the base package
400        this is simply the score. In customized packages this could be
401        something else.
402        """
403        return self.score
404
405    @property
[14531]406    def _getGradeWeightFromScore(self):
407        """Nigerian Course Grading System
408        """
409        if self.total_score is None:
410            return (None, None)
411        if self.total_score >= 70:
412            return ('A',5)
413        if self.total_score >= 60:
414            return ('B',4)
415        if self.total_score >= 50:
416            return ('C',3)
417        if self.total_score >= 45:
418            return ('D',2)
419        if self.total_score >= self.passmark: # passmark changed in 2013 from 40 to 45
420            return ('E',1)
421        return ('F',0)
422
423    @property
[9684]424    def grade(self):
[14133]425        """Returns the grade calculated from total score.
[9684]426        """
[14531]427        return self._getGradeWeightFromScore[0]
[7633]428
[9684]429    @property
430    def weight(self):
[14133]431        """Returns the weight calculated from total score.
[9684]432        """
[14531]433        return self._getGradeWeightFromScore[1]
[9684]434
[6782]435CourseTicket = attrs_to_fields(CourseTicket)
[7548]436
437class CourseTicketFactory(grok.GlobalUtility):
438    """A factory for student study levels.
439    """
440    grok.implements(IFactory)
441    grok.name(u'waeup.CourseTicket')
442    title = u"Create a new course ticket.",
443    description = u"This factory instantiates new course ticket instances."
444
445    def __call__(self, *args, **kw):
446        return CourseTicket()
447
448    def getInterfaces(self):
449        return implementedBy(CourseTicket)
Note: See TracBrowser for help on using the repository browser.