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

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

Start documenting batch processors.

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