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

Last change on this file since 16902 was 16902, checked in by Henrik Bettermann, 3 years ago

Hide emtpy levels in transcripts.

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