source: main/waeup.kofa/trunk/src/waeup/kofa/students/export.py @ 15051

Last change on this file since 15051 was 15051, checked in by Henrik Bettermann, 7 years ago
  • Property svn:keywords set to Id
File size: 27.5 KB
Line 
1## $Id: export.py 15051 2018-06-12 12:38:33Z henrik $
2##
3## Copyright (C) 2011 Uli Fouquet & Henrik Bettermann
4## This program is free software; you can redistribute it and/or modify
5## it under the terms of the GNU General Public License as published by
6## the Free Software Foundation; either version 2 of the License, or
7## (at your option) any later version.
8##
9## This program is distributed in the hope that it will be useful,
10## but WITHOUT ANY WARRANTY; without even the implied warranty of
11## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12## GNU General Public License for more details.
13##
14## You should have received a copy of the GNU General Public License
15## along with this program; if not, write to the Free Software
16## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
17##
18"""Exporters for student related stuff.
19"""
20import os
21import grok
22from datetime import datetime, timedelta
23from zope.component import getUtility
24from waeup.kofa.interfaces import (
25    IExtFileStore, IFileStoreNameChooser, IKofaUtils)
26from waeup.kofa.interfaces import MessageFactory as _
27from waeup.kofa.students.catalog import StudentsQuery, CourseTicketsQuery
28from waeup.kofa.students.interfaces import (
29    IStudent, IStudentStudyCourse, IStudentStudyLevel, ICourseTicket,
30    IStudentOnlinePayment, ICSVStudentExporter, IBedTicket)
31from waeup.kofa.students.vocabularies import study_levels
32from waeup.kofa.utils.batching import ExporterBase
33from waeup.kofa.utils.helpers import iface_names, to_timezone
34
35
36def get_students(site, stud_filter=StudentsQuery()):
37    """Get all students registered in catalog in `site`.
38    """
39    return stud_filter.query()
40
41def get_studycourses(students):
42    """Get studycourses of `students`.
43    """
44    return [x.get('studycourse', None) for x in students
45            if x is not None]
46
47def get_levels(students):
48    """Get all studylevels of `students`.
49    """
50    levels = []
51    for course in get_studycourses(students):
52        for level in course.values():
53            levels.append(level)
54    return levels
55
56def get_tickets(students, **kw):
57    """Get course tickets of `students`.
58    If code is passed through, filter course tickets
59    which belong to this course code and meet level=level
60    and level_session=level_session.
61    If not, but ct_level and ct_session
62    are passed through, filter course tickets
63    which meet level==ct_level and level_session==ct_session.
64    """
65    tickets = []
66    code = kw.get('code', None)
67    level = kw.get('level', None)
68    session = kw.get('session', None)
69    ct_level = kw.get('ct_level', None)
70    ct_session = kw.get('ct_session', None)
71    if code is None:
72        for level_obj in get_levels(students):
73            for ticket in level_obj.values():
74                if ct_level not in ('all', None):
75                    if level_obj.level in (10, 999, None)  \
76                        and int(ct_level) != level_obj.level:
77                        continue
78                    if level_obj.level not in range(
79                        int(ct_level), int(ct_level)+100, 10):
80                        continue
81                if ct_session not in ('all', None) and \
82                    int(ct_session) != level_obj.level_session:
83                    continue
84                tickets.append(ticket)
85    else:
86        for level_obj in get_levels(students):
87            for ticket in level_obj.values():
88                if ticket.code != code:
89                    continue
90                if level not in ('all', None):
91                    if level_obj.level in (10, 999, None)  \
92                        and int(level) != level_obj.level:
93                        continue
94                    if level_obj.level not in range(
95                        int(level), int(level)+100, 10):
96                        continue
97                if session not in ('all', None) and \
98                    int(session) != level_obj.level_session:
99                    continue
100                tickets.append(ticket)
101    return tickets
102
103def get_tickets_for_lecturer(students, **kw):
104    """Get course tickets of `students`.
105    Filter course tickets which belong to this course code and
106    which are editable by lecturers.
107    """
108    tickets = []
109    code = kw.get('code', None)
110    level_session = kw.get('session', None)
111    level = kw.get('level', None)
112    for level_obj in get_levels(students):
113        for ticket in level_obj.values():
114            if ticket.code != code:
115                continue
116            if not ticket.editable_by_lecturer:
117                continue
118            if level not in ('all', None):
119                if level_obj.level in (10, 999, None)  \
120                    and int(level) != level_obj.level:
121                    continue
122                if level_obj.level not in range(int(level), int(level)+100, 10):
123                    continue
124            if level_session not in ('all', None) and \
125                int(level_session) != level_obj.level_session:
126                continue
127            tickets.append(ticket)
128    return tickets
129
130def get_payments(students, p_states=None, **kw):
131    """Get all payments of `students` within given payment_date period.
132    """
133    date_format = '%d/%m/%Y'
134    payments = []
135    p_start = kw.get('payments_start')
136    p_end = kw.get('payments_end')
137    paycat = kw.get('paycat')
138    for student in students:
139        for payment in student.get('payments', {}).values():
140            if p_start and p_end:
141                if not payment.payment_date:
142                    continue
143                payments_start = datetime.strptime(p_start, date_format)
144                payments_end = datetime.strptime(p_end, date_format)
145                tz = getUtility(IKofaUtils).tzinfo
146                payments_start = tz.localize(payments_start)
147                payments_end = tz.localize(payments_end) + timedelta(days=1)
148                payment_date = to_timezone(payment.payment_date, tz)
149                if payment_date < payments_start or payment_date > payments_end:
150                    continue
151            if p_states and not payment.p_state in p_states:
152                continue
153            if paycat not in ('all', None) and payment.p_category != paycat:
154                continue
155            payments.append(payment)
156    return payments
157
158def get_bedtickets(students):
159    """Get all bedtickets of `students`.
160    """
161    tickets = []
162    for student in students:
163        for ticket in student.get('accommodation', {}).values():
164            tickets.append(ticket)
165    return tickets
166
167class StudentExporterBase(ExporterBase):
168    """Exporter for students or related objects.
169    This is a baseclass.
170    """
171    grok.baseclass()
172    grok.implements(ICSVStudentExporter)
173    grok.provides(ICSVStudentExporter)
174
175    def filter_func(self, x, **kw):
176        return x
177
178    def get_filtered(self, site, **kw):
179        """Get students from a catalog filtered by keywords.
180        students_catalog is the default catalog. The keys must be valid
181        catalog index names.
182        Returns a simple empty list, a list with `Student`
183        objects or a catalog result set with `Student`
184        objects.
185
186        .. seealso:: `waeup.kofa.students.catalog.StudentsCatalog`
187
188        """
189        # Pass only given keywords to create FilteredCatalogQuery objects.
190        # This way we avoid
191        # trouble with `None` value ambivalences and queries are also
192        # faster (normally less indexes to ask). Drawback is, that
193        # developers must look into catalog to see what keywords are
194        # valid.
195        if kw.get('catalog', None) == 'coursetickets':
196            coursetickets = CourseTicketsQuery(**kw).query()
197            students = []
198            for ticket in coursetickets:
199                students.append(ticket.student)
200            return list(set(students))
201        # Payments can be filtered by payment date and payment category.
202        # These parameters are not keys of the catalog and must thus be
203        # removed from kw.
204        try:
205            del kw['payments_start']
206            del kw['payments_end']
207            del kw['paycat']
208        except KeyError:
209            pass
210        # Coursetickets can be filtered level and session.
211        # These parameters are not keys of the catalog and must thus be
212        # removed from kw.
213        try:
214            del kw['ct_level']
215            del kw['ct_session']
216        except KeyError:
217            pass
218        query = StudentsQuery(**kw)
219        return query.query()
220
221    def get_selected(self, site, selected):
222        """Get set of selected students.
223        Returns a simple empty list or a list with `Student`
224        objects.
225        """
226        students = []
227        students_container = site.get('students', {})
228        for id in selected:
229            student = students_container.get(id, None)
230            if student is None:
231                # try matric number
232                result = list(StudentsQuery(matric_number=id).query())
233                if result:
234                    student = result[0]
235                else:
236                    continue
237            students.append(student)
238        return students
239
240    def export(self, values, filepath=None):
241        """Export `values`, an iterable, as CSV file.
242        If `filepath` is ``None``, a raw string with CSV data is returned.
243        """
244        writer, outfile = self.get_csv_writer(filepath)
245        for value in values:
246            self.write_item(value, writer)
247        return self.close_outfile(filepath, outfile)
248
249    def export_all(self, site, filepath=None):
250        """Export students into filepath as CSV data.
251        If `filepath` is ``None``, a raw string with CSV data is returned.
252        """
253        return self.export(self.filter_func(get_students(site)), filepath)
254
255    def export_student(self, student, filepath=None):
256        return self.export(self.filter_func([student]), filepath=filepath)
257
258    def export_filtered(self, site, filepath=None, **kw):
259        """Export items denoted by `kw`.
260        If `filepath` is ``None``, a raw string with CSV data should
261        be returned.
262        """
263        data = self.get_filtered(site, **kw)
264        return self.export(self.filter_func(data, **kw), filepath=filepath)
265
266    def export_selected(self,site, filepath=None, **kw):
267        """Export data for selected set of students.
268        """
269        selected = kw.get('selected', [])
270        data = self.get_selected(site, selected)
271        return self.export(self.filter_func(data, **kw), filepath=filepath)
272
273
274class StudentExporter(grok.GlobalUtility, StudentExporterBase):
275    """The Student Exporter first filters the set of students by searching the
276    students catalog. Then it exports student base data of this set of students.
277    """
278    grok.name('students')
279
280    fields = tuple(sorted(iface_names(IStudent))) + (
281        'password', 'state', 'history', 'certcode', 'is_postgrad',
282        'current_level', 'current_session')
283    title = _(u'Students')
284
285    def mangle_value(self, value, name, context=None):
286        """The mangler prepares the history messages and adds a hash symbol at
287        the end of the phone number to avoid annoying automatic number
288        transformation by Excel or Calc."""
289        if name == 'history':
290            value = value.messages
291        if 'phone' in name and value is not None:
292            # Append hash '#' to phone numbers to circumvent
293            # unwanted excel automatic
294            value = str('%s#' % value)
295        return super(
296            StudentExporter, self).mangle_value(
297            value, name, context=context)
298
299
300class StudentStudyCourseExporter(grok.GlobalUtility, StudentExporterBase):
301    """The Student Study Course Exporter first filters the set of students
302    by searching the students catalog. Then it exports the data of the current
303    study course container of each student from this set. It does
304    not export their content.
305    """
306    grok.name('studentstudycourses')
307
308    fields = tuple(sorted(iface_names(IStudentStudyCourse))) + ('student_id',)
309    title = _(u'Student Study Courses')
310
311    def filter_func(self, x, **kw):
312        return get_studycourses(x)
313
314    def mangle_value(self, value, name, context=None):
315        """The mangler determines the certificate code and the student id.
316        """
317        if name == 'certificate' and value is not None:
318            # XXX: hopefully cert codes are unique site-wide
319            value = value.code
320        if name == 'student_id' and context is not None:
321            student = context.student
322            value = getattr(student, name, None)
323        return super(
324            StudentStudyCourseExporter, self).mangle_value(
325            value, name, context=context)
326
327
328class StudentStudyLevelExporter(grok.GlobalUtility, StudentExporterBase):
329    """The Student Study Level Exporter first filters the set of students
330    by searching the students catalog. Then it exports the data of the student's
331    study level container data but not their content (course tickets).
332    The exporter iterates over all objects in the students' ``studycourse``
333    containers.
334    """
335    grok.name('studentstudylevels')
336
337    fields = tuple(sorted(iface_names(
338        IStudentStudyLevel))) + (
339        'student_id', 'number_of_tickets','certcode')
340    title = _(u'Student Study Levels')
341
342    def filter_func(self, x, **kw):
343        return get_levels(x)
344
345    def mangle_value(self, value, name, context=None):
346        """The mangler determines the student id, nothing else.
347        """
348        if name == 'student_id' and context is not None:
349            student = context.student
350            value = getattr(student, name, None)
351        return super(
352            StudentStudyLevelExporter, self).mangle_value(
353            value, name, context=context)
354
355class CourseTicketExporter(grok.GlobalUtility, StudentExporterBase):
356    """The Course Ticket Exporter exports course tickets. Usually,
357    the exporter first filters the set of students by searching the
358    students catalog. Then it collects and iterates over all ``studylevel``
359    containers of the filtered student set and finally
360    iterates over all items inside these containers.
361
362    If the course code is passed through, the exporter uses a different
363    catalog. It searches for students in the course tickets catalog and
364    exports those course tickets which belong to the given course code and
365    also meet level and session passed through at the same time.
366    This happens if the exporter is called at course level in the academic
367    section.
368    """
369    grok.name('coursetickets')
370
371    fields = tuple(sorted(iface_names(ICourseTicket) +
372        ['level', 'code', 'level_session'])) + ('student_id',
373        'certcode', 'display_fullname')
374    title = _(u'Course Tickets')
375
376    def filter_func(self, x, **kw):
377        return get_tickets(x, **kw)
378
379    def mangle_value(self, value, name, context=None):
380        """The mangler determines the student's id and fullname.
381        """
382        if context is not None:
383            student = context.student
384            if name in ('student_id', 'display_fullname') and student is not None:
385                value = getattr(student, name, None)
386        return super(
387            CourseTicketExporter, self).mangle_value(
388            value, name, context=context)
389
390class DataForLecturerExporter(grok.GlobalUtility, StudentExporterBase):
391    """The Data for Lecturer Exporter searches for students in the course
392    tickets catalog and exports those course tickets which belong to the
393    given course code, meet level and session passed through at the
394    same time, and which are editable by lecturers. This exporter can only
395    be called at course level in the academic section.
396    """
397    grok.name('lecturer')
398
399    fields = ('matric_number', 'student_id', 'display_fullname',
400              'level', 'code', 'level_session', 'score')
401
402    title = _(u'Data for Lecturer')
403
404    def filter_func(self, x, **kw):
405        return get_tickets_for_lecturer(x, **kw)
406
407    def mangle_value(self, value, name, context=None):
408        """The mangler determines the student's id and fullname.
409        """
410        if context is not None:
411            student = context.student
412            if name in ('matric_number',
413                        'reg_number',
414                        'student_id',
415                        'display_fullname',) and student is not None:
416                value = getattr(student, name, None)
417        return super(
418            DataForLecturerExporter, self).mangle_value(
419            value, name, context=context)
420
421class StudentPaymentExporter(grok.GlobalUtility, StudentExporterBase):
422    """The Student Payment Exporter first filters the set of students
423    by searching the students catalog. Then it exports student payment
424    tickets by iterating over the items of the student's ``payments``
425    container. If the payment period is given, only tickets, which were
426    paid in payment period, are considered for export.
427    """
428    grok.name('studentpayments')
429
430    fields = tuple(
431        sorted(iface_names(
432            IStudentOnlinePayment, exclude_attribs=False,
433            omit=['display_item', 'certificate', 'student']))) + (
434            'student_id','state','current_session')
435    title = _(u'Student Payments')
436
437    def filter_func(self, x, **kw):
438        return get_payments(x, **kw)
439
440    def mangle_value(self, value, name, context=None):
441        """The mangler determines the student's id, registration
442        state and current session.
443        """
444        if context is not None:
445            student = context.student
446            if name in ['student_id','state',
447                        'current_session'] and student is not None:
448                value = getattr(student, name, None)
449        return super(
450            StudentPaymentExporter, self).mangle_value(
451            value, name, context=context)
452
453class StudentUnpaidPaymentExporter(StudentPaymentExporter):
454    """The Student Unpaid Payment Exporter works just like the
455    Student Payments Exporter but it exports only unpaid tickets.
456    This exporter is designed for finding and finally purging outdated
457    payment ticket.
458    """
459    grok.name('studentunpaidpayments')
460
461    title = _(u'Student Unpaid Payments')
462
463    def filter_func(self, x, **kw):
464        return get_payments(x, p_states=('unpaid',) , **kw)
465
466class DataForBursaryExporter(StudentPaymentExporter):
467    """The DataForBursary Exporter works just like the Student Payment Exporter
468    but it exports much more information about the student. It combines
469    payment and student data in one table in order to spare postprocessing of
470    two seperate export files. The exporter is primarily used by bursary
471    officers who have exclusively access to this exporter. The exporter
472    exports ``paid`` and ``waived`` payment tickets.
473    """
474    grok.name('bursary')
475
476    def filter_func(self, x, **kw):
477        return get_payments(x, p_states=('paid', 'waived'), **kw)
478
479    fields = tuple(
480        sorted(iface_names(
481            IStudentOnlinePayment, exclude_attribs=False,
482            omit=['display_item', 'certificate', 'student']))) + (
483            'student_id','matric_number','reg_number',
484            'firstname', 'middlename', 'lastname',
485            'state','current_session',
486            'entry_session', 'entry_mode',
487            'faccode', 'depcode','certcode')
488    title = _(u'Payment Data for Bursary')
489
490    def mangle_value(self, value, name, context=None):
491        """The mangler fetches the student data.
492        """
493        if context is not None:
494            student = context.student
495            if name in [
496                'student_id','matric_number', 'reg_number',
497                'firstname', 'middlename', 'lastname',
498                'state', 'current_session',
499                'entry_session', 'entry_mode',
500                'faccode', 'depcode', 'certcode'] and student is not None:
501                value = getattr(student, name, None)
502        return super(
503            StudentPaymentExporter, self).mangle_value(
504            value, name, context=context)
505
506class BedTicketExporter(grok.GlobalUtility, StudentExporterBase):
507    """The Bed Ticket Exporter first filters the set of students
508    by searching the students catalog. Then it exports bed
509    tickets by iterating over the items of the student's ``accommodation``
510    container.
511    """
512    grok.name('bedtickets')
513
514    fields = tuple(
515        sorted(iface_names(
516            IBedTicket, exclude_attribs=False,
517            omit=['display_coordinates', 'maint_payment_made']))) + (
518            'student_id', 'actual_bed_type')
519    title = _(u'Bed Tickets')
520
521    def filter_func(self, x, **kw):
522        return get_bedtickets(x)
523
524    def mangle_value(self, value, name, context=None):
525        """The mangler determines the student id and the type of the bed
526        which has been booked in the ticket.
527        """
528        if context is not None:
529            student = context.student
530            if name in ['student_id'] and student is not None:
531                value = getattr(student, name, None)
532        if name == 'bed' and value is not None:
533            value = getattr(value, 'bed_id', None)
534        if name == 'actual_bed_type':
535            value = getattr(getattr(context, 'bed', None), 'bed_type', None)
536        return super(
537            BedTicketExporter, self).mangle_value(
538            value, name, context=context)
539
540class SchoolFeePaymentsOverviewExporter(StudentExporter):
541    """The School Fee Payments Overview Exporter first filters the set of students
542    by searching the students catalog. Then it exports some student base data
543    together with the total school fee amount paid in each year over a
544    predefined year range (current year - 9, ... , current year + 1).
545    """
546    grok.name('sfpaymentsoverview')
547
548    curr_year = datetime.now().year
549    year_range = range(curr_year - 11, curr_year + 1)
550    year_range_tuple = tuple([str(year) for year in year_range])
551    fields = ('student_id', 'matric_number', 'display_fullname',
552        'state', 'certcode', 'faccode', 'depcode', 'is_postgrad',
553        'current_level', 'current_session', 'current_mode',
554        'entry_session', 'reg_number'
555        ) + year_range_tuple
556    title = _(u'Student School Fee Payments Overview')
557
558    def mangle_value(self, value, name, context=None):
559        """The mangler summarizes the school fee amounts made per year. It
560        iterates over all paid school fee payment tickets and
561        adds together the amounts paid in a year. Waived payments
562        are marked ``waived``.
563        """
564        if name in self.year_range_tuple and context is not None:
565            value = 0
566            for ticket in context['payments'].values():
567                if ticket.p_category == 'schoolfee' and \
568                    ticket.p_session == int(name):
569                    if ticket.p_state == 'waived':
570                        value = 'waived'
571                        break
572                    if ticket.p_state == 'paid':
573                        try:
574                            value += ticket.amount_auth
575                        except TypeError:
576                            pass
577            if value == 0:
578                value = ''
579            elif isinstance(value, float):
580                value = round(value, 2)
581        return super(
582            StudentExporter, self).mangle_value(
583            value, name, context=context)
584
585class SessionPaymentsOverviewExporter(StudentExporter):
586    """The Session Payments Overview Exporter first filters the set of students
587    by searching the students catalog. Then it exports some student base data
588    together with lists of payment years for a predefined range of payment
589    categories.
590    """
591    grok.name('sessionpaymentsoverview')
592
593    paycats = ('schoolfee', 'clearance', 'gown', 'transcript')
594    fields = ('student_id', 'matric_number', 'display_fullname',
595        'state', 'certcode', 'faccode', 'depcode', 'is_postgrad',
596        'current_level', 'current_session', 'current_mode',
597        'entry_session', 'reg_number'
598        ) + paycats
599    title = _(u'Session Payments Overview')
600
601    def mangle_value(self, value, name, context=None):
602        """
603        """
604        if name in self.paycats and context is not None:
605            value = ''
606            for ticket in context['payments'].values():
607                if ticket.p_category == name:
608                    if ticket.p_state in ('waived', 'paid'):
609                        value += '%s ' % ticket.p_session
610            value = value.strip().replace(' ', '+')
611        return super(
612            StudentExporter, self).mangle_value(
613            value, name, context=context)
614
615class StudentStudyLevelsOverviewExporter(StudentExporter):
616    """The Student Study Levels Overview Exporter first filters the set of
617    students by searching the students catalog. Then it exports some student
618    base data together with the session key of registered levels.
619    Sample output:
620
621    header: ``...100,110,120,200,210,220,300...``
622
623    data: ``...2010,,,2011,2012,,2013...``
624
625    This csv data string means that level 100 was registered in session
626    2010/2011, level 200 in session 2011/2012, level 210 (200 on 1st probation)
627    in session 2012/2013 and level 300 in session 2013/2014.
628    """
629    grok.name('studylevelsoverview')
630
631    avail_levels = tuple([str(x) for x in study_levels(None)])
632
633    fields = ('student_id', ) + (
634        'state', 'certcode', 'faccode', 'depcode', 'is_postgrad',
635        'entry_session', 'current_level', 'current_session',
636        ) + avail_levels
637    title = _(u'Student Study Levels Overview')
638
639    def mangle_value(self, value, name, context=None):
640        """The mangler checks if a given level has been registered. It returns
641        the ``level_session`` attribute of the student study level object
642        if the named level exists.
643        """
644        if name in self.avail_levels and context is not None:
645            value = ''
646            for level in context['studycourse'].values():
647                if level.level == int(name):
648                    value = '%s' % level.level_session
649                    break
650        return super(
651            StudentExporter, self).mangle_value(
652            value, name, context=context)
653
654class ComboCardDataExporter(grok.GlobalUtility, StudentExporterBase):
655    """Like all other exporters the Combo Card Data Exporter first filters the
656    set of students by searching the students catalog. Then it exports some
657    student base data which are neccessary to print for the Interswitch combo
658    card (identity card for students). The output contains a ``passport_path``
659    column which contains the filesystem path of the passport image file.
660    If no path is given, no passport image file exists.
661    """
662    grok.name('combocard')
663
664    fields = ('display_fullname',
665              'student_id','matric_number',
666              'certificate', 'faculty', 'department', 'passport_path')
667    title = _(u'Combo Card Data')
668
669    def mangle_value(self, value, name, context=None):
670        """The mangler determines the titles of faculty, department
671        and certificate. It also computes the path of passport image file
672        stored in the filesystem.
673        """
674        certificate = context['studycourse'].certificate
675        if name == 'certificate' and certificate is not None:
676            value = certificate.title
677        if name == 'department' and certificate is not None:
678            value = certificate.__parent__.__parent__.longtitle
679        if name == 'faculty' and certificate is not None:
680            value = certificate.__parent__.__parent__.__parent__.longtitle
681        if name == 'passport_path' and certificate is not None:
682            file_id = IFileStoreNameChooser(context).chooseName(
683                attr='passport.jpg')
684            os_path = getUtility(IExtFileStore)._pathFromFileID(file_id)
685            if not os.path.exists(os_path):
686                value = None
687            else:
688                value = '/'.join(os_path.split('/')[-4:])
689        return super(
690            ComboCardDataExporter, self).mangle_value(
691            value, name, context=context)
Note: See TracBrowser for help on using the repository browser.