source: main/waeup.kofa/branches/henrik-transcript-workflow/src/waeup/kofa/students/utils.py @ 16191

Last change on this file since 16191 was 15150, checked in by Henrik Bettermann, 6 years ago

Print electronic signatures on pdf files.

Remove final transcript file when resetting the transcript process.

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