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

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

Consider ent_combined payments.

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