source: main/waeup.kofa/trunk/src/waeup/kofa/students/batching.py @ 13420

Last change on this file since 13420 was 13024, checked in by Henrik Bettermann, 9 years ago

More docs.

  • Property svn:keywords set to Id
File size: 40.8 KB
Line 
1## $Id: batching.py 13024 2015-05-30 07:47:55Z 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"""Batch processing components for student objects.
19
20Batch processors eat CSV files to add, update or remove large numbers
21of certain kinds of objects at once.
22
23Here we define the processors for students specific objects like
24students, studycourses, payment tickets and accommodation tickets.
25"""
26import grok
27import unicodecsv as csv # XXX: csv ops should move to dedicated module.
28from time import time
29from datetime import datetime
30from zope.i18n import translate
31from zope.interface import Interface
32from zope.schema import getFields
33from zope.component import queryUtility, getUtility, createObject
34from zope.event import notify
35from zope.catalog.interfaces import ICatalog
36from hurry.workflow.interfaces import IWorkflowState, IWorkflowInfo
37from waeup.kofa.interfaces import (
38    IBatchProcessor, FatalCSVError, IObjectConverter, IUserAccount,
39    IObjectHistory, VALIDATED, REGISTERED, IGNORE_MARKER)
40from waeup.kofa.interfaces import IKofaUtils, DuplicationError
41from waeup.kofa.interfaces import MessageFactory as _
42from waeup.kofa.students.interfaces import (
43    IStudent, IStudentStudyCourse, IStudentStudyCourseTransfer,
44    IStudentUpdateByRegNo, IStudentUpdateByMatricNo,
45    IStudentStudyLevel, ICourseTicketImport,
46    IStudentOnlinePayment, IStudentVerdictUpdate)
47from waeup.kofa.students.workflow import  (
48    IMPORTABLE_STATES, IMPORTABLE_TRANSITIONS,
49    FORBIDDEN_POSTGRAD_TRANS, FORBIDDEN_POSTGRAD_STATES)
50from waeup.kofa.utils.batching import BatchProcessor
51
52class StudentProcessor(BatchProcessor):
53    """The Student Processor imports student base data.
54
55    In create mode no locator is required. If no `student_id` is given,
56    the portal automatically assigns a new student id.
57
58    In update or remove mode the processor uses
59    either the `student_id`, `reg_number` or `matric_number` to localize the
60    student object, exactly in this order. If `student_id` is given and an
61    object can be found, `reg_number` and `matric_number` will be overwritten
62    by the values provided in the import file. If `student_id` is missing,
63    `reg_number` is used to localize the object and only `matric_number`
64    will be overwritten. `matric_number` is used as locator only if both
65    `student_id` and `reg_number` are missing. `student_id` can't be changed
66    by the batch processor.
67
68    There are two ways to change the workflow state of the student,
69    an unsafe and a safe way. The safe way makes use of workflow transitions.
70    Transitions are only possible between allowed workflow states. Only
71    transitions ensure that the registration workflow is maintained.
72
73    **Always prefer the safe way!**
74    """
75    grok.implements(IBatchProcessor)
76    grok.provides(IBatchProcessor)
77    grok.context(Interface)
78    util_name = 'studentprocessor'
79    grok.name(util_name)
80
81    name = _('Student Processor')
82    iface = IStudent
83    iface_byregnumber = IStudentUpdateByRegNo
84    iface_bymatricnumber = IStudentUpdateByMatricNo
85
86    factory_name = 'waeup.Student'
87
88    @property
89    def available_fields(self):
90        fields = getFields(self.iface)
91        return sorted(list(set(
92            ['student_id','reg_number','matric_number',
93            'password', 'state', 'transition'] + fields.keys())))
94
95    def checkHeaders(self, headerfields, mode='create'):
96        if 'state' in headerfields and 'transition' in headerfields:
97            raise FatalCSVError(
98                "State and transition can't be imported at the same time!")
99        if not 'reg_number' in headerfields and not 'student_id' \
100            in headerfields and not 'matric_number' in headerfields:
101            raise FatalCSVError(
102                "Need at least columns student_id or reg_number " +
103                "or matric_number for import!")
104        if mode == 'create':
105            for field in self.required_fields:
106                if not field in headerfields:
107                    raise FatalCSVError(
108                        "Need at least columns %s for import!" %
109                        ', '.join(["'%s'" % x for x in self.required_fields]))
110        # Check for fields to be ignored...
111        not_ignored_fields = [x for x in headerfields
112                              if not x.startswith('--')]
113        if len(set(not_ignored_fields)) < len(not_ignored_fields):
114            raise FatalCSVError(
115                "Double headers: each column name may only appear once.")
116        return True
117
118    def parentsExist(self, row, site):
119        return 'students' in site.keys()
120
121    def getLocator(self, row):
122        if row.get('student_id',None) not in (None, IGNORE_MARKER):
123            return 'student_id'
124        elif row.get('reg_number',None) not in (None, IGNORE_MARKER):
125            return 'reg_number'
126        elif row.get('matric_number',None) not in (None, IGNORE_MARKER):
127            return 'matric_number'
128        else:
129            return None
130
131    # The entry never exists in create mode.
132    def entryExists(self, row, site):
133        return self.getEntry(row, site) is not None
134
135    def getParent(self, row, site):
136        return site['students']
137
138    def getEntry(self, row, site):
139        if not 'students' in site.keys():
140            return None
141        if self.getLocator(row) == 'student_id':
142            if row['student_id'] in site['students']:
143                student = site['students'][row['student_id']]
144                return student
145        elif self.getLocator(row) == 'reg_number':
146            reg_number = row['reg_number']
147            cat = queryUtility(ICatalog, name='students_catalog')
148            results = list(
149                cat.searchResults(reg_number=(reg_number, reg_number)))
150            if results:
151                return results[0]
152        elif self.getLocator(row) == 'matric_number':
153            matric_number = row['matric_number']
154            cat = queryUtility(ICatalog, name='students_catalog')
155            results = list(
156                cat.searchResults(matric_number=(matric_number, matric_number)))
157            if results:
158                return results[0]
159        return None
160
161    def addEntry(self, obj, row, site):
162        parent = self.getParent(row, site)
163        parent.addStudent(obj)
164        # Reset _curr_stud_id if student_id has been imported
165        if self.getLocator(row) == 'student_id':
166            parent._curr_stud_id -= 1
167        # We have to log this if state is provided. If not,
168        # logging is done by the event handler handle_student_added
169        if 'state' in row:
170            parent.logger.info('%s - Student record created' % obj.student_id)
171        history = IObjectHistory(obj)
172        history.addMessage(_('Student record created'))
173        return
174
175    def delEntry(self, row, site):
176        student = self.getEntry(row, site)
177        if student is not None:
178            parent = self.getParent(row, site)
179            parent.logger.info('%s - Student removed' % student.student_id)
180            del parent[student.student_id]
181        pass
182
183    def checkUpdateRequirements(self, obj, row, site):
184        """Checks requirements the object must fulfill when being updated.
185
186        This method is not used in case of deleting or adding objects.
187
188        Returns error messages as strings in case of requirement
189        problems.
190        """
191        transition = row.get('transition', IGNORE_MARKER)
192        if transition not in (IGNORE_MARKER, ''):
193            allowed_transitions = IWorkflowInfo(obj).getManualTransitionIds()
194            if transition not in allowed_transitions:
195                return 'Transition not allowed.'
196            if transition in FORBIDDEN_POSTGRAD_TRANS and \
197                obj.is_postgrad:
198                return 'Transition not allowed (pg student).'
199        state = row.get('state', IGNORE_MARKER)
200        if state not in (IGNORE_MARKER, ''):
201            if state in FORBIDDEN_POSTGRAD_STATES and \
202                obj.is_postgrad:
203                return 'State not allowed (pg student).'
204        return None
205
206    def updateEntry(self, obj, row, site, filename):
207        """Update obj to the values given in row.
208        """
209        items_changed = ''
210
211        # Remove student_id from row if empty
212        if 'student_id' in row and row['student_id'] in (None, IGNORE_MARKER):
213            row.pop('student_id')
214
215        # Update password
216        # XXX: Take DELETION_MARKER into consideration
217        if 'password' in row:
218            passwd = row.get('password', IGNORE_MARKER)
219            if passwd not in ('', IGNORE_MARKER):
220                if passwd.startswith('{SSHA}'):
221                    # already encrypted password
222                    obj.password = passwd
223                else:
224                    # not yet encrypted password
225                    IUserAccount(obj).setPassword(passwd)
226                items_changed += ('%s=%s, ' % ('password', passwd))
227            row.pop('password')
228
229        # Update registration state
230        if 'state' in row:
231            state = row.get('state', IGNORE_MARKER)
232            if state not in (IGNORE_MARKER, ''):
233                value = row['state']
234                IWorkflowState(obj).setState(value)
235                msg = _("State '${a}' set", mapping = {'a':value})
236                history = IObjectHistory(obj)
237                history.addMessage(msg)
238                items_changed += ('%s=%s, ' % ('state', state))
239            row.pop('state')
240
241        if 'transition' in row:
242            transition = row.get('transition', IGNORE_MARKER)
243            if transition not in (IGNORE_MARKER, ''):
244                value = row['transition']
245                IWorkflowInfo(obj).fireTransition(value)
246                items_changed += ('%s=%s, ' % ('transition', transition))
247            row.pop('transition')
248
249        # apply other values...
250        items_changed += super(StudentProcessor, self).updateEntry(
251            obj, row, site, filename)
252
253        # Log actions...
254        parent = self.getParent(row, site)
255        if hasattr(obj,'student_id'):
256            # Update mode: the student exists and we can get the student_id.
257            # Create mode: the record contains the student_id
258            parent.logger.info(
259                '%s - %s - %s - updated: %s'
260                % (self.name, filename, obj.student_id, items_changed))
261        else:
262            # Create mode: the student does not yet exist
263            # XXX: It seems that this never happens because student_id
264            # is always set.
265            parent.logger.info(
266                '%s - %s - %s - imported: %s'
267                % (self.name, filename, obj.student_id, items_changed))
268        return items_changed
269
270    def getMapping(self, path, headerfields, mode):
271        """Get a mapping from CSV file headerfields to actually used fieldnames.
272        """
273        result = dict()
274        reader = csv.reader(open(path, 'rb'))
275        raw_header = reader.next()
276        for num, field in enumerate(headerfields):
277            if field not in ['student_id', 'reg_number', 'matric_number'
278                             ] and mode == 'remove':
279                continue
280            if field == u'--IGNORE--':
281                # Skip ignored columns in failed and finished data files.
282                continue
283            result[raw_header[num]] = field
284        return result
285
286    def checkConversion(self, row, mode='create'):
287        """Validates all values in row.
288        """
289        iface = self.iface
290        if mode in ['update', 'remove']:
291            if self.getLocator(row) == 'reg_number':
292                iface = self.iface_byregnumber
293            elif self.getLocator(row) == 'matric_number':
294                iface = self.iface_bymatricnumber
295        converter = IObjectConverter(iface)
296        errs, inv_errs, conv_dict =  converter.fromStringDict(
297            row, self.factory_name, mode=mode)
298        # We cannot import both state and transition.
299        if 'transition' in row and 'state' in row:
300            if row['transition'] not in (IGNORE_MARKER, '') and \
301                row['state'] not in (IGNORE_MARKER, ''):
302                errs.append(('workflow','not allowed'))
303                return errs, inv_errs, conv_dict
304        if 'transition' in row:
305            if row['transition'] not in IMPORTABLE_TRANSITIONS:
306                if row['transition'] not in (IGNORE_MARKER, ''):
307                    errs.append(('transition','not allowed'))
308        if 'state' in row:
309            if row['state'] not in IMPORTABLE_STATES:
310                if row['state'] not in (IGNORE_MARKER, ''):
311                    errs.append(('state','not allowed'))
312                else:
313                    # State is an attribute of Student and must not
314                    # be changed if empty.
315                    conv_dict['state'] = IGNORE_MARKER
316        try:
317            # Correct stud_id counter. As the IConverter for students
318            # creates student objects that are not used afterwards, we
319            # have to fix the site-wide student_id counter.
320            site = grok.getSite()
321            students = site['students']
322            students._curr_stud_id -= 1
323        except (KeyError, TypeError, AttributeError):
324                pass
325        return errs, inv_errs, conv_dict
326
327
328class StudentProcessorBase(BatchProcessor):
329    """A base for student subitem processor.
330
331    Helps reducing redundancy.
332    """
333    grok.baseclass()
334
335    # additional available fields
336    # beside 'student_id', 'reg_number' and 'matric_number'
337    additional_fields = []
338
339    # additional required fields (subset of additional_fields)
340    additional_fields_required = []
341
342    @property
343    def available_fields(self):
344        fields = ['student_id','reg_number','matric_number'
345                  ] + self.additional_fields
346        return sorted(list(set(fields + getFields(
347                self.iface).keys())))
348
349    def checkHeaders(self, headerfields, mode='ignore'):
350        if not 'reg_number' in headerfields and not 'student_id' \
351            in headerfields and not 'matric_number' in headerfields:
352            raise FatalCSVError(
353                "Need at least columns student_id " +
354                "or reg_number or matric_number for import!")
355        for name in self.additional_fields_required:
356            if not name in headerfields:
357                raise FatalCSVError(
358                    "Need %s for import!" % name)
359
360        # Check for fields to be ignored...
361        not_ignored_fields = [x for x in headerfields
362                              if not x.startswith('--')]
363        if len(set(not_ignored_fields)) < len(not_ignored_fields):
364            raise FatalCSVError(
365                "Double headers: each column name may only appear once.")
366        return True
367
368    def _getStudent(self, row, site):
369        NON_VALUES = ['', IGNORE_MARKER]
370        if not 'students' in site.keys():
371            return None
372        if row.get('student_id', '') not in NON_VALUES:
373            if row['student_id'] in site['students']:
374                student = site['students'][row['student_id']]
375                return student
376        elif row.get('reg_number', '') not in NON_VALUES:
377            reg_number = row['reg_number']
378            cat = queryUtility(ICatalog, name='students_catalog')
379            results = list(
380                cat.searchResults(reg_number=(reg_number, reg_number)))
381            if results:
382                return results[0]
383        elif row.get('matric_number', '') not in NON_VALUES:
384            matric_number = row['matric_number']
385            cat = queryUtility(ICatalog, name='students_catalog')
386            results = list(
387                cat.searchResults(matric_number=(matric_number, matric_number)))
388            if results:
389                return results[0]
390        return None
391
392    def parentsExist(self, row, site):
393        return self.getParent(row, site) is not None
394
395    def entryExists(self, row, site):
396        return self.getEntry(row, site) is not None
397
398    def checkConversion(self, row, mode='ignore'):
399        """Validates all values in row.
400        """
401        converter = IObjectConverter(self.iface)
402        errs, inv_errs, conv_dict =  converter.fromStringDict(
403            row, self.factory_name, mode=mode)
404        return errs, inv_errs, conv_dict
405
406    def getMapping(self, path, headerfields, mode):
407        """Get a mapping from CSV file headerfields to actually used fieldnames.
408        """
409        result = dict()
410        reader = csv.reader(open(path, 'rb'))
411        raw_header = reader.next()
412        for num, field in enumerate(headerfields):
413            if field not in ['student_id', 'reg_number', 'matric_number',
414                             'p_id', 'code', 'level'
415                             ] and mode == 'remove':
416                continue
417            if field == u'--IGNORE--':
418                # Skip ignored columns in failed and finished data files.
419                continue
420            result[raw_header[num]] = field
421        return result
422
423
424class StudentStudyCourseProcessor(StudentProcessorBase):
425    """The Student Study Course Processor imports data which refer
426    to the student's course of study. The study course container data
427    describe the current state of the course of study and it stores the
428    entry conditions, i.e. when the student started the course.
429
430    Most important is the `certificate` attribute which tells us which course
431    the student is studying. The terms 'study course' and 'course of study'
432    are used synonymously. The 'certificate' is the study programme described
433    in the acadmic section. The study course object stores a referrer to a
434    certificate in the acadmic section.
435
436    When importing a new certificate code, `checkConversion` does not only
437    check whether a certificate with the same code exists, it also
438    proves if `current_level` is inside the level range of the certificate.
439    For example, some study programmes start at level 200. The imported
440    current level must thus be 200 or higher.
441
442    `checkUpdateRequirements` looks up if the imported values match the
443    certificate already stored with the study course object. The imported
444    `current_level` must be in the range of the certificate already
445    stored.
446
447    .. note::
448
449      The processor does only offer an update mode. An 'empty' study course
450      object is automatically created when the student object is added. So this
451      object always exists. It can neither be added a second time nor
452      be removed.
453
454    Students can be transferred by import. A transfer is initialized if the
455    `entry_mode` value is ``transfer``. In this case `checkConversion` uses a
456    different interface for data validation and `checkUpdateRequirements`
457    ensures that a student can only be transferred twice. The student transfer
458    process is described elsewhere.
459    """
460    grok.implements(IBatchProcessor)
461    grok.provides(IBatchProcessor)
462    grok.context(Interface)
463    util_name = 'studycourseupdater'
464    grok.name(util_name)
465
466    name = _('StudentStudyCourse Processor (update only)')
467    iface = IStudentStudyCourse
468    iface_transfer = IStudentStudyCourseTransfer
469    factory_name = 'waeup.StudentStudyCourse'
470
471    def getParent(self, row, site):
472        return self._getStudent(row, site)
473
474    def getEntry(self, row, site):
475        student = self.getParent(row, site)
476        if student is None:
477            return None
478        return student.get('studycourse')
479
480    def updateEntry(self, obj, row, site, filename):
481        """Update obj to the values given in row.
482        """
483        entry_mode = row.get('entry_mode', None)
484        certificate = row.get('certificate', None)
485        current_session = row.get('current_session', None)
486        student = self.getParent(row, site)
487        if entry_mode == 'transfer':
488            # We do not expect any error here since we
489            # checked all constraints in checkConversion and
490            # in checkUpdateRequirements
491            student.transfer(
492                certificate=certificate, current_session=current_session)
493            obj = student['studycourse']
494        items_changed = super(StudentStudyCourseProcessor, self).updateEntry(
495            obj, row, site, filename)
496        student.__parent__.logger.info(
497            '%s - %s - %s - updated: %s'
498            % (self.name, filename, student.student_id, items_changed))
499        # Update the students_catalog
500        notify(grok.ObjectModifiedEvent(student))
501        return
502
503    def checkConversion(self, row, mode='ignore'):
504        """Validates all values in row.
505        """
506        # We have to use the correct interface. Transfer
507        # updates have different constraints.
508        entry_mode = row.get('entry_mode', None)
509        if entry_mode == 'transfer':
510            converter = IObjectConverter(self.iface_transfer)
511        else:
512            converter = IObjectConverter(self.iface)
513        errs, inv_errs, conv_dict =  converter.fromStringDict(
514            row, self.factory_name, mode=mode)
515
516        # We have to check if current_level is in range of certificate.
517        if 'certificate' in conv_dict and 'current_level' in conv_dict:
518            cert = conv_dict['certificate']
519            level = conv_dict['current_level']
520            if level < cert.start_level or level > cert.end_level+120:
521                errs.append(('current_level','not in range'))
522        return errs, inv_errs, conv_dict
523
524    def checkUpdateRequirements(self, obj, row, site):
525        """Checks requirements the object must fulfill when being updated.
526        Returns error messages as strings in case of requirement
527        problems.
528        """
529        certificate = getattr(obj, 'certificate', None)
530        entry_session = getattr(obj, 'entry_session', None)
531        current_level = row.get('current_level', None)
532        entry_mode = row.get('entry_mode', None)
533        # We have to ensure that the student can be transferred.
534        if entry_mode == 'transfer':
535            if certificate is None or entry_session is None:
536                return 'Former study course record incomplete.'
537            if 'studycourse_1' in obj.__parent__.keys() and \
538                'studycourse_2' in obj.__parent__.keys():
539                return 'Maximum number of transfers exceeded.'
540        if current_level:
541            if current_level == 999 and \
542                obj.__parent__.state in FORBIDDEN_POSTGRAD_STATES:
543                return 'Not a pg student.'
544            cert = row.get('certificate', None)
545            if certificate is None and cert is None:
546                return 'No certificate to check level.'
547            if certificate is not None and cert is None and (
548                current_level < certificate.start_level or \
549                current_level > certificate.end_level+120):
550                return 'current_level not in range.'
551        return None
552
553class StudentStudyLevelProcessor(StudentProcessorBase):
554    """The Student Study Level Processor imports study level data.
555    It overwrites the container attributes but not the content of the container,
556    i.e. the course tickets stored inside the container. There is nothing
557    special about this processor.
558    """
559    grok.implements(IBatchProcessor)
560    grok.provides(IBatchProcessor)
561    grok.context(Interface)
562    util_name = 'studylevelprocessor'
563    grok.name(util_name)
564
565    name = _('StudentStudyLevel Processor')
566    iface = IStudentStudyLevel
567    factory_name = 'waeup.StudentStudyLevel'
568
569    @property
570    def available_fields(self):
571        fields = super(StudentStudyLevelProcessor, self).available_fields
572        fields.remove('total_credits')
573        fields.remove('gpa')
574        return  fields
575
576    def getParent(self, row, site):
577        student = self._getStudent(row, site)
578        if student is None:
579            return None
580        return student['studycourse']
581
582    def getEntry(self, row, site):
583        studycourse = self.getParent(row, site)
584        if studycourse is None:
585            return None
586        return studycourse.get(str(row['level']))
587
588    def delEntry(self, row, site):
589        studylevel = self.getEntry(row, site)
590        parent = self.getParent(row, site)
591        if studylevel is not None:
592            student = self._getStudent(row, site)
593            student.__parent__.logger.info('%s - Level removed: %s'
594                % (student.student_id, studylevel.__name__))
595            del parent[studylevel.__name__]
596        return
597
598    def updateEntry(self, obj, row, site, filename):
599        """Update obj to the values given in row.
600        """
601        items_changed = super(StudentStudyLevelProcessor, self).updateEntry(
602            obj, row, site, filename)
603        student = self.getParent(row, site).__parent__
604        student.__parent__.logger.info(
605            '%s - %s - %s - updated: %s'
606            % (self.name, filename, student.student_id, items_changed))
607        return
608
609    def addEntry(self, obj, row, site):
610        parent = self.getParent(row, site)
611        parent[str(row['level'])] = obj
612        return
613
614class CourseTicketProcessor(StudentProcessorBase):
615    """The Course Ticket Processor imports course tickets, the subobjects
616    of student study levels (= course lists).
617
618    An imported course ticket contains a copy of the original course data.
619    During import only a few attributes can be set/overwritten.
620
621    Like all other student data importers, this processor also requires
622    either `student_id`, `reg_number` or `matric_number` to find the student.
623    Then  it needs `level` and `code` to localize the course ticket.
624
625    `checkConversion` first searches the courses catalog for the imported
626    `code` and ensures that a course with such a code really exists
627    in the academic section. It furthermore checks if `level_session` in
628    the row corresponds with the session of the parent student
629    study level object. It fails if one of the conditions is not met.
630
631    In create mode `fcode`, `dcode`, `title`, `credits`, `passmark` and
632    `semester` are taken from the course found in the academic section.
633    These attributes can nevermore be changed, neither via the user interface
634    nor by import.
635    """
636    grok.implements(IBatchProcessor)
637    grok.provides(IBatchProcessor)
638    grok.context(Interface)
639    util_name = 'courseticketprocessor'
640    grok.name(util_name)
641
642    name = _('CourseTicket Processor')
643    iface = ICourseTicketImport
644    factory_name = 'waeup.CourseTicket'
645
646    additional_fields = ['level', 'code']
647    additional_fields_required = additional_fields
648
649    @property
650    def available_fields(self):
651        fields = [
652            'student_id','reg_number','matric_number',
653            'mandatory', 'score', 'carry_over', 'automatic',
654            'level_session'
655            ] + self.additional_fields
656        return sorted(fields)
657
658    def getParent(self, row, site):
659        student = self._getStudent(row, site)
660        if student is None:
661            return None
662        return student['studycourse'].get(str(row['level']))
663
664    def getEntry(self, row, site):
665        level = self.getParent(row, site)
666        if level is None:
667            return None
668        return level.get(row['code'])
669
670    def updateEntry(self, obj, row, site, filename):
671        """Update obj to the values given in row.
672        """
673        items_changed = super(CourseTicketProcessor, self).updateEntry(
674            obj, row, site, filename)
675        parent = self.getParent(row, site)
676        student = self.getParent(row, site).__parent__.__parent__
677        student.__parent__.logger.info(
678            '%s - %s - %s - %s - updated: %s'
679            % (self.name, filename, student.student_id, parent.level, items_changed))
680        return
681
682    def addEntry(self, obj, row, site):
683        parent = self.getParent(row, site)
684        catalog = getUtility(ICatalog, name='courses_catalog')
685        entries = list(catalog.searchResults(code=(row['code'],row['code'])))
686        obj.fcode = entries[0].__parent__.__parent__.__parent__.code
687        obj.dcode = entries[0].__parent__.__parent__.code
688        obj.title = entries[0].title
689        #if getattr(obj, 'credits', None) is None:
690        obj.credits = entries[0].credits
691        #if getattr(obj, 'passmark', None) is None:
692        obj.passmark = entries[0].passmark
693        obj.semester = entries[0].semester
694        parent[row['code']] = obj
695        return
696
697    def delEntry(self, row, site):
698        ticket = self.getEntry(row, site)
699        parent = self.getParent(row, site)
700        if ticket is not None:
701            student = self._getStudent(row, site)
702            student.__parent__.logger.info('%s - Course ticket in %s removed: %s'
703                % (student.student_id, parent.level, ticket.code))
704            del parent[ticket.code]
705        return
706
707    def checkConversion(self, row, mode='ignore'):
708        """Validates all values in row.
709        """
710        errs, inv_errs, conv_dict = super(
711            CourseTicketProcessor, self).checkConversion(row, mode=mode)
712        if mode == 'remove':
713            return errs, inv_errs, conv_dict
714        # In update and create mode we have to check if course really exists.
715        # This is not done by the converter.
716        catalog = getUtility(ICatalog, name='courses_catalog')
717        entries = catalog.searchResults(code=(row['code'],row['code']))
718        if len(entries) == 0:
719            errs.append(('code','non-existent'))
720            return errs, inv_errs, conv_dict
721        # If level_session is provided in row we have to check if
722        # the parent studylevel exists and if its level_session
723        # attribute corresponds with the expected value in row.
724        level_session = conv_dict.get('level_session', IGNORE_MARKER)
725        if level_session not in (IGNORE_MARKER, None):
726            site = grok.getSite()
727            studylevel = self.getParent(row, site)
728            if studylevel is not None:
729                if studylevel.level_session != level_session:
730                    errs.append(('level_session','does not match %s'
731                        % studylevel.level_session))
732            else:
733                errs.append(('level object','does not exist'))
734        return errs, inv_errs, conv_dict
735
736class StudentOnlinePaymentProcessor(StudentProcessorBase):
737    """The Course Ticket Processor imports student payment tickets. The
738    tickets are located in the ``payments`` subfolder of the student
739    container. The only additional locator is `p_id`, the object id.
740
741    The `checkConversion` method checks the format of the payment identifier.
742    In create mode it does also ensures that same p_id does not exist
743    elsewhere. It must be portal-wide unique.
744
745    When adding a payment ticket, the `addEntry` method checks if the same
746    payment has already been made. It compares `p_category` and `p_session`
747    in the row with the corresponding attributes  of existing payment
748    tickets in state ``paid``. If they match, a `DuplicationError` is raised.
749    """
750    grok.implements(IBatchProcessor)
751    grok.provides(IBatchProcessor)
752    grok.context(Interface)
753    util_name = 'paymentprocessor'
754    grok.name(util_name)
755
756    name = _('StudentOnlinePayment Processor')
757    iface = IStudentOnlinePayment
758    factory_name = 'waeup.StudentOnlinePayment'
759
760    additional_fields = ['p_id']
761
762    def checkHeaders(self, headerfields, mode='ignore'):
763        super(StudentOnlinePaymentProcessor, self).checkHeaders(headerfields)
764        if mode in ('update', 'remove') and not 'p_id' in headerfields:
765            raise FatalCSVError(
766                "Need p_id for import in update and remove modes!")
767        return True
768
769    def parentsExist(self, row, site):
770        return self.getParent(row, site) is not None
771
772    def getParent(self, row, site):
773        student = self._getStudent(row, site)
774        if student is None:
775            return None
776        return student['payments']
777
778    def getEntry(self, row, site):
779        payments = self.getParent(row, site)
780        if payments is None:
781            return None
782        p_id = row.get('p_id', None)
783        if p_id in (None, IGNORE_MARKER):
784            return None
785        # We can use the hash symbol at the end of p_id in import files
786        # to avoid annoying automatic number transformation
787        # by Excel or Calc
788        p_id = p_id.strip('#')
789        if len(p_id.split('-')) != 3 and not p_id.startswith('p'):
790            # For data migration from old SRP only
791            p_id = 'p' + p_id[7:] + '0'
792        entry = payments.get(p_id)
793        return entry
794
795    def updateEntry(self, obj, row, site, filename):
796        """Update obj to the values given in row.
797        """
798        items_changed = super(StudentOnlinePaymentProcessor, self).updateEntry(
799            obj, row, site, filename)
800        student = self.getParent(row, site).__parent__
801        student.__parent__.logger.info(
802            '%s - %s - %s - updated: %s'
803            % (self.name, filename, student.student_id, items_changed))
804        return
805
806    def samePaymentMade(self, student, category, p_session):
807        for key in student['payments'].keys():
808            ticket = student['payments'][key]
809            if ticket.p_state == 'paid' and\
810               ticket.p_category == category and \
811               ticket.p_session == p_session:
812                  return True
813        return False
814
815    def addEntry(self, obj, row, site):
816        parent = self.getParent(row, site)
817        student = parent.student
818        p_id = row['p_id'].strip('#')
819        # Requirement added on 19/02/2015: same payment must not exist.
820        if self.samePaymentMade(student, obj.p_category, obj.p_session):
821            raise DuplicationError('Same payment has already been made.')
822        if len(p_id.split('-')) != 3 and not p_id.startswith('p'):
823            # For data migration from old SRP
824            obj.p_id = 'p' + p_id[7:] + '0'
825            parent[obj.p_id] = obj
826        else:
827            parent[p_id] = obj
828        return
829
830    def delEntry(self, row, site):
831        payment = self.getEntry(row, site)
832        parent = self.getParent(row, site)
833        if payment is not None:
834            student = self._getStudent(row, site)
835            student.__parent__.logger.info('%s - Payment ticket removed: %s'
836                % (student.student_id, payment.p_id))
837            del parent[payment.p_id]
838        return
839
840    def checkConversion(self, row, mode='ignore'):
841        """Validates all values in row.
842        """
843        errs, inv_errs, conv_dict = super(
844            StudentOnlinePaymentProcessor, self).checkConversion(row, mode=mode)
845
846        # We have to check p_id.
847        p_id = row.get('p_id', None)
848        if mode == 'create' and p_id in (None, IGNORE_MARKER):
849            timestamp = ("%d" % int(time()*10000))[1:]
850            p_id = "p%s" % timestamp
851            conv_dict['p_id'] = p_id
852            return errs, inv_errs, conv_dict
853        elif p_id in (None, IGNORE_MARKER):
854            errs.append(('p_id','missing'))
855            return errs, inv_errs, conv_dict
856        else:
857            p_id = p_id.strip('#')
858            if p_id.startswith('p'):
859                if not len(p_id) == 14:
860                    errs.append(('p_id','invalid length'))
861                    return errs, inv_errs, conv_dict
862            elif len(p_id.split('-')) == 3:
863                # The SRP used either pins as keys ...
864                if len(p_id.split('-')[2]) not in (9, 10):
865                    errs.append(('p_id','invalid pin'))
866                    return errs, inv_errs, conv_dict
867            else:
868                # ... or order_ids.
869                if not len(p_id) == 19:
870                    errs.append(('p_id','invalid format'))
871                    return errs, inv_errs, conv_dict
872        # Requirement added on 24/01/2015: p_id must be portal-wide unique.
873        if mode == 'create':
874            cat = getUtility(ICatalog, name='payments_catalog')
875            results = list(cat.searchResults(p_id=(p_id, p_id)))
876            if len(results) > 0:
877                sids = [payment.student.student_id for payment in results]
878                sids_string = ''
879                for id in sids:
880                    sids_string += '%s ' % id
881                errs.append(('p_id','p_id exists in %s' % sids_string))
882                return errs, inv_errs, conv_dict
883        return errs, inv_errs, conv_dict
884
885class StudentVerdictProcessor(StudentStudyCourseProcessor):
886    """The Student Verdict Processor inherits from the Student Study
887    Course Processor. It's a pure updater. Import step 2 raises a warning
888    message if a datacenter manager tries to select another mode.
889    But it does more than only overwriting study course attributes.
890
891    The Student Verdict Processor is the only processor which cannot be
892    used for restoring data. Purpose is to announce a verdict at the end of
893    each academic session. The processor does not only import a verdict,
894    it also conditions the student data so that the student can pay for the
895    next session and proceed to the next study level.
896
897    The `checkUpdateRequirements` method ensures that the imported data
898    really correspond to the actual state of the student.
899    `current_level` and `current_session` in the row must be on par
900    with the attributes of the study course object. Thus, the processor
901    does not use these values to overwrite the attributes of the study course
902    but to control that the verdict is really meant for the current session of
903    the student. The verdict is only imported if a corresponding study level
904    object exists and the student is in the right registration state,
905    either ``courses validated`` or ``courses registered``. Course registration
906    can be bypassed by setting `bypass_validation` to ``True``.
907
908    The `updateEntry` method does not only update the current verdict of
909    the student study course, it also updates the matching student study
910    level object. It saves the current verdict as `level_verdict` and sets
911    the `validated_by` and `validation_date` attributes, whereas `validated_by`
912    is taken from the row of the import file and `validation_date` is set to the
913    actual UTC datetime. Finally, the student is moved to state ``returning``.
914    """
915
916    util_name = 'verdictupdater'
917    grok.name(util_name)
918
919    name = _('Verdict Processor (special processor, update only)')
920    iface = IStudentVerdictUpdate
921    factory_name = 'waeup.StudentStudyCourse'
922
923    def checkUpdateRequirements(self, obj, row, site):
924        """Checks requirements the studycourse and the student must fulfill
925        before being updated.
926        """
927        # Check if current_levels correspond
928        if obj.current_level != row['current_level']:
929            return 'Current level does not correspond.'
930        # Check if current_sessions correspond
931        if obj.current_session != row['current_session']:
932            return 'Current session does not correspond.'
933        # Check if new verdict is provided
934        if row['current_verdict'] in (IGNORE_MARKER, ''):
935            return 'No verdict in import file.'
936        # Check if studylevel exists
937        level_string = str(obj.current_level)
938        if obj.get(level_string) is None:
939            return 'Study level object is missing.'
940        # Check if student is in state REGISTERED or VALIDATED
941        if row.get('bypass_validation'):
942            if obj.student.state not in (VALIDATED, REGISTERED):
943                return 'Student in wrong state.'
944        else:
945            if obj.student.state != VALIDATED:
946                return 'Student in wrong state.'
947        return None
948
949    def updateEntry(self, obj, row, site, filename):
950        """Update obj to the values given in row.
951        """
952        # Don't set current_session, current_level
953        vals_to_set = dict((key, val) for key, val in row.items()
954                           if key not in ('current_session','current_level'))
955        super(StudentVerdictProcessor, self).updateEntry(
956            obj, vals_to_set, site, filename)
957        parent = self.getParent(row, site)
958        # Set current_verdict in corresponding studylevel
959        level_string = str(obj.current_level)
960        obj[level_string].level_verdict = row['current_verdict']
961        # Fire transition and set studylevel attributes
962        # depending on student's state
963        if obj.__parent__.state == REGISTERED:
964            validated_by = row.get('validated_by', '')
965            if validated_by in (IGNORE_MARKER, ''):
966                portal_language = getUtility(IKofaUtils).PORTAL_LANGUAGE
967                system = translate(_('System'),'waeup.kofa',
968                                  target_language=portal_language)
969                obj[level_string].validated_by = system
970            else:
971                obj[level_string].validated_by = validated_by
972            obj[level_string].validation_date = datetime.utcnow()
973            IWorkflowInfo(obj.__parent__).fireTransition('bypass_validation')
974        else:
975            IWorkflowInfo(obj.__parent__).fireTransition('return')
976        # Update the students_catalog
977        notify(grok.ObjectModifiedEvent(obj.__parent__))
978        return
Note: See TracBrowser for help on using the repository browser.