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

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

Add TranscriptDataExporter and reorganize exporter names.

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