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

Last change on this file since 17432 was 17431, checked in by Henrik Bettermann, 17 months ago

Rewrite payment configuration.

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