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

Last change on this file since 15047 was 15047, checked in by Henrik Bettermann, 7 years ago

Rename StudentPaymentsOverviewExporter.

  • Property svn:keywords set to Id
File size: 25.9 KB
Line 
1## $Id: export.py 15047 2018-06-12 06:19:37Z henrik $
2##
3## Copyright (C) 2011 Uli Fouquet & Henrik Bettermann
4## This program is free software; you can redistribute it and/or modify
5## it under the terms of the GNU General Public License as published by
6## the Free Software Foundation; either version 2 of the License, or
7## (at your option) any later version.
8##
9## This program is distributed in the hope that it will be useful,
10## but WITHOUT ANY WARRANTY; without even the implied warranty of
11## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12## GNU General Public License for more details.
13##
14## You should have received a copy of the GNU General Public License
15## along with this program; if not, write to the Free Software
16## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
17##
18"""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    """
392    """
393    grok.name('lecturer')
394
395    fields = ('matric_number', 'student_id', 'display_fullname',
396              'level', 'code', 'level_session', 'score')
397
398    title = _(u'Data for Lecturer')
399
400    def filter_func(self, x, **kw):
401        return get_tickets_for_lecturer(x, **kw)
402
403    def mangle_value(self, value, name, context=None):
404        """The mangler determines the student's id and fullname.
405        """
406        if context is not None:
407            student = context.student
408            if name in ('matric_number',
409                        'reg_number',
410                        'student_id',
411                        'display_fullname',) and student is not None:
412                value = getattr(student, name, None)
413        return super(
414            DataForLecturerExporter, self).mangle_value(
415            value, name, context=context)
416
417class StudentPaymentExporter(grok.GlobalUtility, StudentExporterBase):
418    """The Student Payment Exporter first filters the set of students
419    by searching the students catalog. Then it exports student payment
420    tickets by iterating over the items of the student's ``payments``
421    container. If the payment period is given, only tickets, which were
422    paid in payment period, are considered for export.
423    """
424    grok.name('studentpayments')
425
426    fields = tuple(
427        sorted(iface_names(
428            IStudentOnlinePayment, exclude_attribs=False,
429            omit=['display_item', 'certificate', 'student']))) + (
430            'student_id','state','current_session')
431    title = _(u'Student Payments')
432
433    def filter_func(self, x, **kw):
434        return get_payments(x, **kw)
435
436    def mangle_value(self, value, name, context=None):
437        """The mangler determines the student's id, registration
438        state and current session.
439        """
440        if context is not None:
441            student = context.student
442            if name in ['student_id','state',
443                        'current_session'] and student is not None:
444                value = getattr(student, name, None)
445        return super(
446            StudentPaymentExporter, self).mangle_value(
447            value, name, context=context)
448
449class StudentUnpaidPaymentExporter(StudentPaymentExporter):
450    """The Student Unpaid Payment Exporter works just like the
451    Student Payments Exporter but it exports only unpaid tickets.
452    This exporter is designed for finding and finally purging outdated
453    payment ticket.
454    """
455    grok.name('studentunpaidpayments')
456
457    title = _(u'Student Unpaid Payments')
458
459    def filter_func(self, x, **kw):
460        return get_payments(x, p_states=('unpaid',) , **kw)
461
462class DataForBursaryExporter(StudentPaymentExporter):
463    """The DataForBursary Exporter works just like the Student Payment Exporter
464    but it exports much more information about the student. It combines
465    payment and student data in one table in order to spare postprocessing of
466    two seperate export files. The exporter is primarily used by bursary
467    officers who have exclusively access to this exporter. The exporter
468    exports ``paid`` and ``waived`` payment tickets.
469    """
470    grok.name('bursary')
471
472    def filter_func(self, x, **kw):
473        return get_payments(x, p_states=('paid', 'waived'), **kw)
474
475    fields = tuple(
476        sorted(iface_names(
477            IStudentOnlinePayment, exclude_attribs=False,
478            omit=['display_item', 'certificate', 'student']))) + (
479            'student_id','matric_number','reg_number',
480            'firstname', 'middlename', 'lastname',
481            'state','current_session',
482            'entry_session', 'entry_mode',
483            'faccode', 'depcode','certcode')
484    title = _(u'Payment Data for Bursary')
485
486    def mangle_value(self, value, name, context=None):
487        """The mangler fetches the student data.
488        """
489        if context is not None:
490            student = context.student
491            if name in [
492                'student_id','matric_number', 'reg_number',
493                'firstname', 'middlename', 'lastname',
494                'state', 'current_session',
495                'entry_session', 'entry_mode',
496                'faccode', 'depcode', 'certcode'] and student is not None:
497                value = getattr(student, name, None)
498        return super(
499            StudentPaymentExporter, self).mangle_value(
500            value, name, context=context)
501
502class BedTicketExporter(grok.GlobalUtility, StudentExporterBase):
503    """The Bed Ticket Exporter first filters the set of students
504    by searching the students catalog. Then it exports bed
505    tickets by iterating over the items of the student's ``accommodation``
506    container.
507    """
508    grok.name('bedtickets')
509
510    fields = tuple(
511        sorted(iface_names(
512            IBedTicket, exclude_attribs=False,
513            omit=['display_coordinates', 'maint_payment_made']))) + (
514            'student_id', 'actual_bed_type')
515    title = _(u'Bed Tickets')
516
517    def filter_func(self, x, **kw):
518        return get_bedtickets(x)
519
520    def mangle_value(self, value, name, context=None):
521        """The mangler determines the student id and the type of the bed
522        which has been booked in the ticket.
523        """
524        if context is not None:
525            student = context.student
526            if name in ['student_id'] and student is not None:
527                value = getattr(student, name, None)
528        if name == 'bed' and value is not None:
529            value = getattr(value, 'bed_id', None)
530        if name == 'actual_bed_type':
531            value = getattr(getattr(context, 'bed', None), 'bed_type', None)
532        return super(
533            BedTicketExporter, self).mangle_value(
534            value, name, context=context)
535
536class SchoolFeePaymentsOverviewExporter(StudentExporter):
537    """The School Fee Payments Overview Exporter first filters the set of students
538    by searching the students catalog. Then it exports some student base data
539    together with the total school fee amount paid in each year over a
540    predefined year range (current year - 9, ... , current year + 1).
541    """
542    grok.name('sfpaymentsoverview')
543
544    curr_year = datetime.now().year
545    year_range = range(curr_year - 11, curr_year + 1)
546    year_range_tuple = tuple([str(year) for year in year_range])
547
548    fields = ('student_id', 'matric_number', 'display_fullname',
549        'state', 'certcode', 'faccode', 'depcode', 'is_postgrad',
550        'current_level', 'current_session', 'current_mode',
551        'entry_session', 'reg_number'
552        ) + year_range_tuple
553    title = _(u'Student School Fee Payments Overview')
554
555    def mangle_value(self, value, name, context=None):
556        """The mangler summarizes the school fee amounts made per year. It
557        iterates over all paid school fee payment tickets and
558        adds together the amounts paid in a year. Waived payments
559        are marked ``waived``.
560        """
561        if name in self.year_range_tuple and context is not None:
562            value = 0
563            for ticket in context['payments'].values():
564                if ticket.p_category == 'schoolfee' and \
565                    ticket.p_session == int(name):
566                    if ticket.p_state == 'waived':
567                        value = 'waived'
568                        break
569                    if ticket.p_state == 'paid':
570                        try:
571                            value += ticket.amount_auth
572                        except TypeError:
573                            pass
574            if value == 0:
575                value = ''
576            elif isinstance(value, float):
577                value = round(value, 2)
578        return super(
579            StudentExporter, self).mangle_value(
580            value, name, context=context)
581
582class StudentStudyLevelsOverviewExporter(StudentExporter):
583    """The Student Study Levels Overview Exporter first filters the set of
584    students by searching the students catalog. Then it exports some student
585    base data together with the session key of registered levels.
586    Sample output:
587
588    header: ``...100,110,120,200,210,220,300...``
589
590    data: ``...2010,,,2011,2012,,2013...``
591
592    This csv data string means that level 100 was registered in session
593    2010/2011, level 200 in session 2011/2012, level 210 (200 on 1st probation)
594    in session 2012/2013 and level 300 in session 2013/2014.
595    """
596    grok.name('studylevelsoverview')
597
598    avail_levels = tuple([str(x) for x in study_levels(None)])
599
600    fields = ('student_id', ) + (
601        'state', 'certcode', 'faccode', 'depcode', 'is_postgrad',
602        'entry_session', 'current_level', 'current_session',
603        ) + avail_levels
604    title = _(u'Student Study Levels Overview')
605
606    def mangle_value(self, value, name, context=None):
607        """The mangler checks if a given level has been registered. It returns
608        the ``level_session`` attribute of the student study level object
609        if the named level exists.
610        """
611        if name in self.avail_levels and context is not None:
612            value = ''
613            for level in context['studycourse'].values():
614                if level.level == int(name):
615                    value = '%s' % level.level_session
616                    break
617        return super(
618            StudentExporter, self).mangle_value(
619            value, name, context=context)
620
621class ComboCardDataExporter(grok.GlobalUtility, StudentExporterBase):
622    """Like all other exporters the Combo Card Data Exporter first filters the
623    set of students by searching the students catalog. Then it exports some
624    student base data which are neccessary to print for the Interswitch combo
625    card (identity card for students). The output contains a ``passport_path``
626    column which contains the filesystem path of the passport image file.
627    If no path is given, no passport image file exists.
628    """
629    grok.name('combocard')
630
631    fields = ('display_fullname',
632              'student_id','matric_number',
633              'certificate', 'faculty', 'department', 'passport_path')
634    title = _(u'Combo Card Data')
635
636    def mangle_value(self, value, name, context=None):
637        """The mangler determines the titles of faculty, department
638        and certificate. It also computes the path of passport image file
639        stored in the filesystem.
640        """
641        certificate = context['studycourse'].certificate
642        if name == 'certificate' and certificate is not None:
643            value = certificate.title
644        if name == 'department' and certificate is not None:
645            value = certificate.__parent__.__parent__.longtitle
646        if name == 'faculty' and certificate is not None:
647            value = certificate.__parent__.__parent__.__parent__.longtitle
648        if name == 'passport_path' and certificate is not None:
649            file_id = IFileStoreNameChooser(context).chooseName(
650                attr='passport.jpg')
651            os_path = getUtility(IExtFileStore)._pathFromFileID(file_id)
652            if not os.path.exists(os_path):
653                value = None
654            else:
655                value = '/'.join(os_path.split('/')[-4:])
656        return super(
657            ComboCardDataExporter, self).mangle_value(
658            value, name, context=context)
Note: See TracBrowser for help on using the repository browser.