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

Last change on this file since 14395 was 14395, checked in by Henrik Bettermann, 8 years ago

Catch AttributeError?.

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