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

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

Make max file size customizable.

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