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

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

Two modifications which become necessary for the next waeup.aaue upgrade.

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