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

Last change on this file since 15984 was 15984, checked in by Henrik Bettermann, 5 years ago

Enable page breaks.

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