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

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

Implement BalancePaymentAddFormPage? and adjust interfaces (tests and further components will follow).

  • Property svn:keywords set to Id
File size: 26.0 KB
Line 
1## $Id: utils.py 9864 2013-01-11 17:19:38Z 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, balance_item, 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 = getUtility(IKofaUtils).BALANCE_PAYMENT_ITEMS[balance_item]
429        p_item = unicode(p_item)
430        p_session = balance_session
431        p_level = balance_level
432        p_current = False
433        amount = balance_amount
434        academic_session = self._getSessionConfiguration(p_session)
435        if academic_session == None:
436            return _(u'Session configuration object is not available.'), None
437        if amount in (0.0, None):
438            return _('Amount could not be determined.'), None
439        for key in student['payments'].keys():
440            ticket = student['payments'][key]
441            if ticket.p_state == 'paid' and\
442               ticket.p_category == 'balance' and \
443               ticket.p_item == p_item and \
444               ticket.p_session == p_session:
445                  return _('This type of payment has already been made.'), None
446        payment = createObject(u'waeup.StudentOnlinePayment')
447        timestamp = ("%d" % int(time()*10000))[1:]
448        payment.p_id = "p%s" % timestamp
449        payment.p_category = 'balance'
450        payment.p_item = p_item
451        payment.p_session = p_session
452        payment.p_level = p_level
453        payment.p_current = p_current
454        payment.amount_auth = amount
455        return None, payment
456
457    def getAccommodationDetails(self, student):
458        """Determine the accommodation data of a student.
459        """
460        d = {}
461        d['error'] = u''
462        hostels = grok.getSite()['hostels']
463        d['booking_session'] = hostels.accommodation_session
464        d['allowed_states'] = hostels.accommodation_states
465        d['startdate'] = hostels.startdate
466        d['enddate'] = hostels.enddate
467        d['expired'] = hostels.expired
468        # Determine bed type
469        studycourse = student['studycourse']
470        certificate = getattr(studycourse,'certificate',None)
471        entry_session = studycourse.entry_session
472        current_level = studycourse.current_level
473        if None in (entry_session, current_level, certificate):
474            return d
475        end_level = certificate.end_level
476        if current_level == 10:
477            bt = 'pr'
478        elif entry_session == grok.getSite()['hostels'].accommodation_session:
479            bt = 'fr'
480        elif current_level >= end_level:
481            bt = 'fi'
482        else:
483            bt = 're'
484        if student.sex == 'f':
485            sex = 'female'
486        else:
487            sex = 'male'
488        special_handling = 'regular'
489        d['bt'] = u'%s_%s_%s' % (special_handling,sex,bt)
490        return d
491
492    def selectBed(self, available_beds):
493        """Select a bed from a list of available beds.
494
495        In the base configuration we select the first bed found,
496        but can also randomize the selection if we like.
497        """
498        return available_beds[0]
499
500    def renderPDFAdmissionLetter(self, view, student=None):
501        """Render pdf admission letter.
502        """
503        # XXX: we have to fix the import problems here.
504        from waeup.kofa.browser.interfaces import IPDFCreator
505        from waeup.kofa.browser.pdf import format_html, NOTE_STYLE
506        if student is None:
507            return
508        style = getSampleStyleSheet()
509        creator = getUtility(IPDFCreator)
510        data = []
511        doc_title = view.label
512        author = '%s (%s)' % (view.request.principal.title,
513                              view.request.principal.id)
514        footer_text = view.label
515        if getattr(student, 'student_id', None) is not None:
516            footer_text = "%s - %s - " % (student.student_id, footer_text)
517
518        # Admission text
519        portal_language = getUtility(IKofaUtils).PORTAL_LANGUAGE
520        inst_name = grok.getSite()['configuration'].name
521        text = trans(_(
522            'This is to inform you that you have been provisionally'
523            ' admitted into ${a} as follows:', mapping = {'a': inst_name}),
524            portal_language)
525        html = format_html(text)
526        data.append(Paragraph(html, NOTE_STYLE))
527        data.append(Spacer(1, 20))
528
529        # Student data
530        data.append(render_student_data(view))
531
532        # Insert history
533        data.append(Spacer(1, 20))
534        datelist = student.history.messages[0].split()[0].split('-')
535        creation_date = u'%s/%s/%s' % (datelist[2], datelist[1], datelist[0])
536        text = trans(_(
537            'Your Kofa student record was created on ${a}.',
538            mapping = {'a': creation_date}),
539            portal_language)
540        html = format_html(text)
541        data.append(Paragraph(html, NOTE_STYLE))
542
543        # Create pdf stream
544        view.response.setHeader(
545            'Content-Type', 'application/pdf')
546        pdf_stream = creator.create_pdf(
547            data, None, doc_title, author=author, footer=footer_text,
548            note=None)
549        return pdf_stream
550
551    def renderPDF(self, view, filename='slip.pdf', student=None,
552                  studentview=None, tableheader=None, tabledata=None,
553                  note=None, signatures=None, sigs_in_footer=(),
554                  show_scans=True):
555        """Render pdf slips for various pages.
556        """
557        # XXX: we have to fix the import problems here.
558        from waeup.kofa.browser.interfaces import IPDFCreator
559        from waeup.kofa.browser.pdf import NORMAL_STYLE, ENTRY1_STYLE
560        style = getSampleStyleSheet()
561        creator = getUtility(IPDFCreator)
562        data = []
563        doc_title = view.label
564        author = '%s (%s)' % (view.request.principal.title,
565                              view.request.principal.id)
566        footer_text = view.label
567        if getattr(student, 'student_id', None) is not None:
568            footer_text = "%s - %s - " % (student.student_id, footer_text)
569
570        # Insert student data table
571        portal_language = getUtility(IKofaUtils).PORTAL_LANGUAGE
572        if student is not None:
573            bd_translation = trans(_('Base Data'), portal_language)
574            data.append(Paragraph(bd_translation, style["Heading3"]))
575            data.append(render_student_data(studentview))
576
577        # Insert widgets
578        if view.form_fields:
579            data.append(Paragraph(view.title, style["Heading3"]))
580            portal_language = getUtility(IKofaUtils).PORTAL_LANGUAGE
581            separators = getattr(self, 'SEPARATORS_DICT', {})
582            table = creator.getWidgetsTable(
583                view.form_fields, view.context, None, lang=portal_language,
584                separators=separators)
585            data.append(table)
586
587        # Insert scanned docs
588        if show_scans:
589            data.extend(docs_as_flowables(view, portal_language))
590
591        # Insert history
592        if filename.startswith('clearance') or filename.startswith('course'):
593            hist_translation = trans(_('Workflow History'), portal_language)
594            data.append(Paragraph(hist_translation, style["Heading3"]))
595            data.extend(creator.fromStringList(student.history.messages))
596
597       # Insert content table (optionally on second page)
598        if tabledata and tableheader:
599            #data.append(PageBreak())
600            data.append(Spacer(1, 20))
601            data.append(Paragraph(view.content_title, style["Heading3"]))
602            contenttable = render_table_data(tableheader,tabledata)
603            data.append(contenttable)
604
605        # Insert signatures
606        if signatures and not sigs_in_footer:
607            data.append(Spacer(1, 20))
608            signaturetable = get_signature_table(signatures)
609            data.append(signaturetable)
610
611        view.response.setHeader(
612            'Content-Type', 'application/pdf')
613        try:
614            pdf_stream = creator.create_pdf(
615                data, None, doc_title, author=author, footer=footer_text,
616                note=note, sigs_in_footer=sigs_in_footer)
617        except IOError:
618            view.flash('Error in image file.')
619            return view.redirect(view.url(view.context))
620        return pdf_stream
621
622    def maxCredits(self, studylevel):
623        """Return maximum credits.
624
625        In some universities maximum credits is not constant, it
626        depends on the student's study level.
627        """
628        return 50
629
630    def maxCreditsExceeded(self, studylevel, course):
631        max_credits = self.maxCredits(studylevel)
632        if max_credits and \
633            studylevel.total_credits + course.credits > max_credits:
634            return max_credits
635        return 0
636
637    VERDICTS_DICT = {
638        '0': _('(not yet)'),
639        'A': 'Successful student',
640        'B': 'Student with carryover courses',
641        'C': 'Student on probation',
642        }
643
644    SEPARATORS_DICT = {
645        }
646
647    #: A prefix used when generating new student ids. Each student id will
648    #: start with this string. The default is 'K' for ``Kofa``.
649    STUDENT_ID_PREFIX = u'K'
Note: See TracBrowser for help on using the repository browser.