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

Last change on this file since 9965 was 9965, checked in by Henrik Bettermann, 12 years ago

Use get_signature_tables instead of the old function get_signature_table. Code is not yet functionally tested.

  • Property svn:keywords set to Id
File size: 26.0 KB
Line 
1## $Id: utils.py 9965 2013-02-19 12:11:33Z 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 student section.
19"""
20import grok
21from time import time
22from reportlab.lib import colors
23from reportlab.lib.units import cm
24from reportlab.lib.pagesizes import A4
25from reportlab.lib.styles import getSampleStyleSheet
26from reportlab.platypus import Paragraph, Image, Table, Spacer
27from zope.schema.interfaces import ConstraintNotSatisfied
28from zope.component import getUtility, createObject
29from zope.formlib.form import setUpEditWidgets
30from zope.i18n import translate
31from waeup.kofa.interfaces import (
32    IExtFileStore, IKofaUtils, RETURNING, PAID, CLEARED,
33    academic_sessions_vocab)
34from waeup.kofa.interfaces import MessageFactory as _
35from waeup.kofa.students.interfaces import IStudentsUtils
36from waeup.kofa.browser.pdf import (
37    ENTRY1_STYLE, format_html, NOTE_STYLE, HEADING_STYLE,
38    get_signature_tables)
39from waeup.kofa.browser.interfaces import IPDFCreator
40
41SLIP_STYLE = [
42    ('VALIGN',(0,0),(-1,-1),'TOP'),
43    #('FONT', (0,0), (-1,-1), 'Helvetica', 11),
44    ]
45
46CONTENT_STYLE = [
47    ('VALIGN',(0,0),(-1,-1),'TOP'),
48    #('FONT', (0,0), (-1,-1), 'Helvetica', 8),
49    #('TEXTCOLOR',(0,0),(-1,0),colors.white),
50    #('BACKGROUND',(0,0),(-1,0),colors.black),
51    ('INNERGRID', (0,0), (-1,-1), 0.25, colors.black),
52    ('BOX', (0,0), (-1,-1), 1, colors.black),
53
54    ]
55
56FONT_SIZE = 10
57FONT_COLOR = 'black'
58
59def trans(text, lang):
60    # shortcut
61    return translate(text, 'waeup.kofa', target_language=lang)
62
63def formatted_text(text, color=FONT_COLOR):
64    """Turn `text`, `color` and `size` into an HTML snippet.
65
66    The snippet is suitable for use with reportlab and generating PDFs.
67    Wraps the `text` into a ``<font>`` tag with passed attributes.
68
69    Also non-strings are converted. Raw strings are expected to be
70    utf-8 encoded (usually the case for widgets etc.).
71
72    Finally, a br tag is added if widgets contain div tags
73    which are not supported by reportlab.
74
75    The returned snippet is unicode type.
76    """
77    try:
78        # In unit tests IKofaUtils has not been registered
79        portal_language = getUtility(IKofaUtils).PORTAL_LANGUAGE
80    except:
81        portal_language = 'en'
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    # Mainly for boolean values we need our customized
90    # localisation of the zope domain
91    text = translate(text, 'zope', target_language=portal_language)
92    text = text.replace('</div>', '<br /></div>')
93    tag1 = u'<font color="%s">' % (color)
94    return tag1 + u'%s</font>' % text
95
96def generate_student_id():
97    students = grok.getSite()['students']
98    new_id = students.unique_student_id
99    return new_id
100
101def set_up_widgets(view, ignore_request=False):
102    view.adapters = {}
103    view.widgets = setUpEditWidgets(
104        view.form_fields, view.prefix, view.context, view.request,
105        adapters=view.adapters, for_display=True,
106        ignore_request=ignore_request
107        )
108
109def render_student_data(studentview):
110    """Render student table for an existing frame.
111    """
112    width, height = A4
113    set_up_widgets(studentview, ignore_request=True)
114    data_left = []
115    data_right = []
116    style = getSampleStyleSheet()
117    img = getUtility(IExtFileStore).getFileByContext(
118        studentview.context, attr='passport.jpg')
119    if img is None:
120        from waeup.kofa.browser import DEFAULT_PASSPORT_IMAGE_PATH
121        img = open(DEFAULT_PASSPORT_IMAGE_PATH, 'rb')
122    doc_img = Image(img.name, width=4*cm, height=4*cm, kind='bound')
123    data_left.append([doc_img])
124    #data.append([Spacer(1, 12)])
125    portal_language = getUtility(IKofaUtils).PORTAL_LANGUAGE
126
127    f_label = _('Name:')
128    f_label = Paragraph(f_label, ENTRY1_STYLE)
129    f_text = formatted_text(studentview.context.display_fullname)
130    f_text = Paragraph(f_text, ENTRY1_STYLE)
131    data_right.append([f_label,f_text])
132
133    for widget in studentview.widgets:
134        if 'name' in widget.name:
135            continue
136        f_label = translate(
137            widget.label.strip(), 'waeup.kofa',
138            target_language=portal_language)
139        f_label = Paragraph('%s:' % f_label, ENTRY1_STYLE)
140        f_text = formatted_text(widget())
141        f_text = Paragraph(f_text, ENTRY1_STYLE)
142        data_right.append([f_label,f_text])
143
144    if getattr(studentview.context, 'certcode', None):
145        f_label = _('Study Course:')
146        f_label = Paragraph(f_label, ENTRY1_STYLE)
147        f_text = formatted_text(
148            studentview.context['studycourse'].certificate.longtitle())
149        f_text = Paragraph(f_text, ENTRY1_STYLE)
150        data_right.append([f_label,f_text])
151
152        f_label = _('Department:')
153        f_label = Paragraph(f_label, ENTRY1_STYLE)
154        f_text = formatted_text(
155            studentview.context[
156            'studycourse'].certificate.__parent__.__parent__.longtitle(),
157            )
158        f_text = Paragraph(f_text, ENTRY1_STYLE)
159        data_right.append([f_label,f_text])
160
161        f_label = _('Faculty:')
162        f_label = Paragraph(f_label, ENTRY1_STYLE)
163        f_text = formatted_text(
164            studentview.context[
165            'studycourse'].certificate.__parent__.__parent__.__parent__.longtitle(),
166            )
167        f_text = Paragraph(f_text, ENTRY1_STYLE)
168        data_right.append([f_label,f_text])
169
170        f_label = _('Entry Session: ')
171        f_label = Paragraph(f_label, ENTRY1_STYLE)
172        entry_session = studentview.context['studycourse'].entry_session
173        entry_session = academic_sessions_vocab.getTerm(entry_session).title
174        f_text = formatted_text(entry_session)
175        f_text = Paragraph(f_text, ENTRY1_STYLE)
176        data_right.append([f_label,f_text])
177
178    table_left = Table(data_left,style=SLIP_STYLE)
179    table_right = Table(data_right,style=SLIP_STYLE, colWidths=[5*cm, 6*cm])
180    table = Table([[table_left, table_right],],style=SLIP_STYLE)
181    return table
182
183def render_table_data(tableheader,tabledata):
184    """Render children table for an existing frame.
185    """
186    data = []
187    #data.append([Spacer(1, 12)])
188    line = []
189    style = getSampleStyleSheet()
190    for element in tableheader:
191        field = '<strong>%s</strong>' % formatted_text(element[0])
192        field = Paragraph(field, style["Normal"])
193        line.append(field)
194    data.append(line)
195    for ticket in tabledata:
196        line = []
197        for element in tableheader:
198              field = formatted_text(getattr(ticket,element[1],u' '))
199              field = Paragraph(field, style["Normal"])
200              line.append(field)
201        data.append(line)
202    table = Table(data,colWidths=[
203        element[2]*cm for element in tableheader], style=CONTENT_STYLE)
204    return table
205
206def docs_as_flowables(view, lang='en'):
207    """Create reportlab flowables out of scanned docs.
208    """
209    # XXX: fix circular import problem
210    from waeup.kofa.students.viewlets import FileManager
211    from waeup.kofa.browser import DEFAULT_IMAGE_PATH
212    style = getSampleStyleSheet()
213    data = []
214
215    # Collect viewlets
216    fm = FileManager(view.context, view.request, view)
217    fm.update()
218    if fm.viewlets:
219        sc_translation = trans(_('Scanned Documents'), lang)
220        data.append(Paragraph(sc_translation, HEADING_STYLE))
221        # Insert list of scanned documents
222        table_data = []
223        for viewlet in fm.viewlets:
224            f_label = Paragraph(trans(viewlet.label, lang), ENTRY1_STYLE)
225            img_path = getattr(getUtility(IExtFileStore).getFileByContext(
226                view.context, attr=viewlet.download_name), 'name', None)
227            f_text = Paragraph(trans(_('(not provided)'),lang), ENTRY1_STYLE)
228            if img_path is None:
229                pass
230            elif not img_path[-4:] in ('.jpg', '.JPG'):
231                # reportlab requires jpg images, I think.
232                f_text = Paragraph('%s (not displayable)' % (
233                    viewlet.title,), ENTRY1_STYLE)
234            else:
235                f_text = Image(img_path, width=2*cm, height=1*cm, kind='bound')
236            table_data.append([f_label, f_text])
237        if table_data:
238            # safety belt; empty tables lead to problems.
239            data.append(Table(table_data, style=SLIP_STYLE))
240    return data
241
242class StudentsUtils(grok.GlobalUtility):
243    """A collection of methods subject to customization.
244    """
245    grok.implements(IStudentsUtils)
246
247    def getReturningData(self, student):
248        """ Define what happens after school fee payment
249        depending on the student's senate verdict.
250
251        In the base configuration current level is always increased
252        by 100 no matter which verdict has been assigned.
253        """
254        new_level = student['studycourse'].current_level + 100
255        new_session = student['studycourse'].current_session + 1
256        return new_session, new_level
257
258    def setReturningData(self, student):
259        """ Define what happens after school fee payment
260        depending on the student's senate verdict.
261
262        This method folllows the same algorithm as getReturningData but
263        it also sets the new values.
264        """
265        new_session, new_level = self.getReturningData(student)
266        try:
267            student['studycourse'].current_level = new_level
268        except ConstraintNotSatisfied:
269            # Do not change level if level exceeds the
270            # certificate's end_level.
271            pass
272        student['studycourse'].current_session = new_session
273        verdict = student['studycourse'].current_verdict
274        student['studycourse'].current_verdict = '0'
275        student['studycourse'].previous_verdict = verdict
276        return
277
278    def _getSessionConfiguration(self, session):
279        try:
280            return grok.getSite()['configuration'][str(session)]
281        except KeyError:
282            return None
283
284    def setPaymentDetails(self, category, student,
285            previous_session, previous_level):
286        """Create Payment object and set the payment data of a student for
287        the payment category specified.
288
289        """
290        p_item = u''
291        amount = 0.0
292        if previous_session:
293            if previous_session < student['studycourse'].entry_session:
294                return _('The previous session must not fall below '
295                         'your entry session.'), None
296            if category == 'schoolfee':
297                # School fee is always paid for the following session
298                if previous_session > student['studycourse'].current_session:
299                    return _('This is not a previous session.'), None
300            else:
301                if previous_session > student['studycourse'].current_session - 1:
302                    return _('This is not a previous session.'), None
303            p_session = previous_session
304            p_level = previous_level
305            p_current = False
306        else:
307            p_session = student['studycourse'].current_session
308            p_level = student['studycourse'].current_level
309            p_current = True
310        academic_session = self._getSessionConfiguration(p_session)
311        if academic_session == None:
312            return _(u'Session configuration object is not available.'), None
313        # Determine fee.
314        if category == 'schoolfee':
315            try:
316                certificate = student['studycourse'].certificate
317                p_item = certificate.code
318            except (AttributeError, TypeError):
319                return _('Study course data are incomplete.'), None
320            if previous_session:
321                # Students can pay for previous sessions in all
322                # workflow states.  Fresh students are excluded by the
323                # update method of the PreviousPaymentAddFormPage.
324                if previous_level == 100:
325                    amount = getattr(certificate, 'school_fee_1', 0.0)
326                else:
327                    amount = getattr(certificate, 'school_fee_2', 0.0)
328            else:
329                if student.state == CLEARED:
330                    amount = getattr(certificate, 'school_fee_1', 0.0)
331                elif student.state == RETURNING:
332                    # In case of returning school fee payment the
333                    # payment session and level contain the values of
334                    # the session the student has paid for. Payment
335                    # session is always next session.
336                    p_session, p_level = self.getReturningData(student)
337                    academic_session = self._getSessionConfiguration(p_session)
338                    if academic_session == None:
339                        return _(
340                            u'Session configuration object is not available.'
341                            ), None
342                    amount = getattr(certificate, 'school_fee_2', 0.0)
343                elif student.is_postgrad and student.state == PAID:
344                    # Returning postgraduate students also pay for the
345                    # next session but their level always remains the
346                    # same.
347                    p_session += 1
348                    academic_session = self._getSessionConfiguration(p_session)
349                    if academic_session == None:
350                        return _(
351                            u'Session configuration object is not available.'
352                            ), None
353                    amount = getattr(certificate, 'school_fee_2', 0.0)
354        elif category == 'clearance':
355            try:
356                p_item = student['studycourse'].certificate.code
357            except (AttributeError, TypeError):
358                return _('Study course data are incomplete.'), None
359            amount = academic_session.clearance_fee
360        elif category == 'bed_allocation':
361            p_item = self.getAccommodationDetails(student)['bt']
362            amount = academic_session.booking_fee
363        elif category == 'hostel_maintenance':
364            amount = academic_session.maint_fee
365            bedticket = student['accommodation'].get(
366                str(student.current_session), None)
367            if bedticket:
368                p_item = bedticket.bed_coordinates
369            else:
370                # Should not happen because this is already checked
371                # in the browser module, but anyway ...
372                portal_language = getUtility(IKofaUtils).PORTAL_LANGUAGE
373                p_item = trans(_('no bed allocated'), portal_language)
374        if amount in (0.0, None):
375            return _('Amount could not be determined.'), None
376        for key in student['payments'].keys():
377            ticket = student['payments'][key]
378            if ticket.p_state == 'paid' and\
379               ticket.p_category == category and \
380               ticket.p_item == p_item and \
381               ticket.p_session == p_session:
382                  return _('This type of payment has already been made.'), None
383        payment = createObject(u'waeup.StudentOnlinePayment')
384        timestamp = ("%d" % int(time()*10000))[1:]
385        payment.p_id = "p%s" % timestamp
386        payment.p_category = category
387        payment.p_item = p_item
388        payment.p_session = p_session
389        payment.p_level = p_level
390        payment.p_current = p_current
391        payment.amount_auth = amount
392        return None, payment
393
394    def setBalanceDetails(self, category, student,
395            balance_session, balance_level, balance_amount):
396        """Create Payment object and set the payment data of a student for.
397
398        """
399        p_item = u'Balance'
400        p_session = balance_session
401        p_level = balance_level
402        p_current = False
403        amount = balance_amount
404        academic_session = self._getSessionConfiguration(p_session)
405        if academic_session == None:
406            return _(u'Session configuration object is not available.'), None
407        if amount in (0.0, None) or amount < 0:
408            return _('Amount must be greater than 0.'), None
409        for key in student['payments'].keys():
410            ticket = student['payments'][key]
411            if ticket.p_state == 'paid' and\
412               ticket.p_category == 'balance' and \
413               ticket.p_item == p_item and \
414               ticket.p_session == p_session:
415                  return _('This type of payment has already been made.'), None
416        payment = createObject(u'waeup.StudentOnlinePayment')
417        timestamp = ("%d" % int(time()*10000))[1:]
418        payment.p_id = "p%s" % timestamp
419        payment.p_category = category
420        payment.p_item = p_item
421        payment.p_session = p_session
422        payment.p_level = p_level
423        payment.p_current = p_current
424        payment.amount_auth = amount
425        return None, payment
426
427    def getAccommodationDetails(self, student):
428        """Determine the accommodation data of a student.
429        """
430        d = {}
431        d['error'] = u''
432        hostels = grok.getSite()['hostels']
433        d['booking_session'] = hostels.accommodation_session
434        d['allowed_states'] = hostels.accommodation_states
435        d['startdate'] = hostels.startdate
436        d['enddate'] = hostels.enddate
437        d['expired'] = hostels.expired
438        # Determine bed type
439        studycourse = student['studycourse']
440        certificate = getattr(studycourse,'certificate',None)
441        entry_session = studycourse.entry_session
442        current_level = studycourse.current_level
443        if None in (entry_session, current_level, certificate):
444            return d
445        end_level = certificate.end_level
446        if current_level == 10:
447            bt = 'pr'
448        elif entry_session == grok.getSite()['hostels'].accommodation_session:
449            bt = 'fr'
450        elif current_level >= end_level:
451            bt = 'fi'
452        else:
453            bt = 're'
454        if student.sex == 'f':
455            sex = 'female'
456        else:
457            sex = 'male'
458        special_handling = 'regular'
459        d['bt'] = u'%s_%s_%s' % (special_handling,sex,bt)
460        return d
461
462    def selectBed(self, available_beds):
463        """Select a bed from a list of available beds.
464
465        In the base configuration we select the first bed found,
466        but can also randomize the selection if we like.
467        """
468        return available_beds[0]
469
470    def renderPDFAdmissionLetter(self, view, student=None):
471        """Render pdf admission letter.
472        """
473        if student is None:
474            return
475        style = getSampleStyleSheet()
476        creator = self.getPDFCreator(student)
477        data = []
478        doc_title = view.label
479        author = '%s (%s)' % (view.request.principal.title,
480                              view.request.principal.id)
481        footer_text = view.label.split('\n')
482        if len(footer_text) > 1:
483            # We can add a department in first line
484            footer_text = footer_text[1]
485        else:
486            # Only the first line is used for the footer
487            footer_text = footer_text[0]
488        if getattr(student, 'student_id', None) is not None:
489            footer_text = "%s - %s - " % (student.student_id, footer_text)
490
491        # Admission text
492        portal_language = getUtility(IKofaUtils).PORTAL_LANGUAGE
493        inst_name = grok.getSite()['configuration'].name
494        text = trans(_(
495            'This is to inform you that you have been provisionally'
496            ' admitted into ${a} as follows:', mapping = {'a': inst_name}),
497            portal_language)
498        html = format_html(text)
499        data.append(Paragraph(html, NOTE_STYLE))
500        data.append(Spacer(1, 20))
501
502        # Student data
503        data.append(render_student_data(view))
504
505        # Insert history
506        data.append(Spacer(1, 20))
507        datelist = student.history.messages[0].split()[0].split('-')
508        creation_date = u'%s/%s/%s' % (datelist[2], datelist[1], datelist[0])
509        text = trans(_(
510            'Your Kofa student record was created on ${a}.',
511            mapping = {'a': creation_date}),
512            portal_language)
513        html = format_html(text)
514        data.append(Paragraph(html, NOTE_STYLE))
515
516        # Create pdf stream
517        view.response.setHeader(
518            'Content-Type', 'application/pdf')
519        pdf_stream = creator.create_pdf(
520            data, None, doc_title, author=author, footer=footer_text,
521            note=None)
522        return pdf_stream
523
524    def getPDFCreator(self, context):
525        """Get a pdf creator suitable for `context`.
526
527        The default implementation always returns the default creator.
528        """
529        return getUtility(IPDFCreator)
530
531    def renderPDF(self, view, filename='slip.pdf', student=None,
532                  studentview=None,
533                  tableheader_1=None, tabledata_1=None,
534                  tableheader_2=None, tabledata_2=None,
535                  tableheader_3=None, tabledata_3=None,
536                  note=None, signatures=None, sigs_in_footer=(),
537                  show_scans=True, topMargin=1.5):
538        """Render pdf slips for various pages.
539        """
540        # XXX: tell what the different parameters mean
541        style = getSampleStyleSheet()
542        creator = self.getPDFCreator(student)
543        data = []
544        doc_title = view.label
545        author = '%s (%s)' % (view.request.principal.title,
546                              view.request.principal.id)
547        footer_text = view.label.split('\n')
548        if len(footer_text) > 2:
549            # We can add a department in first line
550            footer_text = footer_text[1]
551        else:
552            # Only the first line is used for the footer
553            footer_text = footer_text[0]
554        if getattr(student, 'student_id', None) is not None:
555            footer_text = "%s - %s - " % (student.student_id, footer_text)
556
557        # Insert student data table
558        portal_language = getUtility(IKofaUtils).PORTAL_LANGUAGE
559        if student is not None:
560            bd_translation = trans(_('Base Data'), portal_language)
561            data.append(Paragraph(bd_translation, HEADING_STYLE))
562            data.append(render_student_data(studentview))
563
564        # Insert widgets
565        if view.form_fields:
566            data.append(Paragraph(view.title, HEADING_STYLE))
567            portal_language = getUtility(IKofaUtils).PORTAL_LANGUAGE
568            separators = getattr(self, 'SEPARATORS_DICT', {})
569            table = creator.getWidgetsTable(
570                view.form_fields, view.context, None, lang=portal_language,
571                separators=separators)
572            data.append(table)
573
574        # Insert scanned docs
575        if show_scans:
576            data.extend(docs_as_flowables(view, portal_language))
577
578        # Insert history
579        if filename.startswith('clearance'):
580            hist_translation = trans(_('Workflow History'), portal_language)
581            data.append(Paragraph(hist_translation, HEADING_STYLE))
582            data.extend(creator.fromStringList(student.history.messages))
583
584       # Insert 1st content table (optionally on second page)
585        if tabledata_1 and tableheader_1:
586            #data.append(PageBreak())
587            #data.append(Spacer(1, 20))
588            data.append(Paragraph(view.content_title_1, HEADING_STYLE))
589            data.append(Spacer(1, 8))
590            contenttable = render_table_data(tableheader_1,tabledata_1)
591            data.append(contenttable)
592
593       # Insert 2nd content table (optionally on second page)
594        if tabledata_2 and tableheader_2:
595            #data.append(PageBreak())
596            #data.append(Spacer(1, 20))
597            data.append(Paragraph(view.content_title_2, HEADING_STYLE))
598            data.append(Spacer(1, 8))
599            contenttable = render_table_data(tableheader_2,tabledata_2)
600            data.append(contenttable)
601
602       # Insert 3rd content table (optionally on second page)
603        if tabledata_3 and tableheader_3:
604            #data.append(PageBreak())
605            #data.append(Spacer(1, 20))
606            data.append(Paragraph(view.content_title_3, HEADING_STYLE))
607            data.append(Spacer(1, 8))
608            contenttable = render_table_data(tableheader_3,tabledata_3)
609            data.append(contenttable)
610
611        # Insert signatures
612        # XXX: We are using only sigs_in_footer in waeup.kofa, so we
613        # do not have a test for the following lines.
614        if signatures and not sigs_in_footer:
615            data.append(Spacer(1, 20))
616            signaturetables = get_signature_tables(signatures)
617            for table in signaturetables:
618                data.append(table)
619
620        view.response.setHeader(
621            'Content-Type', 'application/pdf')
622        try:
623            pdf_stream = creator.create_pdf(
624                data, None, doc_title, author=author, footer=footer_text,
625                note=note, sigs_in_footer=sigs_in_footer, topMargin=topMargin)
626        except IOError:
627            view.flash('Error in image file.')
628            return view.redirect(view.url(view.context))
629        return pdf_stream
630
631    def maxCredits(self, studylevel):
632        """Return maximum credits.
633
634        In some universities maximum credits is not constant, it
635        depends on the student's study level.
636        """
637        return 50
638
639    def maxCreditsExceeded(self, studylevel, course):
640        max_credits = self.maxCredits(studylevel)
641        if max_credits and \
642            studylevel.total_credits + course.credits > max_credits:
643            return max_credits
644        return 0
645
646    VERDICTS_DICT = {
647        '0': _('(not yet)'),
648        'A': 'Successful student',
649        'B': 'Student with carryover courses',
650        'C': 'Student on probation',
651        }
652
653    SEPARATORS_DICT = {
654        }
655
656    #: A prefix used when generating new student ids. Each student id will
657    #: start with this string. The default is 'K' for ``Kofa``.
658    STUDENT_ID_PREFIX = u'K'
Note: See TracBrowser for help on using the repository browser.