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

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

Append hash '#' to all kind of student phone numbers.

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