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

Last change on this file since 15610 was 15102, checked in by Henrik Bettermann, 6 years ago

Minor modifications.

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