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

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

Ease customization of max_credits. In some universities maximum credits is not constant, it
depends on the student's study level.

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