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

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

Implement study level 0 (Level Zero) option for storing for
orphaned course tickets (tickets without level information).
Add ticket_session field to ICourseTicket.

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