source: main/waeup.aaue/trunk/src/waeup/aaue/students/studylevel.py @ 16594

Last change on this file since 16594 was 16579, checked in by Henrik Bettermann, 3 years ago

Adjust to base package.

  • Property svn:keywords set to Id
File size: 18.3 KB
RevLine 
[8326]1## $Id: studylevel.py 16579 2021-08-19 20:29:51Z henrik $
2##
3## Copyright (C) 2012 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
[13036]23import pytz
24from datetime import datetime
[8326]25from zope.component.interfaces import IFactory
[9502]26from zope.component import createObject
[8326]27from zope.interface import implementedBy
28from waeup.kofa.utils.helpers import attrs_to_fields
[15630]29from waeup.kofa.interfaces import RETURNING, VALIDATED, REGISTERED, PAID
[14227]30from waeup.kofa.students.browser import TicketError
[8326]31from waeup.kofa.students.studylevel import (
32    StudentStudyLevel, CourseTicket,
33    CourseTicketFactory, StudentStudyLevelFactory)
[14075]34from waeup.kofa.students.interfaces import IStudentNavigation, ICourseTicket
[8444]35from waeup.aaue.students.interfaces import (
[8867]36    ICustomStudentStudyLevel, ICustomCourseTicket)
[14663]37from waeup.aaue.students.utils import MINIMUM_UNITS_THRESHOLD
[14248]38from waeup.aaue.interfaces import MessageFactory as _
[8326]39
40
41class CustomStudentStudyLevel(StudentStudyLevel):
42    """This is a container for course tickets.
43    """
44    grok.implements(ICustomStudentStudyLevel, IStudentNavigation)
45    grok.provides(ICustomStudentStudyLevel)
46
[9914]47    @property
48    def total_credits_s1(self):
49        total = 0
50        for ticket in self.values():
[14576]51            if ticket.semester == 1 and not ticket.outstanding:
[9914]52                total += ticket.credits
53        return total
54
55    @property
56    def total_credits_s2(self):
57        total = 0
58        for ticket in self.values():
[14576]59            if ticket.semester == 2 and not ticket.outstanding:
[9914]60                total += ticket.credits
61        return total
62
[10443]63    @property
[13834]64    def gpa_params(self):
65        """Calculate gpa parameters for this level.
66        """
67        credits_weighted = 0.0
68        credits_counted = 0
69        level_gpa = 0.0
70        for ticket in self.values():
[14532]71            if ticket.total_score is not None:
[13834]72                credits_counted += ticket.credits
73                credits_weighted += ticket.credits * ticket.weight
74        if credits_counted:
[14384]75            level_gpa = credits_weighted/credits_counted
[14206]76        # Override level_gpa if value has been imported
77        imported_gpa = getattr(self, 'imported_gpa', None)
78        if imported_gpa:
79            level_gpa = imported_gpa
[13834]80        return level_gpa, credits_counted, credits_weighted
81
82    @property
[10480]83    def gpa_params_rectified(self):
84        return self.gpa_params
[10443]85
[13036]86    @property
[14357]87    def passed_params(self):
88        """Determine the number and credits of passed and failed courses.
89        This method is used for level reports.
90        """
91        passed = failed = 0
92        courses_failed = ''
93        credits_failed = 0
94        credits_passed = 0
[14393]95        courses_not_taken = ''
[14357]96        for ticket in self.values():
[14532]97            if ticket.total_score is not None:
[14357]98                if ticket.total_score < ticket.passmark:
99                    failed += 1
100                    credits_failed += ticket.credits
[16042]101                    if ticket.mandatory or ticket.course_category == 'C':
[14357]102                        courses_failed += 'm_%s_m ' % ticket.code
103                    else:
104                        courses_failed += '%s ' % ticket.code
105                else:
106                    passed += 1
107                    credits_passed += ticket.credits
[14370]108            else:
[14393]109                courses_not_taken += '%s ' % ticket.code
[14388]110        if not len(courses_failed):
[14506]111            courses_failed = 'Nil'
[14428]112        if not len(courses_not_taken):
[14506]113            courses_not_taken = 'Nil'
[14370]114        return (passed, failed, credits_passed,
115                credits_failed, courses_failed,
[14393]116                courses_not_taken)
[14357]117
118    @property
[14248]119    def course_registration_forbidden(self):
[14501]120        #fac_dep_paid = True
121        #if self.student.entry_session >= 2016:
122        #    fac_dep_paid = False
123        #    for ticket in self.student['payments'].values():
124        #        if ticket.p_category == 'fac_dep' and \
125        #            ticket.p_session == self.level_session and \
126        #            ticket.p_state == 'paid':
127        #                fac_dep_paid = True
128        #                continue
129        #if not fac_dep_paid:
130        #    return _("Please pay faculty and departmental dues first.")
[14509]131
132
133        ######################################################
[15370]134        # Temporarily disable ug_ft course registration
[15378]135        #if self.student.current_mode == 'ug_ft':
136        #    return _("Course registration has been disabled.")
[14509]137        ######################################################
138
139
[14500]140        restitution_paid = True
[15178]141        if self.student.current_session == 2016 \
142            and self.student.current_mode in ('ug_ft', 'dp_ft') \
143            and not self.student.is_fresh:
[14500]144            restitution_paid = False
145            for ticket in self.student['payments'].values():
146                if ticket.p_category == 'restitution' and \
147                    ticket.p_session == self.level_session and \
148                    ticket.p_state == 'paid':
149                        restitution_paid = True
150                        continue
151        if not restitution_paid:
152            return _("Please pay restitution fee first.")
[15428]153        #if self.student.is_fresh:
154        #    return
[13036]155        try:
[15398]156            academic_session = grok.getSite()['configuration'][
157                str(self.level_session)]
[14117]158            if self.student.is_postgrad:
[15398]159                deadline = academic_session.coursereg_deadline_pg
[14117]160            elif self.student.current_mode.startswith('dp'):
[15398]161                deadline = academic_session.coursereg_deadline_dp
[15477]162            elif self.student.current_mode in (
[15478]163                'ug_pt', 'de_pt', 'de_dsh', 'ug_dsh'):
[15398]164                deadline = academic_session.coursereg_deadline_pt
[14117]165            elif self.student.current_mode == 'found':
[15398]166                deadline = academic_session.coursereg_deadline_found
[15458]167            elif self.student.current_mode == 'bridge':
168                deadline = academic_session.coursereg_deadline_bridge
[14117]169            else:
[15398]170                deadline = academic_session.coursereg_deadline
[13071]171        except (TypeError, KeyError):
[14248]172            return
[13070]173        if not deadline or deadline > datetime.now(pytz.utc):
[14248]174            return
[15398]175        if self.student.is_postgrad:
176            lcrfee = academic_session.late_pg_registration_fee
177        else:
178            lcrfee = academic_session.late_registration_fee
179        if not lcrfee:
180            return _("Course registration has been disabled.")
[13036]181        if len(self.student['payments']):
182            for ticket in self.student['payments'].values():
183                if ticket.p_category == 'late_registration' and \
184                    ticket.p_session == self.level_session and \
185                    ticket.p_state == 'paid':
[14248]186                        return
187        return _("Course registration has ended. "
188                 "Please pay the late registration fee.")
[13036]189
[14082]190    # only AAUE
191    @property
192    def remark(self):
[14161]193        certificate = getattr(self.__parent__,'certificate',None)
194        end_level = getattr(certificate, 'end_level', None)
[14918]195        study_mode = getattr(certificate, 'study_mode', None)
196        is_dp = False
197        if study_mode and study_mode.startswith('dp'):
198            is_dp = True
[14380]199        failed_limit = 1.5
200        if self.student.entry_session < 2013:
201            failed_limit = 1.0
[14377]202        # final level student remark
203        if end_level and self.level >= end_level:
[14464]204            if self.level > end_level:
205                # spill-over level
206                if self.gpa_params[1] == 0:
207                    # no credits taken
[14487]208                    return 'NER'
[14663]209            elif self.gpa_params[1] < MINIMUM_UNITS_THRESHOLD:
[14537]210                # credits taken below limit
211                return 'NER'
[14487]212            if self.level_verdict in ('FRNS', 'NER', 'NYV'):
[14415]213                return self.level_verdict
[14412]214            if '_m' in self.passed_params[4]:
[14161]215                return 'FRNS'
[14412]216            if not self.cumulative_params[0]:
217                return 'FRNS'
[14435]218            if len(self.passed_params[5]) \
[14506]219                and not self.passed_params[5] == 'Nil':
[14412]220                return 'FRNS'
[14955]221            if self.cumulative_params[1] < 60:
[14623]222                return 'FRNS'
[14380]223            if self.cumulative_params[0] < failed_limit:
[14161]224                return 'Fail'
[15054]225            dummy, repeat = divmod(self.level, 100)
[15090]226            if self.cumulative_params[0] < 5.1 and repeat == 20:
[15054]227                # Irrespective of the CGPA of a student, if the He/She has
228                # 3rd Extension, such student will be graduated with a "Pass".
229                return 'Pass'
[14463]230            if self.cumulative_params[0] < 1.5:
[14918]231                if is_dp:
232                    return 'Fail'
[14463]233                return 'Pass'
[14161]234            if self.cumulative_params[0] < 2.4:
[14918]235                if is_dp:
236                    return 'Pass'
[14327]237                return '3s_rd_s'
[14161]238            if self.cumulative_params[0] < 3.5:
[14918]239                if is_dp:
240                    return 'Merit'
[14327]241                return '2s_2_s'
[14161]242            if self.cumulative_params[0] < 4.5:
[14918]243                if is_dp:
244                    return 'Credit'
[14327]245                return '2s_1_s'
[14161]246            if self.cumulative_params[0] < 5.1:
[14918]247                if is_dp:
248                    return 'Distinction'
[14327]249                return '1s_st_s'
[14161]250            return 'N/A'
251        # returning student remark
[14487]252        if self.level_verdict in ('FRNS', 'NER', 'NYV'):
[14475]253            return 'Probation'
254        if self.level_verdict == 'D':
255            return 'Withdrawn'
[14662]256        if self.gpa_params[1] == 0:
257            # no credits taken
258            return 'NER'
[14663]259        if self.gpa_params[1] < MINIMUM_UNITS_THRESHOLD:
[14464]260            # credits taken below limit
[14415]261            return 'Probation'
[14380]262        if self.cumulative_params[0] < failed_limit:
[14082]263            return 'Probation'
264        if self.cumulative_params[0] < 5.1:
265            return 'Proceed'
266        return 'N/A'
267
[14227]268    def _schoolfeePaymentMade(self):
269        if len(self.student['payments']):
270            for ticket in self.student['payments'].values():
271                if ticket.p_state == 'paid' and \
272                    ticket.p_category in (
273                        'schoolfee', 'schoolfee_incl', 'schoolfee_2',)  and \
274                    ticket.p_session == self.student[
275                        'studycourse'].current_session:
276                    return True
277        return False
278
[14259]279    def _coursePaymentsMade(self, course):
[14266]280        if self.level_session < 2016:
281            return True
[14259]282        if not course.code[:3] in ('GST', 'ENT'):
283            return True
284        if len(self.student['payments']):
285            paid_cats = list()
286            for pticket in self.student['payments'].values():
287                if pticket.p_state == 'paid':
288                    paid_cats.append(pticket.p_category)
289            if course.code in ('GST101', 'GST102', 'GST111', 'GST112') and \
290                not 'gst_registration_1' in paid_cats:
291                return False
292            if course.code in ('GST222',) and \
293                not 'gst_registration_2' in paid_cats:
294                return False
[14675]295            #if course.code in ('ENT201',) and \
296            #    not 'ent_registration_1' in paid_cats:
297            #    return False
[14355]298            if course.code in ('GST101', 'GST102') and \
[14348]299                not 'gst_text_book_1' in paid_cats and \
[14355]300                not 'gst_text_book_0' in paid_cats:
[14259]301                return False
[14355]302            if course.code in ('GST111', 'GST112') and \
[14348]303                not 'gst_text_book_2' in paid_cats and \
[14355]304                not 'gst_text_book_0' in paid_cats:
[14348]305                return False
[14259]306            if course.code in ('GST222',) and \
307                not 'gst_text_book_3' in paid_cats:
308                return False
[14675]309            #if course.code in ('ENT201',) and \
310            #    not 'ent_text_book_1' in paid_cats:
311            #    return False
[14259]312            return True
313        return False
314
[14075]315    def addCourseTicket(self, ticket, course):
316        """Add a course ticket object.
317        """
318        if not ICourseTicket.providedBy(ticket):
319            raise TypeError(
320                'StudentStudyLeves contain only ICourseTicket instances')
[14227]321        # Raise TicketError if course is in 2nd semester but
322        # schoolfee has not yet been fully paid.
323        if course.semester == 2 and not self._schoolfeePaymentMade():
[14252]324            raise TicketError(
325                _('%s is a 2nd semester course which can only be added '
326                  'if school fees have been fully paid.' % course.code))
[14259]327        # Raise TicketError if registration fee or text
328        # book fee haven't been paid.
329        if not self._coursePaymentsMade(course):
330            raise TicketError(
331                _('%s can only be added if both registration fee and text '
332                  'book fee have been paid.'
333                  % course.code))
[15429]334        # We check if there exists a certificate course in the certificate
335        # container which refers to the course. If such an object does
336        # not exist, students and managers will be prevented from registering
337        # the corresponding course.
338        cert = self.__parent__.certificate
339        ticket_allowed = False
340        for val in cert.values():
341            if val.course == course:
342                ticket_allowed = True
343                break
344        if not ticket_allowed:
345            raise TicketError(
346                _('%s is not part of the %s curriculum.'
347                  % (course.code, cert.code)))
[14075]348        ticket.code = course.code
349        ticket.title = course.title
350        ticket.fcode = course.__parent__.__parent__.__parent__.code
351        ticket.dcode = course.__parent__.__parent__.code
352        ticket.credits = course.credits
353        if self.student.entry_session < 2013:
354            ticket.passmark = course.passmark - 5
355        else:
356            ticket.passmark = course.passmark
357        ticket.semester = course.semester
358        self[ticket.code] = ticket
359        return
360
[14227]361    def addCertCourseTickets(self, cert):
362        """Collect all certificate courses and create course
363        tickets automatically.
364        """
365        if cert is not None:
366            for key, val in cert.items():
367                if val.level != self.level:
368                    continue
369                ticket = createObject(u'waeup.CourseTicket')
370                ticket.automatic = True
371                ticket.mandatory = val.mandatory
372                ticket.carry_over = False
[16022]373                ticket.course_category = val.course_category
[14227]374                try:
375                    self.addCourseTicket(ticket, val.course)
376                except TicketError:
377                    pass
378        return
379
[9692]380CustomStudentStudyLevel = attrs_to_fields(
[9914]381    CustomStudentStudyLevel, omit=[
[10480]382    'total_credits', 'total_credits_s1', 'total_credits_s2', 'gpa'])
[8326]383
384class CustomStudentStudyLevelFactory(StudentStudyLevelFactory):
385    """A factory for student study levels.
386    """
387
388    def __call__(self, *args, **kw):
389        return CustomStudentStudyLevel()
390
391    def getInterfaces(self):
392        return implementedBy(CustomStudentStudyLevel)
393
394class CustomCourseTicket(CourseTicket):
395    """This is a course ticket which allows the
396    student to attend the course. Lecturers will enter scores and more at
397    the end of the term.
398
399    A course ticket contains a copy of the original course and
400    course referrer data. If the courses and/or their referrers are removed, the
401    corresponding tickets remain unchanged. So we do not need any event
402    triggered actions on course tickets.
403    """
404    grok.implements(ICustomCourseTicket, IStudentNavigation)
405    grok.provides(ICustomCourseTicket)
406
[13834]407    @property
[14532]408    def _getGradeWeightFromScore(self):
[16579]409        """AAUE Course Grading System
[14532]410        """
411        if self.score == -1:
412            return ('-',0) # core course and result not yet available (used by AAUE)
413        if self.total_score is None:
414            return (None, None)
415        if self.total_score >= 70:
416            return ('A',5)
417        if self.total_score >= 60:
418            return ('B',4)
419        if self.total_score >= 50:
420            return ('C',3)
421        if self.total_score >= 45:
422            return ('D',2)
423        if self.total_score >= self.passmark: # passmark changed in 2013 from 40 to 45
424            return ('E',1)
425        return ('F',0)
426
427    @property
[14136]428    def total_score(self):
[15228]429        """Returns ca + score or imported total score.
[14136]430        """
[15226]431        # Override total_score if value has been imported
432        if getattr(self, 'imported_ts', None):
433            return self.imported_ts
[14532]434        if self.score == -1:
435            return 0
[14136]436        if not None in (self.score, self.ca):
437            return self.score + self.ca
[14532]438        return None
[14136]439
[14288]440    @property
[15804]441    def removable_by_student(self):
442        """True if student is allowed to remove the ticket.
443        """
[16042]444        if self.mandatory:
445            return False
446        if self.score:
447            return False
448        #if self.course_category == 'C':
449        #    return False
450        return True
[15804]451
452    @property
[14288]453    def editable_by_lecturer(self):
454        """True if lecturer is allowed to edit the ticket.
455        """
[15412]456        try:
457            cas = grok.getSite()[
458                'configuration'].current_academic_session
[15869]459            # Temporarily we allow students to pay for next session, so their
460            # current_session might have increased
[15876]461            if self.student.state in (
462                VALIDATED, REGISTERED, PAID, RETURNING) and \
[15869]463                self.student.current_session in (cas, cas+1):
[15412]464                return True
465        except (AttributeError, TypeError): # in unit tests
466            pass
467        return False
[14288]468
[8326]469CustomCourseTicket = attrs_to_fields(CustomCourseTicket)
470
471class CustomCourseTicketFactory(CourseTicketFactory):
472    """A factory for student study levels.
473    """
474
475    def __call__(self, *args, **kw):
476        return CustomCourseTicket()
477
478    def getInterfaces(self):
479        return implementedBy(CustomCourseTicket)
Note: See TracBrowser for help on using the repository browser.