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

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

Balance must not be negative or zero.

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