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

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

Adjust column width for matric numbers.

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