source: main/waeup.aaue/trunk/src/waeup/aaue/students/utils.py @ 17601

Last change on this file since 17601 was 17580, checked in by Henrik Bettermann, 14 months ago

New fees in 2023.

  • Property svn:keywords set to Id
File size: 26.8 KB
Line 
1## $Id: utils.py 17580 2023-09-14 09:14:25Z 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##
18import grok
19import os
20import csv
21from time import time
22from zope.component import createObject, queryUtility
23from zope.catalog.interfaces import ICatalog
24from waeup.kofa.interfaces import (
25    ADMITTED, CLEARANCE, REQUESTED, CLEARED, RETURNING, PAID,
26    REGISTERED, VALIDATED, academic_sessions_vocab)
27from kofacustom.nigeria.students.utils import NigeriaStudentsUtils
28from waeup.kofa.accesscodes import create_accesscode
29from waeup.kofa.students.utils import trans
30from waeup.aaue.interfaces import MessageFactory as _
31
32MINIMUM_UNITS_THRESHOLD = 15
33
34SCHOOLFEES = dict()
35SFEECHANGES = (12, 13, 14, 15, 20, 21, 22, 23)
36
37for year in SFEECHANGES:
38    schoolfees_path = os.path.join(
39        os.path.dirname(__file__), 'schoolfees_%s.csv' %year)
40    reader = csv.DictReader(open(schoolfees_path, 'rb'))
41    SCHOOLFEES[year] = {item['code']:(item['tuition'], item.values()) for item in reader}
42
43acceptancefees_path = os.path.join(
44    os.path.dirname(__file__), 'acceptancefees.csv')
45reader = csv.DictReader(open(acceptancefees_path, 'rb'))
46ACCEPTANCEFEES = {item['code']:(item['acceptance'], item.values()) for item in reader}
47
48class CustomStudentsUtils(NigeriaStudentsUtils):
49    """A collection of customized methods.
50
51    """
52
53    # PORTRAIT_CHANGE_STATES = (ADMITTED, CLEARANCE, REQUESTED, CLEARED)
54
55    def allowPortraitChange(self, student):
56        if student.is_fresh:
57            return True
58        return False
59
60    def GPABoundaries(self, faccode=None, depcode=None,
61                            certcode=None, student=None):
62        if student and student.current_mode.startswith('dp'):
63            return ((1.5, 'IRNS / NER / NYV'),
64                    (2.4, 'Pass'),
65                    (3.5, 'Merit'),
66                    (4.5, 'Credit'),
67                    (5, 'Distinction'))
68        elif student:
69            return ((1, 'FRNS / NER / NYV'),
70                    (1.5, 'Pass'),
71                    (2.4, '3rd Class Honours'),
72                    (3.5, '2nd Class Honours Lower Division'),
73                    (4.5, '2nd Class Honours Upper Division'),
74                    (5, '1st Class Honours'))
75        # Session Results Presentations depend on certificate
76        results = None
77        if certcode:
78            cat = queryUtility(ICatalog, name='certificates_catalog')
79            results = list(
80                cat.searchResults(code=(certcode, certcode)))
81        if results and results[0].study_mode.startswith('dp'):
82            return ((1.5, 'IRNS / NER / NYV'),
83                    (2.4, 'Pass'),
84                    (3.5, 'Merit'),
85                    (4.5, 'Credit'),
86                    (5, 'Distinction'))
87        else:
88            return ((1, 'FRNS / NER / NYV'),
89                    (1.5, 'Pass'),
90                    (2.4, '3rd Class Honours'),
91                    (3.5, '2nd Class Honours Lower Division'),
92                    (4.5, '2nd Class Honours Upper Division'),
93                    (5, '1st Class Honours'))
94
95    def getClassFromCGPA(self, gpa, student):
96        gpa_boundaries = self.GPABoundaries(student=student)
97
98        try:
99            certificate = getattr(student['studycourse'],'certificate',None)
100            end_level = getattr(certificate, 'end_level', None)
101            final_level = max([studylevel.level for studylevel in student['studycourse'].values()])
102            if end_level and final_level >= end_level:
103                dummy, repeat = divmod(final_level, 100)
104                if gpa <= 5.1 and repeat == 20:
105                    # Irrespective of the CGPA of a student, if the He/She has
106                    # 3rd Extension, such student will be graduated with a "Pass".
107                    return 1, gpa_boundaries[1][1]
108        except ValueError:
109            pass
110
111        if gpa < gpa_boundaries[0][0]:
112            # FRNS / Fail
113            return 0, gpa_boundaries[0][1]
114        if student.entry_session < 2013 or \
115            student.current_mode.startswith('dp'):
116            if gpa < gpa_boundaries[1][0]:
117                # Pass
118                return 1, gpa_boundaries[1][1]
119        else:
120            if gpa < gpa_boundaries[1][0]:
121                # FRNS
122                # Pass degree has been phased out in 2013 for non-diploma
123                # students
124                return 0, gpa_boundaries[0][1]
125        if gpa < gpa_boundaries[2][0]:
126            # 3rd / Merit
127            return 2, gpa_boundaries[2][1]
128        if gpa < gpa_boundaries[3][0]:
129            # 2nd L / Credit
130            return 3, gpa_boundaries[3][1]
131        if gpa < gpa_boundaries[4][0]:
132            # 2nd U / Distinction
133            return 4, gpa_boundaries[4][1]
134        if gpa <= gpa_boundaries[5][0]:
135            # 1st
136            return 5, gpa_boundaries[5][1]
137        return
138
139    def getDegreeClassNumber(self, level_obj):
140        """Get degree class number (used for SessionResultsPresentation
141        reports).
142        """
143        certificate = getattr(level_obj.__parent__,'certificate', None)
144        end_level = getattr(certificate, 'end_level', None)
145        if level_obj.level_verdict in ('FRNS', 'NER', 'NYV'):
146            return 0
147        if end_level and level_obj.level >= end_level:
148            if level_obj.level > end_level:
149                # spill-over level
150                if level_obj.gpa_params[1] == 0:
151                    # no credits taken
152                    return 0
153            elif level_obj.gpa_params[1] < MINIMUM_UNITS_THRESHOLD:
154                # credits taken below limit
155                return 0
156            failed_courses = level_obj.passed_params[4]
157            not_taken_courses = level_obj.passed_params[5]
158            if '_m' in failed_courses:
159                return 0
160            if len(not_taken_courses) \
161                and not not_taken_courses == 'Nil':
162                return 0
163        elif level_obj.gpa_params[1] < MINIMUM_UNITS_THRESHOLD:
164            # credits taken below limit
165            return 0
166        # use gpa_boundaries above
167        return self.getClassFromCGPA(
168            level_obj.cumulative_params[0], level_obj.student)[0]
169
170    def increaseMatricInteger(self, student):
171        """Increase counter for matric numbers.
172        This counter can be a centrally stored attribute or an attribute of
173        faculties, departments or certificates. In the base package the counter
174        is as an attribute of the site configuration container.
175        """
176        if student.current_mode in ('ug_pt', 'de_pt', 'ug_dsh', 'de_dsh'):
177            grok.getSite()['configuration'].next_matric_integer += 1
178            return
179        elif student.is_postgrad:
180            grok.getSite()['configuration'].next_matric_integer_3 += 1
181            return
182        elif student.current_mode in ('dp_ft',):
183            grok.getSite()['configuration'].next_matric_integer_4 += 1
184            return
185        grok.getSite()['configuration'].next_matric_integer_2 += 1
186        return
187
188    def _concessionalPaymentMade(self, student):
189        if len(student['payments']):
190            for ticket in student['payments'].values():
191                if ticket.p_state == 'paid' and \
192                    ticket.p_category == 'concessional':
193                    return True
194        return False
195
196    def constructMatricNumber(self, student):
197        faccode = student.faccode
198        depcode = student.depcode
199        certcode = student.certcode
200        degree = getattr(
201            getattr(student.get('studycourse', None), 'certificate', None),
202                'degree', None)
203        year = unicode(student.entry_session)[2:]
204        if not student.state in (PAID, ) or not student.is_fresh or \
205            student.current_mode in ('found', 'ijmbe'):
206            return _('Matriculation number cannot be set.'), None
207        #if student.current_mode not in ('mug_ft', 'mde_ft') and \
208        #    not self._concessionalPaymentMade(student):
209        #    return _('Matriculation number cannot be set.'), None
210        if student.is_postgrad:
211            next_integer = grok.getSite()['configuration'].next_matric_integer_3
212            if not degree or next_integer == 0:
213                return _('Matriculation number cannot be set.'), None
214            if student.faccode in ('IOE'):
215                return None, "AAU/SPS/%s/%s/%s/%05d" % (
216                    faccode, year, degree, next_integer)
217            return None, "AAU/SPS/%s/%s/%s/%s/%05d" % (
218                faccode, depcode, year, degree, next_integer)
219        if student.current_mode in ('ug_dsh', 'de_dsh'):
220            next_integer = grok.getSite()['configuration'].next_matric_integer
221            if next_integer == 0:
222                return _('Matriculation number cannot be set.'), None
223            return None, "DSH/%s/%s/%s/%05d" % (
224                faccode, depcode, year, next_integer)
225        if student.current_mode in ('ug_pt', 'de_pt'):
226            next_integer = grok.getSite()['configuration'].next_matric_integer
227            if next_integer == 0:
228                return _('Matriculation number cannot be set.'), None
229            return None, "PTP/%s/%s/%s/%05d" % (
230                faccode, depcode, year, next_integer)
231        if student.current_mode in ('dp_ft',):
232            next_integer = grok.getSite()['configuration'].next_matric_integer_4
233            if next_integer == 0:
234                return _('Matriculation number cannot be set.'), None
235            return None, "IOE/DIP/%s/%05d" % (year, next_integer)
236        next_integer = grok.getSite()['configuration'].next_matric_integer_2
237        if next_integer == 0:
238            return _('Matriculation number cannot be set.'), None
239        if student.faccode in ('FBM', 'FCS', 'FMLS'):
240            return None, "CMS/%s/%s/%s/%05d" % (
241                faccode, depcode, year, next_integer)
242        return None, "%s/%s/%s/%05d" % (faccode, depcode, year, next_integer)
243
244    def getReturningData(self, student):
245        """ This method defines what happens after school fee payment
246        of returning students depending on the student's senate verdict.
247        """
248        prev_level = student['studycourse'].current_level
249        cur_verdict = student['studycourse'].current_verdict
250        if cur_verdict in ('A','B','L','M','N','Z',):
251            # Successful student
252            new_level = divmod(int(prev_level),100)[0]*100 + 100
253        elif cur_verdict == 'C':
254            # Student on probation
255            new_level = int(prev_level) + 10
256        else:
257            # Student is somehow in an undefined state.
258            # Level has to be set manually.
259            new_level = prev_level
260        new_session = student['studycourse'].current_session + 1
261        return new_session, new_level
262
263    def _isPaymentDisabled(self, p_session, category, student):
264        academic_session = self._getSessionConfiguration(p_session)
265        if category.startswith('schoolfee'):
266            if 'sf_all' in academic_session.payment_disabled:
267                return True
268            if 'sf_pg' in academic_session.payment_disabled and \
269                student.is_postgrad:
270                return True
271            if 'sf_ug_pt' in academic_session.payment_disabled and \
272                student.current_mode in ('ug_pt', 'de_pt'):
273                return True
274            if 'sf_found' in academic_session.payment_disabled and \
275                student.current_mode == 'found':
276                return True
277        if category.startswith('clearance') and \
278            'cl_regular' in academic_session.payment_disabled and \
279            student.current_mode in ('ug_ft', 'de_ft', 'mug_ft', 'mde_ft'):
280            return True
281        if category == 'hostel_maintenance' and \
282            'maint_all' in academic_session.payment_disabled:
283            return True
284        return False
285
286    def setPaymentDetails(self, category, student,
287            previous_session=None, previous_level=None, combi=[]):
288        """Create Payment object and set the payment data of a student for
289        the payment category specified.
290
291        """
292        details = {}
293        p_item = u''
294        amount = 0.0
295        error = u''
296        if previous_session:
297            if previous_session < student['studycourse'].entry_session:
298                return _('The previous session must not fall below '
299                         'your entry session.'), None
300            if category == 'schoolfee':
301                # School fee is always paid for the following session
302                if previous_session > student['studycourse'].current_session:
303                    return _('This is not a previous session.'), None
304            else:
305                if previous_session > student['studycourse'].current_session - 1:
306                    return _('This is not a previous session.'), None
307            p_session = previous_session
308            p_level = previous_level
309            p_current = False
310        else:
311            p_session = student['studycourse'].current_session
312            p_level = student['studycourse'].current_level
313            p_current = True
314        academic_session = self._getSessionConfiguration(p_session)
315        if academic_session == None:
316            return _(u'Session configuration object is not available.'), None
317        # Determine fee.
318        if category == 'transfer':
319            amount = academic_session.transfer_fee
320        elif category == 'transcript_local':
321            amount = academic_session.transcript_fee_local
322        elif category == 'transcript_inter':
323            amount = academic_session.transcript_fee_inter
324        elif category == 'bed_allocation':
325            acco_details = self.getAccommodationDetails(student)
326            p_session = acco_details['booking_session']
327            p_item = acco_details['bt']
328            amount = academic_session.booking_fee
329        elif category == 'hostel_maintenance':
330            amount = 0.0
331            booking_session = grok.getSite()['hostels'].accommodation_session
332            bedticket = student['accommodation'].get(str(booking_session), None)
333            if bedticket is not None and bedticket.bed is not None:
334                p_session = booking_session
335                p_item = bedticket.display_coordinates
336                if bedticket.bed.__parent__.maint_fee > 0:
337                    amount = bedticket.bed.__parent__.maint_fee
338                else:
339                    # fallback
340                    amount = academic_session.maint_fee
341            else:
342                return _(u'No bed allocated.'), None
343        elif student.current_mode == 'found' and category not in (
344            'schoolfee', 'clearance', 'late_registration'):
345            return _('Not allowed.'), None
346        elif category.startswith('clearance'):
347            if student.state not in (ADMITTED, CLEARANCE, REQUESTED, CLEARED):
348                return _(u'Acceptance Fee payments not allowed.'), None
349            try:
350                certificate = student['studycourse'].certificate
351                p_item = certificate.code
352            except (AttributeError, TypeError):
353                return _('Study course data are incomplete.'), None
354            if student.current_mode in (
355                'ug_ft', 'ug_pt', 'de_ft', 'de_pt',
356                'transfer', 'mug_ft', 'mde_ft') \
357                and category != 'clearance_incl':
358                    return _("Additional fees must be included."), None
359            elif student.current_mode == 'special_pg_ft':
360                if category != 'clearance':
361                    return _("No additional fees required."), None
362            try:
363                acceptancefees = ACCEPTANCEFEES[student.certcode]
364            except KeyError:
365                return _('Acceptance fees not yet fixed.'), None
366            if category == 'clearance_incl':
367                for item in acceptancefees[1]:
368                    try:
369                        amount += int(item)
370                    except:
371                        pass
372            else:
373                amount = float(acceptancefees[0])
374        elif category == 'late_registration':
375            if student.is_postgrad:
376                amount = academic_session.late_pg_registration_fee
377            else:
378                amount = academic_session.late_registration_fee
379        elif category == 'ict':
380            if student.is_fresh:
381                amount = 2200
382            else:
383                amount = 1200
384        elif category.startswith('schoolfee'):
385            try:
386                certificate = student['studycourse'].certificate
387                p_item = certificate.code
388            except (AttributeError, TypeError):
389                return _('Study course data are incomplete.'), None
390            try:
391                if student.entry_session < 2013:
392                    schoolfees = SCHOOLFEES[12][student.certcode]
393                elif student.entry_session < 2014:
394                    schoolfees = SCHOOLFEES[13][student.certcode]
395                elif student.entry_session < 2015:
396                    schoolfees = SCHOOLFEES[14][student.certcode]
397                elif student.entry_session < 2020:
398                    schoolfees = SCHOOLFEES[15][student.certcode]
399                elif student.entry_session < 2021:
400                    schoolfees = SCHOOLFEES[20][student.certcode]
401                elif student.entry_session < 2022:
402                    schoolfees = SCHOOLFEES[21][student.certcode]
403                elif student.entry_session < 2023:
404                    schoolfees = SCHOOLFEES[22][student.certcode]
405                else:
406                    schoolfees = SCHOOLFEES[23][student.certcode]
407            except KeyError:
408                return _('School fees not yet fixed.'), None
409            #if student.is_postgrad and category != 'schoolfee':
410            #    return _("No additional fees required."), None
411            if not previous_session and student.current_mode in (
412                'ug_ft', 'ug_pt', 'de_ft', 'de_pt',
413                'transfer', 'mug_ft', 'mde_ft') \
414                and not category in (
415                'schoolfee_incl', 'schoolfee_1', 'schoolfee_2'):
416                    return _("You must choose a payment which includes "
417                             "additional fees."), None
418            if category in ('schoolfee_1', 'schoolfee_2'):
419                #if student.current_mode == 'ug_pt':
420                #    return _("Part-time students are not allowed "
421                #             "to pay by instalments."), None
422                if student.entry_session < 2015:
423                    return _("You are not allowed "
424                             "to pay by instalments."), None
425            additional = 0.0
426            for item in schoolfees[1]:
427                try:
428                    additional += int(item)
429                except:
430                    # 100_ indicates first year payment only
431                    if student.state == CLEARED:
432                        try:
433                            additional += int(item.split('_')[1])
434                        except:
435                            pass
436            amount = float(schoolfees[0])
437            additional -= amount
438            if previous_session:
439                # Students can pay for previous sessions in all
440                # workflow states.  Fresh students are excluded by the
441                # update method of the PreviousPaymentAddFormPage.
442               # Cut school fee by 50%
443                if category in ('schoolfee_1', 'schoolfee_2') and amount:
444                    amount /= 2
445                pass
446            elif student.state == CLEARED:
447                # Cut school fee by 50%
448                if category in ('schoolfee_1', 'schoolfee_2') and amount:
449                    amount /= 2
450            elif student.state == RETURNING and category != 'schoolfee_2':
451                if not student.father_name:
452                    return _("Personal data form is not properly filled."), None
453                # In case of returning school fee payment the payment session
454                # and level contain the values of the session the student
455                # has paid for.
456                p_session, p_level = self.getReturningData(student)
457                try:
458                    academic_session = grok.getSite()[
459                        'configuration'][str(p_session)]
460                except KeyError:
461                    return _(u'Session configuration object is not available.'), None
462                # Cut school fee by 50%
463                if category == 'schoolfee_1' and amount:
464                    amount /= 2
465            elif category == 'schoolfee_2' and amount:
466                amount /= 2
467            else:
468                return _('Wrong state.'), None
469            if amount in (0.0, None):
470                return _(u'Amount could not be determined.'), None
471            # Add additional fees
472            if category in ('schoolfee_incl', 'schoolfee_1'):
473                amount += additional
474            # Add non-indigenous fee and session specific penalty fees
475            if student.is_postgrad:
476                amount += academic_session.penalty_pg
477                if student.lga and not student.lga.startswith('edo') \
478                    and student.entry_session < 2022:
479                    amount += 20000.0
480            else:
481                amount += academic_session.penalty_ug
482        elif not student.is_postgrad:
483            fee_name = category + '_fee'
484            amount = getattr(academic_session, fee_name, 0.0)
485        if amount in (0.0, None):
486            return _(u'Amount could not be determined.'), None
487        # Create ticket.
488        for key in student['payments'].keys():
489            ticket = student['payments'][key]
490            if ticket.p_state == 'paid' and\
491               ticket.p_category == category and \
492               not ticket.p_category.startswith('transcript') and \
493               ticket.p_item == p_item and \
494               ticket.p_session == p_session:
495                  return _('This type of payment has already been made.'), None
496            # Additional condition in AAUE
497            if category in ('schoolfee', 'schoolfee_incl', 'schoolfee_1'):
498                if ticket.p_state == 'paid' and \
499                   ticket.p_category in ('schoolfee',
500                                         'schoolfee_incl',
501                                         'schoolfee_1') and \
502                   ticket.p_item == p_item and \
503                   ticket.p_session == p_session:
504                      return _(
505                          'Another school fee payment for this '
506                          'session has already been made.'), None
507
508        if self._isPaymentDisabled(p_session, category, student):
509            return _('This category of payments has been disabled.'), None
510        payment = createObject(u'waeup.StudentOnlinePayment')
511        timestamp = ("%d" % int(time()*10000))[1:]
512        payment.p_id = "p%s" % timestamp
513        payment.p_category = category
514        payment.p_item = p_item
515        payment.p_session = p_session
516        payment.p_level = p_level
517        payment.p_current = p_current
518        payment.amount_auth = amount
519        return None, payment
520
521    def _admissionText(self, student, portal_language):
522        inst_name = grok.getSite()['configuration'].name
523        entry_session = student['studycourse'].entry_session
524        entry_session = academic_sessions_vocab.getTerm(entry_session).title
525        text = trans(_(
526            'This is to inform you that you have been offered provisional'
527            ' admission into ${a} for the ${b} academic session as follows:',
528            mapping = {'a': inst_name, 'b': entry_session}),
529            portal_language)
530        return text
531
532    def warnCreditsOOR(self, studylevel, course=None):
533        studycourse = studylevel.__parent__
534        certificate = getattr(studycourse,'certificate', None)
535        current_level = studycourse.current_level
536        if None in (current_level, certificate):
537            return
538        end_level = certificate.end_level
539        if current_level >= end_level:
540            limit = 52
541        else:
542            limit = 48
543        if course and studylevel.total_credits + course.credits > limit:
544            return  _('Maximum credits exceeded.')
545        elif studylevel.total_credits > limit:
546            return _('Maximum credits exceeded.')
547        return
548
549    def getBedCoordinates(self, bedticket):
550        """Return descriptive bed coordinates.
551        This method can be used to customize the `display_coordinates`
552        property method in order to  display a
553        customary description of the bed space.
554        """
555        bc = bedticket.bed_coordinates.split(',')
556        if len(bc) == 4:
557            return bc[0]
558        return bedticket.bed_coordinates
559
560    def getAccommodationDetails(self, student):
561        """Determine the accommodation data of a student.
562        We are usimng the base package method again.
563        """
564        d = {}
565        d['error'] = u''
566        hostels = grok.getSite()['hostels']
567        d['booking_session'] = hostels.accommodation_session
568        d['allowed_states'] = hostels.accommodation_states
569        d['startdate'] = hostels.startdate
570        d['enddate'] = hostels.enddate
571        d['expired'] = hostels.expired
572        # Determine bed type
573        bt = 'all'
574        if student.sex == 'f':
575            sex = 'female'
576        else:
577            sex = 'male'
578        special_handling = 'regular'
579        d['bt'] = u'%s_%s_%s' % (special_handling,sex,bt)
580        return d
581
582    def checkAccommodationRequirements(self, student, acc_details):
583        msg = super(CustomStudentsUtils, self).checkAccommodationRequirements(
584            student, acc_details)
585        if msg:
586            return msg
587        if student.current_mode not in ('ug_ft', 'de_ft', 'mug_ft', 'mde_ft'):
588            return _('You are not eligible to book accommodation.')
589        return
590
591    # AAUE prefix
592    STUDENT_ID_PREFIX = u'E'
593
594    STUDENT_EXPORTER_NAMES = (
595            'students',
596            'studentstudycourses',
597            'studentstudycourses_1',
598            'studentstudylevels',
599            #'studentstudylevels_1',
600            'coursetickets',
601            #'coursetickets_1',
602            'studentpayments',
603            'bedtickets',
604            'unpaidpayments',
605            'sfpaymentsoverview',
606            'studylevelsoverview',
607            'combocard',
608            'bursary',
609            'levelreportdata',
610            'outstandingcourses',
611            'sessionpaymentsoverview',
612            'accommodationpayments',
613            'transcriptdata',
614            'trimmedpayments',
615            'trimmed',
616            'outstandingcourses_2'
617            )
618
619    # Maximum size of upload files in kB
620    MAX_KB = 500
Note: See TracBrowser for help on using the repository browser.