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

Last change on this file since 18067 was 18063, checked in by Henrik Bettermann, 5 weeks ago

Allow lecturers to edit scores in state 'courses registered‘.
Prepare renderPDFCourseticketsOverview to render mark sheets.

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