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

Last change on this file since 16303 was 16029, checked in by Henrik Bettermann, 5 years ago

Specify level in logfile when editing studylevels.

  • Property svn:keywords set to Id
File size: 15.5 KB
Line 
1## $Id: studylevel.py 16029 2020-03-06 21:06:36Z henrik $
2##
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
23import pytz
24from datetime import datetime
25from zope.component.interfaces import IFactory
26from zope.catalog.interfaces import ICatalog
27from zope.component import createObject, queryUtility, getUtility
28from zope.interface import implementedBy
29from zope.event import notify
30from waeup.kofa.interfaces import academic_sessions_vocab, VALIDATED, IKofaUtils
31from waeup.kofa.students.interfaces import (
32    IStudentStudyLevel, IStudentNavigation, ICourseTicket)
33from waeup.kofa.utils.helpers import attrs_to_fields
34from waeup.kofa.students.vocabularies import StudyLevelSource
35from waeup.kofa.interfaces import MessageFactory as _
36
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
47def find_carry_over(ticket):
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
59
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
70    @property
71    def student(self):
72        try:
73            return self.__parent__.__parent__
74        except AttributeError:
75            return None
76
77    @property
78    def certcode(self):
79        try:
80            return self.__parent__.certificate.code
81        except AttributeError:
82            return None
83
84    @property
85    def number_of_tickets(self):
86        return len(self)
87
88    @property
89    def total_credits(self):
90        total = 0
91        for ticket in self.values():
92            if not ticket.outstanding:
93                total += ticket.credits
94        return total
95
96    @property
97    def getSessionString(self):
98        try:
99            session_string = academic_sessions_vocab.getTerm(
100                self.level_session).title
101        except LookupError:
102            return None
103        return session_string
104
105    @property
106    def gpa_params_rectified(self):
107        """Calculate corrected level (sessional) gpa parameters.
108        The corrected gpa is displayed on transcripts only.
109        """
110        credits_weighted = 0.0
111        credits_counted = 0
112        level_gpa = 0.0
113        for ticket in self.values():
114            if ticket.carry_over is False and ticket.total_score:
115                if ticket.total_score < ticket.passmark:
116                    co_ticket = find_carry_over(ticket)
117                    if co_ticket is not None and co_ticket.weight is not None:
118                        credits_counted += co_ticket.credits
119                        credits_weighted += co_ticket.credits * co_ticket.weight
120                    continue
121                credits_counted += ticket.credits
122                credits_weighted += ticket.credits * ticket.weight
123        if credits_counted:
124            level_gpa = credits_weighted/credits_counted
125        return level_gpa, credits_counted, credits_weighted
126
127    @property
128    def gpa_params(self):
129        """Calculate gpa parameters for this level.
130        """
131        credits_weighted = 0.0
132        credits_counted = 0
133        level_gpa = 0.0
134        for ticket in self.values():
135            if ticket.total_score is not None:
136                credits_counted += ticket.credits
137                credits_weighted += ticket.credits * ticket.weight
138        if credits_counted:
139            level_gpa = credits_weighted / credits_counted
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
145        return level_gpa, credits_counted, credits_weighted
146
147    @property
148    def gpa(self):
149        """Return string formatted gpa value.
150        """
151        format_float = getUtility(IKofaUtils).format_float
152        return format_float(self.gpa_params[0], 2)
153
154    @property
155    def passed_params(self):
156        """Determine the number and credits of passed and failed courses.
157        Count the number of courses registered but not taken.
158        This method is used for level reports and the
159        OutstandingCoursesExporter.
160        """
161        passed = failed = 0
162        courses_failed = ''
163        credits_failed = 0
164        credits_passed = 0
165        courses_not_taken = ''
166        for ticket in self.values():
167            if ticket.total_score is not None:
168                if ticket.total_score < ticket.passmark:
169                    failed += 1
170                    credits_failed += ticket.credits
171                    if ticket.mandatory:
172                        courses_failed += 'm_%s_m ' % ticket.code
173                    else:
174                        courses_failed += '%s ' % ticket.code
175                else:
176                    passed += 1
177                    credits_passed += ticket.credits
178            else:
179                courses_not_taken += '%s ' % ticket.code
180        return (passed, failed, credits_passed,
181                credits_failed, courses_failed,
182                courses_not_taken)
183
184    @property
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
189        (including repeating levels).
190        This method is used for level reports and meanwhile also
191        for session results presentations.
192        """
193        credits_passed = 0
194        total_credits = 0
195        total_credits_counted = 0
196        total_credits_weighted = 0
197        cgpa = 0.0
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:
208                cgpa = total_credits_weighted/total_credits_counted
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
214        return (cgpa, total_credits_counted, total_credits_weighted,
215               total_credits, credits_passed)
216
217    @property
218    def is_current_level(self):
219        try:
220            return self.__parent__.current_level == self.level
221        except AttributeError:
222            return False
223
224    def writeLogMessage(self, view, message):
225        ob_class = view.__implemented__.__name__.replace('waeup.kofa.','')
226        self.__parent__.__parent__.__parent__.logger.info(
227            '%s - %s - level %s - %s' % (
228                ob_class,
229                self.__parent__.__parent__.__name__,
230                self.__name__,
231                message))
232        return
233
234    @property
235    def level_title(self):
236        studylevelsource = StudyLevelSource()
237        return studylevelsource.factory.getTitle(self.__parent__, self.level)
238
239    @property
240    def course_registration_forbidden(self):
241        try:
242            deadline = grok.getSite()['configuration'][
243                str(self.level_session)].coursereg_deadline
244        except (TypeError, KeyError):
245            return
246        if not deadline or deadline > datetime.now(pytz.utc):
247            return
248        if len(self.student['payments']):
249            for ticket in self.student['payments'].values():
250                if ticket.p_category == 'late_registration' and \
251                    ticket.p_session == self.level_session and \
252                    ticket.p_state == 'paid':
253                        return
254        return _("Course registration has ended. "
255                 "Please pay the late registration fee.")
256
257    def addCourseTicket(self, ticket, course):
258        """Add a course ticket object.
259        """
260        if not ICourseTicket.providedBy(ticket):
261            raise TypeError(
262                'StudentStudyLevels contain only ICourseTicket instances')
263        ticket.code = course.code
264        ticket.title = course.title
265        ticket.fcode = course.__parent__.__parent__.__parent__.code
266        ticket.dcode = course.__parent__.__parent__.code
267        ticket.credits = course.credits
268        ticket.passmark = course.passmark
269        ticket.semester = course.semester
270        self[ticket.code] = ticket
271        return
272
273    def addCertCourseTickets(self, cert):
274        """Collect all certificate courses and create course
275        tickets automatically.
276        """
277        if cert is not None:
278            for key, val in cert.items():
279                if val.level != self.level:
280                    continue
281                ticket = createObject(u'waeup.CourseTicket')
282                ticket.automatic = True
283                ticket.mandatory = val.mandatory
284                ticket.carry_over = False
285                ticket.course_category = val.course_category
286                self.addCourseTicket(ticket, val.course)
287        return
288
289    def updateCourseTicket(self, ticket, course):
290        """Updates a course ticket object and return code
291        if ticket has been invalidated.
292        """
293        if not course:
294            if ticket.title.endswith('cancelled)'):
295                # Skip this tiket
296                return
297            # Invalidate course ticket
298            ticket.title += u' (course cancelled)'
299            ticket.credits = 0
300            ticket.passmark = 0
301            return ticket.code
302        ticket.code = course.code
303        ticket.title = course.title
304        ticket.fcode = course.__parent__.__parent__.__parent__.code
305        ticket.dcode = course.__parent__.__parent__.code
306        ticket.credits = course.credits
307        ticket.passmark = course.passmark
308        ticket.semester = course.semester
309        return
310
311StudentStudyLevel = attrs_to_fields(
312    StudentStudyLevel, omit=['total_credits', 'gpa'])
313
314class StudentStudyLevelFactory(grok.GlobalUtility):
315    """A factory for student study levels.
316    """
317    grok.implements(IFactory)
318    grok.name(u'waeup.StudentStudyLevel')
319    title = u"Create a new student study level.",
320    description = u"This factory instantiates new student study level instances."
321
322    def __call__(self, *args, **kw):
323        return StudentStudyLevel()
324
325    def getInterfaces(self):
326        return implementedBy(StudentStudyLevel)
327
328class CourseTicket(grok.Model):
329    """This is a course ticket which allows the
330    student to attend the course. Lecturers will enter scores and more at
331    the end of the term.
332
333    A course ticket contains a copy of the original course and
334    certificate course data. If the courses and/or the referring certificate
335    courses are removed, the corresponding tickets remain unchanged.
336    So we do not need any event triggered actions on course tickets.
337    """
338    grok.implements(ICourseTicket, IStudentNavigation)
339    grok.provides(ICourseTicket)
340
341    def __init__(self):
342        super(CourseTicket, self).__init__()
343        self.code = None
344        return
345
346    @property
347    def student(self):
348        """Get the associated student object.
349        """
350        try:
351            return self.__parent__.__parent__.__parent__
352        except AttributeError: # in unit tests
353            return None
354
355    @property
356    def certcode(self):
357        try:
358            return self.__parent__.__parent__.certificate.code
359        except AttributeError: # in unit tests
360            return None
361
362    @property
363    def removable_by_student(self):
364        """True if student is allowed to remove the ticket.
365        """
366        return not self.mandatory
367
368    @property
369    def editable_by_lecturer(self):
370        """True if lecturer is allowed to edit the ticket.
371        """
372        try:
373            cas = grok.getSite()[
374                'configuration'].current_academic_session
375            if self.student.state == VALIDATED and \
376                self.student.current_session == cas:
377                return True
378        except (AttributeError, TypeError): # in unit tests
379            pass
380        return False
381
382    def writeLogMessage(self, view, message):
383        return self.__parent__.__parent__.__parent__.writeLogMessage(
384            view, message)
385
386    @property
387    def level(self):
388        """Returns the id of the level the ticket has been added to.
389        """
390        try:
391            return self.__parent__.level
392        except AttributeError: # in unit tests
393            return None
394
395    @property
396    def level_session(self):
397        """Returns the session of the level the ticket has been added to.
398        """
399        try:
400            return self.__parent__.level_session
401        except AttributeError: # in unit tests
402            return None
403
404    @property
405    def total_score(self):
406        """Returns the total score of this ticket. In the base package
407        this is simply the score. In customized packages this could be
408        something else.
409        """
410        return self.score
411
412    @property
413    def _getGradeWeightFromScore(self):
414        """Nigerian Course Grading System
415        """
416        if self.total_score is None:
417            return (None, None)
418        if self.total_score >= 70:
419            return ('A',5)
420        if self.total_score >= 60:
421            return ('B',4)
422        if self.total_score >= 50:
423            return ('C',3)
424        if self.total_score >= 45:
425            return ('D',2)
426        if self.total_score >= self.passmark: # passmark changed in 2013 from 40 to 45
427            return ('E',1)
428        return ('F',0)
429
430    @property
431    def grade(self):
432        """Returns the grade calculated from total score.
433        """
434        return self._getGradeWeightFromScore[0]
435
436    @property
437    def weight(self):
438        """Returns the weight calculated from total score.
439        """
440        return self._getGradeWeightFromScore[1]
441
442CourseTicket = attrs_to_fields(CourseTicket)
443
444class CourseTicketFactory(grok.GlobalUtility):
445    """A factory for student study levels.
446    """
447    grok.implements(IFactory)
448    grok.name(u'waeup.CourseTicket')
449    title = u"Create a new course ticket.",
450    description = u"This factory instantiates new course ticket instances."
451
452    def __call__(self, *args, **kw):
453        return CourseTicket()
454
455    def getInterfaces(self):
456        return implementedBy(CourseTicket)
Note: See TracBrowser for help on using the repository browser.