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

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

Implement ExportPDFBaseDataPlusSlip (without button in the base package)

  • Property svn:keywords set to Id
File size: 51.5 KB
Line 
1## $Id: utils.py 16086 2020-05-06 13:39:48Z 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 == 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 warnCourseAlreadyPassed(self, studylevel, course):
735        """Return message if course has already been passed at
736        previous levels.
737        """
738        for slevel in studylevel.__parent__.values():
739            for cticket in slevel.values():
740                if cticket.code == course.code \
741                    and cticket.total_score >= cticket.passmark:
742                    return _('Course has already been passed at previous level.')
743        return False
744
745    def getBedCoordinates(self, bedticket):
746        """Return descriptive bed coordinates.
747        This method can be used to customize the `display_coordinates`
748        property method in order to  display a
749        customary description of the bed space.
750        """
751        return bedticket.bed_coordinates
752
753    def clearance_disabled_message(self, student):
754        """Render message if clearance is disabled.
755        """
756        try:
757            session_config = grok.getSite()[
758                'configuration'][str(student.current_session)]
759        except KeyError:
760            return _('Session configuration object is not available.')
761        if not session_config.clearance_enabled:
762            return _('Clearance is disabled for this session.')
763        return None
764
765    def getPDFCreator(self, context):
766        """Get a pdf creator suitable for `context`.
767        The default implementation always returns the default creator.
768        """
769        return getUtility(IPDFCreator)
770
771    def renderPDF(self, view, filename='slip.pdf', student=None,
772                  studentview=None,
773                  tableheader=[], tabledata=[],
774                  note=None, signatures=None, sigs_in_footer=(),
775                  show_scans=True, topMargin=1.5,
776                  omit_fields=()):
777        """Render pdf slips for various pages (also some pages
778        in the applicants module).
779        """
780        portal_language = getUtility(IKofaUtils).PORTAL_LANGUAGE
781        # XXX: tell what the different parameters mean
782        style = getSampleStyleSheet()
783        creator = self.getPDFCreator(student)
784        data = []
785        doc_title = view.label
786        author = '%s (%s)' % (view.request.principal.title,
787                              view.request.principal.id)
788        footer_text = view.label.split('\n')
789        if len(footer_text) > 1:
790            # We can add a department in first line, second line is used
791            footer_text = footer_text[1]
792        else:
793            # Only the first line is used for the footer
794            footer_text = footer_text[0]
795        if getattr(student, 'student_id', None) is not None:
796            footer_text = "%s - %s - " % (student.student_id, footer_text)
797
798        # Insert student data table
799        if student is not None:
800            if view.form_fields:
801                bd_translation = trans(_('Base Data'), portal_language)
802                data.append(Paragraph(bd_translation, HEADING_STYLE))
803            data.append(render_student_data(
804                studentview, view.context, omit_fields, lang=portal_language,
805                slipname=filename))
806
807        # Insert widgets
808        if view.form_fields:
809            data.append(Paragraph(view.title, HEADING_STYLE))
810            separators = getattr(self, 'SEPARATORS_DICT', {})
811            table = creator.getWidgetsTable(
812                view.form_fields, view.context, None, lang=portal_language,
813                separators=separators)
814            data.append(table)
815
816        # Insert scanned docs
817        if show_scans:
818            data.extend(docs_as_flowables(view, portal_language))
819
820        # Insert history
821        if filename == 'clearance_slip.pdf':
822            hist_translation = trans(_('Workflow History'), portal_language)
823            data.append(Paragraph(hist_translation, HEADING_STYLE))
824            data.extend(creator.fromStringList(student.history.messages))
825
826        # Insert content tables (optionally on second page)
827        if hasattr(view, 'tabletitle'):
828            for i in range(len(view.tabletitle)):
829                if tabledata[i] and tableheader[i]:
830                    tabletitle = view.tabletitle[i]
831                    if tabletitle.startswith('_PB_'):
832                        data.append(PageBreak())
833                        tabletitle = view.tabletitle[i].strip('_PB_')
834                    #data.append(Spacer(1, 20))
835                    data.append(Paragraph(tabletitle, HEADING_STYLE))
836                    data.append(Spacer(1, 8))
837                    contenttable = render_table_data(tableheader[i],tabledata[i])
838                    data.append(contenttable)
839
840        # Insert signatures
841        # XXX: We are using only sigs_in_footer in waeup.kofa, so we
842        # do not have a test for the following lines.
843        if signatures and not sigs_in_footer:
844            data.append(Spacer(1, 20))
845            # Render one signature table per signature to
846            # get date and signature in line.
847            for signature in signatures:
848                signaturetables = get_signature_tables(signature)
849                data.append(signaturetables[0])
850
851        view.response.setHeader(
852            'Content-Type', 'application/pdf')
853        try:
854            pdf_stream = creator.create_pdf(
855                data, None, doc_title, author=author, footer=footer_text,
856                note=note, sigs_in_footer=sigs_in_footer, topMargin=topMargin)
857        except IOError:
858            view.flash('Error in image file.')
859            return view.redirect(view.url(view.context))
860        except LayoutError, err:
861            view.flash(
862                'PDF file could not be created. Reportlab error message: %s'
863                % escape(err.message),
864                type="danger")
865            return view.redirect(view.url(view.context))
866        return pdf_stream
867
868    def _admissionText(self, student, portal_language):
869        inst_name = grok.getSite()['configuration'].name
870        text = trans(_(
871            'This is to inform you that you have been provisionally'
872            ' admitted into ${a} as follows:', mapping = {'a': inst_name}),
873            portal_language)
874        return text
875
876    def renderPDFAdmissionLetter(self, view, student=None, omit_fields=(),
877                                 pre_text=None, post_text=None,
878                                 topMargin = 1.5,
879                                 letterhead_path=None):
880        """Render pdf admission letter.
881        """
882        if student is None:
883            return
884        style = getSampleStyleSheet()
885        if letterhead_path:
886            creator = getUtility(IPDFCreator, name='letter')
887        else:
888            creator = self.getPDFCreator(student)
889        portal_language = getUtility(IKofaUtils).PORTAL_LANGUAGE
890        data = []
891        doc_title = view.label
892        author = '%s (%s)' % (view.request.principal.title,
893                              view.request.principal.id)
894        footer_text = view.label.split('\n')
895        if len(footer_text) > 1:
896            # We can add a department in first line
897            footer_text = footer_text[1]
898        else:
899            # Only the first line is used for the footer
900            footer_text = footer_text[0]
901        if getattr(student, 'student_id', None) is not None:
902            footer_text = "%s - %s - " % (student.student_id, footer_text)
903
904        # Text before student data
905        if pre_text is None:
906            html = format_html(self._admissionText(student, portal_language))
907        else:
908            html = format_html(pre_text)
909        if html:
910            data.append(Paragraph(html, NOTE_STYLE))
911            data.append(Spacer(1, 20))
912
913        # Student data
914        data.append(render_student_data(view, student,
915                    omit_fields, lang=portal_language,
916                    slipname='admission_slip.pdf'))
917
918        # Text after student data
919        data.append(Spacer(1, 20))
920        if post_text is None:
921            datelist = student.history.messages[0].split()[0].split('-')
922            creation_date = u'%s/%s/%s' % (datelist[2], datelist[1], datelist[0])
923            post_text = trans(_(
924                'Your Kofa student record was created on ${a}.',
925                mapping = {'a': creation_date}),
926                portal_language)
927        #html = format_html(post_text)
928        #data.append(Paragraph(html, NOTE_STYLE))
929
930        # Create pdf stream
931        view.response.setHeader(
932            'Content-Type', 'application/pdf')
933        pdf_stream = creator.create_pdf(
934            data, None, doc_title, author=author, footer=footer_text,
935            note=post_text, topMargin=topMargin,
936            letterhead_path=letterhead_path)
937        return pdf_stream
938
939    def renderPDFTranscript(self, view, filename='transcript.pdf',
940                  student=None,
941                  studentview=None,
942                  note=None,
943                  signatures=(),
944                  sigs_in_footer=(),
945                  digital_sigs=(),
946                  show_scans=True, topMargin=1.5,
947                  omit_fields=(),
948                  tableheader=None,
949                  no_passport=False,
950                  save_file=False):
951        """Render pdf slip of a transcripts.
952        """
953        portal_language = getUtility(IKofaUtils).PORTAL_LANGUAGE
954        # XXX: tell what the different parameters mean
955        style = getSampleStyleSheet()
956        creator = self.getPDFCreator(student)
957        data = []
958        doc_title = view.label
959        author = '%s (%s)' % (view.request.principal.title,
960                              view.request.principal.id)
961        footer_text = view.label.split('\n')
962        if len(footer_text) > 2:
963            # We can add a department in first line
964            footer_text = footer_text[1]
965        else:
966            # Only the first line is used for the footer
967            footer_text = footer_text[0]
968        if getattr(student, 'student_id', None) is not None:
969            footer_text = "%s - %s - " % (student.student_id, footer_text)
970
971        # Insert student data table
972        if student is not None:
973            #bd_translation = trans(_('Base Data'), portal_language)
974            #data.append(Paragraph(bd_translation, HEADING_STYLE))
975            data.append(render_student_data(
976                studentview, view.context,
977                omit_fields, lang=portal_language,
978                slipname=filename,
979                no_passport=no_passport))
980
981        transcript_data = view.context.getTranscriptData()
982        levels_data = transcript_data[0]
983
984        contextdata = []
985        f_label = trans(_('Course of Study:'), portal_language)
986        f_label = Paragraph(f_label, ENTRY1_STYLE)
987        f_text = formatted_text(view.context.certificate.longtitle)
988        f_text = Paragraph(f_text, ENTRY1_STYLE)
989        contextdata.append([f_label,f_text])
990
991        f_label = trans(_('Faculty:'), portal_language)
992        f_label = Paragraph(f_label, ENTRY1_STYLE)
993        f_text = formatted_text(
994            view.context.certificate.__parent__.__parent__.__parent__.longtitle)
995        f_text = Paragraph(f_text, ENTRY1_STYLE)
996        contextdata.append([f_label,f_text])
997
998        f_label = trans(_('Department:'), portal_language)
999        f_label = Paragraph(f_label, ENTRY1_STYLE)
1000        f_text = formatted_text(
1001            view.context.certificate.__parent__.__parent__.longtitle)
1002        f_text = Paragraph(f_text, ENTRY1_STYLE)
1003        contextdata.append([f_label,f_text])
1004
1005        f_label = trans(_('Entry Session:'), portal_language)
1006        f_label = Paragraph(f_label, ENTRY1_STYLE)
1007        f_text = formatted_text(
1008            view.session_dict.get(view.context.entry_session))
1009        f_text = Paragraph(f_text, ENTRY1_STYLE)
1010        contextdata.append([f_label,f_text])
1011
1012        f_label = trans(_('Entry Mode:'), portal_language)
1013        f_label = Paragraph(f_label, ENTRY1_STYLE)
1014        f_text = formatted_text(view.studymode_dict.get(
1015            view.context.entry_mode))
1016        f_text = Paragraph(f_text, ENTRY1_STYLE)
1017        contextdata.append([f_label,f_text])
1018
1019        f_label = trans(_('Cumulative GPA:'), portal_language)
1020        f_label = Paragraph(f_label, ENTRY1_STYLE)
1021        format_float = getUtility(IKofaUtils).format_float
1022        cgpa = format_float(transcript_data[1], 3)
1023        if student.state == GRADUATED:
1024            f_text = formatted_text('%s (%s)' % (
1025                cgpa, self.getClassFromCGPA(transcript_data[1], student)[1]))
1026        else:
1027            f_text = formatted_text('%s' % cgpa)
1028        f_text = Paragraph(f_text, ENTRY1_STYLE)
1029        contextdata.append([f_label,f_text])
1030
1031        contexttable = Table(contextdata,style=SLIP_STYLE)
1032        data.append(contexttable)
1033
1034        transcripttables = render_transcript_data(
1035            view, tableheader, levels_data, lang=portal_language)
1036        data.extend(transcripttables)
1037
1038        # Insert signatures
1039        # XXX: We are using only sigs_in_footer in waeup.kofa, so we
1040        # do not have a test for the following lines.
1041        if signatures and not sigs_in_footer:
1042            data.append(Spacer(1, 20))
1043            # Render one signature table per signature to
1044            # get date and signature in line.
1045            for signature in signatures:
1046                signaturetables = get_signature_tables(signature)
1047                data.append(signaturetables[0])
1048
1049        # Insert digital signatures
1050        if digital_sigs:
1051            data.append(Spacer(1, 20))
1052            sigs = digital_sigs.split('\n')
1053            for sig in sigs:
1054                data.append(Paragraph(sig, NOTE_STYLE))
1055
1056        view.response.setHeader(
1057            'Content-Type', 'application/pdf')
1058        try:
1059            pdf_stream = creator.create_pdf(
1060                data, None, doc_title, author=author, footer=footer_text,
1061                note=note, sigs_in_footer=sigs_in_footer, topMargin=topMargin)
1062        except IOError:
1063            view.flash(_('Error in image file.'))
1064            return view.redirect(view.url(view.context))
1065        if save_file:
1066            self._saveTranscriptPDF(student, pdf_stream)
1067            return
1068        return pdf_stream
1069
1070    def renderPDFCourseticketsOverview(
1071            self, view, name, session, data, lecturers, orientation,
1072            title_length, note):
1073        """Render pdf slip of course tickets for a lecturer.
1074        """
1075        filename = '%s_%s_%s_%s.pdf' % (
1076            name, view.context.code, session, view.request.principal.id)
1077        try:
1078            session = academic_sessions_vocab.getTerm(session).title
1079        except LookupError:
1080            session = _('void')
1081        creator = getUtility(IPDFCreator, name=orientation)
1082        style = getSampleStyleSheet()
1083        pdf_data = []
1084        pdf_data += [Paragraph(
1085            translate(_('<b>Lecturer(s): ${a}</b>',
1086                      mapping = {'a':lecturers})), style["Normal"]),]
1087        pdf_data += [Paragraph(
1088            translate(_('<b>Credits: ${a}</b>',
1089                      mapping = {'a':view.context.credits})), style["Normal"]),]
1090        # Not used in base package.
1091        if data[1]:
1092            pdf_data += [Paragraph(
1093                translate(_('<b>${a}</b>',
1094                    mapping = {'a':data[1][0]})), style["Normal"]),]
1095            pdf_data += [Paragraph(
1096                translate(_('<b>${a}</b>',
1097                    mapping = {'a':data[1][1]})), style["Normal"]),]
1098            pdf_data += [Paragraph(
1099                translate(_('<b>Total Students: ${a}</b>',
1100                    mapping = {'a':data[1][2]})), style["Normal"]),]
1101            pdf_data += [Paragraph(
1102                translate(_('<b>Total Pass: ${a} (${b}%)</b>',
1103                mapping = {'a':data[1][3],'b':data[1][4]})), style["Normal"]),]
1104            pdf_data += [Paragraph(
1105                translate(_('<b>Total Fail: ${a} (${b}%)</b>',
1106                mapping = {'a':data[1][5],'b':data[1][6]})), style["Normal"]),]
1107            grade_stats = []
1108            for item in sorted(data[1][7].items()):
1109                grade_stats.append(('%s=%s' % (item[0], item[1])))
1110            grade_stats_string = ', '.join(grade_stats)
1111            pdf_data += [Paragraph(
1112                translate(_('<b>${a}</b>',
1113                mapping = {'a':grade_stats_string})), style["Normal"]),]
1114        pdf_data.append(Spacer(1, 20))
1115        colWidths = [None] * len(data[0][0])
1116        pdf_data += [Table(data[0], colWidths=colWidths, style=CONTENT_STYLE,
1117                           repeatRows=1)]
1118        # Process title if too long
1119        title = " ".join(view.context.title.split())
1120        ct = textwrap.fill(title, title_length)
1121        ft = "" # title
1122        #if len(textwrap.wrap(title, title_length)) > 1:
1123        #    ft = textwrap.wrap(title, title_length)[0] + ' ...'
1124        doc_title = translate(_('${a} (${b})\nAcademic Session ${d}',
1125            mapping = {'a':ct,
1126                       'b':view.context.code,
1127                       'd':session}))
1128        if name == 'attendance':
1129            doc_title += '\n' + translate(_('Attendance Sheet'))
1130        if name == 'coursetickets':
1131            doc_title += '\n' + translate(_('Course Tickets Overview'))
1132        #footer_title = translate(_('${a} (${b}) - ${d}',
1133        #    mapping = {'a':ft,
1134        #               'b':view.context.code,
1135        #               'd':session}))
1136        footer_title = translate(_('${b} - ${d}',
1137            mapping = {'b':view.context.code,
1138                       'd':session}))
1139        author = '%s (%s)' % (view.request.principal.title,
1140                              view.request.principal.id)
1141        view.response.setHeader(
1142            'Content-Type', 'application/pdf')
1143        view.response.setHeader(
1144            'Content-Disposition:', 'attachment; filename="%s' % filename)
1145        pdf_stream = creator.create_pdf(
1146            pdf_data, None, doc_title, author, footer_title + ' -', note
1147            )
1148        return pdf_stream
1149
1150    def updateCourseTickets(self, course):
1151        """Udate course tickets if course attributes were changed.
1152        """
1153        current_academic_session = grok.getSite()[
1154            'configuration'].current_academic_session
1155        if not current_academic_session:
1156            return
1157        cat = queryUtility(ICatalog, name='coursetickets_catalog')
1158        coursetickets = cat.searchResults(
1159            code=(course.code, course.code),
1160            session=(current_academic_session,current_academic_session))
1161        number = 0
1162        ob_class = self.__implemented__.__name__.replace('waeup.kofa.', '')
1163        for ticket in coursetickets:
1164            if ticket.credits == course.credits:
1165                continue
1166            if ticket.student.current_session != current_academic_session:
1167                continue
1168            if ticket.student.state not in (PAID,):
1169                continue
1170            number += 1
1171            ticket.student.__parent__.logger.info(
1172                '%s - %s %s/%s credits updated (%s->%s)' % (
1173                    ob_class, ticket.student.student_id,
1174                    ticket.level, ticket.code, course.credits,
1175                    ticket.credits))
1176            ticket.credits = course.credits
1177        return number
1178
1179    #: A dictionary which maps widget names to headlines. The headline
1180    #: is rendered in forms and on pdf slips above the respective
1181    #: display or input widget. There are no separating headlines
1182    #: in the base package.
1183    SEPARATORS_DICT = {}
1184
1185    #: A tuple containing names of file upload viewlets which are not shown
1186    #: on the `StudentClearanceManageFormPage`. Nothing is being skipped
1187    #: in the base package. This attribute makes only sense, if intermediate
1188    #: custom packages are being used, like we do for all Nigerian portals.
1189    SKIP_UPLOAD_VIEWLETS = ()
1190
1191    #: A tuple containing the names of registration states in which changing of
1192    #: passport pictures is allowed.
1193    PORTRAIT_CHANGE_STATES = (ADMITTED,)
1194
1195    #: A tuple containing all exporter names referring to students or
1196    #: subobjects thereof.
1197    STUDENT_EXPORTER_NAMES = (
1198            'students',
1199            'studentstudycourses',
1200            'studentstudylevels',
1201            'coursetickets',
1202            'studentpayments',
1203            'bedtickets',
1204            'trimmed',
1205            'outstandingcourses',
1206            'unpaidpayments',
1207            'sfpaymentsoverview',
1208            'sessionpaymentsoverview',
1209            'studylevelsoverview',
1210            'combocard',
1211            'bursary',
1212            'accommodationpayments',
1213            'transcriptdata',
1214            'trimmedpayments',
1215            )
1216
1217    #: A tuple containing all exporter names needed for backing
1218    #: up student data
1219    STUDENT_BACKUP_EXPORTER_NAMES = ('students', 'studentstudycourses',
1220            'studentstudylevels', 'coursetickets',
1221            'studentpayments', 'bedtickets')
1222
1223    # Maximum size of upload files in kB
1224    MAX_KB = 250
1225
1226    #: A prefix used when generating new student ids. Each student id will
1227    #: start with this string. The default is 'K' for Kofa.
1228    STUDENT_ID_PREFIX = u'K'
Note: See TracBrowser for help on using the repository browser.