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

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

Add ticket level and session filter to CourseTicketExporter.

  • Property svn:keywords set to Id
File size: 26.6 KB
Line 
1## $Id: export.py 14984 2018-04-05 15:30:08Z 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    payments_start = kw.get('payments_start')
136    payments_end = kw.get('payments_end')
137    if payments_start and payments_end:
138        # Payment period given
139        payments_start = datetime.strptime(payments_start, date_format)
140        payments_end = datetime.strptime(payments_end, date_format)
141        tz = getUtility(IKofaUtils).tzinfo
142        payments_start = tz.localize(payments_start)
143        payments_end = tz.localize(payments_end) + timedelta(days=1)
144        if p_states:
145            # Only tickets in certain states and payment period are considered
146            for student in students:
147                for payment in student.get('payments', {}).values():
148                    if payment.payment_date and payment.p_state in p_states:
149                        payment_date = to_timezone(payment.payment_date, tz)
150                        if payment_date > payments_start and \
151                            payment_date < payments_end:
152                            payments.append(payment)
153        else:
154            # All tickets in payment period are considered
155            for student in students:
156                for payment in student.get('payments', {}).values():
157                    if payment.payment_date:
158                        payment_date = to_timezone(payment.payment_date, tz)
159                        if payment_date > payments_start and \
160                            payment_date < payments_end:
161                            payments.append(payment)
162    else:
163        # Payment period not given
164        if p_states:
165            # Only paid tickets are considered
166            for student in students:
167                for payment in student.get('payments', {}).values():
168                    if payment.p_state in p_states:
169                        payments.append(payment)
170        else:
171            # All tickets are considered
172            for student in students:
173                for payment in student.get('payments', {}).values():
174                    payments.append(payment)
175    return payments
176
177def get_bedtickets(students):
178    """Get all bedtickets of `students`.
179    """
180    tickets = []
181    for student in students:
182        for ticket in student.get('accommodation', {}).values():
183            tickets.append(ticket)
184    return tickets
185
186class StudentExporterBase(ExporterBase):
187    """Exporter for students or related objects.
188    This is a baseclass.
189    """
190    grok.baseclass()
191    grok.implements(ICSVStudentExporter)
192    grok.provides(ICSVStudentExporter)
193
194    def filter_func(self, x, **kw):
195        return x
196
197    def get_filtered(self, site, **kw):
198        """Get students from a catalog filtered by keywords.
199        students_catalog is the default catalog. The keys must be valid
200        catalog index names.
201        Returns a simple empty list, a list with `Student`
202        objects or a catalog result set with `Student`
203        objects.
204
205        .. seealso:: `waeup.kofa.students.catalog.StudentsCatalog`
206
207        """
208        # Pass only given keywords to create FilteredCatalogQuery objects.
209        # This way we avoid
210        # trouble with `None` value ambivalences and queries are also
211        # faster (normally less indexes to ask). Drawback is, that
212        # developers must look into catalog to see what keywords are
213        # valid.
214        if kw.get('catalog', None) == 'coursetickets':
215            coursetickets = CourseTicketsQuery(**kw).query()
216            students = []
217            for ticket in coursetickets:
218                students.append(ticket.student)
219            return list(set(students))
220        # Payments can be filtered by payment_date. The period boundaries
221        # are not keys of the catalog and must thus be removed from kw.
222        try:
223            del kw['payments_start']
224            del kw['payments_end']
225        except KeyError:
226            pass
227        try:
228            del kw['ct_level']
229            del kw['ct_session']
230        except KeyError:
231            pass
232        query = StudentsQuery(**kw)
233        return query.query()
234
235    def get_selected(self, site, selected):
236        """Get set of selected students.
237        Returns a simple empty list or a list with `Student`
238        objects.
239        """
240        students = []
241        students_container = site.get('students', {})
242        for id in selected:
243            student = students_container.get(id, None)
244            if student is None:
245                # try matric number
246                result = list(StudentsQuery(matric_number=id).query())
247                if result:
248                    student = result[0]
249                else:
250                    continue
251            students.append(student)
252        return students
253
254    def export(self, values, filepath=None):
255        """Export `values`, an iterable, as CSV file.
256        If `filepath` is ``None``, a raw string with CSV data is returned.
257        """
258        writer, outfile = self.get_csv_writer(filepath)
259        for value in values:
260            self.write_item(value, writer)
261        return self.close_outfile(filepath, outfile)
262
263    def export_all(self, site, filepath=None):
264        """Export students into filepath as CSV data.
265        If `filepath` is ``None``, a raw string with CSV data is returned.
266        """
267        return self.export(self.filter_func(get_students(site)), filepath)
268
269    def export_student(self, student, filepath=None):
270        return self.export(self.filter_func([student]), filepath=filepath)
271
272    def export_filtered(self, site, filepath=None, **kw):
273        """Export items denoted by `kw`.
274        If `filepath` is ``None``, a raw string with CSV data should
275        be returned.
276        """
277        data = self.get_filtered(site, **kw)
278        return self.export(self.filter_func(data, **kw), filepath=filepath)
279
280    def export_selected(self,site, filepath=None, **kw):
281        """Export data for selected set of students.
282        """
283        selected = kw.get('selected', [])
284        data = self.get_selected(site, selected)
285        return self.export(self.filter_func(data, **kw), filepath=filepath)
286
287
288class StudentExporter(grok.GlobalUtility, StudentExporterBase):
289    """The Student Exporter first filters the set of students by searching the
290    students catalog. Then it exports student base data of this set of students.
291    """
292    grok.name('students')
293
294    fields = tuple(sorted(iface_names(IStudent))) + (
295        'password', 'state', 'history', 'certcode', 'is_postgrad',
296        'current_level', 'current_session')
297    title = _(u'Students')
298
299    def mangle_value(self, value, name, context=None):
300        """The mangler prepares the history messages and adds a hash symbol at
301        the end of the phone number to avoid annoying automatic number
302        transformation by Excel or Calc."""
303        if name == 'history':
304            value = value.messages
305        if 'phone' in name and value is not None:
306            # Append hash '#' to phone numbers to circumvent
307            # unwanted excel automatic
308            value = str('%s#' % value)
309        return super(
310            StudentExporter, self).mangle_value(
311            value, name, context=context)
312
313
314class StudentStudyCourseExporter(grok.GlobalUtility, StudentExporterBase):
315    """The Student Study Course Exporter first filters the set of students
316    by searching the students catalog. Then it exports the data of the current
317    study course container of each student from this set. It does
318    not export their content.
319    """
320    grok.name('studentstudycourses')
321
322    fields = tuple(sorted(iface_names(IStudentStudyCourse))) + ('student_id',)
323    title = _(u'Student Study Courses')
324
325    def filter_func(self, x, **kw):
326        return get_studycourses(x)
327
328    def mangle_value(self, value, name, context=None):
329        """The mangler determines the certificate code and the student id.
330        """
331        if name == 'certificate' and value is not None:
332            # XXX: hopefully cert codes are unique site-wide
333            value = value.code
334        if name == 'student_id' and context is not None:
335            student = context.student
336            value = getattr(student, name, None)
337        return super(
338            StudentStudyCourseExporter, self).mangle_value(
339            value, name, context=context)
340
341
342class StudentStudyLevelExporter(grok.GlobalUtility, StudentExporterBase):
343    """The Student Study Level Exporter first filters the set of students
344    by searching the students catalog. Then it exports the data of the student's
345    study level container data but not their content (course tickets).
346    The exporter iterates over all objects in the students' ``studycourse``
347    containers.
348    """
349    grok.name('studentstudylevels')
350
351    fields = tuple(sorted(iface_names(
352        IStudentStudyLevel))) + (
353        'student_id', 'number_of_tickets','certcode')
354    title = _(u'Student Study Levels')
355
356    def filter_func(self, x, **kw):
357        return get_levels(x)
358
359    def mangle_value(self, value, name, context=None):
360        """The mangler determines the student id, nothing else.
361        """
362        if name == 'student_id' and context is not None:
363            student = context.student
364            value = getattr(student, name, None)
365        return super(
366            StudentStudyLevelExporter, self).mangle_value(
367            value, name, context=context)
368
369class CourseTicketExporter(grok.GlobalUtility, StudentExporterBase):
370    """The Course Ticket Exporter exports course tickets. Usually,
371    the exporter first filters the set of students by searching the
372    students catalog. Then it collects and iterates over all ``studylevel``
373    containers of the filtered student set and finally
374    iterates over all items inside these containers.
375
376    If the course code is passed through, the exporter uses a different
377    catalog. It searches for students in the course tickets catalog and
378    exports those course tickets which belong to the given course code and
379    also meet level and session passed through at the same time.
380    This happens if the exporter is called at course level in the academic
381    section.
382    """
383    grok.name('coursetickets')
384
385    fields = tuple(sorted(iface_names(ICourseTicket) +
386        ['level', 'code', 'level_session'])) + ('student_id',
387        'certcode', 'display_fullname')
388    title = _(u'Course Tickets')
389
390    def filter_func(self, x, **kw):
391        return get_tickets(x, **kw)
392
393    def mangle_value(self, value, name, context=None):
394        """The mangler determines the student's id and fullname.
395        """
396        if context is not None:
397            student = context.student
398            if name in ('student_id', 'display_fullname') and student is not None:
399                value = getattr(student, name, None)
400        return super(
401            CourseTicketExporter, self).mangle_value(
402            value, name, context=context)
403
404class DataForLecturerExporter(grok.GlobalUtility, StudentExporterBase):
405    """
406    """
407    grok.name('lecturer')
408
409    fields = ('matric_number', 'student_id', 'display_fullname',
410              'level', 'code', 'level_session', 'score')
411
412    title = _(u'Data for Lecturer')
413
414    def filter_func(self, x, **kw):
415        return get_tickets_for_lecturer(x, **kw)
416
417    def mangle_value(self, value, name, context=None):
418        """The mangler determines the student's id and fullname.
419        """
420        if context is not None:
421            student = context.student
422            if name in ('matric_number',
423                        'reg_number',
424                        'student_id',
425                        'display_fullname',) and student is not None:
426                value = getattr(student, name, None)
427        return super(
428            DataForLecturerExporter, self).mangle_value(
429            value, name, context=context)
430
431class StudentPaymentExporter(grok.GlobalUtility, StudentExporterBase):
432    """The Student Payment Exporter first filters the set of students
433    by searching the students catalog. Then it exports student payment
434    tickets by iterating over the items of the student's ``payments``
435    container. If the payment period is given only tickets, which were
436    paid in payment period, are considered for export.
437    """
438    grok.name('studentpayments')
439
440    fields = tuple(
441        sorted(iface_names(
442            IStudentOnlinePayment, exclude_attribs=False,
443            omit=['display_item', 'certificate', 'student']))) + (
444            'student_id','state','current_session')
445    title = _(u'Student Payments')
446
447    def filter_func(self, x, **kw):
448        return get_payments(x, **kw)
449
450    def mangle_value(self, value, name, context=None):
451        """The mangler determines the student's id, registration
452        state and current session.
453        """
454        if context is not None:
455            student = context.student
456            if name in ['student_id','state',
457                        'current_session'] and student is not None:
458                value = getattr(student, name, None)
459        return super(
460            StudentPaymentExporter, self).mangle_value(
461            value, name, context=context)
462
463class StudentUnpaidPaymentExporter(StudentPaymentExporter):
464    """The Student Unpaid Payment Exporter works just like the
465    Student Payments Exporter but it exports only unpaid tickets.
466    This exporter is designed for finding and finally purging outdated
467    payment ticket.
468    """
469    grok.name('studentunpaidpayments')
470
471    title = _(u'Student Unpaid Payments')
472
473    def filter_func(self, x, **kw):
474        return get_payments(x, p_states=('unpaid',) , **kw)
475
476class DataForBursaryExporter(StudentPaymentExporter):
477    """The DataForBursary Exporter works just like the Student Payments Exporter
478    but it exports much more information about the student. It combines
479    payment and student data in one table in order to spare postprocessing of
480    two seperate export files. The exporter is primarily used by bursary
481    officers who have exclusively access to this exporter. The exporter
482    exports ``paid`` and ``waived`` payment tickets.
483    """
484    grok.name('bursary')
485
486    def filter_func(self, x, **kw):
487        return get_payments(x, p_states=('paid', 'waived'), **kw)
488
489    fields = tuple(
490        sorted(iface_names(
491            IStudentOnlinePayment, exclude_attribs=False,
492            omit=['display_item', 'certificate', 'student']))) + (
493            'student_id','matric_number','reg_number',
494            'firstname', 'middlename', 'lastname',
495            'state','current_session',
496            'entry_session', 'entry_mode',
497            'faccode', 'depcode','certcode')
498    title = _(u'Payment Data for Bursary')
499
500    def mangle_value(self, value, name, context=None):
501        """The mangler fetches the student data.
502        """
503        if context is not None:
504            student = context.student
505            if name in [
506                'student_id','matric_number', 'reg_number',
507                'firstname', 'middlename', 'lastname',
508                'state', 'current_session',
509                'entry_session', 'entry_mode',
510                'faccode', 'depcode', 'certcode'] and student is not None:
511                value = getattr(student, name, None)
512        return super(
513            StudentPaymentExporter, self).mangle_value(
514            value, name, context=context)
515
516class BedTicketExporter(grok.GlobalUtility, StudentExporterBase):
517    """The Bed Ticket Exporter first filters the set of students
518    by searching the students catalog. Then it exports bed
519    tickets by iterating over the items of the student's ``accommodation``
520    container.
521    """
522    grok.name('bedtickets')
523
524    fields = tuple(
525        sorted(iface_names(
526            IBedTicket, exclude_attribs=False,
527            omit=['display_coordinates', 'maint_payment_made']))) + (
528            'student_id', 'actual_bed_type')
529    title = _(u'Bed Tickets')
530
531    def filter_func(self, x, **kw):
532        return get_bedtickets(x)
533
534    def mangle_value(self, value, name, context=None):
535        """The mangler determines the student id and the type of the bed
536        which has been booked in the ticket.
537        """
538        if context is not None:
539            student = context.student
540            if name in ['student_id'] and student is not None:
541                value = getattr(student, name, None)
542        if name == 'bed' and value is not None:
543            value = getattr(value, 'bed_id', None)
544        if name == 'actual_bed_type':
545            value = getattr(getattr(context, 'bed', None), 'bed_type', None)
546        return super(
547            BedTicketExporter, self).mangle_value(
548            value, name, context=context)
549
550class StudentPaymentsOverviewExporter(StudentExporter):
551    """The Student Payments Overview Exporter first filters the set of students
552    by searching the students catalog. Then it exports some student base data
553    together with the total school fee amount paid in each year over a
554    predefined year range (current year - 9, ... , current year + 1).
555    """
556    grok.name('paymentsoverview')
557
558    curr_year = datetime.now().year
559    year_range = range(curr_year - 11, curr_year + 1)
560    year_range_tuple = tuple([str(year) for year in year_range])
561
562    fields = ('student_id', 'matric_number', 'display_fullname',
563        'state', 'certcode', 'faccode', 'depcode', 'is_postgrad',
564        'current_level', 'current_session', 'current_mode',
565        'entry_session', 'reg_number'
566        ) + year_range_tuple
567    title = _(u'Student Payments Overview')
568
569    def mangle_value(self, value, name, context=None):
570        """The mangler summarizes the school fee amounts made per year. It
571        iterates over all paid school fee payment tickets and
572        adds together the amounts paid in a year. Waived payments
573        are marked ``waived``.
574        """
575        if name in self.year_range_tuple and context is not None:
576            value = 0
577            for ticket in context['payments'].values():
578                if ticket.p_category == 'schoolfee' and \
579                    ticket.p_session == int(name):
580                    if ticket.p_state == 'waived':
581                        value = 'waived'
582                        break
583                    if ticket.p_state == 'paid':
584                        try:
585                            value += ticket.amount_auth
586                        except TypeError:
587                            pass
588            if value == 0:
589                value = ''
590            elif isinstance(value, float):
591                value = round(value, 2)
592        return super(
593            StudentExporter, self).mangle_value(
594            value, name, context=context)
595
596class StudentStudyLevelsOverviewExporter(StudentExporter):
597    """The Student Study Levels Overview Exporter first filters the set of
598    students by searching the students catalog. Then it exports some student
599    base data together with the session key of registered levels.
600    Sample output:
601
602    header: ``...100,110,120,200,210,220,300...``
603
604    data: ``...2010,,,2011,2012,,2013...``
605
606    This csv data string means that level 100 was registered in session
607    2010/2011, level 200 in session 2011/2012, level 210 (200 on 1st probation)
608    in session 2012/2013 and level 300 in session 2013/2014.
609    """
610    grok.name('studylevelsoverview')
611
612    avail_levels = tuple([str(x) for x in study_levels(None)])
613
614    fields = ('student_id', ) + (
615        'state', 'certcode', 'faccode', 'depcode', 'is_postgrad',
616        'entry_session', 'current_level', 'current_session',
617        ) + avail_levels
618    title = _(u'Student Study Levels Overview')
619
620    def mangle_value(self, value, name, context=None):
621        """The mangler checks if a given level has been registered. It returns
622        the ``level_session`` attribute of the student study level object
623        if the named level exists.
624        """
625        if name in self.avail_levels and context is not None:
626            value = ''
627            for level in context['studycourse'].values():
628                if level.level == int(name):
629                    value = '%s' % level.level_session
630                    break
631        return super(
632            StudentExporter, self).mangle_value(
633            value, name, context=context)
634
635class ComboCardDataExporter(grok.GlobalUtility, StudentExporterBase):
636    """Like all other exporters the Combo Card Data Exporter first filters the
637    set of students by searching the students catalog. Then it exports some
638    student base data which are neccessary to print for the Interswitch combo
639    card (identity card for students). The output contains a ``passport_path``
640    column which contains the filesystem path of the passport image file.
641    If no path is given, no passport image file exists.
642    """
643    grok.name('combocard')
644
645    fields = ('display_fullname',
646              'student_id','matric_number',
647              'certificate', 'faculty', 'department', 'passport_path')
648    title = _(u'Combo Card Data')
649
650    def mangle_value(self, value, name, context=None):
651        """The mangler determines the titles of faculty, department
652        and certificate. It also computes the path of passport image file
653        stored in the filesystem.
654        """
655        certificate = context['studycourse'].certificate
656        if name == 'certificate' and certificate is not None:
657            value = certificate.title
658        if name == 'department' and certificate is not None:
659            value = certificate.__parent__.__parent__.longtitle
660        if name == 'faculty' and certificate is not None:
661            value = certificate.__parent__.__parent__.__parent__.longtitle
662        if name == 'passport_path' and certificate is not None:
663            file_id = IFileStoreNameChooser(context).chooseName(
664                attr='passport.jpg')
665            os_path = getUtility(IExtFileStore)._pathFromFileID(file_id)
666            if not os.path.exists(os_path):
667                value = None
668            else:
669                value = '/'.join(os_path.split('/')[-4:])
670        return super(
671            ComboCardDataExporter, self).mangle_value(
672            value, name, context=context)
Note: See TracBrowser for help on using the repository browser.