source: main/waeup.kofa/trunk/src/waeup/kofa/students/utils.py @ 16113

Last change on this file since 16113 was 16113, checked in by Henrik Bettermann, 4 years ago

Balance payments can be made several times for same session and level.

  • Property svn:keywords set to Id
File size: 51.6 KB
Line 
1## $Id: utils.py 16113 2020-06-10 06:07:04Z henrik $
2##
3## Copyright (C) 2011 Uli Fouquet & Henrik Bettermann
4## This program is free software; you can redistribute it and/or modify
5## it under the terms of the GNU General Public License as published by
6## the Free Software Foundation; either version 2 of the License, or
7## (at your option) any later version.
8##
9## This program is distributed in the hope that it will be useful,
10## but WITHOUT ANY WARRANTY; without even the implied warranty of
11## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12## GNU General Public License for more details.
13##
14## You should have received a copy of the GNU General Public License
15## along with this program; if not, write to the Free Software
16## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
17##
18"""General helper functions and utilities for the students section.
19"""
20import grok
21import textwrap
22from cgi import escape
23from time import time
24from cStringIO import StringIO
25from reportlab.lib import colors
26from reportlab.lib.units import cm
27from reportlab.lib.pagesizes import A4
28from reportlab.lib.styles import getSampleStyleSheet
29from reportlab.platypus import Paragraph, Image, Table, Spacer
30from reportlab.platypus.doctemplate import LayoutError
31from reportlab.platypus.flowables import PageBreak
32from zope.event import notify
33from zope.schema.interfaces import ConstraintNotSatisfied
34from zope.component import getUtility, createObject, queryUtility
35from zope.catalog.interfaces import ICatalog
36from zope.formlib.form import setUpEditWidgets
37from zope.i18n import translate
38from waeup.kofa.interfaces import (
39    IExtFileStore, IKofaUtils, RETURNING, PAID, CLEARED, GRADUATED,
40    academic_sessions_vocab, IFileStoreNameChooser)
41from waeup.kofa.interfaces import MessageFactory as _
42from waeup.kofa.students.interfaces import IStudentsUtils
43from waeup.kofa.students.workflow import ADMITTED
44from waeup.kofa.students.vocabularies import StudyLevelSource, MatNumNotInSource
45from waeup.kofa.browser.pdf import (
46    ENTRY1_STYLE, format_html, NOTE_STYLE, HEADING_STYLE,
47    get_signature_tables, get_qrcode)
48from waeup.kofa.browser.interfaces import IPDFCreator
49from waeup.kofa.utils.helpers import to_timezone
50
51SLIP_STYLE = [
52    ('VALIGN',(0,0),(-1,-1),'TOP'),
53    #('FONT', (0,0), (-1,-1), 'Helvetica', 11),
54    ]
55
56CONTENT_STYLE = [
57    ('VALIGN',(0,0),(-1,-1),'TOP'),
58    #('FONT', (0,0), (-1,-1), 'Helvetica', 8),
59    #('TEXTCOLOR',(0,0),(-1,0),colors.white),
60    #('BACKGROUND',(0,0),(-1,0),colors.black),
61    ('INNERGRID', (0,0), (-1,-1), 0.25, colors.black),
62    ('BOX', (0,0), (-1,-1), 1, colors.black),
63    ]
64
65FONT_SIZE = 10
66FONT_COLOR = 'black'
67
68def trans(text, lang):
69    # shortcut
70    return translate(text, 'waeup.kofa', target_language=lang)
71
72def formatted_text(text, color=FONT_COLOR, lang='en'):
73    """Turn `text`, `color` and `size` into an HTML snippet.
74
75    The snippet is suitable for use with reportlab and generating PDFs.
76    Wraps the `text` into a ``<font>`` tag with passed attributes.
77
78    Also non-strings are converted. Raw strings are expected to be
79    utf-8 encoded (usually the case for widgets etc.).
80
81    Finally, a br tag is added if widgets contain div tags
82    which are not supported by reportlab.
83
84    The returned snippet is unicode type.
85    """
86    if not isinstance(text, unicode):
87        if isinstance(text, basestring):
88            text = text.decode('utf-8')
89        else:
90            text = unicode(text)
91    if text == 'None':
92        text = ''
93    # Very long matriculation numbers need to be wrapped
94    if text.find(' ') == -1 and len(text.split('/')) > 6:
95        text = '/'.join(text.split('/')[:5]) + \
96            '/ ' + '/'.join(text.split('/')[5:])
97    # Mainly for boolean values we need our customized
98    # localisation of the zope domain
99    text = translate(text, 'zope', target_language=lang)
100    text = text.replace('</div>', '<br /></div>')
101    tag1 = u'<font color="%s">' % (color)
102    return tag1 + u'%s</font>' % text
103
104def generate_student_id():
105    students = grok.getSite()['students']
106    new_id = students.unique_student_id
107    return new_id
108
109def set_up_widgets(view, ignore_request=False):
110    view.adapters = {}
111    view.widgets = setUpEditWidgets(
112        view.form_fields, view.prefix, view.context, view.request,
113        adapters=view.adapters, for_display=True,
114        ignore_request=ignore_request
115        )
116
117def render_student_data(studentview, context, omit_fields=(),
118                        lang='en', slipname=None, no_passport=False):
119    """Render student table for an existing frame.
120    """
121    width, height = A4
122    set_up_widgets(studentview, ignore_request=True)
123    data_left = []
124    data_middle = []
125    style = getSampleStyleSheet()
126    img = getUtility(IExtFileStore).getFileByContext(
127        studentview.context, attr='passport.jpg')
128    if img is None:
129        from waeup.kofa.browser import DEFAULT_PASSPORT_IMAGE_PATH
130        img = open(DEFAULT_PASSPORT_IMAGE_PATH, 'rb')
131    doc_img = Image(img.name, width=4*cm, height=4*cm, kind='bound')
132    data_left.append([doc_img])
133    #data.append([Spacer(1, 12)])
134
135    f_label = trans(_('Name:'), lang)
136    f_label = Paragraph(f_label, ENTRY1_STYLE)
137    f_text = formatted_text(studentview.context.display_fullname)
138    f_text = Paragraph(f_text, ENTRY1_STYLE)
139    data_middle.append([f_label,f_text])
140
141    for widget in studentview.widgets:
142        if 'name' in widget.name:
143            continue
144        f_label = translate(
145            widget.label.strip(), 'waeup.kofa',
146            target_language=lang)
147        f_label = Paragraph('%s:' % f_label, ENTRY1_STYLE)
148        f_text = formatted_text(widget(), lang=lang)
149        f_text = Paragraph(f_text, ENTRY1_STYLE)
150        data_middle.append([f_label,f_text])
151    if not 'date_of_birth' in omit_fields:
152        f_label = trans(_('Date of Birth:'), lang)
153        f_label = Paragraph(f_label, ENTRY1_STYLE)
154        date_of_birth = studentview.context.date_of_birth
155        tz = getUtility(IKofaUtils).tzinfo
156        date_of_birth = to_timezone(date_of_birth, tz)
157        if date_of_birth is not None:
158            date_of_birth = date_of_birth.strftime("%d/%m/%Y")
159        f_text = formatted_text(date_of_birth)
160        f_text = Paragraph(f_text, ENTRY1_STYLE)
161        data_middle.append([f_label,f_text])
162    if getattr(studentview.context, 'certcode', None):
163        if not 'certificate' in omit_fields:
164            f_label = trans(_('Study Course:'), lang)
165            f_label = Paragraph(f_label, ENTRY1_STYLE)
166            f_text = formatted_text(
167                studentview.context['studycourse'].certificate.longtitle)
168            f_text = Paragraph(f_text, ENTRY1_STYLE)
169            data_middle.append([f_label,f_text])
170        if not 'department' in omit_fields:
171            f_label = trans(_('Department:'), lang)
172            f_label = Paragraph(f_label, ENTRY1_STYLE)
173            f_text = formatted_text(
174                studentview.context[
175                'studycourse'].certificate.__parent__.__parent__.longtitle,
176                )
177            f_text = Paragraph(f_text, ENTRY1_STYLE)
178            data_middle.append([f_label,f_text])
179        if not 'faculty' in omit_fields:
180            f_label = trans(_('Faculty:'), lang)
181            f_label = Paragraph(f_label, ENTRY1_STYLE)
182            f_text = formatted_text(
183                studentview.context[
184                'studycourse'].certificate.__parent__.__parent__.__parent__.longtitle,
185                )
186            f_text = Paragraph(f_text, ENTRY1_STYLE)
187            data_middle.append([f_label,f_text])
188        if not 'current_mode' in omit_fields:
189            studymodes_dict = getUtility(IKofaUtils).STUDY_MODES_DICT
190            sm = studymodes_dict[studentview.context.current_mode]
191            f_label = trans(_('Study Mode:'), lang)
192            f_label = Paragraph(f_label, ENTRY1_STYLE)
193            f_text = formatted_text(sm)
194            f_text = Paragraph(f_text, ENTRY1_STYLE)
195            data_middle.append([f_label,f_text])
196        if not 'entry_session' in omit_fields:
197            f_label = trans(_('Entry Session:'), lang)
198            f_label = Paragraph(f_label, ENTRY1_STYLE)
199            entry_session = studentview.context.entry_session
200            try:
201                entry_session = academic_sessions_vocab.getTerm(
202                    entry_session).title
203            except LookupError:
204                entry_session = _('void')
205            f_text = formatted_text(entry_session)
206            f_text = Paragraph(f_text, ENTRY1_STYLE)
207            data_middle.append([f_label,f_text])
208        # Requested by Uniben, does not really make sense
209        if not 'current_level' in omit_fields:
210            f_label = trans(_('Current Level:'), lang)
211            f_label = Paragraph(f_label, ENTRY1_STYLE)
212            current_level = studentview.context['studycourse'].current_level
213            studylevelsource = StudyLevelSource().factory
214            current_level = studylevelsource.getTitle(
215                studentview.context, current_level)
216            f_text = formatted_text(current_level)
217            f_text = Paragraph(f_text, ENTRY1_STYLE)
218            data_middle.append([f_label,f_text])
219
220    if no_passport:
221        table = Table(data_middle,style=SLIP_STYLE)
222        table.hAlign = 'LEFT'
223        return table
224
225    # append QR code to the right
226    if slipname:
227        url = studentview.url(context, slipname)
228        data_right = [[get_qrcode(url, width=70.0)]]
229        table_right = Table(data_right,style=SLIP_STYLE)
230    else:
231        table_right = None
232
233    table_left = Table(data_left,style=SLIP_STYLE)
234    table_middle = Table(data_middle,style=SLIP_STYLE, colWidths=[5*cm, 5*cm])
235    table = Table([[table_left, table_middle, table_right],],style=SLIP_STYLE)
236    return table
237
238def render_table_data(tableheader, tabledata, lang='en'):
239    """Render children table for an existing frame.
240    """
241    data = []
242    #data.append([Spacer(1, 12)])
243    line = []
244    style = getSampleStyleSheet()
245    for element in tableheader:
246        field = '<strong>%s</strong>' % formatted_text(element[0], lang=lang)
247        field = Paragraph(field, style["Normal"])
248        line.append(field)
249    data.append(line)
250    for ticket in tabledata:
251        line = []
252        for element in tableheader:
253              field = formatted_text(getattr(ticket,element[1],u' '))
254              field = Paragraph(field, style["Normal"])
255              line.append(field)
256        data.append(line)
257    table = Table(data,colWidths=[
258        element[2]*cm for element in tableheader], style=CONTENT_STYLE)
259    return table
260
261def render_transcript_data(view, tableheader, levels_data, lang='en'):
262    """Render children table for an existing frame.
263    """
264    data = []
265    style = getSampleStyleSheet()
266    format_float = getUtility(IKofaUtils).format_float
267    for level in levels_data:
268        level_obj = level['level']
269        tickets = level['tickets_1'] + level['tickets_2'] + level['tickets_3']
270        headerline = []
271        tabledata = []
272        if 'evel' in view.level_dict.get('ticket.level', str(level_obj.level)):
273            subheader = '%s %s, %s' % (
274                trans(_('Session'), lang),
275                view.session_dict[level_obj.level_session],
276                view.level_dict.get('ticket.level', str(level_obj.level)))
277        else:
278            subheader = '%s %s, %s %s' % (
279                trans(_('Session'), lang),
280                view.session_dict[level_obj.level_session],
281                trans(_('Level'), lang),
282                view.level_dict.get(level_obj.level, str(level_obj.level)))
283        data.append(Paragraph(subheader, HEADING_STYLE))
284        for element in tableheader:
285            field = '<strong>%s</strong>' % formatted_text(element[0])
286            field = Paragraph(field, style["Normal"])
287            headerline.append(field)
288        tabledata.append(headerline)
289        for ticket in tickets:
290            ticketline = []
291            for element in tableheader:
292                  field = formatted_text(getattr(ticket,element[1],u' '))
293                  field = Paragraph(field, style["Normal"])
294                  ticketline.append(field)
295            tabledata.append(ticketline)
296        table = Table(tabledata,colWidths=[
297            element[2]*cm for element in tableheader], style=CONTENT_STYLE)
298        data.append(table)
299        sgpa = format_float(level['sgpa'], 2)
300        sgpa = '%s: %s' % (trans('Sessional GPA (rectified)', lang), sgpa)
301        #sgpa = '%s: %.2f' % (trans('Sessional GPA (rectified)', lang), level['sgpa'])
302        data.append(Paragraph(sgpa, style["Normal"]))
303        if getattr(level_obj, 'transcript_remark', None):
304            remark = '%s: %s' % (
305                trans('Transcript Remark', lang),
306                getattr(level_obj, 'transcript_remark'))
307            data.append(Paragraph(remark, style["Normal"]))
308    return data
309
310def docs_as_flowables(view, lang='en'):
311    """Create reportlab flowables out of scanned docs.
312    """
313    # XXX: fix circular import problem
314    from waeup.kofa.browser.fileviewlets import FileManager
315    from waeup.kofa.browser import DEFAULT_IMAGE_PATH
316    style = getSampleStyleSheet()
317    data = []
318
319    # Collect viewlets
320    fm = FileManager(view.context, view.request, view)
321    fm.update()
322    if fm.viewlets:
323        sc_translation = trans(_('Scanned Documents'), lang)
324        data.append(Paragraph(sc_translation, HEADING_STYLE))
325        # Insert list of scanned documents
326        table_data = []
327        for viewlet in fm.viewlets:
328            if viewlet.file_exists:
329                # Show viewlet only if file exists
330                f_label = Paragraph(trans(viewlet.label, lang), ENTRY1_STYLE)
331                img_path = getattr(getUtility(IExtFileStore).getFileByContext(
332                    view.context, attr=viewlet.download_name), 'name', None)
333                #f_text = Paragraph(trans(_('(not provided)'),lang), ENTRY1_STYLE)
334                if img_path is None:
335                    pass
336                elif not img_path[-4:] in ('.jpg', '.JPG'):
337                    # reportlab requires jpg images, I think.
338                    f_text = Paragraph('%s (not displayable)' % (
339                        viewlet.title,), ENTRY1_STYLE)
340                else:
341                    f_text = Image(img_path, width=2*cm, height=1*cm, kind='bound')
342                table_data.append([f_label, f_text])
343        if table_data:
344            # safety belt; empty tables lead to problems.
345            data.append(Table(table_data, style=SLIP_STYLE))
346    return data
347
348class StudentsUtils(grok.GlobalUtility):
349    """A collection of methods subject to customization.
350    """
351    grok.implements(IStudentsUtils)
352
353    def getReturningData(self, student):
354        """ Define what happens after school fee payment
355        depending on the student's senate verdict.
356        In the base configuration current level is always increased
357        by 100 no matter which verdict has been assigned.
358        """
359        new_level = student['studycourse'].current_level + 100
360        new_session = student['studycourse'].current_session + 1
361        return new_session, new_level
362
363    def setReturningData(self, student):
364        """ Define what happens after school fee payment
365        depending on the student's senate verdict.
366        This method folllows the same algorithm as `getReturningData` but
367        it also sets the new values.
368        """
369        new_session, new_level = self.getReturningData(student)
370        try:
371            student['studycourse'].current_level = new_level
372        except ConstraintNotSatisfied:
373            # Do not change level if level exceeds the
374            # certificate's end_level.
375            pass
376        student['studycourse'].current_session = new_session
377        verdict = student['studycourse'].current_verdict
378        student['studycourse'].current_verdict = '0'
379        student['studycourse'].previous_verdict = verdict
380        return
381
382    def _getSessionConfiguration(self, session):
383        try:
384            return grok.getSite()['configuration'][str(session)]
385        except KeyError:
386            return None
387
388    def _isPaymentDisabled(self, p_session, category, student):
389        academic_session = self._getSessionConfiguration(p_session)
390        if category == 'schoolfee' and \
391            'sf_all' in academic_session.payment_disabled:
392            return True
393        return False
394
395    def samePaymentMade(self, student, category, p_item, p_session):
396        for key in student['payments'].keys():
397            ticket = student['payments'][key]
398            if ticket.p_state == 'paid' and\
399               ticket.p_category == category and \
400               ticket.p_item != 'Balance' and \
401               ticket.p_item == p_item and \
402               ticket.p_session == p_session:
403                  return True
404        return False
405
406    def setPaymentDetails(self, category, student,
407            previous_session, previous_level, combi=[]):
408        """Create a payment ticket and set the payment data of a
409        student for the payment category specified.
410        """
411        p_item = u''
412        amount = 0.0
413        if previous_session:
414            if previous_session < student['studycourse'].entry_session:
415                return _('The previous session must not fall below '
416                         'your entry session.'), None
417            if category == 'schoolfee':
418                # School fee is always paid for the following session
419                if previous_session > student['studycourse'].current_session:
420                    return _('This is not a previous session.'), None
421            else:
422                if previous_session > student['studycourse'].current_session - 1:
423                    return _('This is not a previous session.'), None
424            p_session = previous_session
425            p_level = previous_level
426            p_current = False
427        else:
428            p_session = student['studycourse'].current_session
429            p_level = student['studycourse'].current_level
430            p_current = True
431        academic_session = self._getSessionConfiguration(p_session)
432        if academic_session == None:
433            return _(u'Session configuration object is not available.'), None
434        # Determine fee.
435        if category == 'schoolfee':
436            try:
437                certificate = student['studycourse'].certificate
438                p_item = certificate.code
439            except (AttributeError, TypeError):
440                return _('Study course data are incomplete.'), None
441            if previous_session:
442                # Students can pay for previous sessions in all
443                # workflow states.  Fresh students are excluded by the
444                # update method of the PreviousPaymentAddFormPage.
445                if previous_level == 100:
446                    amount = getattr(certificate, 'school_fee_1', 0.0)
447                else:
448                    amount = getattr(certificate, 'school_fee_2', 0.0)
449            else:
450                if student.state == CLEARED:
451                    amount = getattr(certificate, 'school_fee_1', 0.0)
452                elif student.state == RETURNING:
453                    # In case of returning school fee payment the
454                    # payment session and level contain the values of
455                    # the session the student has paid for. Payment
456                    # session is always next session.
457                    p_session, p_level = self.getReturningData(student)
458                    academic_session = self._getSessionConfiguration(p_session)
459                    if academic_session == None:
460                        return _(
461                            u'Session configuration object is not available.'
462                            ), None
463                    amount = getattr(certificate, 'school_fee_2', 0.0)
464                elif student.is_postgrad and student.state == PAID:
465                    # Returning postgraduate students also pay for the
466                    # next session but their level always remains the
467                    # same.
468                    p_session += 1
469                    academic_session = self._getSessionConfiguration(p_session)
470                    if academic_session == None:
471                        return _(
472                            u'Session configuration object is not available.'
473                            ), None
474                    amount = getattr(certificate, 'school_fee_2', 0.0)
475        elif category == 'clearance':
476            try:
477                p_item = student['studycourse'].certificate.code
478            except (AttributeError, TypeError):
479                return _('Study course data are incomplete.'), None
480            amount = academic_session.clearance_fee
481        elif category == 'bed_allocation':
482            p_item = self.getAccommodationDetails(student)['bt']
483            amount = academic_session.booking_fee
484        elif category == 'hostel_maintenance':
485            amount = 0.0
486            bedticket = student['accommodation'].get(
487                str(student.current_session), None)
488            if bedticket is not None and bedticket.bed is not None:
489                p_item = bedticket.bed_coordinates
490                if bedticket.bed.__parent__.maint_fee > 0:
491                    amount = bedticket.bed.__parent__.maint_fee
492                else:
493                    # fallback
494                    amount = academic_session.maint_fee
495            else:
496                return _(u'No bed allocated.'), None
497        elif category == 'combi' and combi:
498            categories = getUtility(IKofaUtils).COMBI_PAYMENT_CATEGORIES
499            for cat in combi:
500                fee_name = cat + '_fee'
501                cat_amount = getattr(academic_session, fee_name, 0.0)
502                if not cat_amount:
503                    return _('%s undefined.' % categories[cat]), None
504                amount += cat_amount
505                p_item += u'%s + ' % categories[cat]
506            p_item = p_item.strip(' + ')
507        else:
508            fee_name = category + '_fee'
509            amount = getattr(academic_session, fee_name, 0.0)
510        if amount in (0.0, None):
511            return _('Amount could not be determined.'), None
512        if self.samePaymentMade(student, category, p_item, p_session):
513            return _('This type of payment has already been made.'), None
514        if self._isPaymentDisabled(p_session, category, student):
515            return _('This category of payments has been disabled.'), None
516        payment = createObject(u'waeup.StudentOnlinePayment')
517        timestamp = ("%d" % int(time()*10000))[1:]
518        payment.p_id = "p%s" % timestamp
519        payment.p_category = category
520        payment.p_item = p_item
521        payment.p_session = p_session
522        payment.p_level = p_level
523        payment.p_current = p_current
524        payment.amount_auth = amount
525        payment.p_combi = combi
526        return None, payment
527
528    def setBalanceDetails(self, category, student,
529            balance_session, balance_level, balance_amount):
530        """Create a balance payment ticket and set the payment data
531        as selected by the student.
532        """
533        p_item = u'Balance'
534        p_session = balance_session
535        p_level = balance_level
536        p_current = False
537        amount = balance_amount
538        academic_session = self._getSessionConfiguration(p_session)
539        if academic_session == None:
540            return _(u'Session configuration object is not available.'), None
541        if amount in (0.0, None) or amount < 0:
542            return _('Amount must be greater than 0.'), None
543        payment = createObject(u'waeup.StudentOnlinePayment')
544        timestamp = ("%d" % int(time()*10000))[1:]
545        payment.p_id = "p%s" % timestamp
546        payment.p_category = category
547        payment.p_item = p_item
548        payment.p_session = p_session
549        payment.p_level = p_level
550        payment.p_current = p_current
551        payment.amount_auth = amount
552        return None, payment
553
554    def increaseMatricInteger(self, student):
555        """Increase counter for matric numbers.
556        This counter can be a centrally stored attribute or an attribute of
557        faculties, departments or certificates. In the base package the counter
558        is as an attribute of the site configuration container.
559        """
560        grok.getSite()['configuration'].next_matric_integer += 1
561        return
562
563    def constructMatricNumber(self, student):
564        """Fetch the matric number counter which fits the student and
565        construct the new matric number of the student.
566        In the base package the counter is returned which is as an attribute
567        of the site configuration container.
568        """
569        next_integer = grok.getSite()['configuration'].next_matric_integer
570        if next_integer == 0:
571            return _('Matriculation number cannot be set.'), None
572        return None, unicode(next_integer)
573
574    def setMatricNumber(self, student):
575        """Set matriculation number of student. If the student's matric number
576        is unset a new matric number is
577        constructed according to the matriculation number construction rules
578        defined in the `constructMatricNumber` method. The new matric number is
579        set, the students catalog updated. The corresponding matric number
580        counter is increased by one.
581
582        This method is tested but not used in the base package. It can
583        be used in custom packages by adding respective views
584        and by customizing `increaseMatricInteger` and `constructMatricNumber`
585        according to the university's matriculation number construction rules.
586
587        The method can be disabled by setting the counter to zero.
588        """
589        if student.matric_number is not None:
590            return _('Matriculation number already set.'), None
591        if student.certcode is None:
592            return _('No certificate assigned.'), None
593        error, matric_number = self.constructMatricNumber(student)
594        if error:
595            return error, None
596        try:
597            student.matric_number = matric_number
598        except MatNumNotInSource:
599            return _('Matriculation number %s exists.' % matric_number), None
600        notify(grok.ObjectModifiedEvent(student))
601        self.increaseMatricInteger(student)
602        return None, matric_number
603
604    def getAccommodationDetails(self, student):
605        """Determine the accommodation data of a student.
606        """
607        d = {}
608        d['error'] = u''
609        hostels = grok.getSite()['hostels']
610        d['booking_session'] = hostels.accommodation_session
611        d['allowed_states'] = hostels.accommodation_states
612        d['startdate'] = hostels.startdate
613        d['enddate'] = hostels.enddate
614        d['expired'] = hostels.expired
615        # Determine bed type
616        studycourse = student['studycourse']
617        certificate = getattr(studycourse,'certificate',None)
618        entry_session = studycourse.entry_session
619        current_level = studycourse.current_level
620        if None in (entry_session, current_level, certificate):
621            return d
622        end_level = certificate.end_level
623        if current_level == 10:
624            bt = 'pr'
625        elif entry_session == grok.getSite()['hostels'].accommodation_session:
626            bt = 'fr'
627        elif current_level >= end_level:
628            bt = 'fi'
629        else:
630            bt = 're'
631        if student.sex == 'f':
632            sex = 'female'
633        else:
634            sex = 'male'
635        special_handling = 'regular'
636        d['bt'] = u'%s_%s_%s' % (special_handling,sex,bt)
637        return d
638
639    def checkAccommodationRequirements(self, student, acc_details):
640        if acc_details.get('expired', False):
641            startdate = acc_details.get('startdate')
642            enddate = acc_details.get('enddate')
643            if startdate and enddate:
644                tz = getUtility(IKofaUtils).tzinfo
645                startdate = to_timezone(
646                    startdate, tz).strftime("%d/%m/%Y %H:%M:%S")
647                enddate = to_timezone(
648                    enddate, tz).strftime("%d/%m/%Y %H:%M:%S")
649                return _("Outside booking period: ${a} - ${b}",
650                         mapping = {'a': startdate, 'b': enddate})
651            else:
652                return _("Outside booking period.")
653        if not acc_details.get('bt'):
654            return _("Your data are incomplete.")
655        if not student.state in acc_details['allowed_states']:
656            return _("You are in the wrong registration state.")
657        if student['studycourse'].current_session != acc_details[
658            'booking_session']:
659            return _('Your current session does not '
660                     'match accommodation session.')
661        bsession = str(acc_details['booking_session'])
662        if bsession in student['accommodation'].keys() \
663            and not 'booking expired' in \
664            student['accommodation'][bsession].bed_coordinates:
665            return _('You already booked a bed space in '
666                     'current accommodation session.')
667        return
668
669    def selectBed(self, available_beds):
670        """Select a bed from a filtered list of available beds.
671        In the base configuration beds are sorted by the sort id
672        of the hostel and the bed number. The first bed found in
673        this sorted list is taken.
674        """
675        sorted_beds = sorted(available_beds,
676                key=lambda bed: 1000 * bed.__parent__.sort_id + bed.bed_number)
677        return sorted_beds[0]
678
679    def GPABoundaries(self, faccode=None, depcode=None, certcode=None):
680        return ((1, 'Fail'),
681               (1.5, 'Pass'),
682               (2.4, '3rd Class'),
683               (3.5, '2nd Class Lower'),
684               (4.5, '2nd Class Upper'),
685               (5, '1st Class'))
686
687    def getClassFromCGPA(self, gpa, student):
688        """Determine the class of degree. In some custom packages
689        this class depends on e.g. the entry session of the student. In the
690        base package, it does not.
691        """
692        if gpa < self.GPABoundaries()[0][0]:
693            return 0, self.GPABoundaries()[0][1]
694        if gpa < self.GPABoundaries()[1][0]:
695            return 1, self.GPABoundaries()[1][1]
696        if gpa < self.GPABoundaries()[2][0]:
697            return 2, self.GPABoundaries()[2][1]
698        if gpa < self.GPABoundaries()[3][0]:
699            return 3, self.GPABoundaries()[3][1]
700        if gpa < self.GPABoundaries()[4][0]:
701            return 4, self.GPABoundaries()[4][1]
702        if gpa <= self.GPABoundaries()[5][0]:
703            return 5, self.GPABoundaries()[5][1]
704        return
705
706    def getDegreeClassNumber(self, level_obj):
707        """Get degree class number (used for SessionResultsPresentation
708        reports).
709        """
710        if level_obj.gpa_params[1] == 0:
711            # No credits weighted
712            return 6
713        return self.getClassFromCGPA(
714            level_obj.cumulative_params[0], level_obj.student)[0]
715
716    def _saveTranscriptPDF(self, student, transcript):
717        """Create a transcript PDF file and store it in student folder.
718        """
719        file_store = getUtility(IExtFileStore)
720        file_id = IFileStoreNameChooser(student).chooseName(
721            attr="final_transcript.pdf")
722        file_store.createFile(file_id, StringIO(transcript))
723        return
724
725    def warnCreditsOOR(self, studylevel, course=None):
726        """Return message if credits are out of range. In the base
727        package only maximum credits is set.
728        """
729        if course and studylevel.total_credits + course.credits > 50:
730            return _('Maximum credits exceeded.')
731        elif studylevel.total_credits > 50:
732            return _('Maximum credits exceeded.')
733        return
734
735    def warnCourseAlreadyPassed(self, studylevel, course):
736        """Return message if course has already been passed at
737        previous levels.
738        """
739        for slevel in studylevel.__parent__.values():
740            for cticket in slevel.values():
741                if cticket.code == course.code \
742                    and cticket.total_score >= cticket.passmark:
743                    return _('Course has already been passed at previous level.')
744        return False
745
746    def getBedCoordinates(self, bedticket):
747        """Return descriptive bed coordinates.
748        This method can be used to customize the `display_coordinates`
749        property method in order to  display a
750        customary description of the bed space.
751        """
752        return bedticket.bed_coordinates
753
754    def clearance_disabled_message(self, student):
755        """Render message if clearance is disabled.
756        """
757        try:
758            session_config = grok.getSite()[
759                'configuration'][str(student.current_session)]
760        except KeyError:
761            return _('Session configuration object is not available.')
762        if not session_config.clearance_enabled:
763            return _('Clearance is disabled for this session.')
764        return None
765
766    def getPDFCreator(self, context):
767        """Get a pdf creator suitable for `context`.
768        The default implementation always returns the default creator.
769        """
770        return getUtility(IPDFCreator)
771
772    def renderPDF(self, view, filename='slip.pdf', student=None,
773                  studentview=None,
774                  tableheader=[], tabledata=[],
775                  note=None, signatures=None, sigs_in_footer=(),
776                  show_scans=True, topMargin=1.5,
777                  omit_fields=()):
778        """Render pdf slips for various pages (also some pages
779        in the applicants module).
780        """
781        portal_language = getUtility(IKofaUtils).PORTAL_LANGUAGE
782        # XXX: tell what the different parameters mean
783        style = getSampleStyleSheet()
784        creator = self.getPDFCreator(student)
785        data = []
786        doc_title = view.label
787        author = '%s (%s)' % (view.request.principal.title,
788                              view.request.principal.id)
789        footer_text = view.label.split('\n')
790        if len(footer_text) > 1:
791            # We can add a department in first line, second line is used
792            footer_text = footer_text[1]
793        else:
794            # Only the first line is used for the footer
795            footer_text = footer_text[0]
796        if getattr(student, 'student_id', None) is not None:
797            footer_text = "%s - %s - " % (student.student_id, footer_text)
798
799        # Insert student data table
800        if student is not None:
801            if view.form_fields:
802                bd_translation = trans(_('Base Data'), portal_language)
803                data.append(Paragraph(bd_translation, HEADING_STYLE))
804            data.append(render_student_data(
805                studentview, view.context, omit_fields, lang=portal_language,
806                slipname=filename))
807
808        # Insert widgets
809        if view.form_fields:
810            data.append(Paragraph(view.title, HEADING_STYLE))
811            separators = getattr(self, 'SEPARATORS_DICT', {})
812            table = creator.getWidgetsTable(
813                view.form_fields, view.context, None, lang=portal_language,
814                separators=separators)
815            data.append(table)
816
817        # Insert scanned docs
818        if show_scans:
819            data.extend(docs_as_flowables(view, portal_language))
820
821        # Insert history
822        if filename == 'clearance_slip.pdf':
823            hist_translation = trans(_('Workflow History'), portal_language)
824            data.append(Paragraph(hist_translation, HEADING_STYLE))
825            data.extend(creator.fromStringList(student.history.messages))
826
827        # Insert content tables (optionally on second page)
828        if hasattr(view, 'tabletitle'):
829            for i in range(len(view.tabletitle)):
830                if tabledata[i] and tableheader[i]:
831                    tabletitle = view.tabletitle[i]
832                    if tabletitle.startswith('_PB_'):
833                        data.append(PageBreak())
834                        tabletitle = view.tabletitle[i].strip('_PB_')
835                    #data.append(Spacer(1, 20))
836                    data.append(Paragraph(tabletitle, HEADING_STYLE))
837                    data.append(Spacer(1, 8))
838                    contenttable = render_table_data(tableheader[i],tabledata[i])
839                    data.append(contenttable)
840
841        # Insert signatures
842        # XXX: We are using only sigs_in_footer in waeup.kofa, so we
843        # do not have a test for the following lines.
844        if signatures and not sigs_in_footer:
845            data.append(Spacer(1, 20))
846            # Render one signature table per signature to
847            # get date and signature in line.
848            for signature in signatures:
849                signaturetables = get_signature_tables(signature)
850                data.append(signaturetables[0])
851
852        view.response.setHeader(
853            'Content-Type', 'application/pdf')
854        try:
855            pdf_stream = creator.create_pdf(
856                data, None, doc_title, author=author, footer=footer_text,
857                note=note, sigs_in_footer=sigs_in_footer, topMargin=topMargin)
858        except IOError:
859            view.flash('Error in image file.')
860            return view.redirect(view.url(view.context))
861        except LayoutError, err:
862            view.flash(
863                'PDF file could not be created. Reportlab error message: %s'
864                % escape(err.message),
865                type="danger")
866            return view.redirect(view.url(view.context))
867        return pdf_stream
868
869    def _admissionText(self, student, portal_language):
870        inst_name = grok.getSite()['configuration'].name
871        text = trans(_(
872            'This is to inform you that you have been provisionally'
873            ' admitted into ${a} as follows:', mapping = {'a': inst_name}),
874            portal_language)
875        return text
876
877    def renderPDFAdmissionLetter(self, view, student=None, omit_fields=(),
878                                 pre_text=None, post_text=None,
879                                 topMargin = 1.5,
880                                 letterhead_path=None):
881        """Render pdf admission letter.
882        """
883        if student is None:
884            return
885        style = getSampleStyleSheet()
886        if letterhead_path:
887            creator = getUtility(IPDFCreator, name='letter')
888        else:
889            creator = self.getPDFCreator(student)
890        portal_language = getUtility(IKofaUtils).PORTAL_LANGUAGE
891        data = []
892        doc_title = view.label
893        author = '%s (%s)' % (view.request.principal.title,
894                              view.request.principal.id)
895        footer_text = view.label.split('\n')
896        if len(footer_text) > 1:
897            # We can add a department in first line
898            footer_text = footer_text[1]
899        else:
900            # Only the first line is used for the footer
901            footer_text = footer_text[0]
902        if getattr(student, 'student_id', None) is not None:
903            footer_text = "%s - %s - " % (student.student_id, footer_text)
904
905        # Text before student data
906        if pre_text is None:
907            html = format_html(self._admissionText(student, portal_language))
908        else:
909            html = format_html(pre_text)
910        if html:
911            data.append(Paragraph(html, NOTE_STYLE))
912            data.append(Spacer(1, 20))
913
914        # Student data
915        data.append(render_student_data(view, student,
916                    omit_fields, lang=portal_language,
917                    slipname='admission_slip.pdf'))
918
919        # Text after student data
920        data.append(Spacer(1, 20))
921        if post_text is None:
922            datelist = student.history.messages[0].split()[0].split('-')
923            creation_date = u'%s/%s/%s' % (datelist[2], datelist[1], datelist[0])
924            post_text = trans(_(
925                'Your Kofa student record was created on ${a}.',
926                mapping = {'a': creation_date}),
927                portal_language)
928        #html = format_html(post_text)
929        #data.append(Paragraph(html, NOTE_STYLE))
930
931        # Create pdf stream
932        view.response.setHeader(
933            'Content-Type', 'application/pdf')
934        pdf_stream = creator.create_pdf(
935            data, None, doc_title, author=author, footer=footer_text,
936            note=post_text, topMargin=topMargin,
937            letterhead_path=letterhead_path)
938        return pdf_stream
939
940    def renderPDFTranscript(self, view, filename='transcript.pdf',
941                  student=None,
942                  studentview=None,
943                  note=None,
944                  signatures=(),
945                  sigs_in_footer=(),
946                  digital_sigs=(),
947                  show_scans=True, topMargin=1.5,
948                  omit_fields=(),
949                  tableheader=None,
950                  no_passport=False,
951                  save_file=False):
952        """Render pdf slip of a transcripts.
953        """
954        portal_language = getUtility(IKofaUtils).PORTAL_LANGUAGE
955        # XXX: tell what the different parameters mean
956        style = getSampleStyleSheet()
957        creator = self.getPDFCreator(student)
958        data = []
959        doc_title = view.label
960        author = '%s (%s)' % (view.request.principal.title,
961                              view.request.principal.id)
962        footer_text = view.label.split('\n')
963        if len(footer_text) > 2:
964            # We can add a department in first line
965            footer_text = footer_text[1]
966        else:
967            # Only the first line is used for the footer
968            footer_text = footer_text[0]
969        if getattr(student, 'student_id', None) is not None:
970            footer_text = "%s - %s - " % (student.student_id, footer_text)
971
972        # Insert student data table
973        if student is not None:
974            #bd_translation = trans(_('Base Data'), portal_language)
975            #data.append(Paragraph(bd_translation, HEADING_STYLE))
976            data.append(render_student_data(
977                studentview, view.context,
978                omit_fields, lang=portal_language,
979                slipname=filename,
980                no_passport=no_passport))
981
982        transcript_data = view.context.getTranscriptData()
983        levels_data = transcript_data[0]
984
985        contextdata = []
986        f_label = trans(_('Course of Study:'), portal_language)
987        f_label = Paragraph(f_label, ENTRY1_STYLE)
988        f_text = formatted_text(view.context.certificate.longtitle)
989        f_text = Paragraph(f_text, ENTRY1_STYLE)
990        contextdata.append([f_label,f_text])
991
992        f_label = trans(_('Faculty:'), portal_language)
993        f_label = Paragraph(f_label, ENTRY1_STYLE)
994        f_text = formatted_text(
995            view.context.certificate.__parent__.__parent__.__parent__.longtitle)
996        f_text = Paragraph(f_text, ENTRY1_STYLE)
997        contextdata.append([f_label,f_text])
998
999        f_label = trans(_('Department:'), portal_language)
1000        f_label = Paragraph(f_label, ENTRY1_STYLE)
1001        f_text = formatted_text(
1002            view.context.certificate.__parent__.__parent__.longtitle)
1003        f_text = Paragraph(f_text, ENTRY1_STYLE)
1004        contextdata.append([f_label,f_text])
1005
1006        f_label = trans(_('Entry Session:'), portal_language)
1007        f_label = Paragraph(f_label, ENTRY1_STYLE)
1008        f_text = formatted_text(
1009            view.session_dict.get(view.context.entry_session))
1010        f_text = Paragraph(f_text, ENTRY1_STYLE)
1011        contextdata.append([f_label,f_text])
1012
1013        f_label = trans(_('Entry Mode:'), portal_language)
1014        f_label = Paragraph(f_label, ENTRY1_STYLE)
1015        f_text = formatted_text(view.studymode_dict.get(
1016            view.context.entry_mode))
1017        f_text = Paragraph(f_text, ENTRY1_STYLE)
1018        contextdata.append([f_label,f_text])
1019
1020        f_label = trans(_('Cumulative GPA:'), portal_language)
1021        f_label = Paragraph(f_label, ENTRY1_STYLE)
1022        format_float = getUtility(IKofaUtils).format_float
1023        cgpa = format_float(transcript_data[1], 3)
1024        if student.state == GRADUATED:
1025            f_text = formatted_text('%s (%s)' % (
1026                cgpa, self.getClassFromCGPA(transcript_data[1], student)[1]))
1027        else:
1028            f_text = formatted_text('%s' % cgpa)
1029        f_text = Paragraph(f_text, ENTRY1_STYLE)
1030        contextdata.append([f_label,f_text])
1031
1032        contexttable = Table(contextdata,style=SLIP_STYLE)
1033        data.append(contexttable)
1034
1035        transcripttables = render_transcript_data(
1036            view, tableheader, levels_data, lang=portal_language)
1037        data.extend(transcripttables)
1038
1039        # Insert signatures
1040        # XXX: We are using only sigs_in_footer in waeup.kofa, so we
1041        # do not have a test for the following lines.
1042        if signatures and not sigs_in_footer:
1043            data.append(Spacer(1, 20))
1044            # Render one signature table per signature to
1045            # get date and signature in line.
1046            for signature in signatures:
1047                signaturetables = get_signature_tables(signature)
1048                data.append(signaturetables[0])
1049
1050        # Insert digital signatures
1051        if digital_sigs:
1052            data.append(Spacer(1, 20))
1053            sigs = digital_sigs.split('\n')
1054            for sig in sigs:
1055                data.append(Paragraph(sig, NOTE_STYLE))
1056
1057        view.response.setHeader(
1058            'Content-Type', 'application/pdf')
1059        try:
1060            pdf_stream = creator.create_pdf(
1061                data, None, doc_title, author=author, footer=footer_text,
1062                note=note, sigs_in_footer=sigs_in_footer, topMargin=topMargin)
1063        except IOError:
1064            view.flash(_('Error in image file.'))
1065            return view.redirect(view.url(view.context))
1066        if save_file:
1067            self._saveTranscriptPDF(student, pdf_stream)
1068            return
1069        return pdf_stream
1070
1071    def renderPDFCourseticketsOverview(
1072            self, view, name, session, data, lecturers, orientation,
1073            title_length, note):
1074        """Render pdf slip of course tickets for a lecturer.
1075        """
1076        filename = '%s_%s_%s_%s.pdf' % (
1077            name, view.context.code, session, view.request.principal.id)
1078        try:
1079            session = academic_sessions_vocab.getTerm(session).title
1080        except LookupError:
1081            session = _('void')
1082        creator = getUtility(IPDFCreator, name=orientation)
1083        style = getSampleStyleSheet()
1084        pdf_data = []
1085        pdf_data += [Paragraph(
1086            translate(_('<b>Lecturer(s): ${a}</b>',
1087                      mapping = {'a':lecturers})), style["Normal"]),]
1088        pdf_data += [Paragraph(
1089            translate(_('<b>Credits: ${a}</b>',
1090                      mapping = {'a':view.context.credits})), style["Normal"]),]
1091        # Not used in base package.
1092        if data[1]:
1093            pdf_data += [Paragraph(
1094                translate(_('<b>${a}</b>',
1095                    mapping = {'a':data[1][0]})), style["Normal"]),]
1096            pdf_data += [Paragraph(
1097                translate(_('<b>${a}</b>',
1098                    mapping = {'a':data[1][1]})), style["Normal"]),]
1099            pdf_data += [Paragraph(
1100                translate(_('<b>Total Students: ${a}</b>',
1101                    mapping = {'a':data[1][2]})), style["Normal"]),]
1102            pdf_data += [Paragraph(
1103                translate(_('<b>Total Pass: ${a} (${b}%)</b>',
1104                mapping = {'a':data[1][3],'b':data[1][4]})), style["Normal"]),]
1105            pdf_data += [Paragraph(
1106                translate(_('<b>Total Fail: ${a} (${b}%)</b>',
1107                mapping = {'a':data[1][5],'b':data[1][6]})), style["Normal"]),]
1108            grade_stats = []
1109            for item in sorted(data[1][7].items()):
1110                grade_stats.append(('%s=%s' % (item[0], item[1])))
1111            grade_stats_string = ', '.join(grade_stats)
1112            pdf_data += [Paragraph(
1113                translate(_('<b>${a}</b>',
1114                mapping = {'a':grade_stats_string})), style["Normal"]),]
1115        pdf_data.append(Spacer(1, 20))
1116        colWidths = [None] * len(data[0][0])
1117        pdf_data += [Table(data[0], colWidths=colWidths, style=CONTENT_STYLE,
1118                           repeatRows=1)]
1119        # Process title if too long
1120        title = " ".join(view.context.title.split())
1121        ct = textwrap.fill(title, title_length)
1122        ft = "" # title
1123        #if len(textwrap.wrap(title, title_length)) > 1:
1124        #    ft = textwrap.wrap(title, title_length)[0] + ' ...'
1125        doc_title = translate(_('${a} (${b})\nAcademic Session ${d}',
1126            mapping = {'a':ct,
1127                       'b':view.context.code,
1128                       'd':session}))
1129        if name == 'attendance':
1130            doc_title += '\n' + translate(_('Attendance Sheet'))
1131        if name == 'coursetickets':
1132            doc_title += '\n' + translate(_('Course Tickets Overview'))
1133        #footer_title = translate(_('${a} (${b}) - ${d}',
1134        #    mapping = {'a':ft,
1135        #               'b':view.context.code,
1136        #               'd':session}))
1137        footer_title = translate(_('${b} - ${d}',
1138            mapping = {'b':view.context.code,
1139                       'd':session}))
1140        author = '%s (%s)' % (view.request.principal.title,
1141                              view.request.principal.id)
1142        view.response.setHeader(
1143            'Content-Type', 'application/pdf')
1144        view.response.setHeader(
1145            'Content-Disposition:', 'attachment; filename="%s' % filename)
1146        pdf_stream = creator.create_pdf(
1147            pdf_data, None, doc_title, author, footer_title + ' -', note
1148            )
1149        return pdf_stream
1150
1151    def updateCourseTickets(self, course):
1152        """Udate course tickets if course attributes were changed.
1153        """
1154        current_academic_session = grok.getSite()[
1155            'configuration'].current_academic_session
1156        if not current_academic_session:
1157            return
1158        cat = queryUtility(ICatalog, name='coursetickets_catalog')
1159        coursetickets = cat.searchResults(
1160            code=(course.code, course.code),
1161            session=(current_academic_session,current_academic_session))
1162        number = 0
1163        ob_class = self.__implemented__.__name__.replace('waeup.kofa.', '')
1164        for ticket in coursetickets:
1165            if ticket.credits == course.credits:
1166                continue
1167            if ticket.student.current_session != current_academic_session:
1168                continue
1169            if ticket.student.state not in (PAID,):
1170                continue
1171            number += 1
1172            ticket.student.__parent__.logger.info(
1173                '%s - %s %s/%s credits updated (%s->%s)' % (
1174                    ob_class, ticket.student.student_id,
1175                    ticket.level, ticket.code, course.credits,
1176                    ticket.credits))
1177            ticket.credits = course.credits
1178        return number
1179
1180    #: A dictionary which maps widget names to headlines. The headline
1181    #: is rendered in forms and on pdf slips above the respective
1182    #: display or input widget. There are no separating headlines
1183    #: in the base package.
1184    SEPARATORS_DICT = {}
1185
1186    #: A tuple containing names of file upload viewlets which are not shown
1187    #: on the `StudentClearanceManageFormPage`. Nothing is being skipped
1188    #: in the base package. This attribute makes only sense, if intermediate
1189    #: custom packages are being used, like we do for all Nigerian portals.
1190    SKIP_UPLOAD_VIEWLETS = ()
1191
1192    #: A tuple containing the names of registration states in which changing of
1193    #: passport pictures is allowed.
1194    PORTRAIT_CHANGE_STATES = (ADMITTED,)
1195
1196    #: A tuple containing all exporter names referring to students or
1197    #: subobjects thereof.
1198    STUDENT_EXPORTER_NAMES = (
1199            'students',
1200            'studentstudycourses',
1201            'studentstudylevels',
1202            'coursetickets',
1203            'studentpayments',
1204            'bedtickets',
1205            'trimmed',
1206            'outstandingcourses',
1207            'unpaidpayments',
1208            'sfpaymentsoverview',
1209            'sessionpaymentsoverview',
1210            'studylevelsoverview',
1211            'combocard',
1212            'bursary',
1213            'accommodationpayments',
1214            'transcriptdata',
1215            'trimmedpayments',
1216            )
1217
1218    #: A tuple containing all exporter names needed for backing
1219    #: up student data
1220    STUDENT_BACKUP_EXPORTER_NAMES = ('students', 'studentstudycourses',
1221            'studentstudylevels', 'coursetickets',
1222            'studentpayments', 'bedtickets')
1223
1224    # Maximum size of upload files in kB
1225    MAX_KB = 250
1226
1227    #: A prefix used when generating new student ids. Each student id will
1228    #: start with this string. The default is 'K' for Kofa.
1229    STUDENT_ID_PREFIX = u'K'
Note: See TracBrowser for help on using the repository browser.