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

Last change on this file since 12882 was 12882, checked in by Henrik Bettermann, 10 years ago

More docs.

  • Property svn:keywords set to Id
File size: 40.3 KB
Line 
1## $Id: batching.py 12882 2015-04-24 10:07:26Z 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 registration 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        if 'transition' in row:
299            if row['transition'] not in IMPORTABLE_TRANSITIONS:
300                if row['transition'] not in (IGNORE_MARKER, ''):
301                    errs.append(('transition','not allowed'))
302        if 'state' in row:
303            if row['state'] not in IMPORTABLE_STATES:
304                if row['state'] not in (IGNORE_MARKER, ''):
305                    errs.append(('state','not allowed'))
306                else:
307                    # State is an attribute of Student and must not
308                    # be changed if empty.
309                    conv_dict['state'] = IGNORE_MARKER
310        try:
311            # Correct stud_id counter. As the IConverter for students
312            # creates student objects that are not used afterwards, we
313            # have to fix the site-wide student_id counter.
314            site = grok.getSite()
315            students = site['students']
316            students._curr_stud_id -= 1
317        except (KeyError, TypeError, AttributeError):
318                pass
319        return errs, inv_errs, conv_dict
320
321
322class StudentProcessorBase(BatchProcessor):
323    """A base for student subitem processor.
324
325    Helps reducing redundancy.
326    """
327    grok.baseclass()
328
329    # additional available fields
330    # beside 'student_id', 'reg_number' and 'matric_number'
331    additional_fields = []
332
333    # additional required fields (subset of additional_fields)
334    additional_fields_required = []
335
336    @property
337    def available_fields(self):
338        fields = ['student_id','reg_number','matric_number'
339                  ] + self.additional_fields
340        return sorted(list(set(fields + getFields(
341                self.iface).keys())))
342
343    def checkHeaders(self, headerfields, mode='ignore'):
344        if not 'reg_number' in headerfields and not 'student_id' \
345            in headerfields and not 'matric_number' in headerfields:
346            raise FatalCSVError(
347                "Need at least columns student_id " +
348                "or reg_number or matric_number for import!")
349        for name in self.additional_fields_required:
350            if not name in headerfields:
351                raise FatalCSVError(
352                    "Need %s for import!" % name)
353
354        # Check for fields to be ignored...
355        not_ignored_fields = [x for x in headerfields
356                              if not x.startswith('--')]
357        if len(set(not_ignored_fields)) < len(not_ignored_fields):
358            raise FatalCSVError(
359                "Double headers: each column name may only appear once.")
360        return True
361
362    def _getStudent(self, row, site):
363        NON_VALUES = ['', IGNORE_MARKER]
364        if not 'students' in site.keys():
365            return None
366        if row.get('student_id', '') not in NON_VALUES:
367            if row['student_id'] in site['students']:
368                student = site['students'][row['student_id']]
369                return student
370        elif row.get('reg_number', '') not in NON_VALUES:
371            reg_number = row['reg_number']
372            cat = queryUtility(ICatalog, name='students_catalog')
373            results = list(
374                cat.searchResults(reg_number=(reg_number, reg_number)))
375            if results:
376                return results[0]
377        elif row.get('matric_number', '') not in NON_VALUES:
378            matric_number = row['matric_number']
379            cat = queryUtility(ICatalog, name='students_catalog')
380            results = list(
381                cat.searchResults(matric_number=(matric_number, matric_number)))
382            if results:
383                return results[0]
384        return None
385
386    def parentsExist(self, row, site):
387        return self.getParent(row, site) is not None
388
389    def entryExists(self, row, site):
390        return self.getEntry(row, site) is not None
391
392    def checkConversion(self, row, mode='ignore'):
393        """Validates all values in row.
394        """
395        converter = IObjectConverter(self.iface)
396        errs, inv_errs, conv_dict =  converter.fromStringDict(
397            row, self.factory_name, mode=mode)
398        return errs, inv_errs, conv_dict
399
400    def getMapping(self, path, headerfields, mode):
401        """Get a mapping from CSV file headerfields to actually used fieldnames.
402        """
403        result = dict()
404        reader = csv.reader(open(path, 'rb'))
405        raw_header = reader.next()
406        for num, field in enumerate(headerfields):
407            if field not in ['student_id', 'reg_number', 'matric_number',
408                             'p_id', 'code', 'level'
409                             ] and mode == 'remove':
410                continue
411            if field == u'--IGNORE--':
412                # Skip ignored columns in failed and finished data files.
413                continue
414            result[raw_header[num]] = field
415        return result
416
417
418class StudentStudyCourseProcessor(StudentProcessorBase):
419    """The Student Study Course Processor imports data which refer
420    to the student's course of study. The study course container data
421    describe the current state of the course of study and it stores the
422    entry conditions, i.e. when the student started the course.
423
424    Most important is the `certificate` attribute which tells us which course
425    the student is studying. The terms 'study course' and 'course of study'
426    are used synonymously. The 'certificate' is the study programme described
427    in the acadmic section. The study course object stores a referrer to a
428    certificate in the acadmic section.
429
430    When importing a new certificate code, `checkConversion` does not only
431    check whether a certificate with the same code exists, it also
432    proves if `current_level` is inside the level range of the certificate.
433    For example, some study programmes start at level 200. The imported
434    current level must thus be 200 or higher.
435
436    `checkUpdateRequirements` looks up if the imported values match the
437    certificate already stored with the study course object. The imported
438    `current_level` must be in the range of the certificate already
439    stored.
440
441    .. note::
442
443      The processor does only offer an update mode. An 'empty' study course
444      object is automatically created when the student object is added. So this
445      object always exists. It can neither be added a second time nor
446      be removed.
447
448    Students can be transferred by import. A transfer is initialized if the
449    `entry_mode` value is ``transfer``. In this case `checkConversion` uses a
450    different interface for data validation and `checkUpdateRequirements`
451    ensures that a student can only be transferred twice. The student transfer
452    process is described elsewhere.
453    """
454    grok.implements(IBatchProcessor)
455    grok.provides(IBatchProcessor)
456    grok.context(Interface)
457    util_name = 'studycourseupdater'
458    grok.name(util_name)
459
460    name = _('StudentStudyCourse Processor (update only)')
461    iface = IStudentStudyCourse
462    iface_transfer = IStudentStudyCourseTransfer
463    factory_name = 'waeup.StudentStudyCourse'
464
465    def getParent(self, row, site):
466        return self._getStudent(row, site)
467
468    def getEntry(self, row, site):
469        student = self.getParent(row, site)
470        if student is None:
471            return None
472        return student.get('studycourse')
473
474    def updateEntry(self, obj, row, site, filename):
475        """Update obj to the values given in row.
476        """
477        entry_mode = row.get('entry_mode', None)
478        certificate = row.get('certificate', None)
479        current_session = row.get('current_session', None)
480        student = self.getParent(row, site)
481        if entry_mode == 'transfer':
482            # We do not expect any error here since we
483            # checked all constraints in checkConversion and
484            # in checkUpdateRequirements
485            student.transfer(
486                certificate=certificate, current_session=current_session)
487            obj = student['studycourse']
488        items_changed = super(StudentStudyCourseProcessor, self).updateEntry(
489            obj, row, site, filename)
490        student.__parent__.logger.info(
491            '%s - %s - %s - updated: %s'
492            % (self.name, filename, student.student_id, items_changed))
493        # Update the students_catalog
494        notify(grok.ObjectModifiedEvent(student))
495        return
496
497    def checkConversion(self, row, mode='ignore'):
498        """Validates all values in row.
499        """
500        # We have to use the correct interface. Transfer
501        # updates have different constraints.
502        entry_mode = row.get('entry_mode', None)
503        if entry_mode == 'transfer':
504            converter = IObjectConverter(self.iface_transfer)
505        else:
506            converter = IObjectConverter(self.iface)
507        errs, inv_errs, conv_dict =  converter.fromStringDict(
508            row, self.factory_name, mode=mode)
509
510        # We have to check if current_level is in range of certificate.
511        if 'certificate' in conv_dict and 'current_level' in conv_dict:
512            cert = conv_dict['certificate']
513            level = conv_dict['current_level']
514            if level < cert.start_level or level > cert.end_level+120:
515                errs.append(('current_level','not in range'))
516        return errs, inv_errs, conv_dict
517
518    def checkUpdateRequirements(self, obj, row, site):
519        """Checks requirements the object must fulfill when being updated.
520        Returns error messages as strings in case of requirement
521        problems.
522        """
523        certificate = getattr(obj, 'certificate', None)
524        entry_session = getattr(obj, 'entry_session', None)
525        current_level = row.get('current_level', None)
526        entry_mode = row.get('entry_mode', None)
527        # We have to ensure that the student can be transferred.
528        if entry_mode == 'transfer':
529            if certificate is None or entry_session is None:
530                return 'Former study course record incomplete.'
531            if 'studycourse_1' in obj.__parent__.keys() and \
532                'studycourse_2' in obj.__parent__.keys():
533                return 'Maximum number of transfers exceeded.'
534        if current_level:
535            if current_level == 999 and \
536                obj.__parent__.state in FORBIDDEN_POSTGRAD_STATES:
537                return 'Not a pg student.'
538            cert = row.get('certificate', None)
539            if certificate is None and cert is None:
540                return 'No certificate to check level.'
541            if certificate is not None and cert is None and (
542                current_level < certificate.start_level or \
543                current_level > certificate.end_level+120):
544                return 'current_level not in range.'
545        return None
546
547class StudentStudyLevelProcessor(StudentProcessorBase):
548    """The Student Study Level Processor imports study level data.
549    It overwrites the container attributes but not the content of the container,
550    i.e. the course tickets stored inside the container. There is nothing
551    special about this processor.
552    """
553    grok.implements(IBatchProcessor)
554    grok.provides(IBatchProcessor)
555    grok.context(Interface)
556    util_name = 'studylevelprocessor'
557    grok.name(util_name)
558
559    name = _('StudentStudyLevel Processor')
560    iface = IStudentStudyLevel
561    factory_name = 'waeup.StudentStudyLevel'
562
563    @property
564    def available_fields(self):
565        fields = super(StudentStudyLevelProcessor, self).available_fields
566        fields.remove('total_credits')
567        fields.remove('gpa')
568        return  fields
569
570    def getParent(self, row, site):
571        student = self._getStudent(row, site)
572        if student is None:
573            return None
574        return student['studycourse']
575
576    def getEntry(self, row, site):
577        studycourse = self.getParent(row, site)
578        if studycourse is None:
579            return None
580        return studycourse.get(str(row['level']))
581
582    def delEntry(self, row, site):
583        studylevel = self.getEntry(row, site)
584        parent = self.getParent(row, site)
585        if studylevel is not None:
586            student = self._getStudent(row, site)
587            student.__parent__.logger.info('%s - Level removed: %s'
588                % (student.student_id, studylevel.__name__))
589            del parent[studylevel.__name__]
590        return
591
592    def updateEntry(self, obj, row, site, filename):
593        """Update obj to the values given in row.
594        """
595        items_changed = super(StudentStudyLevelProcessor, self).updateEntry(
596            obj, row, site, filename)
597        student = self.getParent(row, site).__parent__
598        student.__parent__.logger.info(
599            '%s - %s - %s - updated: %s'
600            % (self.name, filename, student.student_id, items_changed))
601        return
602
603    def addEntry(self, obj, row, site):
604        parent = self.getParent(row, site)
605        parent[str(row['level'])] = obj
606        return
607
608class CourseTicketProcessor(StudentProcessorBase):
609    """The Course Ticket Processor imports course tickets, the subobjects
610    of student study levels (= course lists).
611
612    An imported course ticket contains a copy of the original course data.
613    During import only a few attributes can be set/overwritten.
614
615    Like all other student data importers, this processor also requires
616    either `student_id`, `reg_number` or `matric_number` to find the student.
617    Then  it needs `level` and `code` to localize the course ticket.
618
619    `checkConversion` first searches the courses catalog for the imported
620    `code` and ensures that a course with such a code really exists
621    in the academic section. It furthermore checks if `level_session` in
622    the row corresponds with the session of the parent student
623    study level object. It fails if one of the conditions is not met.
624
625    In create mode `fcode`, `dcode`, `title`, `credits`, `passmark` and
626    `semester` are taken from the course found in the academic section.
627    These attributes can nevermore be changed, neither via the user interface
628    nor by import.
629    """
630    grok.implements(IBatchProcessor)
631    grok.provides(IBatchProcessor)
632    grok.context(Interface)
633    util_name = 'courseticketprocessor'
634    grok.name(util_name)
635
636    name = _('CourseTicket Processor')
637    iface = ICourseTicketImport
638    factory_name = 'waeup.CourseTicket'
639
640    additional_fields = ['level', 'code']
641    additional_fields_required = additional_fields
642
643    @property
644    def available_fields(self):
645        fields = [
646            'student_id','reg_number','matric_number',
647            'mandatory', 'score', 'carry_over', 'automatic',
648            'level_session'
649            ] + self.additional_fields
650        return sorted(fields)
651
652    def getParent(self, row, site):
653        student = self._getStudent(row, site)
654        if student is None:
655            return None
656        return student['studycourse'].get(str(row['level']))
657
658    def getEntry(self, row, site):
659        level = self.getParent(row, site)
660        if level is None:
661            return None
662        return level.get(row['code'])
663
664    def updateEntry(self, obj, row, site, filename):
665        """Update obj to the values given in row.
666        """
667        items_changed = super(CourseTicketProcessor, self).updateEntry(
668            obj, row, site, filename)
669        parent = self.getParent(row, site)
670        student = self.getParent(row, site).__parent__.__parent__
671        student.__parent__.logger.info(
672            '%s - %s - %s - %s - updated: %s'
673            % (self.name, filename, student.student_id, parent.level, items_changed))
674        return
675
676    def addEntry(self, obj, row, site):
677        parent = self.getParent(row, site)
678        catalog = getUtility(ICatalog, name='courses_catalog')
679        entries = list(catalog.searchResults(code=(row['code'],row['code'])))
680        obj.fcode = entries[0].__parent__.__parent__.__parent__.code
681        obj.dcode = entries[0].__parent__.__parent__.code
682        obj.title = entries[0].title
683        #if getattr(obj, 'credits', None) is None:
684        obj.credits = entries[0].credits
685        #if getattr(obj, 'passmark', None) is None:
686        obj.passmark = entries[0].passmark
687        obj.semester = entries[0].semester
688        parent[row['code']] = obj
689        return
690
691    def delEntry(self, row, site):
692        ticket = self.getEntry(row, site)
693        parent = self.getParent(row, site)
694        if ticket is not None:
695            student = self._getStudent(row, site)
696            student.__parent__.logger.info('%s - Course ticket in %s removed: %s'
697                % (student.student_id, parent.level, ticket.code))
698            del parent[ticket.code]
699        return
700
701    def checkConversion(self, row, mode='ignore'):
702        """Validates all values in row.
703        """
704        errs, inv_errs, conv_dict = super(
705            CourseTicketProcessor, self).checkConversion(row, mode=mode)
706        if mode == 'remove':
707            return errs, inv_errs, conv_dict
708        # In update and create mode we have to check if course really exists.
709        # This is not done by the converter.
710        catalog = getUtility(ICatalog, name='courses_catalog')
711        entries = catalog.searchResults(code=(row['code'],row['code']))
712        if len(entries) == 0:
713            errs.append(('code','non-existent'))
714            return errs, inv_errs, conv_dict
715        # If level_session is provided in row we have to check if
716        # the parent studylevel exists and if its level_session
717        # attribute corresponds with the expected value in row.
718        level_session = conv_dict.get('level_session', IGNORE_MARKER)
719        if level_session not in (IGNORE_MARKER, None):
720            site = grok.getSite()
721            studylevel = self.getParent(row, site)
722            if studylevel is not None:
723                if studylevel.level_session != level_session:
724                    errs.append(('level_session','does not match %s'
725                        % studylevel.level_session))
726            else:
727                errs.append(('level','does not exist'))
728        return errs, inv_errs, conv_dict
729
730class StudentOnlinePaymentProcessor(StudentProcessorBase):
731    """The Course Ticket Processor imports student payment tickets. The
732    tickets are located in the ``payments`` subfolder of the student
733    container. The only additional locator is `p_id`, the object id.
734
735    The `checkConversion` method checks the format of the payment identifier.
736    In create mode it does also ensures that same p_id does not exist
737    elsewhere. It must be portal-wide unique.
738
739    When adding a payment ticket, the `addEntry` method checks if the same
740    payment has already been made. It compares `p_category` and `p_session`
741    in the row with the corresponding attributes  of existing payment
742    tickets in state ``paid``. If they match, a `DuplicationError` is raised.
743    """
744    grok.implements(IBatchProcessor)
745    grok.provides(IBatchProcessor)
746    grok.context(Interface)
747    util_name = 'paymentprocessor'
748    grok.name(util_name)
749
750    name = _('StudentOnlinePayment Processor')
751    iface = IStudentOnlinePayment
752    factory_name = 'waeup.StudentOnlinePayment'
753
754    additional_fields = ['p_id']
755
756    def checkHeaders(self, headerfields, mode='ignore'):
757        super(StudentOnlinePaymentProcessor, self).checkHeaders(headerfields)
758        if mode in ('update', 'remove') and not 'p_id' in headerfields:
759            raise FatalCSVError(
760                "Need p_id for import in update and remove modes!")
761        return True
762
763    def parentsExist(self, row, site):
764        return self.getParent(row, site) is not None
765
766    def getParent(self, row, site):
767        student = self._getStudent(row, site)
768        if student is None:
769            return None
770        return student['payments']
771
772    def getEntry(self, row, site):
773        payments = self.getParent(row, site)
774        if payments is None:
775            return None
776        p_id = row.get('p_id', None)
777        if p_id is None:
778            return None
779        # We can use the hash symbol at the end of p_id in import files
780        # to avoid annoying automatic number transformation
781        # by Excel or Calc
782        p_id = p_id.strip('#')
783        if len(p_id.split('-')) != 3 and not p_id.startswith('p'):
784            # For data migration from old SRP only
785            p_id = 'p' + p_id[7:] + '0'
786        entry = payments.get(p_id)
787        return entry
788
789    def updateEntry(self, obj, row, site, filename):
790        """Update obj to the values given in row.
791        """
792        items_changed = super(StudentOnlinePaymentProcessor, self).updateEntry(
793            obj, row, site, filename)
794        student = self.getParent(row, site).__parent__
795        student.__parent__.logger.info(
796            '%s - %s - %s - updated: %s'
797            % (self.name, filename, student.student_id, items_changed))
798        return
799
800    def samePaymentMade(self, student, category, p_session):
801        for key in student['payments'].keys():
802            ticket = student['payments'][key]
803            if ticket.p_state == 'paid' and\
804               ticket.p_category == category and \
805               ticket.p_session == p_session:
806                  return True
807        return False
808
809    def addEntry(self, obj, row, site):
810        parent = self.getParent(row, site)
811        student = parent.student
812        p_id = row['p_id'].strip('#')
813        # Requirement added on 19/02/2015: same payment must not exist.
814        if self.samePaymentMade(student, obj.p_category, obj.p_session):
815            raise DuplicationError('Same payment has already been made.')
816        if len(p_id.split('-')) != 3 and not p_id.startswith('p'):
817            # For data migration from old SRP
818            obj.p_id = 'p' + p_id[7:] + '0'
819            parent[obj.p_id] = obj
820        else:
821            parent[p_id] = obj
822        return
823
824    def delEntry(self, row, site):
825        payment = self.getEntry(row, site)
826        parent = self.getParent(row, site)
827        if payment is not None:
828            student = self._getStudent(row, site)
829            student.__parent__.logger.info('%s - Payment ticket removed: %s'
830                % (student.student_id, payment.p_id))
831            del parent[payment.p_id]
832        return
833
834    def checkConversion(self, row, mode='ignore'):
835        """Validates all values in row.
836        """
837        errs, inv_errs, conv_dict = super(
838            StudentOnlinePaymentProcessor, self).checkConversion(row, mode=mode)
839
840        # We have to check p_id.
841        p_id = row.get('p_id', None)
842        if not p_id:
843            timestamp = ("%d" % int(time()*10000))[1:]
844            p_id = "p%s" % timestamp
845            conv_dict['p_id'] = p_id
846            return errs, inv_errs, conv_dict
847        else:
848            p_id = p_id.strip('#')
849        if p_id.startswith('p'):
850            if not len(p_id) == 14:
851                errs.append(('p_id','invalid length'))
852                return errs, inv_errs, conv_dict
853        elif len(p_id.split('-')) == 3:
854            # The SRP used either pins as keys ...
855            if len(p_id.split('-')[2]) not in (9, 10):
856                errs.append(('p_id','invalid pin'))
857                return errs, inv_errs, conv_dict
858        else:
859            # ... or order_ids.
860            if not len(p_id) == 19:
861                errs.append(('p_id','invalid format'))
862                return errs, inv_errs, conv_dict
863        # Requirement added on 24/01/2015: p_id must be portal-wide unique.
864        if mode == 'create':
865            cat = getUtility(ICatalog, name='payments_catalog')
866            results = list(cat.searchResults(p_id=(p_id, p_id)))
867            if len(results) > 0:
868                sids = [payment.student.student_id for payment in results]
869                sids_string = ''
870                for id in sids:
871                    sids_string += '%s ' % id
872                errs.append(('p_id','p_id exists in %s' % sids_string))
873                return errs, inv_errs, conv_dict
874        return errs, inv_errs, conv_dict
875
876class StudentVerdictProcessor(StudentStudyCourseProcessor):
877    """The Student Verdict Processor inherits from the Student Study
878    Course Processor. It's a pure updater. Import step 2 raises a warning
879    message if a datacenter manager tries to select another mode.
880    But it does more than only overwriting study course attributes.
881
882    The Student Verdict Processor is the only processor which cannot be
883    used for restoring data. Purpose is to announce a verdict at the end of
884    each academic session. The processor does not only import a verdict,
885    it also conditions the student data so that the student can pay for the
886    next session and proceed to the next study level.
887
888    The `checkUpdateRequirements` method ensures that the imported data
889    really correspond to the actual state of the student.
890    `current_level` and `current_session` in the row must be on par
891    with the attributes of the study course object. Thus, the processor
892    does not use these values to overwrite the attributes of the study course
893    but to control that the verdict is really meant for the current session of
894    the student. The verdict is only imported if a corresponding study level
895    object exists and the student is in the right registration state,
896    either ``courses validated`` or ``courses registered``. Course registration
897    can be bypassed by setting `bypass_validation` to ``True``.
898
899    The `updateEntry` method does not only update the current verdict of
900    the student study course, it also updates the matching student study
901    level object. It saves the current verdict as `level_verdict` and sets
902    the `validated_by` and `validation_date` attributes, whereas `validated_by`
903    is taken from the row of the import file and `validation_date` is set to the
904    actual UTC datetime. Finally, the student is moved to state ``returning``.
905    """
906
907    util_name = 'verdictupdater'
908    grok.name(util_name)
909
910    name = _('Verdict Processor (special processor, update only)')
911    iface = IStudentVerdictUpdate
912    factory_name = 'waeup.StudentStudyCourse'
913
914    def checkUpdateRequirements(self, obj, row, site):
915        """Checks requirements the studycourse and the student must fulfill
916        before being updated.
917        """
918        # Check if current_levels correspond
919        if obj.current_level != row['current_level']:
920            return 'Current level does not correspond.'
921        # Check if current_sessions correspond
922        if obj.current_session != row['current_session']:
923            return 'Current session does not correspond.'
924        # Check if new verdict is provided
925        if row['current_verdict'] in (IGNORE_MARKER, ''):
926            return 'No verdict in import file.'
927        # Check if studylevel exists
928        level_string = str(obj.current_level)
929        if obj.get(level_string) is None:
930            return 'Study level object is missing.'
931        # Check if student is in state REGISTERED or VALIDATED
932        if row.get('bypass_validation'):
933            if obj.student.state not in (VALIDATED, REGISTERED):
934                return 'Student in wrong state.'
935        else:
936            if obj.student.state != VALIDATED:
937                return 'Student in wrong state.'
938        return None
939
940    def updateEntry(self, obj, row, site, filename):
941        """Update obj to the values given in row.
942        """
943        # Don't set current_session, current_level
944        vals_to_set = dict((key, val) for key, val in row.items()
945                           if key not in ('current_session','current_level'))
946        super(StudentVerdictProcessor, self).updateEntry(
947            obj, vals_to_set, site, filename)
948        parent = self.getParent(row, site)
949        # Set current_verdict in corresponding studylevel
950        level_string = str(obj.current_level)
951        obj[level_string].level_verdict = row['current_verdict']
952        # Fire transition and set studylevel attributes
953        # depending on student's state
954        if obj.__parent__.state == REGISTERED:
955            validated_by = row.get('validated_by', '')
956            if validated_by in (IGNORE_MARKER, ''):
957                portal_language = getUtility(IKofaUtils).PORTAL_LANGUAGE
958                system = translate(_('System'),'waeup.kofa',
959                                  target_language=portal_language)
960                obj[level_string].validated_by = system
961            else:
962                obj[level_string].validated_by = validated_by
963            obj[level_string].validation_date = datetime.utcnow()
964            IWorkflowInfo(obj.__parent__).fireTransition('bypass_validation')
965        else:
966            IWorkflowInfo(obj.__parent__).fireTransition('return')
967        # Update the students_catalog
968        notify(grok.ObjectModifiedEvent(obj.__parent__))
969        return
Note: See TracBrowser for help on using the repository browser.